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