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.
8 * TODO(mtomasz): Rewrite the entire audio player.
10 * @param {HTMLElement} container Container element.
13 function 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);
22 this.container_.classList.add('collapsed');
24 function createChild(opt_className, opt_tag) {
25 var child = container.ownerDocument.createElement(opt_tag || 'div');
27 child.className = opt_className;
28 container.appendChild(child);
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');
38 createChild('title-button collapse').addEventListener(
39 'click', this.onExpandCollapse_.bind(this));
41 this.audioControls_ = new FullWindowAudioControls(
42 createChild(), this.advance_.bind(this), this.onError_.bind(this));
44 this.audioControls_.attachMedia(createChild('', 'audio'));
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'];
54 this.volumeManager_.addEventListener('externally-unmounted',
55 this.onExternallyUnmounted_.bind(this));
57 window.addEventListener('resize', this.onResize_.bind(this));
59 // Show the window after DOM is processed.
60 var currentWindow = chrome.app.window.current();
61 setTimeout(currentWindow.show.bind(currentWindow), 0);
65 * Initial load method (static).
67 AudioPlayer.load = function() {
68 document.ondragstart = function(e) { e.preventDefault() };
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');
75 AudioPlayer.instance =
76 new AudioPlayer(document.querySelector('.audio-player'));
80 util.addPageLoadHandler(AudioPlayer.load);
86 if (AudioPlayer.instance)
87 AudioPlayer.instance.onUnload();
94 if (window.appState) {
96 AudioPlayer.instance.load(window.appState);
102 * Load a new playlist.
103 * @param {Playlist} playlist Playlist object passed via mediaPlayerPrivate.
105 AudioPlayer.prototype.load = function(playlist) {
106 this.playlistGeneration_++;
107 this.audioControls_.pause();
108 this.currentTrack_ = -1;
110 // Save the app state, in case of restart.
111 window.appState = playlist;
114 util.URLsToEntries(playlist.items, function(entries) {
115 this.entries_ = entries;
116 this.invalidTracks_ = {};
117 this.cancelAutoAdvance_();
119 if (this.entries_.length <= 1)
120 this.container_.classList.add('single-track');
122 this.container_.classList.remove('single-track');
126 this.trackList_.textContent = '';
127 this.trackStack_.textContent = '';
129 this.trackListItems_ = [];
130 this.trackStackItems_ = [];
132 if (this.entries_.length == 0)
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));
144 this.select_(playlist.position, !!playlist.time);
146 // This class will be removed if at least one track has art.
147 this.container_.classList.add('noart');
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);
159 * Load metadata for a track.
160 * @param {number} track Track number.
163 AudioPlayer.prototype.loadMetadata_ = function(track) {
165 this.entries_[track], this.displayMetadata_.bind(this, track));
169 * Display track's metadata.
170 * @param {number} track Track number.
171 * @param {Object} metadata Metadata object.
172 * @param {string=} opt_error Error message.
175 AudioPlayer.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);
183 * Closes audio player when a volume containing the selected item is unmounted.
184 * @param {Event} event The unmount event.
187 AudioPlayer.prototype.onExternallyUnmounted_ = function(event) {
188 if (!this.selectedEntry_)
191 if (this.volumeManager_.getVolumeInfo(this.selectedEntry_) ===
198 * Called on window is being unloaded.
200 AudioPlayer.prototype.onUnload = function() {
201 this.audioControls_.cleanup();
202 this.volumeManager_.dispose();
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.
211 AudioPlayer.prototype.select_ = function(newTrack, opt_restoreState) {
212 if (this.currentTrack_ == newTrack) return;
214 this.changeSelectionInList_(this.currentTrack_, newTrack);
215 this.changeSelectionInStack_(this.currentTrack_, newTrack);
217 this.currentTrack_ = newTrack;
219 if (window.appState) {
220 window.appState.position = this.currentTrack_;
221 window.appState.time = 0;
224 util.platform.setPreference(AudioPlayer.TRACK_KEY, this.currentTrack_);
227 this.scrollToCurrent_(false);
229 var currentTrack = this.currentTrack_;
230 var entry = this.entries_[currentTrack];
231 this.fetchMetadata_(entry, function(metadata) {
232 if (this.currentTrack_ != currentTrack)
234 this.audioControls_.load(entry, opt_restoreState);
236 // Resolve real filesystem path of the current audio file.
237 this.selectedEntry_ = entry;
242 * @param {Entry} entry Track file entry.
243 * @param {function(object)} callback Callback.
246 AudioPlayer.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)
252 }.bind(this, this.playlistGeneration_));
256 * @param {number} oldTrack Old track number.
257 * @param {number} newTrack New track number.
260 AudioPlayer.prototype.changeSelectionInList_ = function(oldTrack, newTrack) {
261 this.trackListItems_[newTrack].getBox().classList.add('selected');
264 this.trackListItems_[oldTrack].getBox().classList.remove('selected');
269 * @param {number} oldTrack Old track number.
270 * @param {number} newTrack New track number.
273 AudioPlayer.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.
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');
292 * Scrolls the current track into the viewport.
294 * @param {boolean} keepAtBottom If true, make the selected track the last
295 * of the visible (if possible). If false, perform minimal scrolling.
298 AudioPlayer.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);
306 * @return {boolean} True if the player is be displayed in compact mode.
309 AudioPlayer.prototype.isCompact_ = function() {
310 return this.container_.classList.contains('collapsed') ||
311 this.container_.classList.contains('single-track');
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.
320 AudioPlayer.prototype.advance_ = function(forward, opt_onlyIfValid) {
321 this.cancelAutoAdvance_();
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])
328 this.select_(newTrack);
332 * Media error handler.
335 AudioPlayer.prototype.onError_ = function() {
336 var track = this.currentTrack_;
338 this.invalidTracks_[track] = true;
341 this.entries_[track],
343 var error = (!navigator.onLine && metadata.streaming) ?
344 this.offlineString_ : this.errorString_;
345 this.displayMetadata_(track, metadata, error);
346 this.scheduleAutoAdvance_();
351 * Schedule automatic advance to the next track after a timeout.
354 AudioPlayer.prototype.scheduleAutoAdvance_ = function() {
355 this.cancelAutoAdvance_();
356 this.autoAdvanceTimer_ = setTimeout(
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 */);
368 * Cancel the scheduled auto advance.
371 AudioPlayer.prototype.cancelAutoAdvance_ = function() {
372 if (this.autoAdvanceTimer_) {
373 clearTimeout(this.autoAdvanceTimer_);
374 this.autoAdvanceTimer_ = null;
379 * Expand/collapse button click handler. Toggles the mode and updates the
380 * height of the window.
384 AudioPlayer.prototype.onExpandCollapse_ = function() {
385 if (!this.isCompact_()) {
386 this.setExpanded_(false);
387 this.lastExpandedHeight_ = window.innerHeight;
389 this.setExpanded_(true);
395 * Toggles the current expand mode.
397 * @param {boolean} on True if on, false otherwise.
400 AudioPlayer.prototype.setExpanded_ = function(on) {
402 this.container_.classList.remove('collapsed');
403 this.scrollToCurrent_(true);
405 this.container_.classList.add('collapsed');
410 * Toggles the expanded mode when resizing.
412 * @param {Event} event Resize event.
415 AudioPlayer.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);
425 /* Keep the below constants in sync with the CSS. */
428 * Window header size in pixels.
432 AudioPlayer.HEADER_HEIGHT = 28;
435 * Track height in pixels.
439 AudioPlayer.TRACK_HEIGHT = 58;
442 * Controls bar height in pixels.
446 AudioPlayer.CONTROLS_HEIGHT = 35;
449 * Default number of items in the expanded mode.
453 AudioPlayer.DEFAULT_EXPANDED_ITEMS = 5;
456 * Minimum size of the window in the expanded mode in pixels.
460 AudioPlayer.EXPANDED_MODE_MIN_HEIGHT = AudioPlayer.CONTROLS_HEIGHT +
461 AudioPlayer.TRACK_HEIGHT * 2;
464 * Set the correct player window height.
467 AudioPlayer.prototype.syncHeight_ = function() {
470 if (!this.isCompact_()) {
472 if (this.lastExpandedHeight_) {
473 targetHeight = this.lastExpandedHeight_;
475 var expandedListHeight =
476 Math.min(this.entries_.length, AudioPlayer.DEFAULT_EXPANDED_ITEMS) *
477 AudioPlayer.TRACK_HEIGHT;
478 targetHeight = AudioPlayer.CONTROLS_HEIGHT + expandedListHeight;
482 targetHeight = AudioPlayer.CONTROLS_HEIGHT + AudioPlayer.TRACK_HEIGHT;
485 window.resizeTo(window.innerWidth, targetHeight + AudioPlayer.HEADER_HEIGHT);
489 * Create a TrackInfo object encapsulating the information about one track.
491 * @param {HTMLElement} container Container element.
492 * @param {Entry} entry Track entry.
493 * @param {function} onClick Click handler.
496 AudioPlayer.TrackInfo = function(container, entry, onClick) {
499 var doc = container.ownerDocument;
501 this.box_ = doc.createElement('div');
502 this.box_.className = 'track';
503 this.box_.addEventListener('click', onClick);
504 container.appendChild(this.box_);
506 this.art_ = doc.createElement('div');
507 this.art_.className = 'art blank';
508 this.box_.appendChild(this.art_);
510 this.img_ = doc.createElement('img');
511 this.art_.appendChild(this.img_);
513 this.data_ = doc.createElement('div');
514 this.data_.className = 'data';
515 this.box_.appendChild(this.data_);
517 this.title_ = doc.createElement('div');
518 this.title_.className = 'data-title';
519 this.data_.appendChild(this.title_);
521 this.artist_ = doc.createElement('div');
522 this.artist_.className = 'data-artist';
523 this.data_.appendChild(this.artist_);
527 * @return {HTMLDivElement} The wrapper element for the track.
529 AudioPlayer.TrackInfo.prototype.getBox = function() { return this.box_ };
532 * @return {string} Default track title (file name extracted from the entry).
534 AudioPlayer.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;
543 * TODO(kaznacheev): Localize.
545 AudioPlayer.TrackInfo.DEFAULT_ARTIST = 'Unknown Artist';
548 * @return {string} 'Unknown artist' string.
550 AudioPlayer.TrackInfo.prototype.getDefaultArtist = function() {
551 return AudioPlayer.TrackInfo.DEFAULT_ARTIST;
555 * @param {Object} metadata The metadata object.
556 * @param {HTMLElement} container The container for the tracks.
557 * @param {string} error Error string.
559 AudioPlayer.TrackInfo.prototype.setMetadata = function(
560 metadata, container, 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');
571 this.img_.src = metadata.thumbnail.url;
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();
580 * Audio controls specific for the Audio Player.
582 * @param {HTMLElement} container Parent container.
583 * @param {function(boolean)} advanceTrack Parameter: true=forward.
584 * @param {function} onError Error handler.
587 function FullWindowAudioControls(container, advanceTrack, onError) {
588 AudioControls.apply(this, arguments);
590 document.addEventListener('keydown', function(e) {
591 if (e.keyIdentifier == 'U+0020') {
592 this.togglePlayState();
598 FullWindowAudioControls.prototype = { __proto__: AudioControls.prototype };
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.
605 FullWindowAudioControls.prototype.load = function(entry, restore) {
606 this.media_.src = entry.toURL();
608 this.restoreWhenLoaded_ = restore;
612 * Save the current state so that it survives page/app reload.
614 FullWindowAudioControls.prototype.onPlayStateChanged = function() {
619 * Restore the state after page/app reload.
621 FullWindowAudioControls.prototype.restorePlayState = function() {
622 if (this.restoreWhenLoaded_) {
623 this.restoreWhenLoaded_ = false; // This should only work once.
624 if (this.decodeState())