blob: f85974ab8e437d110ddb805c896b16514d1a7bf6 [file] [log] [blame]
// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview MediaControls class implements media playback controls
* that exist outside of the audio/video HTML element.
*/
/**
* Model of a volume slider and a mute switch and its user interaction.
* @constructor
* @struct
*/
function VolumeModel() {
/**
* @type {boolean}
*/
this.isMuted_ = false;
/**
* The volume level in [0..1].
* @type {number}
*/
this.volume_ = 0.5;
}
/**
* After unmuting, the volume should be non-zero value to avoid that the mute
* button gives no response to user.
*/
VolumeModel.MIN_VOLUME_AFTER_UNMUTE = 0.01;
/**
* @return {number} the value to be set as the volume level of a media element.
*/
VolumeModel.prototype.getMediaVolume = function() {
return this.isMuted_ ? 0 : this.volume_;
};
/**
* Handles operation to the volume level slider.
* @param {number} value new position of the slider in [0..1].
*/
VolumeModel.prototype.onVolumeChanged = function(value) {
if (value == 0) {
this.isMuted_ = true;
} else {
this.isMuted_ = false;
this.volume_ = value;
}
};
/**
* Toggles the mute state.
*/
VolumeModel.prototype.toggleMute = function() {
this.isMuted_ = !this.isMuted_;
if (!this.isMuted_) {
this.volume_ = Math.max(VolumeModel.MIN_VOLUME_AFTER_UNMUTE, this.volume_);
}
};
/**
* Sets the status of the model.
* @param {number} volume the volume level in [0..1].
* @param {boolean} mute whether to mute the sound.
*/
VolumeModel.prototype.set = function(volume, mute) {
this.volume_ = volume;
this.isMuted_ = mute;
};
/**
* @param {!HTMLElement} containerElement The container for the controls.
* @param {function(Event)} onMediaError Function to display an error message.
* @constructor
* @struct
*/
function MediaControls(containerElement, onMediaError) {
this.container_ = containerElement;
this.document_ = this.container_.ownerDocument;
this.media_ = null;
this.onMediaPlayBound_ = this.onMediaPlay_.bind(this, true);
this.onMediaPauseBound_ = this.onMediaPlay_.bind(this, false);
this.onMediaDurationBound_ = this.onMediaDuration_.bind(this);
this.onMediaProgressBound_ = this.onMediaProgress_.bind(this);
this.onMediaError_ = onMediaError || function() {};
/**
* @type {VolumeModel}
* @private
*/
this.volumeModel_ = new VolumeModel();
/**
* @type {HTMLElement}
* @private
*/
this.playButton_ = null;
/**
* @type {PaperSliderElement}
* @private
*/
this.progressSlider_ = null;
/**
* @type {PaperSliderElement}
* @private
*/
this.volume_ = null;
/**
* @type {HTMLElement}
* @private
*/
this.textBanner_ = null;
/**
* @type {HTMLElement}
* @private
*/
this.soundButton_ = null;
/**
* @type {HTMLElement}
* @private
*/
this.subtitlesButton_ = null;
/**
* @private {TextTrack}
*/
this.subtitlesTrack_ = null;
/**
* @type {boolean}
* @private
*/
this.resumeAfterDrag_ = false;
/**
* @type {HTMLElement}
* @private
*/
this.currentTime_ = null;
/**
* @type {HTMLElement}
* @private
*/
this.currentTimeSpacer_ = null;
/**
* @private {boolean}
*/
this.seeking_ = false;
/**
* @private {boolean}
*/
this.showRemainingTime_ = false;
}
/**
* Button's state types. Values are used as CSS class names.
* @enum {string}
*/
MediaControls.ButtonStateType = {
DEFAULT: 'default',
PLAYING: 'playing',
ENDED: 'ended'
};
/**
* @return {HTMLAudioElement|HTMLVideoElement} The media element.
*/
MediaControls.prototype.getMedia = function() {
return this.media_;
};
/**
* Format the time in hh:mm:ss format (omitting redundant leading zeros)
* adding '-' sign if given value is negative.
* @param {number} timeInSec Time in seconds.
* @return {string} Formatted time string.
* @private
*/
MediaControls.formatTime_ = function(timeInSec) {
var result = '';
if (timeInSec < 0) {
timeInSec *= -1;
result += '-';
}
var seconds = Math.floor(timeInSec % 60);
var minutes = Math.floor((timeInSec / 60) % 60);
var hours = Math.floor(timeInSec / 60 / 60);
if (hours) result += hours + ':';
if (hours && (minutes < 10)) result += '0';
result += minutes + ':';
if (seconds < 10) result += '0';
result += seconds;
return result;
};
/**
* Create a custom control.
*
* @param {string} className Class name.
* @param {HTMLElement=} opt_parent Parent element or container if undefined.
* @param {string=} opt_tagName Tag name of the control. 'div' if undefined.
* @return {!HTMLElement} The new control element.
*/
MediaControls.prototype.createControl =
function(className, opt_parent, opt_tagName) {
var parent = opt_parent || this.container_;
var control = /** @type {!HTMLElement} */
(this.document_.createElement(opt_tagName || 'div'));
control.className = className;
parent.appendChild(control);
return control;
};
/**
* Create a custom button.
*
* @param {string} className Class name.
* @param {function(Event)=} opt_handler Click handler.
* @param {HTMLElement=} opt_parent Parent element or container if undefined.
* @param {number=} opt_numStates Number of states, default: 1.
* @return {!HTMLElement} The new button element.
*/
MediaControls.prototype.createButton = function(
className, opt_handler, opt_parent, opt_numStates) {
opt_numStates = opt_numStates || 1;
var button = this.createControl(className, opt_parent, 'files-icon-button');
button.classList.add('media-button');
button.setAttribute('state', MediaControls.ButtonStateType.DEFAULT);
if (opt_handler)
button.addEventListener('click', opt_handler);
return button;
};
/**
* Enable/disable controls.
*
* @param {boolean} on True if enable, false if disable.
* @private
*/
MediaControls.prototype.enableControls_ = function(on) {
var controls = this.container_.querySelectorAll('.media-control');
for (var i = 0; i != controls.length; i++) {
var classList = controls[i].classList;
if (on)
classList.remove('disabled');
else
classList.add('disabled');
}
this.progressSlider_.disabled = !on;
this.volume_.disabled = !on;
};
/*
* Playback control.
*/
/**
* Play the media.
*/
MediaControls.prototype.play = function() {
if (!this.media_)
return; // Media is detached.
this.media_.play();
};
/**
* Pause the media.
*/
MediaControls.prototype.pause = function() {
if (!this.media_)
return; // Media is detached.
this.media_.pause();
};
/**
* @return {boolean} True if the media is currently playing.
*/
MediaControls.prototype.isPlaying = function() {
return !!this.media_ && !this.media_.paused && !this.media_.ended;
};
/**
* Toggle play/pause.
*/
MediaControls.prototype.togglePlayState = function() {
if (this.isPlaying())
this.pause();
else
this.play();
};
/**
* Toggles play/pause state on a mouse click on the play/pause button.
*
* @param {Event} event Mouse click event.
*/
MediaControls.prototype.onPlayButtonClicked = function(event) {
this.togglePlayState();
};
/**
* @param {HTMLElement=} opt_parent Parent container.
*/
MediaControls.prototype.initPlayButton = function(opt_parent) {
this.playButton_ = this.createButton('play media-control',
this.onPlayButtonClicked.bind(this), opt_parent, 3 /* States. */);
this.playButton_.setAttribute('aria-label',
str('MEDIA_PLAYER_PLAY_BUTTON_LABEL'));
};
/*
* Time controls
*/
/**
* The default range of 100 is too coarse for the media progress slider.
*/
MediaControls.PROGRESS_RANGE = 5000;
/**
* 5 seconds should be skipped when left/right key is pressed.
*/
MediaControls.PROGRESS_MAX_SECONDS_TO_SMALL_SKIP = 5;
/**
* 10 seconds should be skipped when J/L key is pressed.
*/
MediaControls.PROGRESS_MAX_SECONDS_TO_BIG_SKIP = 10;
/**
* 10% of duration should be skipped when the video is too short to skip 5
* seconds.
*/
MediaControls.PROGRESS_MAX_RATIO_TO_SMALL_SKIP = 0.1;
/**
* 20% of duration should be skipped when the video is too short to skip 10
* seconds.
*/
MediaControls.PROGRESS_MAX_RATIO_TO_BIG_SKIP = 0.2;
/**
* @param {HTMLElement=} opt_parent Parent container.
*/
MediaControls.prototype.initTimeControls = function(opt_parent) {
var timeControls = this.createControl('time-controls', opt_parent);
var timeBox = this.createControl('time media-control', timeControls);
this.currentTimeSpacer_ = this.createControl('spacer', timeBox);
this.currentTime_ = this.createControl('current', timeBox);
this.currentTime_.addEventListener('click',
this.onTimeLabelClick_.bind(this));
// Set the initial width to the minimum to reduce the flicker.
this.updateTimeLabel_(0, 0);
this.progressSlider_ = /** @type {!PaperSliderElement} */ (
document.createElement('paper-slider'));
this.progressSlider_.classList.add('progress', 'media-control');
this.progressSlider_.max = MediaControls.PROGRESS_RANGE;
this.progressSlider_.setAttribute('aria-label',
str('MEDIA_PLAYER_SEEK_SLIDER_LABEL'));
this.progressSlider_.addEventListener('change', function(event) {
this.onProgressChange_(this.progressSlider_.ratio / 100);
}.bind(this));
this.progressSlider_.addEventListener(
'immediate-value-change',
function(event) {
this.onProgressDrag_();
}.bind(this));
timeControls.appendChild(this.progressSlider_);
};
/**
* @param {number} current Current time is seconds.
* @param {number} duration Duration in seconds.
* @private
*/
MediaControls.prototype.displayProgress_ = function(current, duration) {
var ratio = current / duration;
this.progressSlider_.value = ratio * this.progressSlider_.max;
this.updateTimeLabel_(current);
};
/**
* @param {number} value Progress [0..1].
* @private
*/
MediaControls.prototype.onProgressChange_ = function(value) {
if (!this.media_)
return; // Media is detached.
if (!this.media_.seekable || !this.media_.duration) {
console.error('Inconsistent media state');
return;
}
this.setSeeking_(false);
// Re-start playing the video when the seek bar is moved from ending point.
if (this.media_.ended)
this.play();
var current = this.media_.duration * value;
this.media_.currentTime = current;
this.updateTimeLabel_(current);
};
/**
* @private
*/
MediaControls.prototype.onProgressDrag_ = function() {
if (!this.media_)
return; // Media is detached.
this.setSeeking_(true);
// Show seeking position instead of playing position while dragging.
if (this.media_.duration && this.progressSlider_.max > 0) {
var immediateRatio =
this.progressSlider_.immediateValue / this.progressSlider_.max;
var current = this.media_.duration * immediateRatio;
this.updateTimeLabel_(current);
}
};
/**
* Skips forward/backword.
* @param {number} sec Seconds to skip. Set negative value to skip backword.
* @private
*/
MediaControls.prototype.skip_ = function(sec) {
if (this.media_ && this.media_.duration > 0) {
var stepsToSkip = MediaControls.PROGRESS_RANGE *
(sec / this.media_.duration);
this.progressSlider_.value = Math.max(Math.min(
this.progressSlider_.value + stepsToSkip,
this.progressSlider_.max), 0);
this.onProgressChange_(this.progressSlider_.ratio / 100);
}
};
/**
* Invokes small skip.
* @param {boolean} forward Whether to skip forward or backword.
*/
MediaControls.prototype.smallSkip = function(forward) {
var secondsToSkip = Math.min(
MediaControls.PROGRESS_MAX_SECONDS_TO_SMALL_SKIP,
this.media_.duration * MediaControls.PROGRESS_MAX_RATIO_TO_SMALL_SKIP);
if (!forward)
secondsToSkip *= -1;
this.skip_(secondsToSkip);
};
/**
* Invokes big skip.
* @param {boolean} forward Whether to skip forward or backword.
*/
MediaControls.prototype.bigSkip = function(forward) {
var secondsToSkip = Math.min(
MediaControls.PROGRESS_MAX_SECONDS_TO_BIG_SKIP,
this.media_.duration * MediaControls.PROGRESS_MAX_RATIO_TO_BIG_SKIP);
if (!forward)
secondsToSkip *= -1;
this.skip_(secondsToSkip);
};
/**
* Handles 'seeking' state, which starts by dragging slider knob and finishes by
* releasing it. While seeking, we pause the video when seeking starts and
* resume the last play state when seeking ends.
* @private
*/
MediaControls.prototype.setSeeking_ = function(seeking) {
if (seeking === this.seeking_)
return;
this.seeking_ = seeking;
if (seeking) {
this.resumeAfterDrag_ = this.isPlaying();
this.media_.pause(true /* seeking */);
} else {
if (this.resumeAfterDrag_) {
if (this.media_.ended)
this.onMediaPlay_(false);
else
this.media_.play(true /* seeking */);
}
this.resumeAfterDrag_ = false;
}
this.updatePlayButtonState_(this.isPlaying());
};
/**
* Click handler for the time label.
* @private
*/
MediaControls.prototype.onTimeLabelClick_ = function(event) {
this.showRemainingTime_ = !this.showRemainingTime_;
this.updateTimeLabel_(this.media_.currentTime, this.media_.duration);
};
/**
* Update the label for current playing position and video duration.
* The label should be like "0:06 / 0:32" or "-0:26 / 0:32".
* @param {number} current Current playing position.
* @param {number=} opt_duration Video's duration.
* @private
*/
MediaControls.prototype.updateTimeLabel_ = function(current, opt_duration) {
var duration = opt_duration;
if (duration === undefined)
duration = this.media_ ? this.media_.duration : 0;
// media's duration and currentTime can be NaN. Default to 0.
if (isNaN(duration))
duration = 0;
if (isNaN(current))
current = 0;
if (isFinite(duration)) {
this.currentTime_.textContent =
(this.showRemainingTime_ ? MediaControls.formatTime_(current - duration)
: MediaControls.formatTime_(current)) + ' / ' +
MediaControls.formatTime_(duration);
// Keep the maximum space to prevent time label from moving while playing.
this.currentTimeSpacer_.textContent =
(this.showRemainingTime_ ? '-' : '') +
MediaControls.formatTime_(duration) + ' / ' +
MediaControls.formatTime_(duration);
} else {
// Media's duration can be positive infinity value when the media source is
// not known to be bounded yet. In such cases, we should hide duration.
this.currentTime_.textContent = MediaControls.formatTime_(current);
this.currentTimeSpacer_.textContent = MediaControls.formatTime_(current);
}
};
/*
* Volume controls
*/
MediaControls.STORAGE_PREFIX = 'videoplayer-';
MediaControls.KEY_NORMALIZED_VOLUME =
MediaControls.STORAGE_PREFIX + 'normalized-volume';
MediaControls.KEY_MUTED =
MediaControls.STORAGE_PREFIX + 'muted';
/**
* @param {HTMLElement=} opt_parent Parent element for the controls.
*/
MediaControls.prototype.initVolumeControls = function(opt_parent) {
var volumeControls = this.createControl('volume-controls', opt_parent);
this.soundButton_ = this.createButton('sound media-control',
this.onSoundButtonClick_.bind(this), volumeControls);
this.soundButton_.setAttribute('level', 3); // max level.
this.soundButton_.setAttribute('aria-label',
str('MEDIA_PLAYER_MUTE_BUTTON_LABEL'));
this.volume_ = /** @type {!PaperSliderElement} */ (
document.createElement('paper-slider'));
this.volume_.classList.add('volume', 'media-control');
this.volume_.setAttribute('aria-label',
str('MEDIA_PLAYER_VOLUME_SLIDER_LABEL'));
this.volume_.addEventListener('change', function(event) {
this.onVolumeChange_(this.volume_.ratio / 100);
}.bind(this));
this.volume_.addEventListener('immediate-value-change', function(event) {
this.onVolumeDrag_();
}.bind(this));
this.loadVolumeControlState();
volumeControls.appendChild(this.volume_);
};
MediaControls.prototype.loadVolumeControlState = function() {
chrome.storage.local.get([MediaControls.KEY_NORMALIZED_VOLUME,
MediaControls.KEY_MUTED],
function(retrieved) {
var normalizedVolume = (MediaControls.KEY_NORMALIZED_VOLUME
in retrieved)
? retrieved[MediaControls.KEY_NORMALIZED_VOLUME] : 1;
var isMuted = (MediaControls.KEY_MUTED in retrieved)
? retrieved[MediaControls.KEY_MUTED] : false;
this.volumeModel_.set(normalizedVolume, isMuted);
this.reflectVolumeToUi_();
}.bind(this));
};
MediaControls.prototype.saveVolumeControlState = function() {
var valuesToStore = {};
valuesToStore[MediaControls.KEY_NORMALIZED_VOLUME] =
this.volumeModel_.volume_;
valuesToStore[MediaControls.KEY_MUTED] = this.volumeModel_.isMuted_;
chrome.storage.local.set(valuesToStore);
};
/**
* Click handler for the sound level button.
* @private
*/
MediaControls.prototype.onSoundButtonClick_ = function() {
this.volumeModel_.toggleMute();
this.saveVolumeControlState();
this.reflectVolumeToUi_();
};
/**
* @param {number} value Volume [0..1].
* @return {number} The rough level [0..3] used to pick an icon.
* @private
*/
MediaControls.getVolumeLevel_ = function(value) {
if (value == 0) return 0;
if (value <= 1 / 3) return 1;
if (value <= 2 / 3) return 2;
return 3;
};
/**
* Reflects volume model to the UI elements.
* @private
*/
MediaControls.prototype.reflectVolumeToUi_ = function() {
this.soundButton_.setAttribute('level',
MediaControls.getVolumeLevel_(this.volumeModel_.getMediaVolume()));
this.soundButton_.setAttribute('aria-label', this.volumeModel_.isMuted_
? str('MEDIA_PLAYER_UNMUTE_BUTTON_LABEL')
: str('MEDIA_PLAYER_MUTE_BUTTON_LABEL'));
this.volume_.value = this.volumeModel_.getMediaVolume() * this.volume_.max;
if (this.media_) {
this.media_.volume = this.volumeModel_.getMediaVolume();
}
};
/**
* Handles change event of the volume slider.
* @param {number} value Volume [0..1].
* @private
*/
MediaControls.prototype.onVolumeChange_ = function(value) {
if (!this.media_)
return; // Media is detached.
this.volumeModel_.onVolumeChanged(value);
this.saveVolumeControlState();
this.reflectVolumeToUi_();
};
/**
* @private
*/
MediaControls.prototype.onVolumeDrag_ = function() {
if (this.media_.volume !== 0) {
this.volumeModel_.onVolumeChanged(this.media_.volume);
}
};
/**
* Initializes subtitles button.
*/
MediaControls.prototype.initSubtitlesButton = function() {
this.subtitlesTrack_ = null;
this.subtitlesButton_ =
this.createButton('subtitles', this.onSubtitlesButtonClicked_.bind(this));
};
/**
* @param {Event} event Mouse click event.
* @private
*/
MediaControls.prototype.onSubtitlesButtonClicked_ = function(event) {
if (!this.subtitlesTrack_) {
return;
}
this.toggleSubtitlesMode_(this.subtitlesTrack_.mode === 'hidden');
};
/**
* @param {boolean} on Whether enabled or not.
* @private
*/
MediaControls.prototype.toggleSubtitlesMode_ = function(on) {
if (!this.subtitlesTrack_) {
return;
}
if (on) {
this.subtitlesTrack_.mode = 'showing';
this.subtitlesButton_.setAttribute('showing', '');
this.subtitlesButton_.setAttribute('aria-label',
str('VIDEO_PLAYER_DISABLE_SUBTITLES_BUTTON_LABEL'));
} else {
this.subtitlesTrack_.mode = 'hidden';
this.subtitlesButton_.removeAttribute('showing');
this.subtitlesButton_.setAttribute('aria-label',
str('VIDEO_PLAYER_ENABLE_SUBTITLES_BUTTON_LABEL'));
}
};
/**
* @param {TextTrack} track Subtitles track
* @private
*/
MediaControls.prototype.attachTextTrack_ = function(track) {
this.subtitlesTrack_ = track;
if (this.subtitlesTrack_) {
this.toggleSubtitlesMode_(true);
this.subtitlesButton_.removeAttribute('unavailable');
} else {
this.subtitlesButton_.setAttribute('unavailable', '');
}
};
/**
* @private
*/
MediaControls.prototype.detachTextTrack_ = function() {
this.subtitlesTrack_ = null;
};
/*
* Media event handlers.
*/
/**
* Attach a media element.
*
* @param {!HTMLMediaElement} mediaElement The media element to control.
*/
MediaControls.prototype.attachMedia = function(mediaElement) {
this.media_ = mediaElement;
this.media_.addEventListener('play', this.onMediaPlayBound_);
this.media_.addEventListener('pause', this.onMediaPauseBound_);
this.media_.addEventListener('durationchange', this.onMediaDurationBound_);
this.media_.addEventListener('timeupdate', this.onMediaProgressBound_);
this.media_.addEventListener('error', this.onMediaError_);
// If the text banner is being displayed, hide it immediately, since it is
// related to the previous media.
this.textBanner_.removeAttribute('visible');
// Reflect the media state in the UI.
this.onMediaDuration_();
this.onMediaPlay_(this.isPlaying());
this.onMediaProgress_();
// Reflect the user specified volume to the media.
this.media_.volume = this.volumeModel_.getMediaVolume();
if (this.media_.textTracks && this.media_.textTracks.length > 0) {
this.attachTextTrack_(this.media_.textTracks[0]);
} else {
this.attachTextTrack_(null);
}
};
/**
* Detach media event handlers.
*/
MediaControls.prototype.detachMedia = function() {
if (!this.media_)
return;
this.media_.removeEventListener('play', this.onMediaPlayBound_);
this.media_.removeEventListener('pause', this.onMediaPauseBound_);
this.media_.removeEventListener('durationchange', this.onMediaDurationBound_);
this.media_.removeEventListener('timeupdate', this.onMediaProgressBound_);
this.media_.removeEventListener('error', this.onMediaError_);
this.media_ = null;
this.detachTextTrack_();
};
/**
* Force-empty the media pipeline. This is a workaround for crbug.com/149957.
* The document is not going to be GC-ed until the last Files app window closes,
* but we want the media pipeline to deinitialize ASAP to minimize leakage.
*/
MediaControls.prototype.cleanup = function() {
if (!this.media_)
return;
this.media_.src = '';
this.media_.load();
this.detachMedia();
};
/**
* 'play' and 'pause' event handler.
* @param {boolean} playing True if playing.
* @private
*/
MediaControls.prototype.onMediaPlay_ = function(playing) {
if (this.progressSlider_.dragging)
return;
this.updatePlayButtonState_(playing);
this.onPlayStateChanged();
};
/**
* 'durationchange' event handler.
* @private
*/
MediaControls.prototype.onMediaDuration_ = function() {
if (!this.media_ || !this.media_.duration) {
this.enableControls_(false);
return;
}
this.enableControls_(true);
if (this.media_.seekable)
this.progressSlider_.classList.remove('readonly');
else
this.progressSlider_.classList.add('readonly');
this.updateTimeLabel_(this.media_.currentTime, this.media_.duration);
if (this.media_.seekable)
this.restorePlayState();
};
/**
* 'timeupdate' event handler.
* @private
*/
MediaControls.prototype.onMediaProgress_ = function() {
if (!this.media_ || !this.media_.duration) {
this.displayProgress_(0, 1);
return;
}
var current = this.media_.currentTime;
var duration = this.media_.duration;
if (this.progressSlider_.dragging)
return;
this.displayProgress_(current, duration);
if (current == duration) {
this.onMediaComplete();
}
this.onPlayStateChanged();
};
/**
* Called when the media playback is complete.
*/
MediaControls.prototype.onMediaComplete = function() {};
/**
* Called when play/pause state is changed or on playback progress.
* This is the right moment to save the play state.
*/
MediaControls.prototype.onPlayStateChanged = function() {};
/**
* Updates the play button state.
* @param {boolean} playing If the video is playing.
* @private
*/
MediaControls.prototype.updatePlayButtonState_ = function(playing) {
if (this.media_.ended &&
this.progressSlider_.value === this.progressSlider_.max) {
this.playButton_.setAttribute('state',
MediaControls.ButtonStateType.ENDED);
this.playButton_.setAttribute('aria-label',
str('MEDIA_PLAYER_PLAY_BUTTON_LABEL'));
} else if (playing) {
this.playButton_.setAttribute('state',
MediaControls.ButtonStateType.PLAYING);
this.playButton_.setAttribute('aria-label',
str('MEDIA_PLAYER_PAUSE_BUTTON_LABEL'));
} else {
this.playButton_.setAttribute('state',
MediaControls.ButtonStateType.DEFAULT);
this.playButton_.setAttribute('aria-label',
str('MEDIA_PLAYER_PLAY_BUTTON_LABEL'));
}
};
/**
* Restore play state. Base implementation is empty.
*/
MediaControls.prototype.restorePlayState = function() {};
/**
* Encode current state into the page URL or the app state.
*/
MediaControls.prototype.encodeState = function() {
if (!this.media_ || !this.media_.duration)
return;
if (window.appState) {
window.appState.time = this.media_.currentTime;
util.saveAppState();
}
return;
};
/**
* Decode current state from the page URL or the app state.
* @return {boolean} True if decode succeeded.
*/
MediaControls.prototype.decodeState = function() {
if (!this.media_ || !window.appState || !('time' in window.appState))
return false;
// There is no page reload for apps v2, only app restart.
// Always restart in paused state.
this.media_.currentTime = window.appState.time;
this.pause();
return true;
};
/**
* Remove current state from the page URL or the app state.
*/
MediaControls.prototype.clearState = function() {
if (!window.appState)
return;
if ('time' in window.appState)
delete window.appState.time;
util.saveAppState();
return;
};
/**
* Create video controls.
*
* @param {!HTMLElement} containerElement The container for the controls.
* @param {function(Event)} onMediaError Function to display an error message.
* @param {function(Event)=} opt_fullScreenToggle Function to toggle fullscreen
* mode.
* @param {HTMLElement=} opt_stateIconParent The parent for the icon that
* gives visual feedback when the playback state changes.
* @constructor
* @struct
* @extends {MediaControls}
*/
function VideoControls(
containerElement, onMediaError, opt_fullScreenToggle, opt_stateIconParent) {
MediaControls.call(this, containerElement, onMediaError);
this.container_.classList.add('video-controls');
this.initPlayButton();
this.initTimeControls();
this.initVolumeControls();
this.initSubtitlesButton();
// Create the cast menu button.
// We need to use <button> since cr.ui.MenuButton.decorate modifies prototype
// chain, by which <files-icon-button> will not work correctly.
// TODO(fukino): Find a way to use files-icon-button consistently.
this.castButton_ = this.createControl(
'cast media-button', undefined, 'button');
this.castButton_.setAttribute('menu', '#cast-menu');
this.castButton_.setAttribute('aria-label', str('VIDEO_PLAYER_PLAY_ON'));
this.castButton_.setAttribute('state', MediaControls.ButtonStateType.DEFAULT);
this.castButton_.appendChild(document.createElement('files-ripple'));
cr.ui.decorate(this.castButton_, cr.ui.MenuButton);
// Create the cast button, which is a normal button and is used when we cast
// videos usign Media Router.
this.createButton('cast-button');
if (opt_fullScreenToggle) {
this.fullscreenButton_ =
this.createButton('fullscreen', opt_fullScreenToggle);
this.fullscreenButton_.setAttribute('aria-label',
str('VIDEO_PLAYER_FULL_SCREEN_BUTTON_LABEL'));
}
if (opt_stateIconParent) {
this.stateIcon_ = this.createControl(
'playback-state-icon', opt_stateIconParent);
this.textBanner_ = this.createControl('text-banner', opt_stateIconParent);
}
// Disables all controls at first.
this.enableControls_(false);
var videoControls = this;
chrome.mediaPlayerPrivate.onTogglePlayState.addListener(
function() { videoControls.togglePlayStateWithFeedback(); });
}
/**
* No resume if we are within this margin from the start or the end.
*/
VideoControls.RESUME_MARGIN = 0.03;
/**
* No resume for videos shorter than this.
*/
VideoControls.RESUME_THRESHOLD = 5 * 60; // 5 min.
/**
* When resuming rewind back this much.
*/
VideoControls.RESUME_REWIND = 5; // seconds.
VideoControls.prototype = { __proto__: MediaControls.prototype };
/**
* Shows icon feedback for the current state of the video player.
* @private
*/
VideoControls.prototype.showIconFeedback_ = function() {
var stateIcon = this.stateIcon_;
stateIcon.removeAttribute('state');
setTimeout(function() {
var newState = this.isPlaying() ? 'play' : 'pause';
var onAnimationEnd = function(state, event) {
if (stateIcon.getAttribute('state') === state)
stateIcon.removeAttribute('state');
stateIcon.removeEventListener('animationend', onAnimationEnd);
}.bind(null, newState);
stateIcon.addEventListener('animationend', onAnimationEnd);
// Shows the icon with animation.
stateIcon.setAttribute('state', newState);
}.bind(this), 0);
};
/**
* Shows a text banner.
*
* @param {string} identifier String identifier.
* @private
*/
VideoControls.prototype.showTextBanner_ = function(identifier) {
this.textBanner_.removeAttribute('visible');
this.textBanner_.textContent = str(identifier);
setTimeout(function() {
var onAnimationEnd = function(event) {
this.textBanner_.removeEventListener(
'animationend', onAnimationEnd);
this.textBanner_.removeAttribute('visible');
}.bind(this);
this.textBanner_.addEventListener('animationend', onAnimationEnd);
this.textBanner_.setAttribute('visible', 'true');
}.bind(this), 0);
};
/**
* @override
*/
VideoControls.prototype.onPlayButtonClicked = function(event) {
if (event.ctrlKey) {
this.toggleLoopedModeWithFeedback(true);
if (!this.isPlaying())
this.togglePlayState();
} else {
this.togglePlayState();
}
};
/**
* Media completion handler.
*/
VideoControls.prototype.onMediaComplete = function() {
this.onMediaPlay_(false); // Just update the UI.
this.savePosition(); // This will effectively forget the position.
};
/**
* Toggles the looped mode with feedback.
* @param {boolean} on Whether enabled or not.
*/
VideoControls.prototype.toggleLoopedModeWithFeedback = function(on) {
if (!this.getMedia().duration)
return;
this.toggleLoopedMode(on);
if (on) {
// TODO(mtomasz): Simplify, crbug.com/254318.
this.showTextBanner_('VIDEO_PLAYER_LOOPED_MODE');
}
};
/**
* Toggles the looped mode.
* @param {boolean} on Whether enabled or not.
*/
VideoControls.prototype.toggleLoopedMode = function(on) {
this.getMedia().loop = on;
};
/**
* Toggles play/pause state and flash an icon over the video.
*/
VideoControls.prototype.togglePlayStateWithFeedback = function() {
if (!this.getMedia().duration)
return;
this.togglePlayState();
this.showIconFeedback_();
};
/**
* Toggles play/pause state.
*/
VideoControls.prototype.togglePlayState = function() {
if (this.isPlaying()) {
// User gave the Pause command. Save the state and reset the loop mode.
this.toggleLoopedMode(false);
this.savePosition();
}
MediaControls.prototype.togglePlayState.apply(this, arguments);
};
/**
* Saves the playback position to the persistent storage.
* @param {boolean=} opt_sync True if the position must be saved synchronously
* (required when closing app windows).
*/
VideoControls.prototype.savePosition = function(opt_sync) {
if (!this.media_ ||
!this.media_.duration ||
this.media_.duration < VideoControls.RESUME_THRESHOLD) {
return;
}
var ratio = this.media_.currentTime / this.media_.duration;
var position;
if (ratio < VideoControls.RESUME_MARGIN ||
ratio > (1 - VideoControls.RESUME_MARGIN)) {
// We are too close to the beginning or the end.
// Remove the resume position so that next time we start from the beginning.
position = null;
} else {
position = Math.floor(
Math.max(0, this.media_.currentTime - VideoControls.RESUME_REWIND));
}
if (opt_sync) {
// Packaged apps cannot save synchronously.
// Pass the data to the background page.
if (!window.saveOnExit)
window.saveOnExit = [];
window.saveOnExit.push({ key: this.media_.src, value: position });
} else {
util.AppCache.update(this.media_.src, position);
}
};
/**
* Resumes the playback position saved in the persistent storage.
*/
VideoControls.prototype.restorePlayState = function() {
if (this.media_ && this.media_.duration >= VideoControls.RESUME_THRESHOLD) {
util.AppCache.getValue(this.media_.src, function(position) {
if (position)
this.media_.currentTime = position;
}.bind(this));
}
};
/**
* Updates video control when the window is fullscreened or restored.
*/
VideoControls.prototype.onFullScreenChanged = function() {
var fullscreen = util.isFullScreen(chrome.app.window.current());
if (fullscreen) {
this.container_.setAttribute('fullscreen', '');
} else {
this.container_.removeAttribute('fullscreen');
}
if (this.fullscreenButton_) {
this.fullscreenButton_.setAttribute(
'aria-label',
fullscreen ? str('VIDEO_PLAYER_EXIT_FULL_SCREEN_BUTTON_LABEL') :
str('VIDEO_PLAYER_FULL_SCREEN_BUTTON_LABEL'));
// If the fullscreen button has focus on entering fullscreen mode, reset the
// focus to make the spacebar toggle play/pause state. This is the
// consistent behavior with Youtube Web UI.
if (fullscreen)
this.fullscreenButton_.blur();
}
};