Home | History | Annotate | Download | only in media
      1 // Copyright (c) 2012 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 {HTMLElement} container Container element.
      9  * @constructor
     10  */
     11 function AudioPlayer(container) {
     12   this.container_ = container;
     13   this.metadataCache_ = MetadataCache.createFull();
     14   this.currentTrack_ = -1;
     15   this.playlistGeneration_ = 0;
     16   this.volumeManager_ = VolumeManager.getInstance();
     17 
     18   this.container_.classList.add('collapsed');
     19 
     20   function createChild(opt_className, opt_tag) {
     21     var child = container.ownerDocument.createElement(opt_tag || 'div');
     22     if (opt_className)
     23       child.className = opt_className;
     24     container.appendChild(child);
     25     return child;
     26   }
     27 
     28   // We create two separate containers (for expanded and compact view) and keep
     29   // two sets of TrackInfo instances. We could fiddle with a single set instead
     30   // but it would make keeping the list scroll position very tricky.
     31   this.trackList_ = createChild('track-list');
     32   this.trackStack_ = createChild('track-stack');
     33 
     34   createChild('title-button collapse').addEventListener(
     35       'click', this.onExpandCollapse_.bind(this));
     36 
     37   this.audioControls_ = new FullWindowAudioControls(
     38       createChild(), this.advance_.bind(this), this.onError_.bind(this));
     39 
     40   this.audioControls_.attachMedia(createChild('', 'audio'));
     41 
     42   chrome.fileBrowserPrivate.getStrings(function(strings) {
     43     container.ownerDocument.title = strings['AUDIO_PLAYER_TITLE'];
     44     this.errorString_ = strings['AUDIO_ERROR'];
     45     this.offlineString_ = strings['AUDIO_OFFLINE'];
     46     AudioPlayer.TrackInfo.DEFAULT_ARTIST =
     47         strings['AUDIO_PLAYER_DEFAULT_ARTIST'];
     48   }.bind(this));
     49 
     50   this.volumeManager_.addEventListener('externally-unmounted',
     51       this.onExternallyUnmounted_.bind(this));
     52 }
     53 
     54 /**
     55  * Key in the local storage for the list of track urls.
     56  */
     57 AudioPlayer.PLAYLIST_KEY = 'audioPlaylist';
     58 
     59 /**
     60  * Key in the local storage for the number of the current track.
     61  */
     62 AudioPlayer.TRACK_KEY = 'audioTrack';
     63 
     64 /**
     65  * Initial load method (static).
     66  */
     67 AudioPlayer.load = function() {
     68   document.ondragstart = function(e) { e.preventDefault() };
     69 
     70   // If the audio player is starting before the first instance of the File
     71   // Manager then it does not have access to filesystem URLs. Request it now.
     72   chrome.fileBrowserPrivate.requestFileSystem(function() {
     73     AudioPlayer.instance =
     74         new AudioPlayer(document.querySelector('.audio-player'));
     75     chrome.mediaPlayerPrivate.onPlaylistChanged.addListener(getPlaylist);
     76     reload();
     77   });
     78 };
     79 
     80 util.addPageLoadHandler(AudioPlayer.load);
     81 
     82 /**
     83  * Unload the player.
     84  */
     85 function unload() {
     86   AudioPlayer.instance.audioControls_.cleanup();
     87 }
     88 
     89 /**
     90  * Reload the player.
     91  */
     92 function reload() {
     93   if (window.appState) {
     94     // Launching/reloading a v2 app.
     95     util.saveAppState();
     96     AudioPlayer.instance.load(window.appState);
     97     return;
     98   }
     99 
    100   // Lauching/reloading a v1 app.
    101   if (document.location.hash) {
    102     // The window is reloading, restore the state.
    103     AudioPlayer.instance.load(null);
    104   } else {
    105     getPlaylist();
    106   }
    107 }
    108 
    109 /**
    110  * Get the playlist from Chrome.
    111  */
    112 function getPlaylist() {
    113   chrome.mediaPlayerPrivate.getPlaylist(
    114       AudioPlayer.instance.load.bind(AudioPlayer.instance));
    115 }
    116 
    117 /**
    118  * Load a new playlist.
    119  * @param {Playlist} playlist Playlist object passed via mediaPlayerPrivate.
    120  */
    121 AudioPlayer.prototype.load = function(playlist) {
    122   if (!playlist || !playlist.items.length) {
    123     // playlist is null if the window is being reloaded.
    124     // playlist is empty if ChromeOS has restarted with the Audio Player open.
    125     // Restore the playlist from the local storage.
    126     util.platform.getPreferences(function(prefs) {
    127       try {
    128         var restoredPlaylist = {
    129           items: JSON.parse(prefs[AudioPlayer.PLAYLIST_KEY]),
    130           position: Number(prefs[AudioPlayer.TRACK_KEY]),
    131           time: true // Force restoring time from document.location.
    132         };
    133         if (restoredPlaylist.items.length)
    134           this.load(restoredPlaylist);
    135       } catch (ignore) {}
    136     }.bind(this));
    137     return;
    138   }
    139 
    140   if (!window.appState) {
    141     // Remember the playlist for the restart.
    142     // App v2 handles that in the background page.
    143     util.platform.setPreference(
    144         AudioPlayer.PLAYLIST_KEY, JSON.stringify(playlist.items));
    145     util.platform.setPreference(
    146         AudioPlayer.TRACK_KEY, playlist.position);
    147   }
    148 
    149   this.playlistGeneration_++;
    150 
    151   this.audioControls_.pause();
    152 
    153   this.currentTrack_ = -1;
    154 
    155   this.urls_ = playlist.items;
    156 
    157   this.invalidTracks_ = {};
    158   this.cancelAutoAdvance_();
    159 
    160   if (this.urls_.length <= 1)
    161     this.container_.classList.add('single-track');
    162   else
    163     this.container_.classList.remove('single-track');
    164 
    165   this.syncHeight_();
    166 
    167   this.trackList_.textContent = '';
    168   this.trackStack_.textContent = '';
    169 
    170   this.trackListItems_ = [];
    171   this.trackStackItems_ = [];
    172 
    173   if (this.urls_.length == 0)
    174     return;
    175 
    176   for (var i = 0; i != this.urls_.length; i++) {
    177     var url = this.urls_[i];
    178     var onClick = this.select_.bind(this, i, false /* no restore */);
    179     this.trackListItems_.push(
    180         new AudioPlayer.TrackInfo(this.trackList_, url, onClick));
    181     this.trackStackItems_.push(
    182         new AudioPlayer.TrackInfo(this.trackStack_, url, onClick));
    183   }
    184 
    185   this.select_(playlist.position, !!playlist.time);
    186 
    187   // This class will be removed if at least one track has art.
    188   this.container_.classList.add('noart');
    189 
    190   // Load the selected track metadata first, then load the rest.
    191   this.loadMetadata_(playlist.position);
    192   for (i = 0; i != this.urls_.length; i++) {
    193     if (i != playlist.position)
    194       this.loadMetadata_(i);
    195   }
    196 };
    197 
    198 /**
    199  * Load metadata for a track.
    200  * @param {number} track Track number.
    201  * @private
    202  */
    203 AudioPlayer.prototype.loadMetadata_ = function(track) {
    204   this.fetchMetadata_(
    205       this.urls_[track], this.displayMetadata_.bind(this, track));
    206 };
    207 
    208 /**
    209  * Display track's metadata.
    210  * @param {number} track Track number.
    211  * @param {Object} metadata Metadata object.
    212  * @param {string=} opt_error Error message.
    213  * @private
    214  */
    215 AudioPlayer.prototype.displayMetadata_ = function(track, metadata, opt_error) {
    216   this.trackListItems_[track].
    217       setMetadata(metadata, this.container_, opt_error);
    218   this.trackStackItems_[track].
    219       setMetadata(metadata, this.container_, opt_error);
    220 };
    221 
    222 /**
    223  * Closes audio player when a volume containing the selected item is unmounted.
    224  * @param {Event} event The unmount event.
    225  * @private
    226  */
    227 AudioPlayer.prototype.onExternallyUnmounted_ = function(event) {
    228   if (!this.selectedItemFilesystemPath_)
    229     return;
    230   if (this.selectedItemFilesystemPath_.indexOf(event.mountPath) == 0)
    231     close();
    232 };
    233 
    234 /**
    235  * Select a new track to play.
    236  * @param {number} newTrack New track number.
    237  * @param {boolean=} opt_restoreState True if restoring the play state from URL.
    238  * @private
    239  */
    240 AudioPlayer.prototype.select_ = function(newTrack, opt_restoreState) {
    241   if (this.currentTrack_ == newTrack) return;
    242 
    243   this.changeSelectionInList_(this.currentTrack_, newTrack);
    244   this.changeSelectionInStack_(this.currentTrack_, newTrack);
    245 
    246   this.currentTrack_ = newTrack;
    247 
    248   if (window.appState) {
    249     window.appState.position = this.currentTrack_;
    250     window.appState.time = 0;
    251     util.saveAppState();
    252   } else {
    253     util.platform.setPreference(AudioPlayer.TRACK_KEY, this.currentTrack_);
    254   }
    255 
    256   this.scrollToCurrent_(false);
    257 
    258   var currentTrack = this.currentTrack_;
    259   var url = this.urls_[currentTrack];
    260   this.fetchMetadata_(url, function(metadata) {
    261     if (this.currentTrack_ != currentTrack)
    262       return;
    263     var src = url;
    264     this.audioControls_.load(src, opt_restoreState);
    265 
    266     // Resolve real filesystem path of the current audio file.
    267     this.selectedItemFilesystemPath_ = null;
    268     webkitResolveLocalFileSystemURL(src,
    269       function(entry) {
    270         if (this.currentTrack_ != currentTrack)
    271           return;
    272         this.selectedItemFilesystemPath_ = entry.fullPath;
    273       }.bind(this));
    274   }.bind(this));
    275 };
    276 
    277 /**
    278  * @param {string} url Track file url.
    279  * @param {function(object)} callback Callback.
    280  * @private
    281  */
    282 AudioPlayer.prototype.fetchMetadata_ = function(url, callback) {
    283   this.metadataCache_.get(url, 'thumbnail|media|streaming',
    284       function(generation, metadata) {
    285         // Do nothing if another load happened since the metadata request.
    286         if (this.playlistGeneration_ == generation)
    287           callback(metadata);
    288       }.bind(this, this.playlistGeneration_));
    289 };
    290 
    291 /**
    292  * @param {number} oldTrack Old track number.
    293  * @param {number} newTrack New track number.
    294  * @private
    295  */
    296 AudioPlayer.prototype.changeSelectionInList_ = function(oldTrack, newTrack) {
    297   this.trackListItems_[newTrack].getBox().classList.add('selected');
    298 
    299   if (oldTrack >= 0) {
    300     this.trackListItems_[oldTrack].getBox().classList.remove('selected');
    301   }
    302 };
    303 
    304 /**
    305  * @param {number} oldTrack Old track number.
    306  * @param {number} newTrack New track number.
    307  * @private
    308  */
    309 AudioPlayer.prototype.changeSelectionInStack_ = function(oldTrack, newTrack) {
    310   var newBox = this.trackStackItems_[newTrack].getBox();
    311   newBox.classList.add('selected');  // Put on top immediately.
    312   newBox.classList.add('visible');  // Start fading in.
    313 
    314   if (oldTrack >= 0) {
    315     var oldBox = this.trackStackItems_[oldTrack].getBox();
    316     oldBox.classList.remove('selected'); // Put under immediately.
    317     setTimeout(function() {
    318       if (!oldBox.classList.contains('selected')) {
    319         // This will start fading out which is not really necessary because
    320         // oldBox is already completely obscured by newBox.
    321         oldBox.classList.remove('visible');
    322       }
    323     }, 300);
    324   }
    325 };
    326 
    327 /**
    328  * Scrolls the current track into the viewport.
    329  *
    330  * @param {boolean} keepAtBottom If true, make the selected track the last
    331  *   of the visible (if possible). If false, perform minimal scrolling.
    332  * @private
    333  */
    334 AudioPlayer.prototype.scrollToCurrent_ = function(keepAtBottom) {
    335   var box = this.trackListItems_[this.currentTrack_].getBox();
    336   this.trackList_.scrollTop = Math.max(
    337       keepAtBottom ? 0 : Math.min(box.offsetTop, this.trackList_.scrollTop),
    338       box.offsetTop + box.offsetHeight - this.trackList_.clientHeight);
    339 };
    340 
    341 /**
    342  * @return {boolean} True if the player is be displayed in compact mode.
    343  * @private
    344  */
    345 AudioPlayer.prototype.isCompact_ = function() {
    346   return this.container_.classList.contains('collapsed') ||
    347          this.container_.classList.contains('single-track');
    348 };
    349 
    350 /**
    351  * Go to the previous or the next track.
    352  * @param {boolean} forward True if next, false if previous.
    353  * @param {boolean=} opt_onlyIfValid True if invalid tracks should be selected.
    354  * @private
    355  */
    356 AudioPlayer.prototype.advance_ = function(forward, opt_onlyIfValid) {
    357   this.cancelAutoAdvance_();
    358 
    359   var newTrack = this.currentTrack_ + (forward ? 1 : -1);
    360   if (newTrack < 0) newTrack = this.urls_.length - 1;
    361   if (newTrack == this.urls_.length) newTrack = 0;
    362   if (opt_onlyIfValid && this.invalidTracks_[newTrack])
    363     return;
    364   this.select_(newTrack);
    365 };
    366 
    367 /**
    368  * Media error handler.
    369  * @private
    370  */
    371 AudioPlayer.prototype.onError_ = function() {
    372   var track = this.currentTrack_;
    373 
    374   this.invalidTracks_[track] = true;
    375 
    376   this.fetchMetadata_(
    377       this.urls_[track],
    378       function(metadata) {
    379         var error = (!navigator.onLine && metadata.streaming) ?
    380             this.offlineString_ : this.errorString_;
    381         this.displayMetadata_(track, metadata, error);
    382         this.scheduleAutoAdvance_();
    383       }.bind(this));
    384 };
    385 
    386 /**
    387  * Schedule automatic advance to the next track after a timeout.
    388  * @private
    389  */
    390 AudioPlayer.prototype.scheduleAutoAdvance_ = function() {
    391   this.cancelAutoAdvance_();
    392   this.autoAdvanceTimer_ = setTimeout(
    393       function() {
    394         this.autoAdvanceTimer_ = null;
    395         // We are advancing only if the next track is not known to be invalid.
    396         // This prevents an endless auto-advancing in the case when all tracks
    397         // are invalid (we will only visit each track once).
    398         this.advance_(true /* forward */, true /* only if valid */);
    399       }.bind(this),
    400       3000);
    401 };
    402 
    403 /**
    404  * Cancel the scheduled auto advance.
    405  * @private
    406  */
    407 AudioPlayer.prototype.cancelAutoAdvance_ = function() {
    408   if (this.autoAdvanceTimer_) {
    409     clearTimeout(this.autoAdvanceTimer_);
    410     this.autoAdvanceTimer_ = null;
    411   }
    412 };
    413 
    414 /**
    415  * Expand/collapse button click handler.
    416  * @private
    417  */
    418 AudioPlayer.prototype.onExpandCollapse_ = function() {
    419   this.container_.classList.toggle('collapsed');
    420   this.syncHeight_();
    421   if (!this.isCompact_())
    422     this.scrollToCurrent_(true);
    423 };
    424 
    425 /* Keep the below constants in sync with the CSS. */
    426 
    427 /**
    428  * Player header height.
    429  * TODO(kaznacheev): Set to 30 when the audio player is title-less.
    430  */
    431 AudioPlayer.HEADER_HEIGHT = 0;
    432 
    433 /**
    434  * Track height.
    435  */
    436 AudioPlayer.TRACK_HEIGHT = 58;
    437 
    438 /**
    439  * Controls bar height.
    440  */
    441 AudioPlayer.CONTROLS_HEIGHT = 35;
    442 
    443 /**
    444  * Set the correct player window height.
    445  * @private
    446  */
    447 AudioPlayer.prototype.syncHeight_ = function() {
    448   var expandedListHeight =
    449       Math.min(this.urls_.length, 3) * AudioPlayer.TRACK_HEIGHT;
    450   this.trackList_.style.height = expandedListHeight + 'px';
    451 
    452   var targetClientHeight = AudioPlayer.CONTROLS_HEIGHT +
    453       (this.isCompact_() ?
    454       AudioPlayer.TRACK_HEIGHT :
    455       AudioPlayer.HEADER_HEIGHT + expandedListHeight);
    456 
    457   var appWindow = chrome.app.window.current();
    458   var oldHeight = appWindow.contentWindow.outerHeight;
    459   var bottom = appWindow.contentWindow.screenY + oldHeight;
    460   var newTop = Math.max(0, bottom - targetClientHeight);
    461   appWindow.moveTo(appWindow.contentWindow.screenX, newTop);
    462   appWindow.resizeTo(appWindow.contentWindow.outerWidth,
    463       oldHeight + targetClientHeight - this.container_.clientHeight);
    464 };
    465 
    466 
    467 /**
    468  * Create a TrackInfo object encapsulating the information about one track.
    469  *
    470  * @param {HTMLElement} container Container element.
    471  * @param {string} url Track url.
    472  * @param {function} onClick Click handler.
    473  * @constructor
    474  */
    475 AudioPlayer.TrackInfo = function(container, url, onClick) {
    476   this.url_ = url;
    477 
    478   var doc = container.ownerDocument;
    479 
    480   this.box_ = doc.createElement('div');
    481   this.box_.className = 'track';
    482   this.box_.addEventListener('click', onClick);
    483   container.appendChild(this.box_);
    484 
    485   this.art_ = doc.createElement('div');
    486   this.art_.className = 'art blank';
    487   this.box_.appendChild(this.art_);
    488 
    489   this.img_ = doc.createElement('img');
    490   this.art_.appendChild(this.img_);
    491 
    492   this.data_ = doc.createElement('div');
    493   this.data_.className = 'data';
    494   this.box_.appendChild(this.data_);
    495 
    496   this.title_ = doc.createElement('div');
    497   this.title_.className = 'data-title';
    498   this.data_.appendChild(this.title_);
    499 
    500   this.artist_ = doc.createElement('div');
    501   this.artist_.className = 'data-artist';
    502   this.data_.appendChild(this.artist_);
    503 };
    504 
    505 /**
    506  * @return {HTMLDivElement} The wrapper element for the track.
    507  */
    508 AudioPlayer.TrackInfo.prototype.getBox = function() { return this.box_ };
    509 
    510 /**
    511  * @return {string} Default track title (file name extracted from the url).
    512  */
    513 AudioPlayer.TrackInfo.prototype.getDefaultTitle = function() {
    514   var title = this.url_.split('/').pop();
    515   var dotIndex = title.lastIndexOf('.');
    516   if (dotIndex >= 0) title = title.substr(0, dotIndex);
    517   title = decodeURIComponent(title);
    518   return title;
    519 };
    520 
    521 /**
    522  * TODO(kaznacheev): Localize.
    523  */
    524 AudioPlayer.TrackInfo.DEFAULT_ARTIST = 'Unknown Artist';
    525 
    526 /**
    527  * @return {string} 'Unknown artist' string.
    528  */
    529 AudioPlayer.TrackInfo.prototype.getDefaultArtist = function() {
    530   return AudioPlayer.TrackInfo.DEFAULT_ARTIST;
    531 };
    532 
    533 /**
    534  * @param {Object} metadata The metadata object.
    535  * @param {HTMLElement} container The container for the tracks.
    536  * @param {string} error Error string.
    537  */
    538 AudioPlayer.TrackInfo.prototype.setMetadata = function(
    539     metadata, container, error) {
    540   if (error) {
    541     this.art_.classList.add('blank');
    542     this.art_.classList.add('error');
    543     container.classList.remove('noart');
    544   } else if (metadata.thumbnail && metadata.thumbnail.url) {
    545     this.img_.onload = function() {
    546       // Only display the image if the thumbnail loaded successfully.
    547       this.art_.classList.remove('blank');
    548       container.classList.remove('noart');
    549     }.bind(this);
    550     this.img_.src = metadata.thumbnail.url;
    551   }
    552   this.title_.textContent = (metadata.media && metadata.media.title) ||
    553       this.getDefaultTitle();
    554   this.artist_.textContent = error ||
    555       (metadata.media && metadata.media.artist) || this.getDefaultArtist();
    556 };
    557 
    558 /**
    559  * Audio controls specific for the Audio Player.
    560  *
    561  * @param {HTMLElement} container Parent container.
    562  * @param {function(boolean)} advanceTrack Parameter: true=forward.
    563  * @param {function} onError Error handler.
    564  * @constructor
    565  */
    566 function FullWindowAudioControls(container, advanceTrack, onError) {
    567   AudioControls.apply(this, arguments);
    568 
    569   document.addEventListener('keydown', function(e) {
    570     if (e.keyIdentifier == 'U+0020') {
    571       this.togglePlayState();
    572       e.preventDefault();
    573     }
    574   }.bind(this));
    575 }
    576 
    577 FullWindowAudioControls.prototype = { __proto__: AudioControls.prototype };
    578 
    579 /**
    580  * Enable play state restore from the location hash.
    581  * @param {string} src Source URL.
    582  * @param {boolean} restore True if need to restore the play state.
    583  */
    584 FullWindowAudioControls.prototype.load = function(src, restore) {
    585   this.media_.src = src;
    586   this.media_.load();
    587   this.restoreWhenLoaded_ = restore;
    588 };
    589 
    590 /**
    591  * Save the current state so that it survives page/app reload.
    592  */
    593 FullWindowAudioControls.prototype.onPlayStateChanged = function() {
    594   this.encodeState();
    595 };
    596 
    597 /**
    598  * Restore the state after page/app reload.
    599  */
    600 FullWindowAudioControls.prototype.restorePlayState = function() {
    601   if (this.restoreWhenLoaded_) {
    602     this.restoreWhenLoaded_ = false;  // This should only work once.
    603     if (this.decodeState())
    604       return;
    605   }
    606   this.play();
    607 };
    608