1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5'use strict';
6
7/**
8 * @param {Element} playerContainer Main container.
9 * @param {Element} videoContainer Container for the video element.
10 * @param {Element} controlsContainer Container for video controls.
11 * @constructor
12 */
13function FullWindowVideoControls(
14    playerContainer, videoContainer, controlsContainer) {
15  VideoControls.call(this,
16      controlsContainer,
17      this.onPlaybackError_.wrap(this),
18      loadTimeData.getString.wrap(loadTimeData),
19      this.toggleFullScreen_.wrap(this),
20      videoContainer);
21
22  this.playerContainer_ = playerContainer;
23  this.decodeErrorOccured = false;
24
25  this.casting = false;
26
27  this.updateStyle();
28  window.addEventListener('resize', this.updateStyle.wrap(this));
29  document.addEventListener('keydown', function(e) {
30    switch (e.keyIdentifier) {
31      case 'U+0020': // Space
32      case 'MediaPlayPause':
33        this.togglePlayStateWithFeedback();
34        break;
35      case 'U+001B': // Escape
36        util.toggleFullScreen(
37            chrome.app.window.current(),
38            false);  // Leave the full screen mode.
39        break;
40      case 'Right':
41      case 'MediaNextTrack':
42        player.advance_(1);
43        break;
44      case 'Left':
45      case 'MediaPreviousTrack':
46        player.advance_(0);
47        break;
48      case 'MediaStop':
49        // TODO: Define "Stop" behavior.
50        break;
51    }
52  }.wrap(this));
53
54  // TODO(mtomasz): Simplify. crbug.com/254318.
55  var clickInProgress = false;
56  videoContainer.addEventListener('click', function(e) {
57    if (clickInProgress)
58      return;
59
60    clickInProgress = true;
61    var togglePlayState = function() {
62      clickInProgress = false;
63
64      if (e.ctrlKey) {
65        this.toggleLoopedModeWithFeedback(true);
66        if (!this.isPlaying())
67          this.togglePlayStateWithFeedback();
68      } else {
69        this.togglePlayStateWithFeedback();
70      }
71    }.wrap(this);
72
73    if (!this.media_)
74      player.reloadCurrentVideo(togglePlayState);
75    else
76      setTimeout(togglePlayState);
77  }.wrap(this));
78
79  this.inactivityWatcher_ = new MouseInactivityWatcher(playerContainer);
80  this.__defineGetter__('inactivityWatcher', function() {
81    return this.inactivityWatcher_;
82  }.wrap(this));
83
84  this.inactivityWatcher_.check();
85}
86
87FullWindowVideoControls.prototype = { __proto__: VideoControls.prototype };
88
89/**
90 * Displays error message.
91 *
92 * @param {string} message Message id.
93 */
94FullWindowVideoControls.prototype.showErrorMessage = function(message) {
95  var errorBanner = document.querySelector('#error');
96  errorBanner.textContent = loadTimeData.getString(message);
97  errorBanner.setAttribute('visible', 'true');
98
99  // The window is hidden if the video has not loaded yet.
100  chrome.app.window.current().show();
101};
102
103/**
104 * Handles playback (decoder) errors.
105 * @param {MediaError} error Error object.
106 * @private
107 */
108FullWindowVideoControls.prototype.onPlaybackError_ = function(error) {
109  if (error.target && error.target.error &&
110      error.target.error.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) {
111    if (this.casting)
112      this.showErrorMessage('VIDEO_PLAYER_VIDEO_FILE_UNSUPPORTED_FOR_CAST');
113    else
114      this.showErrorMessage('GALLERY_VIDEO_ERROR');
115    this.decodeErrorOccured = false;
116  } else {
117    this.showErrorMessage('GALLERY_VIDEO_DECODING_ERROR');
118    this.decodeErrorOccured = true;
119  }
120
121  // Disable inactivity watcher, and disable the ui, by hiding tools manually.
122  this.inactivityWatcher.disabled = true;
123  document.querySelector('#video-player').setAttribute('disabled', 'true');
124
125  // Detach the video element, since it may be unreliable and reset stored
126  // current playback time.
127  this.cleanup();
128  this.clearState();
129
130  // Avoid reusing a video element.
131  player.unloadVideo();
132};
133
134/**
135 * Toggles the full screen mode.
136 * @private
137 */
138FullWindowVideoControls.prototype.toggleFullScreen_ = function() {
139  var appWindow = chrome.app.window.current();
140  util.toggleFullScreen(appWindow, !util.isFullScreen(appWindow));
141};
142
143/**
144 * Media completion handler.
145 */
146FullWindowVideoControls.prototype.onMediaComplete = function() {
147  VideoControls.prototype.onMediaComplete.apply(this, arguments);
148  if (!this.getMedia().loop)
149    player.advance_(1);
150};
151
152/**
153 * @constructor
154 */
155function VideoPlayer() {
156  this.controls_ = null;
157  this.videoElement_ = null;
158  this.videos_ = null;
159  this.currentPos_ = 0;
160
161  this.currentSession_ = null;
162  this.currentCast_ = null;
163
164  this.loadQueue_ = new AsyncUtil.Queue();
165
166  this.onCastSessionUpdateBound_ = this.onCastSessionUpdate_.wrap(this);
167
168  Object.seal(this);
169}
170
171VideoPlayer.prototype = {
172  get controls() {
173    return this.controls_;
174  }
175};
176
177/**
178 * Initializes the video player window. This method must be called after DOM
179 * initialization.
180 * @param {Array.<Object.<string, Object>>} videos List of videos.
181 */
182VideoPlayer.prototype.prepare = function(videos) {
183  this.videos_ = videos;
184
185  var preventDefault = function(event) { event.preventDefault(); }.wrap(null);
186
187  document.ondragstart = preventDefault;
188
189  var maximizeButton = document.querySelector('.maximize-button');
190  maximizeButton.addEventListener(
191      'click',
192      function(event) {
193        var appWindow = chrome.app.window.current();
194        if (appWindow.isMaximized())
195          appWindow.restore();
196        else
197          appWindow.maximize();
198        event.stopPropagation();
199      }.wrap(null));
200  maximizeButton.addEventListener('mousedown', preventDefault);
201
202  var minimizeButton = document.querySelector('.minimize-button');
203  minimizeButton.addEventListener(
204      'click',
205      function(event) {
206        chrome.app.window.current().minimize();
207        event.stopPropagation();
208      }.wrap(null));
209  minimizeButton.addEventListener('mousedown', preventDefault);
210
211  var closeButton = document.querySelector('.close-button');
212  closeButton.addEventListener(
213      'click',
214      function(event) {
215        close();
216        event.stopPropagation();
217      }.wrap(null));
218  closeButton.addEventListener('mousedown', preventDefault);
219
220  var menu = document.querySelector('#cast-menu');
221  cr.ui.decorate(menu, cr.ui.Menu);
222
223  this.controls_ = new FullWindowVideoControls(
224      document.querySelector('#video-player'),
225      document.querySelector('#video-container'),
226      document.querySelector('#controls'));
227
228  var reloadVideo = function(e) {
229    if (this.controls_.decodeErrorOccured &&
230        // Ignore shortcut keys
231        !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
232      this.reloadCurrentVideo(function() {
233        this.videoElement_.play();
234      }.wrap(this));
235      e.preventDefault();
236    }
237  }.wrap(this);
238
239  var arrowRight = document.querySelector('.arrow-box .arrow.right');
240  arrowRight.addEventListener('click', this.advance_.wrap(this, 1));
241  var arrowLeft = document.querySelector('.arrow-box .arrow.left');
242  arrowLeft.addEventListener('click', this.advance_.wrap(this, 0));
243
244  var videoPlayerElement = document.querySelector('#video-player');
245  if (videos.length > 1)
246    videoPlayerElement.setAttribute('multiple', true);
247  else
248    videoPlayerElement.removeAttribute('multiple');
249
250  document.addEventListener('keydown', reloadVideo);
251  document.addEventListener('click', reloadVideo);
252};
253
254/**
255 * Unloads the player.
256 */
257function unload() {
258  // Releases keep awake just in case (should be released on unloading video).
259  chrome.power.releaseKeepAwake();
260
261  if (!player.controls || !player.controls.getMedia())
262    return;
263
264  player.controls.savePosition(true /* exiting */);
265  player.controls.cleanup();
266}
267
268/**
269 * Loads the video file.
270 * @param {Object} video Data of the video file.
271 * @param {function()=} opt_callback Completion callback.
272 * @private
273 */
274VideoPlayer.prototype.loadVideo_ = function(video, opt_callback) {
275  this.unloadVideo(true);
276
277  this.loadQueue_.run(function(callback) {
278    document.title = video.title;
279
280    document.querySelector('#title').innerText = video.title;
281
282    var videoPlayerElement = document.querySelector('#video-player');
283    if (this.currentPos_ === (this.videos_.length - 1))
284      videoPlayerElement.setAttribute('last-video', true);
285    else
286      videoPlayerElement.removeAttribute('last-video');
287
288    if (this.currentPos_ === 0)
289      videoPlayerElement.setAttribute('first-video', true);
290    else
291      videoPlayerElement.removeAttribute('first-video');
292
293    // Re-enables ui and hides error message if already displayed.
294    document.querySelector('#video-player').removeAttribute('disabled');
295    document.querySelector('#error').removeAttribute('visible');
296    this.controls.detachMedia();
297    this.controls.inactivityWatcher.disabled = true;
298    this.controls.decodeErrorOccured = false;
299    this.controls.casting = !!this.currentCast_;
300
301    videoPlayerElement.setAttribute('loading', true);
302
303    var media = new MediaManager(video.entry);
304
305    Promise.all([media.getThumbnail(), media.getToken()])
306        .then(function(results) {
307          var url = results[0];
308          var token = results[1];
309          if (url && token) {
310            document.querySelector('#thumbnail').style.backgroundImage =
311                'url(' + url + '&access_token=' + token + ')';
312          } else {
313            document.querySelector('#thumbnail').style.backgroundImage = '';
314          }
315        })
316        .catch(function() {
317          // Shows no image on error.
318          document.querySelector('#thumbnail').style.backgroundImage = '';
319        });
320
321    var videoElementInitializePromise;
322    if (this.currentCast_) {
323      videoPlayerElement.setAttribute('casting', true);
324
325      document.querySelector('#cast-name').textContent =
326          this.currentCast_.friendlyName;
327
328      videoPlayerElement.setAttribute('castable', true);
329
330      videoElementInitializePromise = media.isAvailableForCast()
331          .then(function(result) {
332            if (!result)
333              return Promise.reject('No casts are available.');
334
335            return new Promise(function(fulfill, reject) {
336              chrome.cast.requestSession(
337                  fulfill, reject, undefined, this.currentCast_.label);
338            }.bind(this)).then(function(session) {
339              session.addUpdateListener(this.onCastSessionUpdateBound_);
340
341              this.currentSession_ = session;
342              this.videoElement_ = new CastVideoElement(media, session);
343              this.controls.attachMedia(this.videoElement_);
344            }.bind(this));
345          }.bind(this));
346    } else {
347      videoPlayerElement.removeAttribute('casting');
348
349      this.videoElement_ = document.createElement('video');
350      document.querySelector('#video-container').appendChild(
351          this.videoElement_);
352
353      this.controls.attachMedia(this.videoElement_);
354      this.videoElement_.src = video.url;
355
356      media.isAvailableForCast().then(function(result) {
357        if (result)
358          videoPlayerElement.setAttribute('castable', true);
359        else
360          videoPlayerElement.removeAttribute('castable');
361      }).catch(function() {
362        videoPlayerElement.setAttribute('castable', true);
363      });
364
365      videoElementInitializePromise = Promise.resolve();
366    }
367
368    videoElementInitializePromise
369        .then(function() {
370          var handler = function(currentPos) {
371            if (currentPos === this.currentPos_) {
372              if (opt_callback)
373                opt_callback();
374              videoPlayerElement.removeAttribute('loading');
375              this.controls.inactivityWatcher.disabled = false;
376            }
377
378            this.videoElement_.removeEventListener('loadedmetadata', handler);
379          }.wrap(this, this.currentPos_);
380
381          this.videoElement_.addEventListener('loadedmetadata', handler);
382
383          this.videoElement_.addEventListener('play', function() {
384            chrome.power.requestKeepAwake('display');
385          }.wrap());
386          this.videoElement_.addEventListener('pause', function() {
387            chrome.power.releaseKeepAwake();
388          }.wrap());
389
390          this.videoElement_.load();
391          callback();
392        }.bind(this))
393        // In case of error.
394        .catch(function(error) {
395          videoPlayerElement.removeAttribute('loading');
396          console.error('Failed to initialize the video element.',
397                        error.stack || error);
398          this.controls_.showErrorMessage('GALLERY_VIDEO_ERROR');
399          callback();
400        }.bind(this));
401  }.wrap(this));
402};
403
404/**
405 * Plays the first video.
406 */
407VideoPlayer.prototype.playFirstVideo = function() {
408  this.currentPos_ = 0;
409  this.reloadCurrentVideo(this.onFirstVideoReady_.wrap(this));
410};
411
412/**
413 * Unloads the current video.
414 * @param {boolean=} opt_keepSession If true, keep using the current session.
415 *     Otherwise, discards the session.
416 */
417VideoPlayer.prototype.unloadVideo = function(opt_keepSession) {
418  this.loadQueue_.run(function(callback) {
419    chrome.power.releaseKeepAwake();
420
421    // Detaches the media from the control.
422    this.controls.detachMedia();
423
424    if (this.videoElement_) {
425      // If the element has dispose method, call it (CastVideoElement has it).
426      if (this.videoElement_.dispose)
427        this.videoElement_.dispose();
428      // Detach the previous video element, if exists.
429      if (this.videoElement_.parentNode)
430        this.videoElement_.parentNode.removeChild(this.videoElement_);
431    }
432    this.videoElement_ = null;
433
434    if (!opt_keepSession && this.currentSession_) {
435      this.currentSession_.stop(callback, callback);
436      this.currentSession_.removeUpdateListener(this.onCastSessionUpdateBound_);
437      this.currentSession_ = null;
438    } else {
439      callback();
440    }
441  }.wrap(this));
442};
443
444/**
445 * Called when the first video is ready after starting to load.
446 * @private
447 */
448VideoPlayer.prototype.onFirstVideoReady_ = function() {
449  var videoWidth = this.videoElement_.videoWidth;
450  var videoHeight = this.videoElement_.videoHeight;
451
452  var aspect = videoWidth / videoHeight;
453  var newWidth = videoWidth;
454  var newHeight = videoHeight;
455
456  var shrinkX = newWidth / window.screen.availWidth;
457  var shrinkY = newHeight / window.screen.availHeight;
458  if (shrinkX > 1 || shrinkY > 1) {
459    if (shrinkY > shrinkX) {
460      newHeight = newHeight / shrinkY;
461      newWidth = newHeight * aspect;
462    } else {
463      newWidth = newWidth / shrinkX;
464      newHeight = newWidth / aspect;
465    }
466  }
467
468  var oldLeft = window.screenX;
469  var oldTop = window.screenY;
470  var oldWidth = window.outerWidth;
471  var oldHeight = window.outerHeight;
472
473  if (!oldWidth && !oldHeight) {
474    oldLeft = window.screen.availWidth / 2;
475    oldTop = window.screen.availHeight / 2;
476  }
477
478  var appWindow = chrome.app.window.current();
479  appWindow.resizeTo(newWidth, newHeight);
480  appWindow.moveTo(oldLeft - (newWidth - oldWidth) / 2,
481                   oldTop - (newHeight - oldHeight) / 2);
482  appWindow.show();
483
484  this.videoElement_.play();
485};
486
487/**
488 * Advances to the next (or previous) track.
489 *
490 * @param {boolean} direction True to the next, false to the previous.
491 * @private
492 */
493VideoPlayer.prototype.advance_ = function(direction) {
494  var newPos = this.currentPos_ + (direction ? 1 : -1);
495  if (0 <= newPos && newPos < this.videos_.length) {
496    this.currentPos_ = newPos;
497    this.reloadCurrentVideo(function() {
498      this.videoElement_.play();
499    }.wrap(this));
500  }
501};
502
503/**
504 * Reloads the current video.
505 *
506 * @param {function()=} opt_callback Completion callback.
507 */
508VideoPlayer.prototype.reloadCurrentVideo = function(opt_callback) {
509  var currentVideo = this.videos_[this.currentPos_];
510  this.loadVideo_(currentVideo, opt_callback);
511};
512
513/**
514 * Invokes when a menuitem in the cast menu is selected.
515 * @param {Object} cast Selected element in the list of casts.
516 * @private
517 */
518VideoPlayer.prototype.onCastSelected_ = function(cast) {
519  // If the selected item is same as the current item, do nothing.
520  if ((this.currentCast_ && this.currentCast_.label) === (cast && cast.label))
521    return;
522
523  this.unloadVideo(false);
524
525  // Waits for unloading video.
526  this.loadQueue_.run(function(callback) {
527    this.currentCast_ = cast || null;
528    this.updateCheckOnCastMenu_();
529    this.reloadCurrentVideo();
530    callback();
531  }.wrap(this));
532};
533
534/**
535 * Set the list of casts.
536 * @param {Array.<Object>} casts List of casts.
537 */
538VideoPlayer.prototype.setCastList = function(casts) {
539  var videoPlayerElement = document.querySelector('#video-player');
540  var menu = document.querySelector('#cast-menu');
541  menu.innerHTML = '';
542
543  // TODO(yoshiki): Handle the case that the current cast disappears.
544
545  if (casts.length === 0) {
546    videoPlayerElement.removeAttribute('cast-available');
547    if (this.currentCast_)
548      this.onCurrentCastDisappear_();
549    return;
550  }
551
552  if (this.currentCast_) {
553    var currentCastAvailable = casts.some(function(cast) {
554      return this.currentCast_.label === cast.label;
555    }.wrap(this));
556
557    if (!currentCastAvailable)
558      this.onCurrentCastDisappear_();
559  }
560
561  var item = new cr.ui.MenuItem();
562  item.label = loadTimeData.getString('VIDEO_PLAYER_PLAY_THIS_COMPUTER');
563  item.setAttribute('aria-label', item.label);
564  item.castLabel = '';
565  item.addEventListener('activate', this.onCastSelected_.wrap(this, null));
566  menu.appendChild(item);
567
568  for (var i = 0; i < casts.length; i++) {
569    var item = new cr.ui.MenuItem();
570    item.label = casts[i].friendlyName;
571    item.setAttribute('aria-label', item.label);
572    item.castLabel = casts[i].label;
573    item.addEventListener('activate',
574                          this.onCastSelected_.wrap(this, casts[i]));
575    menu.appendChild(item);
576  }
577  this.updateCheckOnCastMenu_();
578  videoPlayerElement.setAttribute('cast-available', true);
579};
580
581/**
582 * Updates the check status of the cast menu items.
583 * @private
584 */
585VideoPlayer.prototype.updateCheckOnCastMenu_ = function() {
586  var menu = document.querySelector('#cast-menu');
587  var menuItems = menu.menuItems;
588  for (var i = 0; i < menuItems.length; i++) {
589    var item = menuItems[i];
590    if (this.currentCast_ === null) {
591      // Playing on this computer.
592      if (item.castLabel === '')
593        item.checked = true;
594      else
595        item.checked = false;
596    } else {
597      // Playing on cast device.
598      if (item.castLabel === this.currentCast_.label)
599        item.checked = true;
600      else
601        item.checked = false;
602    }
603  }
604};
605
606/**
607 * Called when the current cast is disappear from the cast list.
608 * @private
609 */
610VideoPlayer.prototype.onCurrentCastDisappear_ = function() {
611  this.currentCast_ = null;
612  if (this.currentSession_) {
613    this.currentSession_.removeUpdateListener(this.onCastSessionUpdateBound_);
614    this.currentSession_ = null;
615  }
616  this.controls.showErrorMessage('GALLERY_VIDEO_DECODING_ERROR');
617  this.unloadVideo();
618};
619
620/**
621 * This method should be called when the session is updated.
622 * @param {boolean} alive Whether the session is alive or not.
623 * @private
624 */
625VideoPlayer.prototype.onCastSessionUpdate_ = function(alive) {
626  if (!alive)
627    this.unloadVideo();
628};
629
630/**
631 * Initialize the list of videos.
632 * @param {function(Array.<Object>)} callback Called with the video list when
633 *     it is ready.
634 */
635function initVideos(callback) {
636  if (window.videos) {
637    var videos = window.videos;
638    window.videos = null;
639    callback(videos);
640    return;
641  }
642
643  chrome.runtime.onMessage.addListener(
644      function(request, sender, sendResponse) {
645        var videos = window.videos;
646        window.videos = null;
647        callback(videos);
648      }.wrap(null));
649}
650
651var player = new VideoPlayer();
652
653/**
654 * Initializes the strings.
655 * @param {function()} callback Called when the sting data is ready.
656 */
657function initStrings(callback) {
658  chrome.fileManagerPrivate.getStrings(function(strings) {
659    loadTimeData.data = strings;
660    i18nTemplate.process(document, loadTimeData);
661    callback();
662  }.wrap(null));
663}
664
665var initPromise = Promise.all(
666    [new Promise(initVideos.wrap(null)),
667     new Promise(initStrings.wrap(null)),
668     new Promise(util.addPageLoadHandler.wrap(null))]);
669
670initPromise.then(function(results) {
671  var videos = results[0];
672  player.prepare(videos);
673  return new Promise(player.playFirstVideo.wrap(player));
674}.wrap(null));
675