WebVideoFullscreenHUDWindowController.mm   [plain text]

/*
* Copyright (C) 2009-2017 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

#if ENABLE(VIDEO) && PLATFORM(MAC)

#import "WebVideoFullscreenHUDWindowController.h"

#import <WebCore/FloatConversion.h>
#import <WebCore/HTMLVideoElement.h>
#import <pal/spi/cg/CoreGraphicsSPI.h>
#import <pal/spi/mac/QTKitSPI.h>
#import <wtf/SoftLinking.h>

SOFT_LINK_FRAMEWORK(QTKit)

SOFT_LINK_CLASS(QTKit, QTHUDBackgroundView)
SOFT_LINK_CLASS(QTKit, QTHUDButton)
SOFT_LINK_CLASS(QTKit, QTHUDSlider)
SOFT_LINK_CLASS(QTKit, QTHUDTimeline)

#define QTHUDBackgroundView getQTHUDBackgroundViewClass()
#define QTHUDButton getQTHUDButtonClass()
#define QTHUDSlider getQTHUDSliderClass()
#define QTHUDTimeline getQTHUDTimelineClass()

enum class MediaUIControl {
Timeline,
Slider,
PlayPauseButton,
ExitFullscreenButton,
RewindButton,
FastForwardButton,
VolumeUpButton,
VolumeDownButton,
};

using WebCore::HTMLVideoElement;
using WebCore::narrowPrecisionToFloat;

@interface WebVideoFullscreenHUDWindowController (Private) <NSWindowDelegate>

- (void)updateTime;
- (void)timelinePositionChanged:(id)sender;
- (float)currentTime;
- (void)setCurrentTime:(float)currentTime;
- (double)duration;

- (void)volumeChanged:(id)sender;
- (float)maxVolume;
- (float)volume;
- (void)setVolume:(float)volume;
- (void)decrementVolume;
- (void)incrementVolume;

- (void)updatePlayButton;
- (void)togglePlaying:(id)sender;
- (BOOL)playing;
- (void)setPlaying:(BOOL)playing;

- (void)rewind:(id)sender;
- (void)fastForward:(id)sender;

- (NSString *)remainingTimeText;
- (NSString *)elapsedTimeText;

- (void)exitFullscreen:(id)sender;
@end

@interface WebVideoFullscreenHUDWindow : NSWindow
@end

@implementation WebVideoFullscreenHUDWindow

- (id)initWithContentRect:(NSRect)contentRect styleMask:(NSUInteger)aStyle backing:(NSBackingStoreType)bufferingType defer:(BOOL)flag
{
UNUSED_PARAM(aStyle);
self = [super initWithContentRect:contentRect styleMask:NSWindowStyleMaskBorderless backing:bufferingType defer:flag];
if (!self)
return nil;

[self setOpaque:NO];
[self setBackgroundColor:[NSColor clearColor]];
[self setLevel:NSPopUpMenuWindowLevel];
[self setAcceptsMouseMovedEvents:YES];
[self setIgnoresMouseEvents:NO];
[self setMovableByWindowBackground:YES];

return self;
}

- (BOOL)canBecomeKeyWindow
{
return YES;
}

- (void)cancelOperation:(id)sender
{
UNUSED_PARAM(sender);
[[self windowController] exitFullscreen:self];
}

- (void)center
{
NSRect hudFrame = [self frame];
NSRect screenFrame = [[NSScreen mainScreen] frame];
[self setFrameTopLeftPoint:NSMakePoint(screenFrame.origin.x + (screenFrame.size.width - hudFrame.size.width) / 2, screenFrame.origin.y + (screenFrame.size.height - hudFrame.size.height) / 6)];
}

- (void)keyDown:(NSEvent *)event
{
[super keyDown:event];
[[self windowController] fadeWindowIn];
}

- (BOOL)resignFirstResponder
{
return NO;
}

- (BOOL)performKeyEquivalent:(NSEvent *)event
{
// Block all command key events while the fullscreen window is up.
if ([event type] != NSEventTypeKeyDown)
return NO;

if (!([event modifierFlags] & NSEventModifierFlagCommand))
return NO;
return YES;
}

@end

static const CGFloat windowHeight = 59;
static const CGFloat windowWidth = 438;

static const NSTimeInterval HUDWindowFadeOutDelay = 3;

@implementation WebVideoFullscreenHUDWindowController

- (id)init
{
NSWindow *window = [[WebVideoFullscreenHUDWindow alloc] initWithContentRect:NSMakeRect(0, 0, windowWidth, windowHeight) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
self = [super initWithWindow:window];
[window setDelegate:self];
[window release];
if (!self)
return nil;
[self windowDidLoad];
return self;
}

- (void)dealloc
{
ASSERT(!_timelineUpdateTimer);
ASSERT(!_area);
ASSERT(!_isScrubbing);
[_timeline release];
[_remainingTimeText release];
[_elapsedTimeText release];
[_volumeSlider release];
[_playButton release];
[super dealloc];
}

- (void)setArea:(NSTrackingArea *)area
{
if (area == _area)
return;
[_area release];
_area = [area retain];
}

- (void)keyDown:(NSEvent *)event
{
NSString *charactersIgnoringModifiers = [event charactersIgnoringModifiers];
if ([charactersIgnoringModifiers length] == 1) {
switch ([charactersIgnoringModifiers characterAtIndex:0]) {
case ' ':
[self togglePlaying:nil];
return;
case NSUpArrowFunctionKey:
if ([event modifierFlags] & NSEventModifierFlagOption)
[self setVolume:[self maxVolume]];
else
[self incrementVolume];
return;
case NSDownArrowFunctionKey:
if ([event modifierFlags] & NSEventModifierFlagOption)
[self setVolume:0];
else
[self decrementVolume];
return;
default:
break;
}
}

[super keyDown:event];
}

- (id <WebVideoFullscreenHUDWindowControllerDelegate>)delegate
{
return _delegate;
}

- (void)setDelegate:(id <WebVideoFullscreenHUDWindowControllerDelegate>)delegate
{
_delegate = delegate;
}

- (void)scheduleTimeUpdate
{
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(unscheduleTimeUpdate) object:self];

// First, update right away, then schedule future update
[self updateTime];
[self updatePlayButton];

[_timelineUpdateTimer invalidate];
[_timelineUpdateTimer release];

// Note that this creates a retain cycle between the window and us.
_timelineUpdateTimer = [[NSTimer timerWithTimeInterval:0.25 target:self selector:@selector(updateTime) userInfo:nil repeats:YES] retain];
[[NSRunLoop currentRunLoop] addTimer:_timelineUpdateTimer forMode:NSRunLoopCommonModes];
}

- (void)unscheduleTimeUpdate
{
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(unscheduleTimeUpdate) object:nil];

[_timelineUpdateTimer invalidate];
[_timelineUpdateTimer release];
_timelineUpdateTimer = nil;
}

- (void)fadeWindowIn
{
NSWindow *window = [self window];
if (![window isVisible])
[window setAlphaValue:0];

[window makeKeyAndOrderFront:self];
[[window animator] setAlphaValue:1];
[self scheduleTimeUpdate];

[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(fadeWindowOut) object:nil];
if (!_mouseIsInHUD && [self playing]) // Don't fade out when paused.
[self performSelector:@selector(fadeWindowOut) withObject:nil afterDelay:HUDWindowFadeOutDelay];
}

- (void)fadeWindowOut
{
[NSCursor setHiddenUntilMouseMoves:YES];
[[[self window] animator] setAlphaValue:0];
[self performSelector:@selector(unscheduleTimeUpdate) withObject:nil afterDelay:1];
}

- (void)closeWindow
{
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(fadeWindowOut) object:nil];
[self unscheduleTimeUpdate];
NSWindow *window = [self window];
[[window contentView] removeTrackingArea:_area];
[self setArea:nil];
[window close];
[window setDelegate:nil];
[self setWindow:nil];
}

static NSControl *createMediaUIControl(MediaUIControl controlType)
{
switch (controlType) {
case MediaUIControl::Timeline: {
NSSlider *slider = [[QTHUDTimeline alloc] init];
[[slider cell] setContinuous:YES];
return slider;
}
case MediaUIControl::Slider: {
NSButton *slider = [[QTHUDSlider alloc] init];
[[slider cell] setContinuous:YES];
return slider;
}
case MediaUIControl::PlayPauseButton: {
NSButton *button = [[QTHUDButton alloc] init];
[button setImage:[NSImage imageNamed:@"NSPlayTemplate"]];
[button setAlternateImage:[NSImage imageNamed:@"NSPauseQTPrivateTemplate"]];

[[button cell] setShowsStateBy:NSContentsCellMask];
[button setBordered:NO];
return button;
}
case MediaUIControl::ExitFullscreenButton: {
NSButton *button = [[QTHUDButton alloc] init];
[button setImage:[NSImage imageNamed:@"NSExitFullScreenTemplate"]];
[button setBordered:NO];
return button;
}
case MediaUIControl::RewindButton: {
NSButton *button = [[QTHUDButton alloc] init];
[button setImage:[NSImage imageNamed:@"NSRewindTemplate"]];
[button setBordered:NO];
return button;
}
case MediaUIControl::FastForwardButton: {
NSButton *button = [[QTHUDButton alloc] init];
[button setImage:[NSImage imageNamed:@"NSFastForwardTemplate"]];
[button setBordered:NO];
return button;
}
case MediaUIControl::VolumeUpButton: {
NSButton *button = [[QTHUDButton alloc] init];
[button setImage:[NSImage imageNamed:@"NSAudioOutputVolumeHighTemplate"]];
[button setBordered:NO];
return button;
}
case MediaUIControl::VolumeDownButton: {
NSButton *button = [[QTHUDButton alloc] init];
[button setImage:[NSImage imageNamed:@"NSAudioOutputVolumeLowTemplate"]];
[button setBordered:NO];
return button;
}
}

ASSERT_NOT_REACHED();
return nil;
}

static NSControl *createControlWithMediaUIControlType(MediaUIControl controlType, NSRect frame)
{
NSControl *control = createMediaUIControl(controlType);
control.frame = frame;
return control;
}

static NSTextField *createTimeTextField(NSRect frame)
{
NSTextField *textField = [[NSTextField alloc] initWithFrame:frame];
[textField setTextColor:[NSColor whiteColor]];
[textField setBordered:NO];
[textField setFont:[NSFont boldSystemFontOfSize:10]];
[textField setDrawsBackground:NO];
[textField setBezeled:NO];
[textField setEditable:NO];
[textField setSelectable:NO];
return textField;
}

static NSView *createMediaUIBackgroundView()
{
id view = [[QTHUDBackgroundView alloc] init];

const CGFloat quickTimePlayerHUDHeight = 59;
const CGFloat quickTimePlayerHUDContentBorderPosition = 38;

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
[view setContentBorderPosition:quickTimePlayerHUDContentBorderPosition / quickTimePlayerHUDHeight];
#pragma clang diagnostic pop

return view;
}

- (void)windowDidLoad
{
static const CGFloat horizontalMargin = 10;
static const CGFloat playButtonWidth = 41;
static const CGFloat playButtonHeight = 35;
static const CGFloat playButtonTopMargin = 4;
static const CGFloat volumeSliderWidth = 50;
static const CGFloat volumeSliderHeight = 13;
static const CGFloat volumeButtonWidth = 18;
static const CGFloat volumeButtonHeight = 16;
static const CGFloat volumeUpButtonLeftMargin = 4;
static const CGFloat volumeControlsTopMargin = 13;
static const CGFloat exitFullscreenButtonWidth = 25;
static const CGFloat exitFullscreenButtonHeight = 21;
static const CGFloat exitFullscreenButtonTopMargin = 11;
static const CGFloat timelineWidth = 315;
static const CGFloat timelineHeight = 14;
static const CGFloat timelineBottomMargin = 7;
static const CGFloat timeTextFieldWidth = 54;
static const CGFloat timeTextFieldHeight = 13;
static const CGFloat timeTextFieldHorizontalMargin = 7;

NSWindow *window = [self window];
ASSERT(window);

NSView *background = createMediaUIBackgroundView();

[window setContentView:background];
_area = [[NSTrackingArea alloc] initWithRect:[background bounds] options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways owner:self userInfo:nil];
[background addTrackingArea:_area];
[background release];

NSView *contentView = [window contentView];

CGFloat center = CGFloor((windowWidth - playButtonWidth) / 2);
_playButton = (NSButton *)createControlWithMediaUIControlType(MediaUIControl::PlayPauseButton, NSMakeRect(center, windowHeight - playButtonTopMargin - playButtonHeight, playButtonWidth, playButtonHeight));
ASSERT([_playButton isKindOfClass:[NSButton class]]);
[_playButton setTarget:self];
[_playButton setAction:@selector(togglePlaying:)];
[contentView addSubview:_playButton];

CGFloat closeToRight = windowWidth - horizontalMargin - exitFullscreenButtonWidth;
NSControl *exitFullscreenButton = createControlWithMediaUIControlType(MediaUIControl::ExitFullscreenButton, NSMakeRect(closeToRight, windowHeight - exitFullscreenButtonTopMargin - exitFullscreenButtonHeight, exitFullscreenButtonWidth, exitFullscreenButtonHeight));
[exitFullscreenButton setAction:@selector(exitFullscreen:)];
[exitFullscreenButton setTarget:self];
[contentView addSubview:exitFullscreenButton];
[exitFullscreenButton release];

CGFloat volumeControlsBottom = windowHeight - volumeControlsTopMargin - volumeButtonHeight;
CGFloat left = horizontalMargin;
NSControl *volumeDownButton = createControlWithMediaUIControlType(MediaUIControl::VolumeDownButton, NSMakeRect(left, volumeControlsBottom, volumeButtonWidth, volumeButtonHeight));
[contentView addSubview:volumeDownButton];
[volumeDownButton setTarget:self];
[volumeDownButton setAction:@selector(setVolumeToZero:)];
[volumeDownButton release];

left += volumeButtonWidth;
_volumeSlider = createControlWithMediaUIControlType(MediaUIControl::Slider, NSMakeRect(left, volumeControlsBottom + CGFloor((volumeButtonHeight - volumeSliderHeight) / 2), volumeSliderWidth, volumeSliderHeight));
[_volumeSlider setValue:[NSNumber numberWithDouble:[self maxVolume]] forKey:@"maxValue"];
[_volumeSlider setTarget:self];
[_volumeSlider setAction:@selector(volumeChanged:)];
[contentView addSubview:_volumeSlider];

left += volumeSliderWidth + volumeUpButtonLeftMargin;
NSControl *volumeUpButton = createControlWithMediaUIControlType(MediaUIControl::VolumeUpButton, NSMakeRect(left, volumeControlsBottom, volumeButtonWidth, volumeButtonHeight));
[volumeUpButton setTarget:self];
[volumeUpButton setAction:@selector(setVolumeToMaximum:)];
[contentView addSubview:volumeUpButton];
[volumeUpButton release];

_timeline = createMediaUIControl(MediaUIControl::Timeline);

[_timeline setTarget:self];
[_timeline setAction:@selector(timelinePositionChanged:)];
[_timeline setFrame:NSMakeRect(CGFloor((windowWidth - timelineWidth) / 2), timelineBottomMargin, timelineWidth, timelineHeight)];
[contentView addSubview:_timeline];

_elapsedTimeText = createTimeTextField(NSMakeRect(timeTextFieldHorizontalMargin, timelineBottomMargin, timeTextFieldWidth, timeTextFieldHeight));
[_elapsedTimeText setAlignment:NSTextAlignmentLeft];
[contentView addSubview:_elapsedTimeText];

_remainingTimeText = createTimeTextField(NSMakeRect(windowWidth - timeTextFieldHorizontalMargin - timeTextFieldWidth, timelineBottomMargin, timeTextFieldWidth, timeTextFieldHeight));
[_remainingTimeText setAlignment:NSTextAlignmentRight];
[contentView addSubview:_remainingTimeText];

[window recalculateKeyViewLoop];
[window setInitialFirstResponder:_playButton];
[window center];
}

- (void)updateVolume
{
[_volumeSlider setFloatValue:[self volume]];
}

- (void)updateTime
{
[self updateVolume];

[_timeline setFloatValue:[self currentTime]];
[_timeline setValue:[NSNumber numberWithDouble:[self duration]] forKey:@"maxValue"];

[_remainingTimeText setStringValue:[self remainingTimeText]];
[_elapsedTimeText setStringValue:[self elapsedTimeText]];
}

- (void)endScrubbing
{
ASSERT(_isScrubbing);
_isScrubbing = NO;
if (HTMLVideoElement* videoElement = [_delegate videoElement])
videoElement->endScrubbing();
}

- (void)timelinePositionChanged:(id)sender
{
UNUSED_PARAM(sender);
[self setCurrentTime:[_timeline floatValue]];
if (!_isScrubbing) {
_isScrubbing = YES;
if (HTMLVideoElement* videoElement = [_delegate videoElement])
videoElement->beginScrubbing();
static NSArray *endScrubbingModes = [[NSArray alloc] initWithObjects:NSDefaultRunLoopMode, NSModalPanelRunLoopMode, nil];
// Schedule -endScrubbing for when leaving mouse tracking mode.
[[NSRunLoop currentRunLoop] performSelector:@selector(endScrubbing) target:self argument:nil order:0 modes:endScrubbingModes];
}
}

- (float)currentTime
{
return [_delegate videoElement] ? [_delegate videoElement]->currentTime() : 0;
}

- (void)setCurrentTime:(float)currentTime
{
if (![_delegate videoElement])
return;
[_delegate videoElement]->setCurrentTime(currentTime);
[self updateTime];
}

- (double)duration
{
return [_delegate videoElement] ? [_delegate videoElement]->duration() : 0;
}

- (float)maxVolume
{
// Set the volume slider resolution
return 100;
}

- (void)volumeChanged:(id)sender
{
UNUSED_PARAM(sender);
[self setVolume:[_volumeSlider floatValue]];
}

- (void)setVolumeToZero:(id)sender
{
UNUSED_PARAM(sender);
[self setVolume:0];
}

- (void)setVolumeToMaximum:(id)sender
{
UNUSED_PARAM(sender);
[self setVolume:[self maxVolume]];
}

- (void)decrementVolume
{
if (![_delegate videoElement])
return;

float volume = [self volume] - 10;
[self setVolume:std::max(volume, 0.0f)];
}

- (void)incrementVolume
{
if (![_delegate videoElement])
return;

float volume = [self volume] + 10;
[self setVolume:std::min(volume, [self maxVolume])];
}

- (float)volume
{
return [_delegate videoElement] ? [_delegate videoElement]->volume() * [self maxVolume] : 0;
}

- (void)setVolume:(float)volume
{
if (![_delegate videoElement])
return;
if ([_delegate videoElement]->muted())
[_delegate videoElement]->setMuted(false);
[_delegate videoElement]->setVolume(volume / [self maxVolume]);
[self updateVolume];
}

- (void)updatePlayButton
{
[_playButton setIntValue:[self playing]];
}

- (void)updateRate
{
BOOL playing = [self playing];

// Keep the HUD visible when paused.
if (!playing)
[self fadeWindowIn];
else if (!_mouseIsInHUD) {
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(fadeWindowOut) object:nil];
[self performSelector:@selector(fadeWindowOut) withObject:nil afterDelay:HUDWindowFadeOutDelay];
}
[self updatePlayButton];
}

- (void)togglePlaying:(id)sender
{
UNUSED_PARAM(sender);
[self setPlaying:![self playing]];
}

- (BOOL)playing
{
HTMLVideoElement* videoElement = [_delegate videoElement];
if (!videoElement)
return NO;

return !videoElement->canPlay();
}

- (void)setPlaying:(BOOL)playing
{
HTMLVideoElement* videoElement = [_delegate videoElement];

if (!videoElement)
return;

if (playing)
videoElement->play();
else
videoElement->pause();
}

static NSString *timeToString(double time)
{
ASSERT_ARG(time, time >= 0);

if (!std::isfinite(time))
time = 0;

int seconds = narrowPrecisionToFloat(std::abs(time));
int hours = seconds / (60 * 60);
int minutes = (seconds / 60) % 60;
seconds %= 60;

if (hours)
return [NSString stringWithFormat:@"%d:%02d:%02d", hours, minutes, seconds];

return [NSString stringWithFormat:@"%02d:%02d", minutes, seconds];
}

- (NSString *)remainingTimeText
{
HTMLVideoElement* videoElement = [_delegate videoElement];
if (!videoElement)
return @"";

double remainingTime = 0;

if (std::isfinite(videoElement->duration()) && std::isfinite(videoElement->currentTime()))
remainingTime = videoElement->duration() - videoElement->currentTime();

return [@"-" stringByAppendingString:timeToString(remainingTime)];
}

- (NSString *)elapsedTimeText
{
if (![_delegate videoElement])
return @"";

return timeToString([_delegate videoElement]->currentTime());
}

// MARK: NSResponder

- (void)mouseEntered:(NSEvent *)theEvent
{
UNUSED_PARAM(theEvent);
// Make sure the HUD won't be hidden from now
_mouseIsInHUD = YES;
[self fadeWindowIn];
}

- (void)mouseExited:(NSEvent *)theEvent
{
UNUSED_PARAM(theEvent);
_mouseIsInHUD = NO;
[self fadeWindowIn];
}

- (void)rewind:(id)sender
{
UNUSED_PARAM(sender);
if (![_delegate videoElement])
return;
[_delegate videoElement]->rewind(30);
}

- (void)fastForward:(id)sender
{
UNUSED_PARAM(sender);
if (![_delegate videoElement])
return;
}

- (void)exitFullscreen:(id)sender
{
UNUSED_PARAM(sender);
if (_isEndingFullscreen)
return;
_isEndingFullscreen = YES;
[_delegate requestExitFullscreen];
}

// MARK: NSWindowDelegate

- (void)windowDidExpose:(NSNotification *)notification
{
UNUSED_PARAM(notification);
[self scheduleTimeUpdate];
}

- (void)windowDidClose:(NSNotification *)notification
{
UNUSED_PARAM(notification);
[self unscheduleTimeUpdate];
}

@end

#endif