• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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