blob: 8efcba434d9c8a666384a58ee4681266344013e3 [file] [log] [blame]
// Copyright 2019 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.
class NativeControlsVideoPlayer {
constructor() {
this.videos_ = null;
this.currentPos_ = 0;
this.videoElement_ = null;
}
/**
* Initializes the video player window. This method must be called after DOM
* initialization.
* @param {!Array<!FileEntry>} videos List of videos.
*/
prepare(videos) {
this.videos_ = videos;
// TODO: Move these setting to html and css file when
// we are confident to remove the feature flag
this.videoElement_ = document.createElement('video');
this.videoElement_.controls = true;
this.videoElement_.controlsList = 'nodownload';
this.videoElement_.style.pointerEvents = 'auto';
getRequiredElement('video-container').appendChild(this.videoElement_);
// TODO: remove the element in html when remove the feature flag
getRequiredElement('controls-wrapper').style.display = 'none';
getRequiredElement('spinner-container').style.display = 'none';
getRequiredElement('error-wrapper').style.display = 'none';
getRequiredElement('thumbnail').style.display = 'none';
getRequiredElement('cast-container').style.display = 'none';
this.videoElement_.addEventListener('pause', this.onPause_.bind(this));
this.preparePlayList_();
this.addKeyControls_();
}
/**
* Attach arrow box for previous/next track to document and set
* 'multiple' attribute if user opens more than 1 videos.
*
* @private
*/
preparePlayList_() {
let videoPlayerElement = getRequiredElement('video-player');
if (this.videos_.length > 1) {
videoPlayerElement.setAttribute('multiple', true);
} else {
videoPlayerElement.removeAttribute('multiple');
}
let arrowRight = queryRequiredElement('.arrow-box .arrow.right');
arrowRight.addEventListener(
'click', this.advance_.bind(this, true /* next track */));
let arrowLeft = queryRequiredElement('.arrow-box .arrow.left');
arrowLeft.addEventListener(
'click', this.advance_.bind(this, false /* previous track */));
}
/**
* Add keyboard controls to document.
*
* @private
*/
addKeyControls_() {
document.addEventListener('keydown', (/** KeyboardEvent */ event) => {
const key =
(event.ctrlKey && event.shiftKey ? 'Ctrl-Shift-' : '') + event.key;
switch (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 'k':
case 'MediaPlayPause':
this.togglePlayState_();
break;
case 'MediaTrackNext':
this.advance_(true /* next track */);
break;
case 'MediaTrackPrevious':
this.advance_(false /* previous track */);
break;
case 'l':
this.skip_(true /* forward */);
break;
case 'j':
this.skip_(false /* backward */);
break;
case 'BrowserBack':
chrome.app.window.current().close();
break;
case 'MediaStop':
// TODO: Define "Stop" behavior.
break;
}
});
getRequiredElement('video-container')
.addEventListener('click', (/** MouseEvent */ event) => {
// Turn on loop mode when ctrl+click while the video is playing.
// If the video is paused, ignore ctrl and play the video.
if (event.ctrlKey && !this.videoElement_.paused) {
this.setLoopedModeWithFeedback_(true);
event.preventDefault();
}
});
}
/**
* Skips forward/backward.
* @param {boolean} forward Whether to skip forward or backward.
* @private
*/
skip_(forward) {
let secondsToSkip = Math.min(
NativeControlsVideoPlayer.PROGRESS_MAX_SECONDS_TO_SKIP,
this.videoElement_.duration *
NativeControlsVideoPlayer.PROGRESS_MAX_RATIO_TO_SKIP);
if (!forward) {
secondsToSkip *= -1;
}
this.videoElement_.currentTime = Math.max(
Math.min(
this.videoElement_.currentTime + secondsToSkip,
this.videoElement_.duration),
0);
}
/**
* Toggle play/pause.
*
* @private
*/
togglePlayState_() {
if (this.videoElement_.paused) {
this.videoElement_.play();
} else {
this.videoElement_.pause();
}
}
/**
* Set the looped mode with feedback.
*
* @param {boolean} on Whether enabled or not.
* @private
*/
setLoopedModeWithFeedback_(on) {
this.videoElement_.loop = on;
if (on) {
this.showNotification_('VIDEO_PLAYER_LOOPED_MODE');
}
}
/**
* Briefly show a text notification at top left corner.
*
* @param {string} identifier String identifier.
* @private
*/
showNotification_(identifier) {
getRequiredElement('toast-content').textContent =
loadTimeData.getString(identifier);
getRequiredElement('toast').show();
}
/**
* Plays the first video.
*/
playFirstVideo() {
this.currentPos_ = 0;
this.reloadCurrentVideo_(this.onFirstVideoReady_.bind(this));
}
/**
* Called when the first video is ready after starting to load.
* Make the video player app window the same dimension as the video source
* Restrict the app window inside the screen.
*
* @private
*/
onFirstVideoReady_() {
let videoWidth = this.videoElement_.videoWidth;
let videoHeight = this.videoElement_.videoHeight;
let aspect = videoWidth / videoHeight;
let newWidth = videoWidth;
let newHeight = videoHeight;
let shrinkX = newWidth / window.screen.availWidth;
let 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;
}
}
let oldLeft = window.screenX;
let oldTop = window.screenY;
let oldWidth = window.innerWidth;
let oldHeight = window.innerHeight;
if (!oldWidth && !oldHeight) {
oldLeft = window.screen.availWidth / 2;
oldTop = window.screen.availHeight / 2;
}
let 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_.focus();
this.videoElement_.play();
}
/**
* Advance to next video when the current one ends.
* Not using 'ended' event because dragging the timeline
* thumb to the end when seeking will trigger 'ended' event.
*
* @private
*/
onPause_() {
this.videoElement_.loop = false;
if (this.videoElement_.currentTime == this.videoElement_.duration) {
this.advance_(true);
}
}
/**
* Advances to the next (or previous) track.
*
* @param {boolean} direction True to the next, false to the previous.
* @private
*/
advance_(direction) {
let newPos = this.currentPos_ + (direction ? 1 : -1);
if (newPos < 0 || newPos >= this.videos_.length) {
return;
}
this.currentPos_ = newPos;
this.reloadCurrentVideo_(() => {
this.videoElement_.play();
});
}
/**
* Reloads the current video.
*
* @param {function()=} opt_callback Completion callback.
*/
reloadCurrentVideo_(opt_callback) {
let 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');
}
let currentVideo = this.videos_[this.currentPos_];
this.loadVideo_(currentVideo, opt_callback);
}
/**
* Loads the video file.
* @param {!FileEntry} video Entry of the video to be played.
* @param {function()=} opt_callback Completion callback.
* @private
*/
async loadVideo_(video, opt_callback) {
document.title = video.name;
const videoUrl = video.toURL();
if (opt_callback) {
this.videoElement_.addEventListener(
'loadedmetadata', opt_callback, {once: true});
}
this.videoElement_.src = videoUrl;
const subtitleUrl = await this.searchSubtitle_(videoUrl);
if (subtitleUrl) {
const track =
assertInstanceof(document.createElement('track'), HTMLTrackElement);
track.src = subtitleUrl;
track.kind = 'subtitles';
track.default = true;
this.videoElement_.appendChild(track);
}
}
/**
* Search subtitle file corresponding to a video.
* @param {string} url a url of a video.
* @return {Promise} a Promise returns url of subtitle file, or an empty
* string.
*/
async searchSubtitle_(url) {
const resolveLocalFileSystemWithExtension = (extension) => {
const subtitleUrl = this.getSubtitleUrl_(url, extension);
return new Promise(
window.webkitResolveLocalFileSystemURL.bind(null, subtitleUrl));
};
try {
const subtitle = await resolveLocalFileSystemWithExtension('.vtt');
return subtitle.toURL();
} catch (error) {
// TODO: figure out if there could be any error other than
// file not found or not accessible. If not, remove this log.
console.error(error);
return '';
}
}
/**
* Get the subtitle url.
*
* @private
* @param {string} srcUrl Source url of the video file.
* @param {string} extension Extension of the subtitle we are looking for.
* @return {string} Subtitle url.
*/
getSubtitleUrl_(srcUrl, extension) {
return srcUrl.replace(/\.[^\.]+$/, extension);
}
}
/**
* 10 seconds should be skipped when J/L key is pressed.
*/
NativeControlsVideoPlayer.PROGRESS_MAX_SECONDS_TO_SKIP = 10;
/**
* 20% of duration should be skipped when the video is too short to skip 10
* seconds.
*/
NativeControlsVideoPlayer.PROGRESS_MAX_RATIO_TO_SKIP = 0.2;