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