blob: 4791c31f92b9077364414c861a3e2c948efea043 [file] [log] [blame]
// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
'use strict';
/**
* TODO(mtomasz): Rewrite the entire audio player.
*
* @param {HTMLElement} container Container element.
* @constructor
*/
function AudioPlayer(container) {
this.container_ = container;
this.metadataCache_ = MetadataCache.createFull();
this.currentTrack_ = -1;
this.playlistGeneration_ = 0;
this.selectedEntry_ = null;
this.volumeManager_ = new VolumeManagerWrapper(
VolumeManagerWrapper.DriveEnabledStatus.DRIVE_ENABLED);
this.container_.classList.add('collapsed');
function createChild(opt_className, opt_tag) {
var child = container.ownerDocument.createElement(opt_tag || 'div');
if (opt_className)
child.className = opt_className;
container.appendChild(child);
return child;
}
// We create two separate containers (for expanded and compact view) and keep
// two sets of TrackInfo instances. We could fiddle with a single set instead
// but it would make keeping the list scroll position very tricky.
this.trackList_ = createChild('track-list');
this.trackStack_ = createChild('track-stack');
createChild('title-button collapse').addEventListener(
'click', this.onExpandCollapse_.bind(this));
this.audioControls_ = new FullWindowAudioControls(
createChild(), this.advance_.bind(this), this.onError_.bind(this));
this.audioControls_.attachMedia(createChild('', 'audio'));
chrome.fileBrowserPrivate.getStrings(function(strings) {
container.ownerDocument.title = strings['AUDIO_PLAYER_TITLE'];
this.errorString_ = strings['AUDIO_ERROR'];
this.offlineString_ = strings['AUDIO_OFFLINE'];
AudioPlayer.TrackInfo.DEFAULT_ARTIST =
strings['AUDIO_PLAYER_DEFAULT_ARTIST'];
}.bind(this));
this.volumeManager_.addEventListener('externally-unmounted',
this.onExternallyUnmounted_.bind(this));
window.addEventListener('resize', this.onResize_.bind(this));
// Show the window after DOM is processed.
var currentWindow = chrome.app.window.current();
setTimeout(currentWindow.show.bind(currentWindow), 0);
}
/**
* Initial load method (static).
*/
AudioPlayer.load = function() {
document.ondragstart = function(e) { e.preventDefault() };
// TODO(mtomasz): Consider providing an exact size icon, instead of relying
// on downsampling by ash.
chrome.app.window.current().setIcon(
'foreground/images/media/2x/audio_player.png');
AudioPlayer.instance =
new AudioPlayer(document.querySelector('.audio-player'));
reload();
};
util.addPageLoadHandler(AudioPlayer.load);
/**
* Unload the player.
*/
function unload() {
if (AudioPlayer.instance)
AudioPlayer.instance.onUnload();
}
/**
* Reload the player.
*/
function reload() {
if (window.appState) {
util.saveAppState();
AudioPlayer.instance.load(window.appState);
return;
}
}
/**
* Load a new playlist.
* @param {Playlist} playlist Playlist object passed via mediaPlayerPrivate.
*/
AudioPlayer.prototype.load = function(playlist) {
this.playlistGeneration_++;
this.audioControls_.pause();
this.currentTrack_ = -1;
// Save the app state, in case of restart.
window.appState = playlist;
util.saveAppState();
util.URLsToEntries(playlist.items, function(entries) {
this.entries_ = entries;
this.invalidTracks_ = {};
this.cancelAutoAdvance_();
if (this.entries_.length <= 1)
this.container_.classList.add('single-track');
else
this.container_.classList.remove('single-track');
this.syncHeight_();
this.trackList_.textContent = '';
this.trackStack_.textContent = '';
this.trackListItems_ = [];
this.trackStackItems_ = [];
if (this.entries_.length == 0)
return;
for (var i = 0; i != this.entries_.length; i++) {
var entry = this.entries_[i];
var onClick = this.select_.bind(this, i, false /* no restore */);
this.trackListItems_.push(
new AudioPlayer.TrackInfo(this.trackList_, entry, onClick));
this.trackStackItems_.push(
new AudioPlayer.TrackInfo(this.trackStack_, entry, onClick));
}
this.select_(playlist.position, !!playlist.time);
// This class will be removed if at least one track has art.
this.container_.classList.add('noart');
// Load the selected track metadata first, then load the rest.
this.loadMetadata_(playlist.position);
for (i = 0; i != this.entries_.length; i++) {
if (i != playlist.position)
this.loadMetadata_(i);
}
}.bind(this));
};
/**
* Load metadata for a track.
* @param {number} track Track number.
* @private
*/
AudioPlayer.prototype.loadMetadata_ = function(track) {
this.fetchMetadata_(
this.entries_[track], this.displayMetadata_.bind(this, track));
};
/**
* Display track's metadata.
* @param {number} track Track number.
* @param {Object} metadata Metadata object.
* @param {string=} opt_error Error message.
* @private
*/
AudioPlayer.prototype.displayMetadata_ = function(track, metadata, opt_error) {
this.trackListItems_[track].
setMetadata(metadata, this.container_, opt_error);
this.trackStackItems_[track].
setMetadata(metadata, this.container_, opt_error);
};
/**
* Closes audio player when a volume containing the selected item is unmounted.
* @param {Event} event The unmount event.
* @private
*/
AudioPlayer.prototype.onExternallyUnmounted_ = function(event) {
if (!this.selectedEntry_)
return;
if (this.volumeManager_.getVolumeInfo(this.selectedEntry_) ===
event.volumeInfo) {
window.close();
}
};
/**
* Called on window is being unloaded.
*/
AudioPlayer.prototype.onUnload = function() {
this.audioControls_.cleanup();
this.volumeManager_.dispose();
};
/**
* Select a new track to play.
* @param {number} newTrack New track number.
* @param {boolean=} opt_restoreState True if restoring the play state from URL.
* @private
*/
AudioPlayer.prototype.select_ = function(newTrack, opt_restoreState) {
if (this.currentTrack_ == newTrack) return;
this.changeSelectionInList_(this.currentTrack_, newTrack);
this.changeSelectionInStack_(this.currentTrack_, newTrack);
this.currentTrack_ = newTrack;
if (window.appState) {
window.appState.position = this.currentTrack_;
window.appState.time = 0;
util.saveAppState();
} else {
util.platform.setPreference(AudioPlayer.TRACK_KEY, this.currentTrack_);
}
this.scrollToCurrent_(false);
var currentTrack = this.currentTrack_;
var entry = this.entries_[currentTrack];
this.fetchMetadata_(entry, function(metadata) {
if (this.currentTrack_ != currentTrack)
return;
this.audioControls_.load(entry, opt_restoreState);
// Resolve real filesystem path of the current audio file.
this.selectedEntry_ = entry;
}.bind(this));
};
/**
* @param {Entry} entry Track file entry.
* @param {function(object)} callback Callback.
* @private
*/
AudioPlayer.prototype.fetchMetadata_ = function(entry, callback) {
this.metadataCache_.get(entry, 'thumbnail|media|streaming',
function(generation, metadata) {
// Do nothing if another load happened since the metadata request.
if (this.playlistGeneration_ == generation)
callback(metadata);
}.bind(this, this.playlistGeneration_));
};
/**
* @param {number} oldTrack Old track number.
* @param {number} newTrack New track number.
* @private
*/
AudioPlayer.prototype.changeSelectionInList_ = function(oldTrack, newTrack) {
this.trackListItems_[newTrack].getBox().classList.add('selected');
if (oldTrack >= 0) {
this.trackListItems_[oldTrack].getBox().classList.remove('selected');
}
};
/**
* @param {number} oldTrack Old track number.
* @param {number} newTrack New track number.
* @private
*/
AudioPlayer.prototype.changeSelectionInStack_ = function(oldTrack, newTrack) {
var newBox = this.trackStackItems_[newTrack].getBox();
newBox.classList.add('selected'); // Put on top immediately.
newBox.classList.add('visible'); // Start fading in.
if (oldTrack >= 0) {
var oldBox = this.trackStackItems_[oldTrack].getBox();
oldBox.classList.remove('selected'); // Put under immediately.
setTimeout(function() {
if (!oldBox.classList.contains('selected')) {
// This will start fading out which is not really necessary because
// oldBox is already completely obscured by newBox.
oldBox.classList.remove('visible');
}
}, 300);
}
};
/**
* Scrolls the current track into the viewport.
*
* @param {boolean} keepAtBottom If true, make the selected track the last
* of the visible (if possible). If false, perform minimal scrolling.
* @private
*/
AudioPlayer.prototype.scrollToCurrent_ = function(keepAtBottom) {
var box = this.trackListItems_[this.currentTrack_].getBox();
this.trackList_.scrollTop = Math.max(
keepAtBottom ? 0 : Math.min(box.offsetTop, this.trackList_.scrollTop),
box.offsetTop + box.offsetHeight - this.trackList_.clientHeight);
};
/**
* @return {boolean} True if the player is be displayed in compact mode.
* @private
*/
AudioPlayer.prototype.isCompact_ = function() {
return this.container_.classList.contains('collapsed') ||
this.container_.classList.contains('single-track');
};
/**
* Go to the previous or the next track.
* @param {boolean} forward True if next, false if previous.
* @param {boolean=} opt_onlyIfValid True if invalid tracks should be selected.
* @private
*/
AudioPlayer.prototype.advance_ = function(forward, opt_onlyIfValid) {
this.cancelAutoAdvance_();
var newTrack = this.currentTrack_ + (forward ? 1 : -1);
if (newTrack < 0) newTrack = this.entries_.length - 1;
if (newTrack == this.entries_.length) newTrack = 0;
if (opt_onlyIfValid && this.invalidTracks_[newTrack])
return;
this.select_(newTrack);
};
/**
* Media error handler.
* @private
*/
AudioPlayer.prototype.onError_ = function() {
var track = this.currentTrack_;
this.invalidTracks_[track] = true;
this.fetchMetadata_(
this.entries_[track],
function(metadata) {
var error = (!navigator.onLine && metadata.streaming) ?
this.offlineString_ : this.errorString_;
this.displayMetadata_(track, metadata, error);
this.scheduleAutoAdvance_();
}.bind(this));
};
/**
* Schedule automatic advance to the next track after a timeout.
* @private
*/
AudioPlayer.prototype.scheduleAutoAdvance_ = function() {
this.cancelAutoAdvance_();
this.autoAdvanceTimer_ = setTimeout(
function() {
this.autoAdvanceTimer_ = null;
// We are advancing only if the next track is not known to be invalid.
// This prevents an endless auto-advancing in the case when all tracks
// are invalid (we will only visit each track once).
this.advance_(true /* forward */, true /* only if valid */);
}.bind(this),
3000);
};
/**
* Cancel the scheduled auto advance.
* @private
*/
AudioPlayer.prototype.cancelAutoAdvance_ = function() {
if (this.autoAdvanceTimer_) {
clearTimeout(this.autoAdvanceTimer_);
this.autoAdvanceTimer_ = null;
}
};
/**
* Expand/collapse button click handler. Toggles the mode and updates the
* height of the window.
*
* @private
*/
AudioPlayer.prototype.onExpandCollapse_ = function() {
if (!this.isCompact_()) {
this.setExpanded_(false);
this.lastExpandedHeight_ = window.innerHeight;
} else {
this.setExpanded_(true);
}
this.syncHeight_();
};
/**
* Toggles the current expand mode.
*
* @param {boolean} on True if on, false otherwise.
* @private
*/
AudioPlayer.prototype.setExpanded_ = function(on) {
if (on) {
this.container_.classList.remove('collapsed');
this.scrollToCurrent_(true);
} else {
this.container_.classList.add('collapsed');
}
};
/**
* Toggles the expanded mode when resizing.
*
* @param {Event} event Resize event.
* @private
*/
AudioPlayer.prototype.onResize_ = function(event) {
if (this.isCompact_() &&
window.innerHeight >= AudioPlayer.EXPANDED_MODE_MIN_HEIGHT) {
this.setExpanded_(true);
} else if (!this.isCompact_() &&
window.innerHeight < AudioPlayer.EXPANDED_MODE_MIN_HEIGHT) {
this.setExpanded_(false);
}
};
/* Keep the below constants in sync with the CSS. */
/**
* Window header size in pixels.
* @type {number}
* @const
*/
AudioPlayer.HEADER_HEIGHT = 28;
/**
* Track height in pixels.
* @type {number}
* @const
*/
AudioPlayer.TRACK_HEIGHT = 58;
/**
* Controls bar height in pixels.
* @type {number}
* @const
*/
AudioPlayer.CONTROLS_HEIGHT = 35;
/**
* Default number of items in the expanded mode.
* @type {number}
* @const
*/
AudioPlayer.DEFAULT_EXPANDED_ITEMS = 5;
/**
* Minimum size of the window in the expanded mode in pixels.
* @type {number}
* @const
*/
AudioPlayer.EXPANDED_MODE_MIN_HEIGHT = AudioPlayer.CONTROLS_HEIGHT +
AudioPlayer.TRACK_HEIGHT * 2;
/**
* Set the correct player window height.
* @private
*/
AudioPlayer.prototype.syncHeight_ = function() {
var targetHeight;
if (!this.isCompact_()) {
// Expanded.
if (this.lastExpandedHeight_) {
targetHeight = this.lastExpandedHeight_;
} else {
var expandedListHeight =
Math.min(this.entries_.length, AudioPlayer.DEFAULT_EXPANDED_ITEMS) *
AudioPlayer.TRACK_HEIGHT;
targetHeight = AudioPlayer.CONTROLS_HEIGHT + expandedListHeight;
}
} else {
// Not expaned.
targetHeight = AudioPlayer.CONTROLS_HEIGHT + AudioPlayer.TRACK_HEIGHT;
}
window.resizeTo(window.innerWidth, targetHeight + AudioPlayer.HEADER_HEIGHT);
};
/**
* Create a TrackInfo object encapsulating the information about one track.
*
* @param {HTMLElement} container Container element.
* @param {Entry} entry Track entry.
* @param {function} onClick Click handler.
* @constructor
*/
AudioPlayer.TrackInfo = function(container, entry, onClick) {
this.entry_ = entry;
var doc = container.ownerDocument;
this.box_ = doc.createElement('div');
this.box_.className = 'track';
this.box_.addEventListener('click', onClick);
container.appendChild(this.box_);
this.art_ = doc.createElement('div');
this.art_.className = 'art blank';
this.box_.appendChild(this.art_);
this.img_ = doc.createElement('img');
this.art_.appendChild(this.img_);
this.data_ = doc.createElement('div');
this.data_.className = 'data';
this.box_.appendChild(this.data_);
this.title_ = doc.createElement('div');
this.title_.className = 'data-title';
this.data_.appendChild(this.title_);
this.artist_ = doc.createElement('div');
this.artist_.className = 'data-artist';
this.data_.appendChild(this.artist_);
};
/**
* @return {HTMLDivElement} The wrapper element for the track.
*/
AudioPlayer.TrackInfo.prototype.getBox = function() { return this.box_ };
/**
* @return {string} Default track title (file name extracted from the entry).
*/
AudioPlayer.TrackInfo.prototype.getDefaultTitle = function() {
// TODO(mtomasz): Reuse ImageUtil.getDisplayNameFromName().
var name = this.entry_.name;
var dotIndex = name.lastIndexOf('.');
var title = dotIndex >= 0 ? name.substr(0, dotIndex) : name;
return title;
};
/**
* TODO(kaznacheev): Localize.
*/
AudioPlayer.TrackInfo.DEFAULT_ARTIST = 'Unknown Artist';
/**
* @return {string} 'Unknown artist' string.
*/
AudioPlayer.TrackInfo.prototype.getDefaultArtist = function() {
return AudioPlayer.TrackInfo.DEFAULT_ARTIST;
};
/**
* @param {Object} metadata The metadata object.
* @param {HTMLElement} container The container for the tracks.
* @param {string} error Error string.
*/
AudioPlayer.TrackInfo.prototype.setMetadata = function(
metadata, container, error) {
if (error) {
this.art_.classList.add('blank');
this.art_.classList.add('error');
container.classList.remove('noart');
} else if (metadata.thumbnail && metadata.thumbnail.url) {
this.img_.onload = function() {
// Only display the image if the thumbnail loaded successfully.
this.art_.classList.remove('blank');
container.classList.remove('noart');
}.bind(this);
this.img_.src = metadata.thumbnail.url;
}
this.title_.textContent = (metadata.media && metadata.media.title) ||
this.getDefaultTitle();
this.artist_.textContent = error ||
(metadata.media && metadata.media.artist) || this.getDefaultArtist();
};
/**
* Audio controls specific for the Audio Player.
*
* @param {HTMLElement} container Parent container.
* @param {function(boolean)} advanceTrack Parameter: true=forward.
* @param {function} onError Error handler.
* @constructor
*/
function FullWindowAudioControls(container, advanceTrack, onError) {
AudioControls.apply(this, arguments);
document.addEventListener('keydown', function(e) {
if (e.keyIdentifier == 'U+0020') {
this.togglePlayState();
e.preventDefault();
}
}.bind(this));
}
FullWindowAudioControls.prototype = { __proto__: AudioControls.prototype };
/**
* Enable play state restore from the location hash.
* @param {FileEntry} entry Source Entry.
* @param {boolean} restore True if need to restore the play state.
*/
FullWindowAudioControls.prototype.load = function(entry, restore) {
this.media_.src = entry.toURL();
this.media_.load();
this.restoreWhenLoaded_ = restore;
};
/**
* Save the current state so that it survives page/app reload.
*/
FullWindowAudioControls.prototype.onPlayStateChanged = function() {
this.encodeState();
};
/**
* Restore the state after page/app reload.
*/
FullWindowAudioControls.prototype.restorePlayState = function() {
if (this.restoreWhenLoaded_) {
this.restoreWhenLoaded_ = false; // This should only work once.
if (this.decodeState())
return;
}
this.play();
};