blob: 2bb8a2d4e0a42ebac58bb2cc6b5af05d413dd73d [file] [log] [blame]
// Copyright 2014 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.
/**
* @param {!HTMLElement} playerContainer Main container.
* @param {!HTMLElement} videoContainer Container for the video element.
* @param {!HTMLElement} controlsContainer Container for video controls.
* @constructor
* @struct
* @extends {VideoControls}
*/
function FullWindowVideoControls(
playerContainer, videoContainer, controlsContainer) {
VideoControls.call(this,
controlsContainer,
this.onPlaybackError_.wrap(this),
this.toggleFullScreen_.wrap(this),
videoContainer);
this.playerContainer_ = playerContainer;
this.decodeErrorOccured = false;
this.casting = false;
this.isRtl_ =
window.getComputedStyle(this.playerContainer_)['direction'] === 'rtl';
var currentWindow = chrome.app.window.current();
currentWindow.onFullscreened.addListener(this.onFullScreenChanged.bind(this));
currentWindow.onRestored.addListener(this.onFullScreenChanged.bind(this));
document.addEventListener('keydown', function(e) {
this.inactivityWatcher_.kick();
switch (util.getKeyModifiers(e) + e.key) {
// Handle debug shortcut keys.
case 'Ctrl-Shift-I': // Ctrl+Shift+I
chrome.fileManagerPrivate.openInspector('normal');
break;
case 'Ctrl-Shift-J': // Ctrl+Shift+J
chrome.fileManagerPrivate.openInspector('console');
break;
case 'Ctrl-Shift-C': // Ctrl+Shift+C
chrome.fileManagerPrivate.openInspector('element');
break;
case 'Ctrl-Shift-B': // Ctrl+Shift+B
chrome.fileManagerPrivate.openInspector('background');
break;
case ' ': // Space
case 'k':
case 'MediaPlayPause':
if (!e.target.classList.contains('menu-button'))
this.togglePlayStateWithFeedback();
break;
case 'Escape':
util.toggleFullScreen(
chrome.app.window.current(),
false); // Leave the full screen mode.
break;
case 'MediaTrackNext':
player.advance_(1);
break;
case 'MediaTrackPrevious':
player.advance_(0);
break;
case 'ArrowRight':
if (!e.target.classList.contains('volume'))
this.smallSkip(!this.isRtl_ /* forward */);
break;
case 'ArrowLeft':
if (!e.target.classList.contains('volume'))
this.smallSkip(this.isRtl_ /* forward */);
break;
case 'l':
this.bigSkip(true /* forward */);
break;
case 'j':
this.bigSkip(false /* forward */);
break;
case 'BrowserBack':
chrome.app.window.current().close();
break;
case 'MediaStop':
// TODO: Define "Stop" behavior.
break;
}
}.wrap(this));
document.addEventListener('keypress', function(e) {
this.inactivityWatcher_.kick();
}.wrap(this));
controlsContainer.addEventListener('cr-slider-value-changed-from-ui', () => {
this.inactivityWatcher_.kick();
});
// TODO(mtomasz): Simplify. crbug.com/254318.
var clickInProgress = false;
videoContainer.addEventListener('click', function(e) {
if (clickInProgress)
return;
clickInProgress = true;
var togglePlayState = function() {
clickInProgress = false;
if (e.ctrlKey) {
this.toggleLoopedModeWithFeedback(true);
if (!this.isPlaying())
this.togglePlayStateWithFeedback();
} else {
this.togglePlayStateWithFeedback();
}
}.wrap(this);
if (!this.media_)
player.reloadCurrentVideo(togglePlayState);
else
setTimeout(togglePlayState, 0);
}.wrap(this));
/**
* @type {MouseInactivityWatcher}
* @private
*/
this.inactivityWatcher_ = new MouseInactivityWatcher(playerContainer);
this.inactivityWatcher_.check();
}
FullWindowVideoControls.prototype = { __proto__: VideoControls.prototype };
/**
* Gets inactivity watcher.
* @return {MouseInactivityWatcher} An inactivity watcher.
*/
FullWindowVideoControls.prototype.getInactivityWatcher = function() {
return this.inactivityWatcher_;
};
/**
* Displays error message.
*
* @param {string} message Message id.
*/
FullWindowVideoControls.prototype.showErrorMessage = function(message) {
var errorBanner = getRequiredElement('error');
errorBanner.textContent = str(message);
errorBanner.setAttribute('visible', 'true');
// The window is hidden if the video has not loaded yet.
chrome.app.window.current().show();
};
/**
* Handles playback (decoder) errors.
* @param {MediaError} error Error object.
* @private
*/
FullWindowVideoControls.prototype.onPlaybackError_ = function(error) {
if (error.target && error.target.error &&
error.target.error.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) {
if (this.casting)
this.showErrorMessage('VIDEO_PLAYER_VIDEO_FILE_UNSUPPORTED_FOR_CAST');
else
this.showErrorMessage('VIDEO_PLAYER_VIDEO_FILE_UNSUPPORTED');
this.decodeErrorOccured = false;
} else {
this.showErrorMessage('VIDEO_PLAYER_PLAYBACK_ERROR');
this.decodeErrorOccured = true;
}
// Disable controls on the ui.
getRequiredElement('video-player').setAttribute('disabled', 'true');
// Detach the video element, since it may be unreliable and reset stored
// current playback time.
this.cleanup();
this.clearState();
// Avoid reusing a video element.
player.unloadVideo();
};
/**
* Toggles the full screen mode.
* @private
*/
FullWindowVideoControls.prototype.toggleFullScreen_ = function() {
var appWindow = chrome.app.window.current();
util.toggleFullScreen(appWindow, !util.isFullScreen(appWindow));
};
/**
* Media completion handler.
*/
FullWindowVideoControls.prototype.onMediaComplete = function() {
VideoControls.prototype.onMediaComplete.apply(this, arguments);
if (!this.getMedia().loop)
player.advance_(1);
};
/**
* Video Player
*
* @constructor
* @struct
*/
function VideoPlayer() {
this.controls_ = null;
this.videoElement_ = null;
/**
* @type {Array<!FileEntry>}
* @private
*/
this.videos_ = null;
this.currentPos_ = 0;
this.currentSession_ = null;
this.currentCast_ = null;
this.loadQueue_ = new AsyncUtil.Queue();
this.onCastSessionUpdateBound_ = this.onCastSessionUpdate_.wrap(this);
}
VideoPlayer.prototype = /** @struct */ {
/**
* @return {FullWindowVideoControls}
*/
get controls() {
return this.controls_;
}
};
/**
* Initializes the video player window. This method must be called after DOM
* initialization.
* @param {!Array<!FileEntry>} videos List of videos.
*/
VideoPlayer.prototype.prepare = function(videos) {
this.videos_ = videos;
var preventDefault = function(event) { event.preventDefault(); }.wrap(null);
document.ondragstart = preventDefault;
cr.ui.decorate(getRequiredElement('cast-menu'), cr.ui.Menu);
this.controls_ = new FullWindowVideoControls(
getRequiredElement('video-player'),
getRequiredElement('video-container'),
getRequiredElement('controls'));
var observer = new MutationObserver(function(mutations) {
var isLoadingOrDisabledChanged = mutations.some(function(mutation) {
return mutation.attributeName === 'loading' ||
mutation.attributeName === 'disabled';
});
if (isLoadingOrDisabledChanged)
this.updateInactivityWatcherState_();
}.bind(this));
observer.observe(getRequiredElement('video-player'),
{ attributes: true, childList: false });
var reloadVideo = function(e) {
if (this.controls_.decodeErrorOccured &&
// Ignore shortcut keys
!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
this.reloadCurrentVideo(function() {
this.videoElement_.play();
}.wrap(this));
e.preventDefault();
}
}.wrap(this);
var arrowRight = queryRequiredElement('.arrow-box .arrow.right');
arrowRight.addEventListener('click', this.advance_.wrap(this, 1));
var arrowLeft = queryRequiredElement('.arrow-box .arrow.left');
arrowLeft.addEventListener('click', this.advance_.wrap(this, 0));
var videoPlayerElement = getRequiredElement('video-player');
if (videos.length > 1)
videoPlayerElement.setAttribute('multiple', true);
else
videoPlayerElement.removeAttribute('multiple');
var castButton = queryRequiredElement('.cast-button');
castButton.addEventListener('click',
this.onCastButtonClicked_.wrap(this));
document.addEventListener('keydown', reloadVideo);
document.addEventListener('click', reloadVideo);
};
/**
* Unloads the player.
*/
function unload() {
// Releases keep awake just in case (should be released on unloading video).
chrome.power.releaseKeepAwake();
if (!player.controls || !player.controls.getMedia())
return;
player.controls.savePosition(true /* exiting */);
player.controls.cleanup();
}
/**
* Loads the video file.
* @param {!FileEntry} video Entry of the video to be played.
* @param {function()=} opt_callback Completion callback.
* @private
*/
VideoPlayer.prototype.loadVideo_ = function(video, opt_callback) {
this.unloadVideo(true);
this.loadQueue_.run(function(callback) {
document.title = video.name;
var videoPlayerElement = getRequiredElement('video-player');
if (this.currentPos_ === (this.videos_.length - 1))
videoPlayerElement.setAttribute('last-video', true);
else
videoPlayerElement.removeAttribute('last-video');
if (this.currentPos_ === 0)
videoPlayerElement.setAttribute('first-video', true);
else
videoPlayerElement.removeAttribute('first-video');
// Re-enables ui and hides error message if already displayed.
getRequiredElement('video-player').removeAttribute('disabled');
getRequiredElement('error').removeAttribute('visible');
this.controls.detachMedia();
this.controls.decodeErrorOccured = false;
this.controls.casting = !!this.currentCast_;
videoPlayerElement.setAttribute('loading', true);
var media = new MediaManager(video);
// Show video's thumbnail if available while loading the video.
media.getThumbnail()
.then(function(thumbnailUrl) {
if (!thumbnailUrl)
return Promise.reject();
return new Promise(function(resolve, reject) {
ImageLoaderClient.getInstance().load(
thumbnailUrl,
function(result) {
if (result.data)
resolve(result.data);
else
reject();
});
});
})
.then(function(dataUrl) {
getRequiredElement('thumbnail').style.backgroundImage =
'url(' + dataUrl + ')';
})
.catch(function() {
// Shows no image on error.
getRequiredElement('thumbnail').style.backgroundImage = '';
});
var videoElementInitializePromise;
if (this.currentCast_) {
metrics.recordPlayType(metrics.PLAY_TYPE.CAST);
getRequiredElement('cast-name').textContent =
this.currentCast_.friendlyName;
videoPlayerElement.setAttribute('castable', true);
videoElementInitializePromise = media.isAvailableForCast()
.then(function(result) {
if (!result)
return Promise.reject('No casts are available.');
return new Promise(function(fulfill, reject) {
if (this.currentSession_) {
fulfill(this.currentSession_);
} else {
chrome.cast.requestSession(
fulfill, reject, undefined, this.currentCast_.label);
}
}.bind(this)).then(function(session) {
videoPlayerElement.setAttribute('casting', true);
session.addUpdateListener(this.onCastSessionUpdateBound_);
this.currentSession_ = session;
this.videoElement_ = new CastVideoElement(media, session);
}.bind(this));
}.bind(this));
} else {
metrics.recordPlayType(metrics.PLAY_TYPE.LOCAL);
videoPlayerElement.removeAttribute('casting');
this.videoElement_ = document.createElement('video');
getRequiredElement('video-container').appendChild(this.videoElement_);
var videoUrl = video.toURL();
var source = document.createElement('source');
source.src = videoUrl;
this.videoElement_.appendChild(source);
media.isAvailableForCast().then(function(result) {
if (result)
videoPlayerElement.setAttribute('castable', true);
else
videoPlayerElement.removeAttribute('castable');
}).catch(function() {
videoPlayerElement.setAttribute('castable', true);
});
videoElementInitializePromise = this.searchSubtitle_(videoUrl)
.then(function(subltitleUrl) {
if (subltitleUrl) {
var track = document.createElement('track');
track.src = subltitleUrl;
track.kind = 'subtitles';
track.default = true;
this.videoElement_.appendChild(track);
}
}.bind(this));
}
videoElementInitializePromise
.then(function() {
var handler = function(currentPos) {
if (currentPos === this.currentPos_) {
if (opt_callback)
opt_callback();
videoPlayerElement.removeAttribute('loading');
}
this.videoElement_.removeEventListener('loadedmetadata', handler);
}.wrap(this, this.currentPos_);
this.videoElement_.addEventListener('loadedmetadata', handler);
this.videoElement_.addEventListener('play', function() {
chrome.power.requestKeepAwake('display');
this.updateInactivityWatcherState_();
}.wrap(this));
this.videoElement_.addEventListener('pause', function() {
chrome.power.releaseKeepAwake();
this.updateInactivityWatcherState_();
}.wrap(this));
this.controls.attachMedia(this.videoElement_);
this.videoElement_.load();
callback();
}.bind(this))
// In case of error.
.catch(function(error) {
if (this.currentCast_)
metrics.recordCastVideoErrorAction();
videoPlayerElement.removeAttribute('loading');
console.error('Failed to initialize the video element.',
error.stack || error);
this.controls_.showErrorMessage(
'VIDEO_PLAYER_VIDEO_FILE_UNSUPPORTED');
callback();
}.bind(this));
}.wrap(this));
};
/**
* Search subtile file corresponding to a video.
* @param {string} url a url of a video.
* @return {string} a url of subtitle file, or an empty string.
*/
VideoPlayer.prototype.searchSubtitle_ = function(url) {
var baseUrl = util.splitExtension(url)[0];
var resolveLocalFileSystemWithExtension = function(extension) {
return new Promise(
window.webkitResolveLocalFileSystemURL.bind(null, baseUrl + extension));
};
return resolveLocalFileSystemWithExtension('.vtt').then(function(subtitle) {
return subtitle.toURL();
}).catch(function() {
return '';
});
};
/**
* Plays the first video.
*/
VideoPlayer.prototype.playFirstVideo = function() {
this.currentPos_ = 0;
this.reloadCurrentVideo(this.onFirstVideoReady_.wrap(this));
};
/**
* Unloads the current video.
* @param {boolean=} opt_keepSession If true, keep using the current session.
* Otherwise, discards the session.
*/
VideoPlayer.prototype.unloadVideo = function(opt_keepSession) {
this.loadQueue_.run(function(callback) {
chrome.power.releaseKeepAwake();
// Detaches the media from the control.
this.controls.detachMedia();
if (this.videoElement_) {
// If the element has dispose method, call it (CastVideoElement has it).
if (this.videoElement_.dispose)
this.videoElement_.dispose();
// Detach the previous video element, if exists.
if (this.videoElement_.parentNode)
this.videoElement_.parentNode.removeChild(this.videoElement_);
}
this.videoElement_ = null;
if (!opt_keepSession && this.currentSession_) {
// We should not request stop() if the current session is not connected to
// the receiver.
if (this.currentSession_.status === chrome.cast.SessionStatus.CONNECTED)
this.currentSession_.stop(callback, callback);
else
setTimeout(callback);
this.currentSession_.removeUpdateListener(this.onCastSessionUpdateBound_);
this.currentSession_ = null;
} else {
callback();
}
}.wrap(this));
};
/**
* Called when the first video is ready after starting to load.
* @private
*/
VideoPlayer.prototype.onFirstVideoReady_ = function() {
var videoWidth = this.videoElement_.videoWidth;
var videoHeight = this.videoElement_.videoHeight;
var aspect = videoWidth / videoHeight;
var newWidth = videoWidth;
var newHeight = videoHeight;
var shrinkX = newWidth / window.screen.availWidth;
var shrinkY = newHeight / window.screen.availHeight;
if (shrinkX > 1 || shrinkY > 1) {
if (shrinkY > shrinkX) {
newHeight = newHeight / shrinkY;
newWidth = newHeight * aspect;
} else {
newWidth = newWidth / shrinkX;
newHeight = newWidth / aspect;
}
}
var oldLeft = window.screenX;
var oldTop = window.screenY;
var oldWidth = window.innerWidth;
var oldHeight = window.innerHeight;
if (!oldWidth && !oldHeight) {
oldLeft = window.screen.availWidth / 2;
oldTop = window.screen.availHeight / 2;
}
var appWindow = chrome.app.window.current();
appWindow.innerBounds.width = Math.round(newWidth);
appWindow.innerBounds.height = Math.round(newHeight);
appWindow.outerBounds.left = Math.max(
0, Math.round(oldLeft - (newWidth - oldWidth) / 2));
appWindow.outerBounds.top = Math.max(
0, Math.round(oldTop - (newHeight - oldHeight) / 2));
appWindow.show();
this.videoElement_.play();
};
/**
* Advances to the next (or previous) track.
*
* @param {boolean} direction True to the next, false to the previous.
* @private
*/
VideoPlayer.prototype.advance_ = function(direction) {
var newPos = this.currentPos_ + (direction ? 1 : -1);
if (0 <= newPos && newPos < this.videos_.length) {
this.currentPos_ = newPos;
this.reloadCurrentVideo(function() {
this.videoElement_.play();
}.wrap(this));
}
};
/**
* Reloads the current video.
*
* @param {function()=} opt_callback Completion callback.
*/
VideoPlayer.prototype.reloadCurrentVideo = function(opt_callback) {
var currentVideo = this.videos_[this.currentPos_];
this.loadVideo_(currentVideo, opt_callback);
};
/**
* Invokes when a menuitem in the cast menu is selected.
* @param {Object} cast Selected element in the list of casts.
* @private
*/
VideoPlayer.prototype.onCastSelected_ = function(cast) {
// If the selected item is same as the current item, do nothing.
if ((this.currentCast_ && this.currentCast_.label) === (cast && cast.label))
return;
this.unloadVideo(false);
// Waits for unloading video.
this.loadQueue_.run(function(callback) {
this.currentCast_ = cast || null;
this.updateCheckOnCastMenu_();
this.reloadCurrentVideo();
callback();
}.wrap(this));
};
/**
* Set the list of casts.
* @param {Array<Object>} casts List of casts.
*/
VideoPlayer.prototype.setCastList = function(casts) {
var videoPlayerElement = getRequiredElement('video-player');
var menu = getRequiredElement('cast-menu');
menu.innerHTML = '';
// TODO(yoshiki): Handle the case that the current cast disappears.
if (casts.length === 0) {
videoPlayerElement.removeAttribute('cast-available');
if (this.currentCast_)
this.onCurrentCastDisappear_();
return;
}
if (this.currentCast_) {
var currentCastAvailable = casts.some(function(cast) {
return this.currentCast_.label === cast.label;
}.wrap(this));
if (!currentCastAvailable)
this.onCurrentCastDisappear_();
}
var item = new cr.ui.MenuItem();
item.label = str('VIDEO_PLAYER_PLAY_THIS_COMPUTER');
item.setAttribute('aria-label', item.label);
item.castLabel = '';
item.addEventListener('activate', this.onCastSelected_.wrap(this, null));
menu.appendChild(item);
for (var i = 0; i < casts.length; i++) {
var item = new cr.ui.MenuItem();
item.label = casts[i].friendlyName;
item.setAttribute('aria-label', item.label);
item.castLabel = casts[i].label;
item.addEventListener('activate',
this.onCastSelected_.wrap(this, casts[i]));
menu.appendChild(item);
}
this.updateCheckOnCastMenu_();
videoPlayerElement.setAttribute('cast-available', true);
};
/**
* Tells the availability of cast receivers to VideoPlayeru topdate the
* visibility of the cast button..
* @param {boolean} available Whether at least one cast receiver is available.
*/
VideoPlayer.prototype.setCastAvailability = function(available) {
var videoPlayerElement = getRequiredElement('video-player');
if (available) {
videoPlayerElement.setAttribute('mr-cast-available', true);
} else {
videoPlayerElement.removeAttribute('mr-cast-available');
if (this.currentCast_)
this.onCurrentCastDisappear_();
}
};
/**
* Handles click event on cast button to request a session.
* @private
*/
VideoPlayer.prototype.onCastButtonClicked_ = function() {
// This method is called only when Media Router is enabled.
// In this case, requestSession() will open a built-in dialog (not a dropdown
// menu) to choose the receiver, and callback is called with the session
// object after user's operation..
chrome.cast.requestSession(
function(session) {
this.unloadVideo(true);
this.loadQueue_.run(function(callback) {
this.currentCast_ = {
label: session.receiver.label,
friendlyName: session.receiver.friendlyName
};
this.currentSession_ = session;
this.reloadCurrentVideo();
callback();
}.bind(this));
}.bind(this),
function(error) {
if (error.code !== chrome.cast.ErrorCode.CANCEL)
console.error('requestSession from cast button failed', error);
});
};
/**
* Updates the check status of the cast menu items.
* @private
*/
VideoPlayer.prototype.updateCheckOnCastMenu_ = function() {
var menuItems =
/** @type {cr.ui.Menu} */ (getRequiredElement('cast-menu')).menuItems;
for (var i = 0; i < menuItems.length; i++) {
var item = menuItems[i];
if (this.currentCast_ === null) {
// Playing on this computer.
if (item.castLabel === '')
item.checked = true;
else
item.checked = false;
} else {
// Playing on cast device.
if (item.castLabel === this.currentCast_.label)
item.checked = true;
else
item.checked = false;
}
}
};
/**
* Called when the current cast is disappear from the cast list.
* @private
*/
VideoPlayer.prototype.onCurrentCastDisappear_ = function() {
this.currentCast_ = null;
if (this.currentSession_) {
this.currentSession_.removeUpdateListener(this.onCastSessionUpdateBound_);
this.currentSession_ = null;
}
this.controls.showErrorMessage('VIDEO_PLAYER_PLAYBACK_ERROR');
this.unloadVideo();
};
/**
* This method should be called when the session is updated.
* @param {boolean} alive Whether the session is alive or not.
* @private
*/
VideoPlayer.prototype.onCastSessionUpdate_ = function(alive) {
if (!alive) {
var videoPlayerElement = getRequiredElement('video-player');
videoPlayerElement.removeAttribute('casting');
// Loads the current video in local player.
this.unloadVideo();
this.loadQueue_.run(function(callback) {
this.currentCast_ = null;
if (!chrome.cast.usingPresentationApi)
this.updateCheckOnCastMenu_();
this.reloadCurrentVideo();
callback();
}.wrap(this));
}
};
/**
* Updates the MouseInactivityWatcher's disable property to prevent control
* panel from being hidden in some situations.
* @private
*/
VideoPlayer.prototype.updateInactivityWatcherState_ = function() {
var videoPlayerElement = getRequiredElement('video-player');
// If any of following condition is met, we don't hide the tool bar.
// - Loaded video is paused.
// - Loading a video is in progress.
// - Opening video has an error.
this.controls.getInactivityWatcher().disabled =
(this.videoElement_ && this.videoElement_.paused) ||
videoPlayerElement.hasAttribute('loading') ||
videoPlayerElement.hasAttribute('disabled');
};
var player = new VideoPlayer();
/**
* Initializes the load time data.
* @param {function()} callback Called when the load time data is ready.
*/
function initStrings(callback) {
chrome.fileManagerPrivate.getStrings(function(strings) {
loadTimeData.data = strings;
callback();
}.wrap(null));
}
/**
* Initializes the volume manager.
* @param {function()} callback Called when the volume manager is ready.
*/
function initVolumeManager(callback) {
var volumeManager = new FilteredVolumeManager(AllowedPaths.ANY_PATH, false);
volumeManager.ensureInitialized(callback);
}
/**
* Promise to initialize both the volume manager and the load time data.
* @type {!Promise}
*/
const initPromise = Promise.all([
new Promise(initStrings.wrap(null)),
new Promise(initVolumeManager.wrap(null)),
]);
/**
* Initialize the video player.
*/
initPromise.then(function() {
if (document.readyState !== 'loading')
return;
return new Promise(function(fulfill, reject) {
document.addEventListener('DOMContentLoaded', fulfill);
}.wrap());
}.wrap()).then(function() {
const isReady = document.readyState !== 'loading';
assert(isReady, 'VideoPlayer DOM document is still loading');
i18nTemplate.process(document, loadTimeData);
return new Promise(function(fulfill, reject) {
util.URLsToEntries(window.appState.items, function(entries) {
metrics.recordOpenVideoPlayerAction();
metrics.recordNumberOfOpenedFiles(entries.length);
player.prepare(entries);
player.playFirstVideo(player, fulfill);
}.wrap());
}.wrap());
}.wrap());