Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
18
gaia/dev_apps/music-oga/build/build.js
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
'use strict';
|
||||
|
||||
/* jshint node: true */
|
||||
|
||||
var utils = require('utils');
|
||||
|
||||
exports.execute = function(options) {
|
||||
utils.copyToStage(options);
|
||||
var sharedPath = utils.gaia.getInstance(options).sharedFolder.path;
|
||||
var paths = [
|
||||
utils.joinPath(sharedPath, 'js', 'blobview.js'),
|
||||
utils.joinPath(options.APP_DIR, 'js', 'metadata', 'formats.js'),
|
||||
utils.joinPath(options.APP_DIR, 'js', 'metadata', 'core.js')
|
||||
];
|
||||
var targetPath = utils.joinPath(options.STAGE_APP_DIR, 'js',
|
||||
'metadata_scripts.js');
|
||||
utils.concatenatedScripts(paths, targetPath);
|
||||
};
|
||||
222
gaia/dev_apps/music-oga/index.html
Executable file
|
|
@ -0,0 +1,222 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="theme-group" content="theme-media">
|
||||
<meta name="theme-color" content="var(--header-background)">
|
||||
<title>Music OGA</title>
|
||||
<!-- Include shared building blocks -->
|
||||
<link rel="stylesheet" type="text/css" href="shared/elements/gaia-icons/gaia-icons.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="shared/elements/gaia-theme/gaia-theme.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="shared/style/confirm.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="shared/style/input_areas.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="shared/style/progress_activity.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="shared/style/status.css">
|
||||
<link rel="stylesheet" type="text/css" href="shared/style/tabs.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="style/music.css"/>
|
||||
<!-- Localization -->
|
||||
<meta name="defaultLanguage" content="en-US">
|
||||
<meta name="availableLanguages" content="en-US">
|
||||
<link rel="localization" href="locales/music.{locale}.properties">
|
||||
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
|
||||
<!-- Shared code -->
|
||||
<script defer src="shared/js/l10n.js"></script>
|
||||
<script defer src="shared/js/mediadb.js"></script>
|
||||
<script defer src="shared/js/lazy_loader.js"></script>
|
||||
<script defer src="shared/js/font_size_utils.js"></script>
|
||||
<script defer src="shared/js/accessibility_helper.js"></script>
|
||||
<script defer src="shared/js/async_storage.js"></script>
|
||||
<script defer src="shared/js/image_utils.js"></script>
|
||||
<script defer src="shared/js/omadrm/fl.js"></script>
|
||||
<script defer src="shared/js/sanitizer.js"></script>
|
||||
<!-- Web components -->
|
||||
<script defer src="shared/js/component_utils.js"></script>
|
||||
<script defer src="shared/elements/gaia_subheader/script.js"></script>
|
||||
<script defer src="shared/elements/gaia-header/dist/gaia-header.js"></script>
|
||||
<!-- Specific code -->
|
||||
<script defer src="js/utils.js"></script>
|
||||
<script defer src="js/music.js"></script>
|
||||
<script defer src="js/db.js"></script>
|
||||
<script defer src="js/ui/title_bar.js"></script>
|
||||
<script defer src="js/ui/tab_bar.js"></script>
|
||||
<script defer src="js/ui/views/mode_manager.js"></script>
|
||||
<!-- Lazy load code -->
|
||||
<!--
|
||||
<script defer src="shared/js/text_normalizer.js"></script>
|
||||
<script defer src="shared/js/bluetooth_helper.js"></script>
|
||||
<script defer src="shared/js/media/remote_controls.js"></script>
|
||||
<script defer src="js/metadata/*.js"></script>
|
||||
<script defer src="js/communications.js"></script>
|
||||
<script defer src="js/ui/views/tiles_view.js"></script>
|
||||
<script defer src="js/ui/views/list_view.js"></script>
|
||||
<script defer src="js/ui/views/subList_view.js"></script>
|
||||
<script defer src="js/ui/views/search_view.js"></script>
|
||||
-->
|
||||
</head>
|
||||
|
||||
<body role="application" class="theme-media">
|
||||
<gaia-header id="title" action="back" no-font-fit>
|
||||
<h1 id="title-text"><bdi data-l10n-id="music"></bdi></h1>
|
||||
<button id="title-player" data-icon="play-circle" data-l10n-id="go-to-player-view" hidden></button>
|
||||
<button id="title-done" data-l10n-id="done" hidden>Done</button>
|
||||
</gaia-header>
|
||||
<div id="views">
|
||||
<div id="views-tiles" class="view bb-tabpanel hidden" aria-labelledby="tabs-mix" role="tabpanel">
|
||||
<form id="views-tiles-search" role="search" class="skin-dark">
|
||||
<button id="views-tiles-search-close" data-l10n-id="search-close" type="submit">close</button>
|
||||
<p>
|
||||
<input id="views-tiles-search-input" type="search" dir="auto"
|
||||
x-inputmode="verbatim"
|
||||
data-l10n-id="search-music" placeholder="SearcH MusiC">
|
||||
<button id="views-tiles-search-clear" type="reset">Clear</button>
|
||||
</p>
|
||||
</form>
|
||||
<div id="views-tiles-anchor"></div>
|
||||
</div>
|
||||
<div id="views-list" class="view bb-tabpanel hidden" role="tabpanel">
|
||||
<form id="views-list-search" role="search" class="skin-dark">
|
||||
<button id="views-list-search-close" data-l10n-id="search-close" type="submit">close</button>
|
||||
<p>
|
||||
<input id="views-list-search-input" type="search"
|
||||
x-inputmode="verbatim"
|
||||
data-l10n-id="search-music" placeholder="SearcH MusiC">
|
||||
<button id="views-list-search-clear" type="reset">Clear</button>
|
||||
</p>
|
||||
</form>
|
||||
<div id="views-list-anchor" role="listbox"></div>
|
||||
</div>
|
||||
<div id="views-sublist" class="view hidden">
|
||||
<div id="views-sublist-header">
|
||||
<img id="views-sublist-header-image">
|
||||
<div id="views-sublist-header-controls">
|
||||
<bdi id="views-sublist-header-name">Name</bdi>
|
||||
<button id="views-sublist-controls-play" class="album-controls-button" data-icon="play" data-l10n-id="playbackPlay"></button>
|
||||
<button id="views-sublist-controls-shuffle" class="album-controls-button" data-icon="shuffle" data-l10n-id="shuffle-toggle"></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bug 968456. Scrollable area needs to be inside an opaque background div to help gecko -->
|
||||
<div id="views-scrollable-sublist-anchor"><div id="views-sublist-anchor" role="listbox"></div></div>
|
||||
</div>
|
||||
<div id="views-player" class="view hidden">
|
||||
<div id="player-cover">
|
||||
<!-- We have two cover image elements so we can crossfade between
|
||||
album art. -->
|
||||
<div id="player-cover-image-1" class="cover-image visible"></div>
|
||||
<div id="player-cover-image-2" class="cover-image"></div>
|
||||
<div id="player-cover-caption">
|
||||
<div id="player-cover-artist"><bdi data-l10n-id="songArtist">Song Artist</bdi></div>
|
||||
<div id="player-cover-album"><bdi data-l10n-id="songAlbum">Song Album</bdi></div>
|
||||
<button id="player-cover-share" class="player-cover-button" data-icon="share" data-l10n-id="share-song"></button>
|
||||
</div>
|
||||
<div id="player-cover-buttons">
|
||||
<button id="player-album-repeat" data-l10n-id="repeat-off" value="off" class="player-cover-button" data-icon="repeat"></button>
|
||||
<div id="player-album-rating" data-l10n-id="player-album-rating" role="radiogroup">
|
||||
<button class="rating-star" data-rating="1" role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"1"}'></button>
|
||||
<button class="rating-star" data-rating="2" role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"2"}'></button>
|
||||
<button class="rating-star" data-rating="3" role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"3"}'></button>
|
||||
<button class="rating-star" data-rating="4" role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"4"}'></button>
|
||||
<button class="rating-star" data-rating="5" role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"5"}'></button>
|
||||
</div>
|
||||
<button id="player-album-shuffle" class="player-cover-button" data-icon="shuffle" aria-pressed="true" data-l10n-id="shuffle-toggle"></button>
|
||||
</div>
|
||||
<section id="info-banner" role="status">
|
||||
<p data-l10n-id="music-paused-notification"></p>
|
||||
</section>
|
||||
</div>
|
||||
<div id="player-seek" role="slider" aria-valuemin="0">
|
||||
<span id="player-seek-elapsed" dir="ltr">00:00</span>
|
||||
<div id="player-seek-bar">
|
||||
<progress id="player-seek-bar-progress" value="0"></progress>
|
||||
<div id="player-seek-bar-indicator"></div>
|
||||
</div>
|
||||
<span id="player-seek-remaining" dir="ltr">00:00</span>
|
||||
</div>
|
||||
<div id="player-controls" dir="ltr">
|
||||
<button id="player-controls-previous" class="player-controls-button" data-icon="skip-back" data-l10n-id="playbackPrevious"></button>
|
||||
<button id="player-controls-play" class="player-controls-button is-pause" data-icon="pause" data-l10n-id="playbackPause"></button>
|
||||
<button id="player-controls-next" class="player-controls-button" data-icon="skip-forward" data-l10n-id="playbackNext"></button>
|
||||
</div>
|
||||
<audio id="player-audio" class="hidden"></audio>
|
||||
</div>
|
||||
<div id="views-search" class="view hidden">
|
||||
<form id="views-search-form" role="search" class="skin-dark">
|
||||
<button id="views-search-close" data-l10n-id="search-close" type="submit">close</button>
|
||||
<p>
|
||||
<input id="views-search-input" type="search" dir="auto"
|
||||
x-inputmode="verbatim"
|
||||
data-l10n-id="search-music" placeholder="SearcH MusiC">
|
||||
<button id="views-search-clear" type="reset">Clear</button>
|
||||
</p>
|
||||
</form>
|
||||
<div id="views-search-no-result" class="search-category hidden">
|
||||
<p id="search-no-result" data-l10n-id="search-no-result">No musiC founD</p>
|
||||
</div>
|
||||
<div id="views-search-anchor">
|
||||
<div id="views-search-artists" class="search-category hidden">
|
||||
<gaia-subheader skin="dark">
|
||||
<span data-l10n-id="search-artists">ArtistS</span>
|
||||
(<span class="search-result-count"></span>)
|
||||
</gaia-subheader>
|
||||
<div class="search-results" role="listbox"></div>
|
||||
</div>
|
||||
<div id="views-search-albums" class="search-category hidden">
|
||||
<gaia-subheader skin="dark">
|
||||
<span data-l10n-id="search-albums">AlbumS</span>
|
||||
(<span class="search-result-count"></span>)
|
||||
</gaia-subheader>
|
||||
<div class="search-results" role="listbox"></div>
|
||||
</div>
|
||||
<div id="views-search-titles" class="search-category hidden">
|
||||
<gaia-subheader skin="dark">
|
||||
<span data-l10n-id="search-titles">TrackS</span>
|
||||
(<span class="search-result-count"></span>)
|
||||
</gaia-subheader>
|
||||
<div class="search-results" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BB tabs -->
|
||||
<div id="tabs">
|
||||
<ul role="tablist" class="bb-tablist skin-dark">
|
||||
<li id="mix" role="presentation">
|
||||
<a id="tabs-mix" data-option="mix" role="tab" tabindex="0" aria-selected="false" aria-controls="views-tiles" data-icon="grid" data-l10n-id="mix-tab"></a>
|
||||
</li>
|
||||
<li id="playlists" role="presentation">
|
||||
<a id="tabs-playlists" data-option="playlist" role="tab" tabindex="0" aria-selected="false" aria-controls="views-list" data-icon="playlist" data-l10n-id="playlists-tab"></a>
|
||||
</li>
|
||||
<li id="artists" role="presentation">
|
||||
<a id="tabs-artists" data-option="artist" role="tab" tabindex="0" aria-selected="false" aria-controls="views-list" data-icon="artist" data-l10n-id="artists-tab"></a>
|
||||
</li>
|
||||
<li id="albums" role="presentation">
|
||||
<a id="tabs-albums" data-option="album" role="tab" tabindex="0" aria-selected="false" aria-controls="views-list" data-icon="album" data-l10n-id="albums-tab"></a>
|
||||
</li>
|
||||
<li id="songs" role="presentation">
|
||||
<a id="tabs-songs" data-option="title" role="tab" tabindex="0" aria-selected="false" aria-controls="views-list" data-icon="songs" data-l10n-id="songs-tab"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- display scanning progress over the titlebar -->
|
||||
<div id="scan-progress" class="hidden">
|
||||
<progress id="scan-spinner" class="hidden"></progress>
|
||||
<div id="scan-count"></div>
|
||||
<div id="scan-artist-container"><bdi id="scan-artist"></bdi></div>
|
||||
<div id="scan-title-container"><bdi id="scan-title"></bdi></div>
|
||||
</div>
|
||||
<!-- display messages like "Scanning", "No Songs" and "No SD Card" here -->
|
||||
<form id="overlay" role="dialog" data-type="confirm" class="hidden">
|
||||
<section id="overlay-content">
|
||||
<h1 id="overlay-title"></h1>
|
||||
<p id="overlay-text"></p>
|
||||
</section>
|
||||
<menu id="overlay-menu" class="hidden">
|
||||
<button type="button" id="overlay-cancel-button" class="full"
|
||||
data-l10n-id="overlay-cancel-button">Cancel</button>
|
||||
</menu>
|
||||
</form>
|
||||
<!-- display this at startup while we load music -->
|
||||
<div id="spinner-overlay"><progress id="spinner"></progress></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
189
gaia/dev_apps/music-oga/js/communications.js
Executable file
|
|
@ -0,0 +1,189 @@
|
|||
/* exported MusicComms */
|
||||
/* global Database, INTERRUPT_BEGIN, MediaRemoteControls, ModeManager,
|
||||
MODE_PLAYER, PlaybackQueue, PlayerView, PLAYSTATUS_PLAYING,
|
||||
PLAYSTATUS_STOPPED */
|
||||
'use strict';
|
||||
|
||||
var MusicComms = {
|
||||
commands: {
|
||||
play: function(event) {
|
||||
this.commands.playpause.call(this, event);
|
||||
},
|
||||
|
||||
// For those bluetooth devices that do not keep, request or receive
|
||||
// the play status, because the play status may not sync with the player,
|
||||
// they will just send "play" or "playpause" when pressing the "playpause"
|
||||
// button, so for the "play" and "playpause" commands, we will check
|
||||
// the play status for the player then decide we should play or pause.
|
||||
playpause: function(event) {
|
||||
this._getPlayerReady(() => {
|
||||
var isResumedBySCO = this.isSCOEnabled;
|
||||
this.isSCOEnabled = event.detail.isSCOConnected;
|
||||
|
||||
// Check if it's resumed by the SCO disconnection, if so, we need to
|
||||
// recover the player to the original status.
|
||||
if (isResumedBySCO) {
|
||||
if (this._statusBeforeSCO === PLAYSTATUS_PLAYING ||
|
||||
this._statusBeforeSCO === INTERRUPT_BEGIN) {
|
||||
PlayerView.play();
|
||||
} else {
|
||||
PlayerView.pause();
|
||||
}
|
||||
} else {
|
||||
// Play in shuffle order if music app is launched remotely.
|
||||
if (PlayerView.playStatus === PLAYSTATUS_STOPPED) {
|
||||
Database.count('metadata.title', null, (count) => {
|
||||
var info = {
|
||||
key: 'metadata.title',
|
||||
range: null,
|
||||
direction: 'next',
|
||||
option: 'title',
|
||||
count: count
|
||||
};
|
||||
|
||||
PlaybackQueue.shuffle = true;
|
||||
PlayerView.activate(new PlaybackQueue.DynamicQueue(info));
|
||||
PlayerView.start();
|
||||
});
|
||||
} else if (PlayerView.playStatus === PLAYSTATUS_PLAYING) {
|
||||
PlayerView.pause();
|
||||
} else {
|
||||
PlayerView.play();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
pause: function(event) {
|
||||
this.isSCOEnabled = event.detail.isSCOConnected;
|
||||
|
||||
if (!this._isPlayerActivated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record the current play status so that we can recover the player to
|
||||
// the original status after SCO is disconnected.
|
||||
if (this.isSCOEnabled) {
|
||||
this._statusBeforeSCO = PlayerView.playStatus;
|
||||
}
|
||||
|
||||
PlayerView.pause();
|
||||
},
|
||||
|
||||
stop: function(event) {
|
||||
if (!this._isPlayerActivated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerView.stop();
|
||||
},
|
||||
|
||||
next: function(event) {
|
||||
if (!this._isPlayerActivated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerView.next();
|
||||
},
|
||||
|
||||
previous: function(event) {
|
||||
if (!this._isPlayerActivated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerView.previous();
|
||||
},
|
||||
|
||||
seekpress: function(event) {
|
||||
if (!this._isPlayerActivated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PlayerView.isTouching) {
|
||||
PlayerView.startFastSeeking(event.detail.direction);
|
||||
}
|
||||
},
|
||||
|
||||
seekrelease: function(event) {
|
||||
if (!this._isPlayerActivated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerView.stopFastSeeking();
|
||||
},
|
||||
|
||||
updatemetadata: function() {
|
||||
// After A2DP is connected, some bluetooth devices will try to synchronize
|
||||
// the playing metadata with the player, if the player is not ready, we
|
||||
// have to initial the player to have the metadata, even the player is
|
||||
// stopped.
|
||||
this._getPlayerReady(function() {
|
||||
PlayerView.updateRemoteMetadata();
|
||||
});
|
||||
},
|
||||
|
||||
updateplaystatus: function() {
|
||||
// After A2DP is connected, some bluetooth devices will try to synchronize
|
||||
// the playstatus with the player, if the player is not ready, we have to
|
||||
// initial the player to have the play status.
|
||||
this._getPlayerReady(function() {
|
||||
PlayerView.updateRemotePlayStatus();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
enabled: false,
|
||||
|
||||
isSCOEnabled: false,
|
||||
|
||||
_statusBeforeSCO: null,
|
||||
|
||||
init: function() {
|
||||
// The Media Remote Controls object will handle the remote commands.
|
||||
this.mrc = new MediaRemoteControls();
|
||||
// Add command listeners base on what commands the MusicComms has.
|
||||
for (var command in this.commands) {
|
||||
this.mrc.addCommandListener(command, this.commands[command].bind(this));
|
||||
}
|
||||
|
||||
// Update the SCO status after the mrc is ready, so that we can know the
|
||||
// current SCO connection and reflect it to the player.
|
||||
this.mrc.start(this.updateSCOStatus.bind(this));
|
||||
|
||||
this.mrc.notifyAppInfo({
|
||||
origin: window.location.origin,
|
||||
icon: window.location.origin + '/style/icons/default_84.png'
|
||||
});
|
||||
|
||||
this.enabled = true;
|
||||
},
|
||||
|
||||
_getPlayerReady: function(callback) {
|
||||
ModeManager.waitForView(MODE_PLAYER, callback);
|
||||
},
|
||||
|
||||
_isPlayerActivated: function() {
|
||||
return (typeof PlayerView !== 'undefined' &&
|
||||
PlayerView.playStatus !== PLAYSTATUS_STOPPED);
|
||||
},
|
||||
|
||||
notifyMetadataChanged: function(metadata) {
|
||||
if (this.enabled) {
|
||||
this.mrc.notifyMetadataChanged(metadata);
|
||||
}
|
||||
},
|
||||
|
||||
notifyStatusChanged: function(info) {
|
||||
if (this.enabled) {
|
||||
this.mrc.notifyStatusChanged(info);
|
||||
}
|
||||
},
|
||||
|
||||
updateSCOStatus: function() {
|
||||
if (this.enabled) {
|
||||
this.mrc.getSCOStatus(function(status) {
|
||||
this.isSCOEnabled = status;
|
||||
}.bind(this));
|
||||
}
|
||||
}
|
||||
};
|
||||
314
gaia/dev_apps/music-oga/js/db.js
Executable file
|
|
@ -0,0 +1,314 @@
|
|||
/* exported Database */
|
||||
/* global AlbumArt, App, AudioMetadata, ForwardLock, LazyLoader, MediaDB,
|
||||
Normalizer, TitleBar */
|
||||
'use strict';
|
||||
|
||||
var Database = (function() {
|
||||
// The MediaDB object that manages the filesystem and the database of metadata
|
||||
// See init()
|
||||
var musicdb;
|
||||
|
||||
function init() {
|
||||
// We want to exclude some folders that store ringtones so they don't show
|
||||
// up in the music app. The regex matches absolute paths starting with a
|
||||
// volume name (e.g. "/volume-name/Ringtones/") or relative paths starting
|
||||
// with the excluded folder name (e.g. "Ringtones/").
|
||||
var excludedFolders = ['Ringtones', 'Notifications', 'Alarms'];
|
||||
var excludeFilter = new RegExp(
|
||||
'^(/[^/]*/)?(' + excludedFolders.join('|') + ')/', 'i'
|
||||
);
|
||||
|
||||
// Here we use the mediadb.js which gallery is using (in shared/js/)
|
||||
// to index our music contents with metadata parsed.
|
||||
// So the behaviors of musicdb are the same as the MediaDB in gallery
|
||||
musicdb = new MediaDB('music', metadataParserWrapper, {
|
||||
indexes: ['metadata.album', 'metadata.artist', 'metadata.title',
|
||||
'metadata.rated', 'metadata.played', 'date'],
|
||||
excludeFilter: excludeFilter,
|
||||
batchSize: 1,
|
||||
autoscan: false, // We call scan() explicitly after listing music we know
|
||||
updateRecord: updateRecord,
|
||||
reparsedRecord: reparsedRecord,
|
||||
version: 3
|
||||
});
|
||||
|
||||
function metadataParserWrapper(file, onsuccess, onerror) {
|
||||
var files = ['/js/metadata_scripts.js', '/js/metadata/album_art.js'];
|
||||
LazyLoader.load(files).then(() => {
|
||||
return AudioMetadata.parse(file);
|
||||
}).then((metadata) => {
|
||||
return AlbumArt.process(file, metadata);
|
||||
}).then(onsuccess, onerror);
|
||||
}
|
||||
|
||||
var startedReparsing = false;
|
||||
|
||||
function updateRecord(record, oldVersion, newVersion) {
|
||||
if (oldVersion === 2) {
|
||||
// Version 3 of the music DB changes ID3 parsing, so we need to reparse
|
||||
// the file from scratch!
|
||||
record.needsReparse = true;
|
||||
if (!startedReparsing) {
|
||||
startedReparsing = true;
|
||||
App.showOverlay('upgrade');
|
||||
}
|
||||
}
|
||||
return record.metadata;
|
||||
}
|
||||
|
||||
function reparsedRecord(oldMetadata, newMetadata) {
|
||||
// We assume that updateRecord has already changed oldMetadata if
|
||||
// necessary. (It's not necessary at the moment).
|
||||
newMetadata.rated = oldMetadata.rated;
|
||||
newMetadata.played = oldMetadata.played;
|
||||
return newMetadata;
|
||||
}
|
||||
|
||||
// show dialog in upgradestart, when it finished, it will turned to ready.
|
||||
musicdb.onupgrading = function(event) {
|
||||
App.showOverlay('upgrade');
|
||||
};
|
||||
|
||||
// This is called when DeviceStorage becomes unavailable because the
|
||||
// sd card is removed or because it is mounted for USB mass storage
|
||||
// This may be called before onready if it is unavailable to begin with
|
||||
musicdb.onunavailable = function(event) {
|
||||
var why;
|
||||
switch (event.detail) {
|
||||
case MediaDB.NOCARD:
|
||||
why = 'nocard';
|
||||
break;
|
||||
case MediaDB.UNMOUNTED:
|
||||
why = 'unmounted';
|
||||
break;
|
||||
}
|
||||
|
||||
App.dbUnavailable(why);
|
||||
};
|
||||
|
||||
// If the user removed the sdcard (but there is still internal storage)
|
||||
// we just need to stop playing, we don't have to put up an overlay.
|
||||
// This event will be followed by deleted events to remove the songs
|
||||
// that were on the sdcard and are no longer playable.
|
||||
musicdb.oncardremoved = function() {
|
||||
App.dbUnavailable('cardremoved');
|
||||
};
|
||||
|
||||
musicdb.onenumerable = startupOnEnumerable;
|
||||
// Don't refresh the UI on the first onready event, since onenumerable will
|
||||
// have already handled the refresh.
|
||||
var refreshOnReady = false;
|
||||
|
||||
function startupOnEnumerable() {
|
||||
App.dbEnumerable(function() {
|
||||
if (musicdb.state === MediaDB.READY) {
|
||||
onReady();
|
||||
} else {
|
||||
musicdb.onready = onReady;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onReady() {
|
||||
App.dbReady(refreshOnReady, function() {
|
||||
// Start scanning for new music
|
||||
musicdb.scan();
|
||||
});
|
||||
|
||||
// Subsequent onready events need to refresh the UI.
|
||||
refreshOnReady = true;
|
||||
}
|
||||
|
||||
var filesDeletedWhileScanning = 0;
|
||||
var filesFoundWhileScanning = 0;
|
||||
var filesFoundBatch = 0;
|
||||
var scanning = false;
|
||||
var SCAN_UPDATE_BATCH_SIZE = 25; // Redisplay after this many new files
|
||||
var DELETE_BATCH_TIMEOUT = 500; // Redisplay this long after a delete
|
||||
var deleteTimer = null;
|
||||
var firstScanDone = false;
|
||||
|
||||
// When musicdb scans, let the user know
|
||||
musicdb.onscanstart = function() {
|
||||
scanning = true;
|
||||
filesFoundWhileScanning = 0;
|
||||
filesFoundBatch = 0;
|
||||
filesDeletedWhileScanning = 0;
|
||||
};
|
||||
|
||||
// And hide the throbber when scanning is done
|
||||
musicdb.onscanend = function() {
|
||||
scanning = false;
|
||||
TitleBar.hideScanProgress();
|
||||
|
||||
if (filesFoundBatch > 0 || filesDeletedWhileScanning > 0) {
|
||||
filesFoundWhileScanning = 0;
|
||||
filesFoundBatch = 0;
|
||||
filesDeletedWhileScanning = 0;
|
||||
App.refreshViews();
|
||||
}
|
||||
|
||||
// If this was the first scan after startup, tell the performance monitors
|
||||
// that we finished loading everything.
|
||||
if (!firstScanDone) {
|
||||
firstScanDone = true;
|
||||
window.performance.mark('fullyLoaded');
|
||||
}
|
||||
};
|
||||
|
||||
// When MediaDB finds new files, it sends created events. During
|
||||
// scanning we may get lots of them. Bluetooth file transfer can
|
||||
// also result in created events. The way the app is currently
|
||||
// structured, all we can do is rebuild the entire UI with the
|
||||
// updated list of files. We don't want to do this for every new file
|
||||
// but we do want to redisplay every so often.
|
||||
musicdb.oncreated = function(event) {
|
||||
if (scanning) {
|
||||
var metadata = event.detail[0].metadata;
|
||||
var n = event.detail.length;
|
||||
filesFoundWhileScanning += n;
|
||||
filesFoundBatch += n;
|
||||
|
||||
TitleBar.showScanProgress({
|
||||
count: filesFoundWhileScanning,
|
||||
artist: metadata.artist,
|
||||
title: metadata.title
|
||||
});
|
||||
|
||||
if (filesFoundBatch > SCAN_UPDATE_BATCH_SIZE) {
|
||||
filesFoundBatch = 0;
|
||||
App.refreshViews();
|
||||
}
|
||||
}
|
||||
else {
|
||||
// If we get a created event while we are not scanning, then
|
||||
// there was probably a new song saved via bluetooth or MMS.
|
||||
// We don't have any way to be clever about it; we just have to
|
||||
// redisplay the entire view
|
||||
App.refreshViews();
|
||||
}
|
||||
};
|
||||
|
||||
// For deletions, we just set a flag and redisplay when the scan is done.
|
||||
// This means that there is a longer window of time when the app might
|
||||
// display music that is no longer available. But the only way to prevent
|
||||
// this is to refuse to display any music until the scan completes.
|
||||
musicdb.ondeleted = function(event) {
|
||||
if (scanning) {
|
||||
// If we get a deletion during a scan, just note it for processing
|
||||
// when the scan is over
|
||||
filesDeletedWhileScanning += event.detail.length;
|
||||
}
|
||||
else {
|
||||
// Otherwise, if we're not scanning, this may be one in a series
|
||||
// of deletions (we get lots when the sd card is pulled out, for
|
||||
// example). Don't redisplay the UI right away. Instead, wait until the
|
||||
// deletions seem to have stopped or paused before updating
|
||||
if (deleteTimer) {
|
||||
clearTimeout(deleteTimer);
|
||||
}
|
||||
deleteTimer = setTimeout(function() {
|
||||
deleteTimer = null;
|
||||
App.refreshViews();
|
||||
}, DELETE_BATCH_TIMEOUT);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function incrementPlayCount(fileinfo) {
|
||||
fileinfo.metadata.played++;
|
||||
musicdb.updateMetadata(fileinfo.name, {played: fileinfo.metadata.played});
|
||||
}
|
||||
|
||||
function setSongRating(fileinfo, rated) {
|
||||
fileinfo.metadata.rated = rated;
|
||||
musicdb.updateMetadata(fileinfo.name, {rated: fileinfo.metadata.rated});
|
||||
}
|
||||
|
||||
function getFile(fileinfo, decrypt = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
musicdb.getFile(fileinfo.name, (file) => {
|
||||
if (file) {
|
||||
resolve(file);
|
||||
} else {
|
||||
reject('unable to get file: ' + fileinfo.name);
|
||||
}
|
||||
});
|
||||
}).then((blob) => {
|
||||
if (!decrypt || !fileinfo.metadata.locked) {
|
||||
return blob;
|
||||
}
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
ForwardLock.getKey(function(secret) {
|
||||
ForwardLock.unlockBlob(secret, blob, resolve, null, reject);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function enumerate(...args) {
|
||||
return musicdb.enumerate(...args);
|
||||
}
|
||||
|
||||
function enumerateAll(...args) {
|
||||
return musicdb.enumerateAll(...args);
|
||||
}
|
||||
|
||||
function advancedEnumerate(...args) {
|
||||
return musicdb.advancedEnumerate(...args);
|
||||
}
|
||||
|
||||
function count(...args) {
|
||||
return musicdb.count(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the music database for a particular query.
|
||||
*
|
||||
* @param {String} key The field to query against (e.g. 'title').
|
||||
* @param {String} query The string to search for.
|
||||
* @param {Function} callback A function to call with the results of the
|
||||
* search, called once per result (plus a final call passing `null`).
|
||||
* @return {Object} The handle for this query.
|
||||
*/
|
||||
function search(key, query, callback) {
|
||||
// Convert to lowercase and replace accented characters.
|
||||
query = Normalizer.toAscii(query.toLocaleLowerCase());
|
||||
var direction = (key === 'title') ? 'next' : 'nextunique';
|
||||
|
||||
return musicdb.enumerate('metadata.' + key, null, direction, (result) => {
|
||||
if (result === null) {
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
var resultLowerCased = result.metadata[key].toLocaleLowerCase();
|
||||
if (Normalizer.toAscii(resultLowerCased).indexOf(query) !== -1) {
|
||||
callback(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEnumeration(handle) {
|
||||
musicdb.cancelEnumeration(handle);
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
incrementPlayCount: incrementPlayCount,
|
||||
setSongRating: setSongRating,
|
||||
getFile: getFile,
|
||||
enumerate: enumerate,
|
||||
enumerateAll: enumerateAll,
|
||||
advancedEnumerate: advancedEnumerate,
|
||||
count: count,
|
||||
search: search,
|
||||
cancelEnumeration: cancelEnumeration,
|
||||
|
||||
// This is just here for testing.
|
||||
get initialScanComplete() {
|
||||
return musicdb.initialScanComplete;
|
||||
}
|
||||
};
|
||||
})();
|
||||
231
gaia/dev_apps/music-oga/js/metadata/album_art.js
Executable file
|
|
@ -0,0 +1,231 @@
|
|||
/* exported AlbumArt */
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Metadata describing the cover art for a track. Cover art comes in one of
|
||||
* three flavors: embedded (stored in the audio track's metadata), unsynced
|
||||
* (like embedded, but stored in an unsynchronized block of ID3 data; we
|
||||
* ultimately save it in an external file), or external (stored as a separate
|
||||
* file in the same directory as the audio track).
|
||||
*
|
||||
* @typedef {Object} Picture
|
||||
* @property {String} flavor How the art was stored; one of "embedded",
|
||||
* "unsynced", or "external".
|
||||
* @property {Number} [start] The offset in bytes to where the picture is
|
||||
* stored in the audio file; only applies when flavor="embedded".
|
||||
* @property {Number} [end] The offset in bytes to the end of where the
|
||||
* picture is stored in the audio file; only applies when flavor="embedded".
|
||||
* @property {Number} [type] The mimetype of the picture; only applies when
|
||||
* flavor="embedded".
|
||||
* @property {String} [filename] The path on the filesystem to the original
|
||||
* (full-size) picture; only applies when flavor="external" or "unsynced".
|
||||
*/
|
||||
|
||||
var AlbumArt = (function() {
|
||||
var pictureStorage = navigator.getDeviceStorage('pictures');
|
||||
|
||||
// A cache of metadata Picture objects for albums with external cover art. The
|
||||
// key is the directory name for the song in question.
|
||||
var externalCoverCache = {};
|
||||
|
||||
// A cache of filenames for saved cover art (generated from unsynced ID3
|
||||
// tags). The value is the absolute path name.
|
||||
var savedCoverCache = new Set();
|
||||
|
||||
/**
|
||||
* Perform additional processing for cover art, such as checking for external
|
||||
* artwork and saving temporary image blobs (for unsynchronized ID3 data).
|
||||
*
|
||||
* @param {Blob} blob The audio file.
|
||||
* @param {Metadata} metadata The metadata for the file.
|
||||
* @return {Promise} A Promise resolving to the metadata object with any
|
||||
* additional cover art fields added as necessary.
|
||||
*/
|
||||
function process(blob, metadata) {
|
||||
// If a blob isn't backed by an actual file, we can't do anything here
|
||||
// except return what we have.
|
||||
var filename = blob.name;
|
||||
if (!filename) {
|
||||
return Promise.resolve(metadata);
|
||||
}
|
||||
|
||||
if (!metadata.picture) {
|
||||
// We don't have any embedded album art; see if there's any external album
|
||||
// art in the same directory.
|
||||
var lastSlash = filename.lastIndexOf('/');
|
||||
var dirName = filename.substring(0, lastSlash + 1);
|
||||
|
||||
// First, check if we've recently fetched this cover; if so, just use the
|
||||
// cached results so we're not going out to the disk more than necessary.
|
||||
if (dirName in externalCoverCache) {
|
||||
metadata.picture = externalCoverCache[dirName];
|
||||
return Promise.resolve(metadata);
|
||||
}
|
||||
|
||||
// Try to find external album art using a handful of common names. The
|
||||
// possibilities listed here appear to be the most common, based on a
|
||||
// brief survey of what people use in the wild.
|
||||
return new Promise(function(resolve, reject) {
|
||||
var possibleFilenames = ['folder.jpg', 'cover.jpg', 'front.jpg'];
|
||||
var tryFetchExternalCover = function(index) {
|
||||
if (index === possibleFilenames.length) {
|
||||
// We couldn't find any external album art.
|
||||
externalCoverCache[dirName] = null;
|
||||
resolve(metadata);
|
||||
return;
|
||||
}
|
||||
|
||||
var externalCoverFilename = dirName + possibleFilenames[index];
|
||||
var getcoverrequest = pictureStorage.get(externalCoverFilename);
|
||||
getcoverrequest.onsuccess = function() {
|
||||
metadata.picture = { flavor: 'external',
|
||||
filename: externalCoverFilename };
|
||||
// Cache the picture object we generated to make things faster.
|
||||
externalCoverCache[dirName] = metadata.picture;
|
||||
resolve(metadata);
|
||||
};
|
||||
getcoverrequest.onerror = function() {
|
||||
tryFetchExternalCover(index + 1);
|
||||
};
|
||||
};
|
||||
tryFetchExternalCover(0);
|
||||
});
|
||||
} else if (metadata.picture.blob) {
|
||||
// We have album art in a separate blob that we need to save somewhere.
|
||||
// Two reasons :
|
||||
// - we had an unsynced ID3 frame with the art and needed to de-unsync
|
||||
// it.
|
||||
// - we had album art from a VorbisComment.
|
||||
//
|
||||
// First, try to make a unique filename for the image based on the artist,
|
||||
// album, and file size. The file size is essentially a poor man's
|
||||
// checksum to make sure we aren't reusing an inappropriate image, such as
|
||||
// if the user updated the embedded art.
|
||||
var albumKey;
|
||||
if (metadata.artist || metadata.album) {
|
||||
// Don't let the artist or album strings be too long; VFAT has a maximum
|
||||
// of 255 characters for the path, and we need some room for the rest of
|
||||
// it!
|
||||
var artist = (metadata.artist || '').substr(0, 64);
|
||||
var album = (metadata.album || '').substr(0, 64);
|
||||
albumKey = artist + '.' + album;
|
||||
} else {
|
||||
// If there's no album or artist, use the path to the file instead; we
|
||||
// truncate from the end to help ensure uniqueness.
|
||||
albumKey = filename.substr(-128);
|
||||
}
|
||||
|
||||
var coverBlob = metadata.picture.blob;
|
||||
delete metadata.picture.blob;
|
||||
|
||||
// coverBlob is always a JPEG or PNG.
|
||||
var extension = coverBlob.type === 'image/jpeg' ? '.jpg' : '.png';
|
||||
var imageFilename = vfatEscape(albumKey) + '.' + coverBlob.size +
|
||||
extension;
|
||||
var storageName = getStorageName(filename);
|
||||
return checkSaveCover(coverBlob, imageFilename, storageName).then(
|
||||
function(savedFile) {
|
||||
metadata.picture.filename = savedFile;
|
||||
return metadata;
|
||||
}, function(err) {
|
||||
delete metadata.picture;
|
||||
return metadata;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// We have embedded album art! All the processing is already done, so we
|
||||
// can just return.
|
||||
return Promise.resolve(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape any characters that are illegal on the VFAT filesystem.
|
||||
*
|
||||
* @param {string} str The string to escape.
|
||||
* @return {string} The escaped string.
|
||||
*/
|
||||
function vfatEscape(str) {
|
||||
return str.replace(/["*\/:<>?\|]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the storage area for an absolute filename, with leading
|
||||
* and trailing slashes (e.g. '/sdcard/'). If the filename is relative,
|
||||
* return the empty string (i.e. use the default storage area.)
|
||||
*
|
||||
* @param {string} filename The path to the file.
|
||||
* @return {string} The file's storage area, or the empty string if the path
|
||||
* was relative.
|
||||
*/
|
||||
function getStorageName(filename) {
|
||||
// `filename` is usually a fully qualified name (perhaps something like
|
||||
// /sdcard/Music/file.mp3). On desktop, it's a relative name, but desktop
|
||||
// only has one storage area anyway.
|
||||
if (filename[0] === '/') {
|
||||
var slashIndex = filename.indexOf('/', 1);
|
||||
if (slashIndex < 0) {
|
||||
var err = Error('handleCoverArt: Bad filename: "' + filename + '"');
|
||||
console.error(err);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
// Get the storage name, e.g. /sdcard/
|
||||
return filename.substring(0, slashIndex + 1);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for the existence of an image on the audio file's storage area,
|
||||
* and if it's not present, try to save it.
|
||||
*
|
||||
* @param {Blob} coverBlob The blob for the full-size cover art.
|
||||
* @param {string} imageFilename A relative filename for the image.
|
||||
* @param {string} storageName The name of the storage area too use for
|
||||
* saving the image. May be an empty string or a path with trailing '/'.
|
||||
* For instance, to store on the SD card, pass '/sdcard/'.
|
||||
* @return {Promise} A Promise that resolves when finished, providing the
|
||||
* filename of the saved image.
|
||||
*/
|
||||
function checkSaveCover(coverBlob, imageFilename, storageName) {
|
||||
// We want to put the image in the same storage area as the audio track it's
|
||||
// for. Since the audio track could be in any storage area, we examine its
|
||||
// filename to get the storage name; the storage name is always the first
|
||||
// part of the (absolute) filename, so we grab that and build an absolute
|
||||
// path for the image. This will ensure that the generic deviceStorage we
|
||||
// use for pictures ("pictureStorage") puts the image where we want it.
|
||||
var imageAbsPath = storageName + '.music/covers/' + imageFilename;
|
||||
if (savedCoverCache.has(imageAbsPath)) {
|
||||
return Promise.resolve(imageAbsPath);
|
||||
}
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
var getrequest = pictureStorage.get(imageAbsPath);
|
||||
|
||||
// We already have the image. We're done!
|
||||
getrequest.onsuccess = function() {
|
||||
savedCoverCache.add(imageAbsPath);
|
||||
resolve(imageAbsPath);
|
||||
};
|
||||
|
||||
// We don't have the image yet. Let's save it.
|
||||
getrequest.onerror = function() {
|
||||
var saverequest = pictureStorage.addNamed(coverBlob, imageAbsPath);
|
||||
saverequest.onerror = function() {
|
||||
console.error('Could not save cover image', imageFilename);
|
||||
};
|
||||
|
||||
// Don't bother waiting for saving to finish. Just return.
|
||||
savedCoverCache.add(imageAbsPath);
|
||||
resolve(imageAbsPath);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
process: process,
|
||||
pictureStorage: pictureStorage
|
||||
};
|
||||
})();
|
||||
350
gaia/dev_apps/music-oga/js/metadata/album_art_cache.js
Executable file
|
|
@ -0,0 +1,350 @@
|
|||
/* global AlbumArt, asyncStorage, Database, ImageUtils, LazyLoader */
|
||||
/* exported AlbumArtCache */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Handle fetching album art and thumbnail management for already-parsed audio
|
||||
* files.
|
||||
*/
|
||||
var AlbumArtCache = (function() {
|
||||
|
||||
// When we generate our own thumbnails, aim for this size. XXX: This should be
|
||||
// improved to pick a size based on the resolution of our device.
|
||||
var THUMBNAIL_WIDTH = 300;
|
||||
var THUMBNAIL_HEIGHT = 300;
|
||||
|
||||
// The level one caches for thumbnails and full-size images; maps a cache key
|
||||
// (see below) to a blob: URL. There's also a level two cache for thumbnails,
|
||||
// backed by asyncStorage.
|
||||
var L1Cache = {'thumbnail': {}, 'fullsize': {}};
|
||||
|
||||
/**
|
||||
* Get a URL for a full-sized version of the cover art for a given file (if
|
||||
* any).
|
||||
*
|
||||
* @param {Object} fileinfo The info for the file we want album art for.
|
||||
* @param {Boolean} noPlaceholder True if this function should only return an
|
||||
* image if there's cover art for the file. Otherwise, this function will
|
||||
* return a placeholder image if there's no cover art.
|
||||
* @return {Promise} A promise returning either the URL for the cover art, or
|
||||
* null.
|
||||
*/
|
||||
function getFullSizeURL(fileinfo, noPlaceholder) {
|
||||
// If there's no picture info in the metadata, return null or a placeholder.
|
||||
if (!fileinfo.metadata.picture) {
|
||||
return Promise.resolve(
|
||||
noPlaceholder ? null : getDefaultCoverURL(fileinfo)
|
||||
);
|
||||
}
|
||||
|
||||
// See if we've already made a URL for this album. If so, return it.
|
||||
var cacheKey = makeCacheKey(fileinfo);
|
||||
if (cacheKey && cacheKey in L1Cache.fullsize) {
|
||||
return Promise.resolve(L1Cache.fullsize[cacheKey]);
|
||||
}
|
||||
|
||||
// Otherwise, get the blob from the album art, create a URL for it, and
|
||||
// return it in the Promise.
|
||||
return getAlbumArtBlob(fileinfo).then(function(blob) {
|
||||
return makeAndCacheURL(cacheKey, blob, 'fullsize');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Blob for a full-sized version of the cover art for a given file (if
|
||||
* any).
|
||||
*
|
||||
* @param {Object} fileinfo The info for the file we want album art for.
|
||||
* @param {Boolean} noPlaceholder True if this function should only return an
|
||||
* image if there's cover art for the file. Otherwise, this function will
|
||||
* return a placeholder image if there's no cover art.
|
||||
* @return {Promise} A promise returning either the Blob for the cover art, or
|
||||
* null.
|
||||
*/
|
||||
function getFullSizeBlob(fileinfo, noPlaceholder) {
|
||||
// If there's no picture info in the metadata, return null or a placeholder.
|
||||
if (!fileinfo.metadata.picture) {
|
||||
if (noPlaceholder) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return getBlobFromURL(getDefaultCoverURL(fileinfo));
|
||||
}
|
||||
|
||||
// Skip the L1 cache, since we want to return a blob, not a URL.
|
||||
|
||||
return getAlbumArtBlob(fileinfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a URL for a thumbnailized version of the cover art for a given file
|
||||
* (if any).
|
||||
*
|
||||
* @param {Object} fileinfo The info for the file we want album art for.
|
||||
* @param {Boolean} noPlaceholder True if this function should only return an
|
||||
* image if there's cover art for the file. Otherwise, this function will
|
||||
* return a placeholder image if there's no cover art.
|
||||
* @return {Promise} A promise returning either the URL for the thumbnail, or
|
||||
* null.
|
||||
*/
|
||||
function getThumbnailURL(fileinfo, noPlaceholder) {
|
||||
// If there's no picture info in the metadata, return null or a placeholder.
|
||||
if (!fileinfo.metadata.picture) {
|
||||
return Promise.resolve(
|
||||
noPlaceholder ? null : getDefaultCoverURL(fileinfo)
|
||||
);
|
||||
}
|
||||
|
||||
// See if we've already made a URL for this album. If so, return it.
|
||||
var cacheKey = makeCacheKey(fileinfo);
|
||||
if (cacheKey && cacheKey in L1Cache.thumbnail) {
|
||||
return Promise.resolve(L1Cache.thumbnail[cacheKey]);
|
||||
}
|
||||
|
||||
// Otherwise, see if we've saved a blob in asyncStorage. If not, create a
|
||||
// thumbnail blob and store it in the cache. Finally, create a URL for the
|
||||
// blob and return it in the Promise.
|
||||
//
|
||||
// XXX: There's a (minor) race condition here. If two requests for the same
|
||||
// album art get here, the slower one will overwrite the faster one's cached
|
||||
// blob URL, meaning that not all instances of a piece of album art will
|
||||
// have the same URL. This only really matters to tests, though.
|
||||
return checkL2Cache(cacheKey).then(function(cachedBlob) {
|
||||
return cachedBlob || createThumbnail(cacheKey, fileinfo);
|
||||
}).then(function(blob) {
|
||||
return makeAndCacheURL(cacheKey, blob, 'thumbnail');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Blob for a thumbnailized version of the cover art for a given file
|
||||
* (if any).
|
||||
*
|
||||
* @param {Object} fileinfo The info for the file we want album art for.
|
||||
* @param {Boolean} noPlaceholder True if this function should only return an
|
||||
* image if there's cover art for the file. Otherwise, this function will
|
||||
* return a placeholder image if there's no cover art.
|
||||
* @return {Promise} A promise returning either the Blob for the thumbnail, or
|
||||
* null.
|
||||
*/
|
||||
function getThumbnailBlob(fileinfo, noPlaceholder) {
|
||||
// If there's no picture info in the metadata, return null or a placeholder.
|
||||
if (!fileinfo.metadata.picture) {
|
||||
if (noPlaceholder) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return getBlobFromURL(getDefaultCoverURL(fileinfo));
|
||||
}
|
||||
|
||||
// Skip the L1 cache, since we want to return a blob, not a URL.
|
||||
|
||||
// See if we've saved a blob in asyncStorage. If not, create a
|
||||
// thumbnail blob and store it in the cache. In either case, return the
|
||||
// blob.
|
||||
var cacheKey = makeCacheKey(fileinfo);
|
||||
return checkL2Cache(cacheKey).then(function(cachedBlob) {
|
||||
return cachedBlob || createThumbnail(cacheKey, fileinfo);
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultCoverURL(fileinfo) {
|
||||
var metadata = fileinfo.metadata;
|
||||
// If metadata does not contain both album and artist, then use title
|
||||
// instead.
|
||||
var infoForHash = (!metadata.album && !metadata.artist) ?
|
||||
metadata.title : metadata.album + metadata.artist;
|
||||
var hashedNumber = (Math.abs(hash(infoForHash)) % 10) + 1;
|
||||
|
||||
return '/style/images/AlbumArt' + hashedNumber + '_small.png';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a string into an integral value. (This is a Javascript implementation
|
||||
* of Java's String.hashCode() method.
|
||||
*
|
||||
* @param {String} str The string to hash.
|
||||
* @return {Number} The hashed value.
|
||||
*/
|
||||
function hash(str) {
|
||||
var hashCode = 0;
|
||||
if (str.length === 0) { return hashCode; }
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var c = str.charCodeAt(i);
|
||||
hashCode = ((hashCode << 5) - hashCode) + c;
|
||||
hashCode = hashCode & hashCode; // Convert to 32-bit integer
|
||||
}
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cache key for this file. The cache key should be unique for each
|
||||
* album, and (hopefully) the same for tracks in a given album.
|
||||
*
|
||||
* @param {Object} fileinfo The file's info.
|
||||
* @return {String} A cache key for the file, or null if we couldn't generate
|
||||
* one.
|
||||
*/
|
||||
function makeCacheKey(fileinfo) {
|
||||
var metadata = fileinfo.metadata;
|
||||
if (metadata.picture.filename) {
|
||||
return 'external.' + metadata.picture.filename;
|
||||
} else if (metadata.picture.flavor === 'embedded') {
|
||||
var album = metadata.album;
|
||||
var artist = metadata.artist;
|
||||
var size = metadata.picture.end - metadata.picture.start;
|
||||
|
||||
if (album || artist) {
|
||||
return 'thumbnail.' + album + '.' + artist + '.' + size;
|
||||
} else {
|
||||
return 'thumbnail.' + (fileinfo.name || fileinfo.blob.name);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the L2 cache (asyncStorage) to see if we already have a thumbnailized
|
||||
* version of the album art.
|
||||
*
|
||||
* @param {String} cacheKey The cache key generated by makeCacheKey.
|
||||
* @return {Promise} A promise returning either the Blob for the thumbnail, or
|
||||
* null.
|
||||
*/
|
||||
function checkL2Cache(cacheKey) {
|
||||
if (!cacheKey) {
|
||||
return Promise.resolve(null);
|
||||
} else {
|
||||
return new Promise(function(resolve, reject) {
|
||||
asyncStorage.getItem(cacheKey, function(blob) {
|
||||
// Note that this can resolve to null if the blob wasn't cached yet.
|
||||
resolve(blob);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a blob: URL for a Blob and store the URL in our L1 cache.
|
||||
*
|
||||
* @param {String} cacheKey The cache key generated by makeCacheKey.
|
||||
* @param {Blob} blob The blob.
|
||||
* @param {String} type Either 'thumbnail' or 'fullsize'.
|
||||
* @return {String} The blob: URL.
|
||||
*/
|
||||
function makeAndCacheURL(cacheKey, blob, type) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
if (cacheKey) {
|
||||
L1Cache[type][cacheKey] = url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thumbnail for the album art and store the Blob in our L2 cache.
|
||||
*
|
||||
* @param {String} cacheKey The cache key generated by makeCacheKey.
|
||||
* @param {Object} fileinfo The file's info.
|
||||
* @return {Promise} A promise returning the thumbnailized Blob.
|
||||
*/
|
||||
function createThumbnail(cacheKey, fileinfo) {
|
||||
// We don't have a saved blob yet, so grab it, thumbnailize it, and store
|
||||
// it in asyncStorage.
|
||||
return getAlbumArtBlob(fileinfo).then(function(blob) {
|
||||
return ImageUtils.resizeAndCropToCover(
|
||||
blob, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT
|
||||
);
|
||||
}).then(function(thumbnailBlob) {
|
||||
if (cacheKey) {
|
||||
asyncStorage.setItem(cacheKey, thumbnailBlob);
|
||||
}
|
||||
return thumbnailBlob;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the (full-size) Blob for this file's album art.
|
||||
*
|
||||
* @param {Object} fileinfo The file's info.
|
||||
* @return {Promise} A promise returning the full-size Blob.
|
||||
*/
|
||||
function getAlbumArtBlob(fileinfo) {
|
||||
var picture = fileinfo.metadata.picture;
|
||||
if (picture.blob) {
|
||||
// We must have an unsynced picture that came from a temporary blob
|
||||
// (i.e. from the open activity or a unit test).
|
||||
return Promise.resolve(picture.blob);
|
||||
} else if (picture.filename) {
|
||||
// Some audio tracks have an external file for their album art, so we
|
||||
// need to grab it from deviceStorage. This could also be an unsynced
|
||||
// picture that came from a regular file.
|
||||
return LazyLoader.load('/js/metadata/album_art.js').then(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
var getreq = AlbumArt.pictureStorage.get(picture.filename);
|
||||
getreq.onsuccess = function() {
|
||||
resolve(this.result);
|
||||
};
|
||||
getreq.onerror = function() {
|
||||
reject(this.error);
|
||||
};
|
||||
});
|
||||
});
|
||||
} else if (picture.start) {
|
||||
// Other audio tracks have the album art embedded in the file, so we
|
||||
// just need to splice out the part we want.
|
||||
return getSongBlob(fileinfo).then((blob) => {
|
||||
return blob.slice(picture.start, picture.end, picture.type);
|
||||
});
|
||||
} else {
|
||||
// If we got here, something strange happened...
|
||||
var err = new Error('unknown picture flavor: ' + picture.flavor);
|
||||
console.error(err);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Blob for this file (i.e. the audio track itself).
|
||||
*
|
||||
* @param {Object} fileinfo The file's info.
|
||||
* @return {Promise} A promise returning the audio track's Blob.
|
||||
*/
|
||||
function getSongBlob(fileinfo) {
|
||||
if (fileinfo.blob) {
|
||||
// This can happen for the open activity.
|
||||
return Promise.resolve(fileinfo.blob);
|
||||
} else {
|
||||
// This is the normal case.
|
||||
return Database.getFile(fileinfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Blob for a URL.
|
||||
*
|
||||
* @param {String} url The URL to fetch.
|
||||
* @return {Promise} A promise returning the Blob.
|
||||
*/
|
||||
function getBlobFromURL(url) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.responseType = 'blob';
|
||||
|
||||
xhr.onload = function() {
|
||||
resolve(xhr.response);
|
||||
};
|
||||
// I don't think onerror usually gets called, but let's play it safe.
|
||||
xhr.onerror = function() {
|
||||
reject(null);
|
||||
};
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getFullSizeURL: getFullSizeURL,
|
||||
getFullSizeBlob: getFullSizeBlob,
|
||||
getThumbnailURL: getThumbnailURL,
|
||||
getThumbnailBlob: getThumbnailBlob
|
||||
};
|
||||
})();
|
||||
174
gaia/dev_apps/music-oga/js/metadata/core.js
Executable file
|
|
@ -0,0 +1,174 @@
|
|||
/* global BlobView, MetadataFormats */
|
||||
/* exported AudioMetadata */
|
||||
'use strict';
|
||||
|
||||
// XXX We're hiding the fact that these are JSdoc comments because our linter
|
||||
// refuses to accept "property" as a valid tag. Grumble grumble.
|
||||
|
||||
/*
|
||||
* Metadata for a track.
|
||||
*
|
||||
* @typedef {Object} Metadata
|
||||
* @property {String} tag_format The format of the tag (e.g. id3v2.4).
|
||||
* @property {String} artist The track's artist.
|
||||
* @property {String} album The track's album.
|
||||
* @property {String} title The track's title.
|
||||
* @property {Number} [tracknum] The number of the track on the album.
|
||||
* @property {Number} [trackcount] The total number of tracks in the album.
|
||||
* @property {Number} [discnum] The number of the disc on the album.
|
||||
* @property {Number} [disccount] The total number of discs in the album.
|
||||
* @property {Picture} [picture] The cover art, if any.
|
||||
* @property {Number} rated The track's rating; starts at 0.
|
||||
* @property {Number} played The track's play count; starts at 0.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse the metadata for an audio file.
|
||||
*/
|
||||
var AudioMetadata = (function() {
|
||||
/**
|
||||
* Parse the specified blob and return a Promise with the metadata.
|
||||
*
|
||||
* @param {Blob} blob The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blob) {
|
||||
var filename = blob.name;
|
||||
|
||||
// If blob.name exists, it should be an audio file from system
|
||||
// otherwise it should be an audio blob that probably from network/process
|
||||
// we can still parse it but we don't need to care about the filename
|
||||
if (filename) {
|
||||
// If the file is in the DCIM/ directory and has a .3gp extension
|
||||
// then it is a video, not a music file and we ignore it
|
||||
if (filename.slice(0, 5) === 'DCIM/' &&
|
||||
filename.slice(-4).toLowerCase() === '.3gp') {
|
||||
return Promise.reject('skipping 3gp video file');
|
||||
}
|
||||
}
|
||||
|
||||
// If the file is too small to be a music file then ignore it
|
||||
if (blob.size < 128) {
|
||||
return Promise.reject('file is empty or too small');
|
||||
}
|
||||
|
||||
// Read the start of the file, figure out what kind it is, and call
|
||||
// the appropriate parser. Start off with an 64kb chunk of data.
|
||||
// If the metadata is in that initial chunk we won't have to read again.
|
||||
return new Promise(function(resolve, reject) {
|
||||
var headersize = Math.min(64 * 1024, blob.size);
|
||||
BlobView.get(blob, 0, headersize, function(header, error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var parser = MetadataFormats.findParser(header);
|
||||
var promise;
|
||||
if (parser) {
|
||||
promise = parser.parse(header);
|
||||
} else {
|
||||
// This is some kind of file that we don't know about.
|
||||
// Let's see if we can play it.
|
||||
promise = checkPlayability(blob);
|
||||
}
|
||||
|
||||
resolve(promise.then(function(metadata) {
|
||||
return addDefaultMetadata(metadata || {}, filename);
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('AudioMetadata.parse:', e, e.stack);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in any default metadata fields, such as a fallback for the title, and
|
||||
* the rating/playcount.
|
||||
*
|
||||
* @param {Object} metadata The metadata from one of our parsers.
|
||||
* @param {String} filename The name of the underlying file, if any.
|
||||
* @return {Object} The updated metdata object.
|
||||
*/
|
||||
function addDefaultMetadata(metadata, filename) {
|
||||
if (!metadata.artist) {
|
||||
metadata.artist = '';
|
||||
}
|
||||
if (!metadata.album) {
|
||||
metadata.album = '';
|
||||
}
|
||||
if (!metadata.title) {
|
||||
// If the blob has a name, use that as a default title in case
|
||||
// we can't find one in the file
|
||||
if (filename) {
|
||||
var p1 = filename.lastIndexOf('/');
|
||||
var p2 = filename.lastIndexOf('.');
|
||||
if (p2 <= p1) {
|
||||
p2 = filename.length;
|
||||
}
|
||||
metadata.title = filename.substring(p1 + 1, p2);
|
||||
} else {
|
||||
metadata.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
metadata.rated = metadata.played = 0;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a blob can be played as an audio file.
|
||||
*
|
||||
* @param {Blob} blob The file to test.
|
||||
* @return {Promise} A promise that resolves successfully if the file is
|
||||
* playable.
|
||||
*/
|
||||
function checkPlayability(blob) {
|
||||
var player = new Audio();
|
||||
player.mozAudioChannelType = 'content';
|
||||
var canplay = blob.type && player.canPlayType(blob.type);
|
||||
if (canplay === 'probably') {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
player.src = url;
|
||||
|
||||
// XXX: There seems to have been a gecko regression, and error events
|
||||
// are no longer being fired when we try to play some invalid audio
|
||||
// files. To work around this, we use a timeout to assume the file is
|
||||
// unplayable if we nave not gotten any events within a reasonable
|
||||
// amount of time. See also bugs 1208331 and 1198169.
|
||||
const CANPLAY_TIMEOUT = 3000;
|
||||
var timeoutId = setTimeout(() => {
|
||||
console.error('No oncanplay or error events seen yet.',
|
||||
'Assuming file is corrupt:', blob.name);
|
||||
player.onerror();
|
||||
}, CANPLAY_TIMEOUT);
|
||||
|
||||
player.onerror = function() {
|
||||
clearTimeout(timeoutId);
|
||||
URL.revokeObjectURL(url);
|
||||
player.removeAttribute('src');
|
||||
player.load();
|
||||
reject('Unplayable music file');
|
||||
};
|
||||
|
||||
player.oncanplay = function() {
|
||||
clearTimeout(timeoutId);
|
||||
URL.revokeObjectURL(url);
|
||||
player.removeAttribute('src');
|
||||
player.load();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
})();
|
||||
175
gaia/dev_apps/music-oga/js/metadata/flac.js
Executable file
|
|
@ -0,0 +1,175 @@
|
|||
/* global LazyLoader, VorbisPictureComment */
|
||||
/* exported FLACMetadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse metadata for FLAC files (this is very similar to Ogg files, but with a
|
||||
* different container format).
|
||||
*
|
||||
* Format information:
|
||||
* http://flac.sourceforge.net/format.html
|
||||
*/
|
||||
var FLACMetadata = (function() {
|
||||
// Fields that should be stored as integers, not strings
|
||||
var INTFIELDS = [
|
||||
'tracknum', 'trackcount', 'discnum', 'disccount'
|
||||
];
|
||||
|
||||
// Map FLAC field names to metadata property names
|
||||
var FLACFIELDS = {
|
||||
title: 'title',
|
||||
artist: 'artist',
|
||||
album: 'album',
|
||||
tracknumber: 'tracknum',
|
||||
tracktotal: 'trackcount',
|
||||
discnumber: 'discnum',
|
||||
disctotal: 'disccount'
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
// First four bytes are "fLaC" or we wouldn't be here.
|
||||
blobview.seek(4);
|
||||
|
||||
var metadata = {tag_format: 'flac'};
|
||||
var has_vorbis_comment = false;
|
||||
var has_picture = false;
|
||||
|
||||
function processBlock(block) {
|
||||
if (block.block_type === 4) {
|
||||
readAllComments(block.view, metadata);
|
||||
has_vorbis_comment = true;
|
||||
} else if (block.block_type === 6) {
|
||||
metadata.picture = VorbisPictureComment.readPicFrame(block.view);
|
||||
has_picture = true;
|
||||
}
|
||||
|
||||
return (!has_vorbis_comment || !has_picture);
|
||||
}
|
||||
|
||||
return LazyLoader.load('js/metadata/vorbis_picture.js').then(() => {
|
||||
return findMetadataBlocks(blobview, processBlock);
|
||||
}).then(() => {
|
||||
return metadata;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Step over metadata blocks until we find the proper metadata block.
|
||||
*
|
||||
* @param {BlobView} blobview The BlobView for the file.
|
||||
* @param {function} callback The callback to process the block with.
|
||||
* @return {Promise} A Promise that resolves when all relevant metadata blocks
|
||||
* have been processed.
|
||||
*/
|
||||
function findMetadataBlocks(blobview, callback) {
|
||||
return readMetadataBlockHeader(blobview).then((block) => {
|
||||
if (callback(block) && !block.last) {
|
||||
block.view.advance(block.length - block.view.index);
|
||||
findMetadataBlocks(block.view, callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a metadata block header and fetch its contents from the Blob, plus
|
||||
* enough extra data to read the next block's header.
|
||||
*
|
||||
* @param {BlobView} blobview The BlobView for the file.
|
||||
* @return {Promise} A Promise resolving to an object with the following
|
||||
* fields:
|
||||
* {Boolean} last True if this is the last metadata block.
|
||||
* {Number} block_type The block's type, as an integer.
|
||||
* {Number} length The size in bytes of the block's body (excluding the
|
||||
* header).
|
||||
* {BlobView} view The BlobView with the block's data, starting at the
|
||||
* beginning of the metadata block's content (not the header).
|
||||
*/
|
||||
function readMetadataBlockHeader(blobview) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var header = blobview.readUnsignedByte();
|
||||
var last = (header & 0x80) === 0x80;
|
||||
var block_type = header & 0x7F;
|
||||
var length = blobview.readUint24(false);
|
||||
|
||||
// Get the contents of this block, plus enough to read the next block's
|
||||
// header if necessary.
|
||||
blobview.getMore(blobview.viewOffset + blobview.index, length + 4,
|
||||
function(more, err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
last: last,
|
||||
block_type: block_type,
|
||||
length: length,
|
||||
view: more
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all the comments in a metadata header block.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function readAllComments(page, metadata) {
|
||||
var vendor_string_length = page.readUnsignedInt(true);
|
||||
page.advance(vendor_string_length); // skip vendor string
|
||||
|
||||
var num_comments = page.readUnsignedInt(true);
|
||||
for (var i = 0; i < num_comments; i++) {
|
||||
try {
|
||||
var comment = readComment(page);
|
||||
if (comment) {
|
||||
if (!(comment.field in metadata)) {
|
||||
metadata[comment.field] = comment.value;
|
||||
} else {
|
||||
// We already have a value, so append this new one.
|
||||
metadata[comment.field] += ' / ' + comment.value;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error parsing vorbis comment', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single comment field.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
*/
|
||||
function readComment(page) {
|
||||
var comment_length = page.readUnsignedInt(true);
|
||||
var comment = page.readUTF8Text(comment_length);
|
||||
var equal = comment.indexOf('=');
|
||||
if (equal === -1) {
|
||||
throw new Error('missing delimiter in comment');
|
||||
}
|
||||
|
||||
var fieldname = comment.substring(0, equal).toLowerCase().replace(' ', '');
|
||||
var propname = FLACFIELDS[fieldname];
|
||||
if (propname) { // Do we care about this field?
|
||||
var value = comment.substring(equal + 1);
|
||||
if (INTFIELDS.indexOf(propname) !== -1) {
|
||||
value = parseInt(value, 10);
|
||||
}
|
||||
return {field: propname, value: value};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
})();
|
||||
115
gaia/dev_apps/music-oga/js/metadata/formats.js
Executable file
|
|
@ -0,0 +1,115 @@
|
|||
/* global FLACMetadata, ForwardLockMetadata, ID3v1Metadata, ID3v2Metadata,
|
||||
LazyLoader, MP4Metadata, OggMetadata */
|
||||
/* exported MetadataFormats */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Delegates metadata parsing to the appropriate parser based on magic header
|
||||
* values.
|
||||
*/
|
||||
var MetadataFormats = (function() {
|
||||
|
||||
/*
|
||||
* This is the list of formats that we know how to parse. Each format has
|
||||
* three properties:
|
||||
*
|
||||
* @property {String} file The path to the file for parsing this metadata
|
||||
* format.
|
||||
* @property {Object} module A getter that returns the module object for this
|
||||
* parser. Note: It *must* be a getter because it needs to be evaluated
|
||||
* *after* the file is loaded.
|
||||
* @property {Function} match A function that takes a BlobView of the file
|
||||
* and returns true if the file uses this metadata format.
|
||||
*/
|
||||
var formats = [
|
||||
{
|
||||
file: 'js/metadata/forward_lock.js',
|
||||
get module() { return ForwardLockMetadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(0, 9) === 'LOCKED 1 ';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/id3v2.js',
|
||||
get module() { return ID3v2Metadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(0, 3) === 'ID3';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/ogg.js',
|
||||
get module() { return OggMetadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(0, 4) === 'OggS';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/flac.js',
|
||||
get module() { return FLACMetadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(0, 4) === 'fLaC';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/mp4.js',
|
||||
get module() { return MP4Metadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(4, 4) === 'ftyp';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/id3v1.js',
|
||||
get module() { return ID3v1Metadata; },
|
||||
match: function(header) {
|
||||
return (header.getUint16(0, false) & 0xFFFE) === 0xFFFA;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a new metadata parser for a particular file format.
|
||||
*
|
||||
* @param {Object} formatInfo A description of the file format containing a
|
||||
* `file` attribute for the file to load, and a `module` attribute returning
|
||||
* the module that contains the parse() method we should call.
|
||||
*/
|
||||
function MetadataParser(formatInfo) {
|
||||
this._formatInfo = formatInfo;
|
||||
}
|
||||
|
||||
MetadataParser.prototype = {
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} header The file in question.
|
||||
* @return {Promise} A Promise that resolves with the completed metadata
|
||||
* object.
|
||||
*/
|
||||
parse: function(header) {
|
||||
var info = this._formatInfo;
|
||||
return LazyLoader.load(info.file).then(() => {
|
||||
return info.module.parse(header);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the appropriate metadata parser for a given file.
|
||||
*
|
||||
* @param {BlobView} header The file in question.
|
||||
* @return {MetadataParser} The metadata parser to use for this file.
|
||||
*/
|
||||
function findParser(header) {
|
||||
for (var i = 0; i < formats.length; i++) {
|
||||
if (formats[i].match(header)) {
|
||||
return new MetadataParser(formats[i]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
findParser: findParser
|
||||
};
|
||||
|
||||
})();
|
||||
56
gaia/dev_apps/music-oga/js/metadata/forward_lock.js
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
/* global AudioMetadata, ForwardLock */
|
||||
/* exported ForwardLockMetadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse metadata for ForwardLocked files. Generally, this will forward on to
|
||||
* the metadata parser for the underlying format.
|
||||
*/
|
||||
var ForwardLockMetadata = (function() {
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} locked The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(locked) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
ForwardLock.getKey(function(secret) {
|
||||
ForwardLock.unlockBlob(secret, locked.blob, callback, reject);
|
||||
|
||||
function callback(unlocked, unlockedMetadata) {
|
||||
// Now that we have the unlocked content of the locked file, pass it
|
||||
// back to AudioMetadata.parse to get the metadata. When we're done,
|
||||
// add some extra metadata to indicate that this is locked content (so
|
||||
// it isn't shared) and to specify the vendor that locked it.
|
||||
resolve(AudioMetadata.parse(unlocked).then(function(metadata) {
|
||||
metadata.locked = true;
|
||||
if (unlockedMetadata.vendor) {
|
||||
metadata.vendor = unlockedMetadata.vendor;
|
||||
}
|
||||
if (!metadata.title) {
|
||||
metadata.title = unlockedMetadata.name;
|
||||
}
|
||||
if (metadata.picture && metadata.picture.flavor === 'embedded') {
|
||||
// Grab the slice for the embedded album art while we still have
|
||||
// the decrypted blob to look at!
|
||||
metadata.picture = {
|
||||
flavor: 'unsynced',
|
||||
blob: unlocked.slice(metadata.picture.start,
|
||||
metadata.picture.end,
|
||||
metadata.picture.type)
|
||||
};
|
||||
}
|
||||
return metadata;
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
|
||||
})();
|
||||
79
gaia/dev_apps/music-oga/js/metadata/id3v1.js
Executable file
|
|
@ -0,0 +1,79 @@
|
|||
/* global BlobView */
|
||||
/* exported ID3v1Metadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse files with ID3v1 metadata. Metadata includes title, artist, album,
|
||||
* and possibly the track number. Year, comment and genre are ignored.
|
||||
*
|
||||
* Format information:
|
||||
* http://www.id3.org/ID3v1
|
||||
* http://en.wikipedia.org/wiki/ID3
|
||||
*/
|
||||
var ID3v1Metadata = (function() {
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var blob = blobview.blob;
|
||||
BlobView.get(blob, blob.size - 128, 128, function(footer, error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var magic = footer.getBinaryText(0, 3);
|
||||
if (magic === 'TAG') {
|
||||
// It's an MP3 file with an ID3v1 tag.
|
||||
resolve(parseID3v1Metadata(footer));
|
||||
} else {
|
||||
// It's an MP3 file with no metadata. Just resolve to an empty
|
||||
// metatada object.
|
||||
resolve({});
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ID3v1 metadata from the 128 bytes footer at the end of a file.
|
||||
*
|
||||
* @param {BlobView} footer The last 128 bytes of the file.
|
||||
* @return {Metadata} The parsed metadata object.
|
||||
*/
|
||||
function parseID3v1Metadata(footer) {
|
||||
footer.seek(3); // Skip the "TAG" prefix.
|
||||
var title = footer.readNullTerminatedLatin1Text(30, true);
|
||||
var artist = footer.readNullTerminatedLatin1Text(30, true);
|
||||
var album = footer.readNullTerminatedLatin1Text(30, true);
|
||||
|
||||
footer.advance(32); // Skip year and comment.
|
||||
var zerobyte = footer.readUnsignedByte();
|
||||
var track = footer.readUnsignedByte();
|
||||
|
||||
var metadata = {
|
||||
tag_format: 'id3v1',
|
||||
title: title || undefined,
|
||||
artist: artist || undefined,
|
||||
album: album || undefined
|
||||
};
|
||||
if (zerobyte === 0 && track !== 0) {
|
||||
metadata.tracknum = track;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
|
||||
})();
|
||||
452
gaia/dev_apps/music-oga/js/metadata/id3v2.js
Executable file
|
|
@ -0,0 +1,452 @@
|
|||
/* global BlobView, ID3v1Metadata, LazyLoader */
|
||||
/* exported ID3v2Metadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse files with ID3v2 metadata.
|
||||
*
|
||||
* Format information:
|
||||
* http://id3.org/Developer%20Information
|
||||
*/
|
||||
var ID3v2Metadata = (function() {
|
||||
// Map id3v2 frame ids to metadata property names. Each line has two mappings:
|
||||
// one for id3v2.3+ and one for id3v2.2.
|
||||
var ID3V2FRAMES = {
|
||||
TPE1: 'artist', TP1: 'artist',
|
||||
TALB: 'album', TAL: 'album',
|
||||
TIT2: 'title', TT2: 'title',
|
||||
TRCK: 'tracknum', TRK: 'tracknum',
|
||||
TPOS: 'discnum', TPA: 'discnum',
|
||||
APIC: 'picture', PIC: 'picture'
|
||||
};
|
||||
var ID3V2SECONDARYFRAMES = {
|
||||
TRCK: 'trackcount', TRK: 'trackcount',
|
||||
TPOS: 'disccount', TPA: 'disccount'
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
return new Promise((resolve, reject) => {
|
||||
var header = parseHeader(blobview);
|
||||
if (header.version > 4) {
|
||||
console.warn('mp3 file with unknown metadata version');
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
blobview.getMore(blobview.index, header.length, (moreview, err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseFrames(header, moreview));
|
||||
} catch(e) {
|
||||
console.warn('Invalid ID3v2 data for', blobview.blob.name, ':', e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}).catch((e) => {
|
||||
return LazyLoader.load('js/metadata/id3v1.js').then(() => {
|
||||
return ID3v1Metadata.parse(blobview);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the header of an ID3 tag.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Object} An object describing the header's values.
|
||||
*/
|
||||
function parseHeader(blobview) {
|
||||
// First three bytes are "ID3" or we wouldn't be here
|
||||
blobview.seek(3);
|
||||
|
||||
var header = {
|
||||
get versionString() {
|
||||
return 'id3v2.' + this.version + '.' + this.revision;
|
||||
},
|
||||
|
||||
get tag_unsynchronized() {
|
||||
return (this.flags & 0x80) !== 0;
|
||||
},
|
||||
|
||||
get has_extended_header() {
|
||||
return (this.flags & 0x40) !== 0;
|
||||
}
|
||||
};
|
||||
|
||||
header.version = blobview.readUnsignedByte();
|
||||
header.revision = blobview.readUnsignedByte();
|
||||
|
||||
header.flags = blobview.readUnsignedByte();
|
||||
header.length = blobview.readID3Uint28BE();
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all the ID3 frames in this tag.
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Object} The metadata extracted from the ID3 tag.
|
||||
*/
|
||||
function parseFrames(header, blobview) {
|
||||
var metadata = {};
|
||||
metadata.tag_format = header.versionString;
|
||||
|
||||
// In id3v2.3, the "unsynchronized" flag in the tag header applies to
|
||||
// the whole tag (excluding the tag header). In id3v2.4, the flag is just
|
||||
// an indicator that we should expect to see the "unsynchronized" flag
|
||||
// set on all the frames.
|
||||
if (header.tag_unsynchronized && header.version === 3) {
|
||||
blobview = deunsync(blobview, header.length);
|
||||
}
|
||||
|
||||
// Skip the extended header, if there is one.
|
||||
if (header.has_extended_header) {
|
||||
var extended_header_size;
|
||||
if (header.version === 4) {
|
||||
// In id3v2.4, the size includes itself, i.e. the rest of the header
|
||||
// is |extended_header_size - 4|.
|
||||
extended_header_size = blobview.readID3Uint28BE() - 4;
|
||||
} else { // id3version === 3
|
||||
// In id3v2.3, the size *excludes* itself, i.e. the rest of the header
|
||||
// is |extended_header_size|.
|
||||
extended_header_size = blobview.readUnsignedInt();
|
||||
}
|
||||
blobview.advance(extended_header_size);
|
||||
}
|
||||
|
||||
while (blobview.index < blobview.byteLength) {
|
||||
// If there is a null byte here, then we've found padding and we're done.
|
||||
if (blobview.getUint8(blobview.index) === 0) {
|
||||
break;
|
||||
}
|
||||
parseFrame(header, blobview, metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an individual ID3 frame.
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} blobview The audio file being parsed.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function parseFrame(header, blobview, metadata) {
|
||||
var frameid, framesize, frame_format_flags, frame_unsynchronized = false,
|
||||
data_len_indicator = false, unsupported_flags = false;
|
||||
|
||||
switch (header.version) {
|
||||
case 2:
|
||||
frameid = blobview.readBinaryText(3);
|
||||
framesize = blobview.readUint24();
|
||||
break;
|
||||
case 3:
|
||||
frameid = blobview.readBinaryText(4);
|
||||
framesize = blobview.readUnsignedInt();
|
||||
// Ignore the frame status flags, since they only apply when modifying the
|
||||
// file/tag.
|
||||
blobview.readUnsignedByte();
|
||||
frame_format_flags = blobview.readUnsignedByte();
|
||||
|
||||
unsupported_flags = (frame_format_flags !== 0);
|
||||
break;
|
||||
case 4:
|
||||
frameid = blobview.readBinaryText(4);
|
||||
framesize = blobview.readID3Uint28BE();
|
||||
blobview.readUnsignedByte(); // As above, ignore the frame status flags.
|
||||
frame_format_flags = blobview.readUnsignedByte();
|
||||
|
||||
data_len_indicator = ((frame_format_flags & 0b00000001) !== 0);
|
||||
frame_unsynchronized = ((frame_format_flags & 0b00000010) !== 0);
|
||||
unsupported_flags = ((frame_format_flags & 0b11111100) !== 0);
|
||||
break;
|
||||
}
|
||||
|
||||
var nextframe = blobview.index + framesize;
|
||||
var propname = ID3V2FRAMES[frameid];
|
||||
|
||||
// Skip frames we don't care about.
|
||||
if (!propname) {
|
||||
blobview.seek(nextframe);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip frames with flags we don't understand.
|
||||
if (unsupported_flags) {
|
||||
console.warn('Skipping', frameid, 'frame with unsupported format flags',
|
||||
'0b' + frame_format_flags.toString(2));
|
||||
blobview.seek(nextframe);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var frameview, framevalue, data_len;
|
||||
|
||||
if (data_len_indicator) {
|
||||
data_len = blobview.readID3Uint28BE();
|
||||
framesize -= 4;
|
||||
}
|
||||
|
||||
if (frame_unsynchronized) {
|
||||
frameview = deunsync(blobview, framesize);
|
||||
framesize = frameview.sliceLength;
|
||||
if (data_len !== undefined && framesize != data_len) {
|
||||
console.warn('de-unsynced frame size (' + framesize + ') doesn\'t' +
|
||||
'match data length indicator (' + data_len + ')');
|
||||
}
|
||||
} else {
|
||||
frameview = blobview;
|
||||
}
|
||||
|
||||
switch (frameid) {
|
||||
case 'TPE1': case 'TP1': // artist
|
||||
case 'TALB': case 'TAL': // album
|
||||
case 'TIT2': case 'TT2': // title
|
||||
framevalue = readTextFrame(header, frameview, framesize);
|
||||
break;
|
||||
case 'TRCK': case 'TRK': // track
|
||||
case 'TPOS': case 'TPA': // disc
|
||||
framevalue = readNumericPairFrame(header, frameview, framesize);
|
||||
if (framevalue[1] !== null) {
|
||||
metadata[ID3V2SECONDARYFRAMES[frameid]] = framevalue[1];
|
||||
}
|
||||
framevalue = framevalue[0];
|
||||
break;
|
||||
case 'APIC': case 'PIC': // picture
|
||||
framevalue = readPicFrame(header, frameview, framesize);
|
||||
break;
|
||||
default:
|
||||
console.error('Should not have gotten here');
|
||||
}
|
||||
|
||||
if (framevalue !== null) {
|
||||
metadata[propname] = framevalue;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error parsing mp3 metadata frame', frameid, ':', e);
|
||||
}
|
||||
|
||||
// Make sure we're at the start of the next frame before continuing
|
||||
blobview.seek(nextframe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the ID3 unsynchronization so that we get the unescaped data. This
|
||||
* will advance the view to the end of the unsynchronized block so that you
|
||||
* can continue to use the original view once you're finished with the
|
||||
* deunsynced view.
|
||||
*
|
||||
* @param {BlobView} view The original BlobView for the ID3 tag.
|
||||
* @param {Number} framesize The size of the unsynchronized frame.
|
||||
* @return {BlobView} A new BlobView with the deunsynced data.
|
||||
*/
|
||||
function deunsync(view, framesize) {
|
||||
// To de-unsychronize a frame, we need to convert all instances of
|
||||
// |0xff 00| to |0xff|.
|
||||
var data = new Uint8Array(framesize), was0xff = false, dataIndex = 0;
|
||||
for (var i = 0; i < framesize; i++) {
|
||||
var b = view.readUnsignedByte();
|
||||
if (was0xff && b === 0x00) {
|
||||
continue;
|
||||
}
|
||||
was0xff = (b === 0xff);
|
||||
data[dataIndex++] = b;
|
||||
}
|
||||
|
||||
// Create a new BlobView with the de-unsynchronized data.
|
||||
var deunsynced_view = new BlobView.getFromArrayBuffer(
|
||||
data.buffer, 0, dataIndex, view.littleEndian
|
||||
);
|
||||
deunsynced_view.deunsynced = true;
|
||||
return deunsynced_view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from a textual ID3 frame (one whose name starts with "T",
|
||||
* and isn't "TXXX").
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Number} encoding The encoding for this string (0: latin-1,
|
||||
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
||||
* the first byte of the value.
|
||||
* @param {String} The value of this frame.
|
||||
*/
|
||||
function readTextFrame(header, view, size, encoding) {
|
||||
if (encoding === undefined) {
|
||||
encoding = view.readUnsignedByte();
|
||||
size = size - 1;
|
||||
}
|
||||
if (header.version === 4) {
|
||||
// In id3v2.4, all text frames can have multiple values, separated by
|
||||
// the null character. For now, we join them together to make one big
|
||||
// string.
|
||||
return readEncodedTextArray(view, size, encoding);
|
||||
} else {
|
||||
// In id3v2.3, some text frames can have multiple values, separated by
|
||||
// '/', so we don't need to do anything special here. In fact, even if
|
||||
// we start treating multi-value text frames as an array, we probably
|
||||
// shouldn't do anything, since '/' is a bad delimiter.
|
||||
return readEncodedText(view, size, encoding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from a numeric pair frame (track number or disc number).
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Number} encoding The encoding for this string (0: latin-1,
|
||||
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
||||
* the first byte of the value.
|
||||
* @param {Array} The value of this frame, as two Numbers.
|
||||
*/
|
||||
function readNumericPairFrame(header, view, size, encoding) {
|
||||
var text = readTextFrame(header, view, size, encoding);
|
||||
var pair = text.split('/', 2).map(function(val) {
|
||||
var i = parseInt(val, 10);
|
||||
return isNaN(i) || !isFinite(i) ? null : i;
|
||||
});
|
||||
if (pair.length === 1) {
|
||||
pair.push(null);
|
||||
}
|
||||
return pair;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from a picture frame.
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Picture} The value of this frame.
|
||||
*/
|
||||
function readPicFrame(header, view, size) {
|
||||
var start = view.index;
|
||||
var encoding = view.readUnsignedByte();
|
||||
var mimetype;
|
||||
// mimetype is different for old PIC frames and new APIC frames
|
||||
if (header.version === 2) {
|
||||
mimetype = view.readLatin1Text(3);
|
||||
if (mimetype === 'JPG') {
|
||||
mimetype = 'image/jpeg';
|
||||
} else if (mimetype === 'PNG') {
|
||||
mimetype = 'image/png';
|
||||
}
|
||||
} else {
|
||||
mimetype = view.readNullTerminatedLatin1Text(size - 1);
|
||||
if (mimetype === '-->') {
|
||||
// XXX: We don't support URLs (the spec recommends against them anyway).
|
||||
console.error('URL for cover art is not supported.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// We ignore these next two fields.
|
||||
view.readUnsignedByte(); // kind
|
||||
readEncodedText(view, size - (view.index - start), encoding); // desc
|
||||
|
||||
var picstart = view.sliceOffset + view.viewOffset + view.index;
|
||||
var picsize = size - (view.index - start);
|
||||
var picend = picstart + picsize;
|
||||
|
||||
if (view.deunsynced) {
|
||||
return {
|
||||
flavor: 'unsynced',
|
||||
blob: new Blob(
|
||||
[view.buffer.slice(picstart, picend)],
|
||||
{type: mimetype}
|
||||
)
|
||||
};
|
||||
} else {
|
||||
// Now return an object that specifies where to pull the image from
|
||||
// The properties of this object can be passed to blob.slice()
|
||||
return {
|
||||
flavor: 'embedded',
|
||||
start: picstart,
|
||||
end: picend,
|
||||
type: mimetype
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an array of strings from a frame.
|
||||
*
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Number} encoding The encoding for this string (0: latin-1,
|
||||
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
||||
* the first byte of the value.
|
||||
* @param {String} The strings, joined by " / ".
|
||||
*/
|
||||
function readEncodedTextArray(view, size, encoding) {
|
||||
var text;
|
||||
switch (encoding) {
|
||||
case 0:
|
||||
text = view.readLatin1Text(size);
|
||||
break;
|
||||
case 1:
|
||||
text = view.readUTF16Text(size, undefined);
|
||||
break;
|
||||
case 2:
|
||||
text = view.readUTF16Text(size, false);
|
||||
break;
|
||||
case 3:
|
||||
text = view.readUTF8Text(size);
|
||||
break;
|
||||
default:
|
||||
throw Error('unknown text encoding');
|
||||
}
|
||||
|
||||
// Remove any trailing nulls and replace nulls in the middle with ' / '.
|
||||
// XXX: When the database supports arrays of values for each field, we
|
||||
// should split this into a proper array!
|
||||
return text.replace(/\0+$/, '').replace('\0', ' / ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single string from a frame.
|
||||
*
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Number} encoding The encoding for this string (0: latin-1,
|
||||
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
||||
* the first byte of the value.
|
||||
* @param {String} The string.
|
||||
*/
|
||||
function readEncodedText(view, size, encoding) {
|
||||
switch (encoding) {
|
||||
case 0:
|
||||
return view.readNullTerminatedLatin1Text(size);
|
||||
case 1:
|
||||
return view.readNullTerminatedUTF16Text(size, undefined);
|
||||
case 2:
|
||||
return view.readNullTerminatedUTF16Text(size, false);
|
||||
case 3:
|
||||
return view.readNullTerminatedUTF8Text(size);
|
||||
default:
|
||||
throw Error('unknown text encoding');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
|
||||
})();
|
||||
421
gaia/dev_apps/music-oga/js/metadata/mp4.js
Executable file
|
|
@ -0,0 +1,421 @@
|
|||
/* exported MP4Metadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse files with MP4 metadata.
|
||||
*
|
||||
* Format information:
|
||||
* https://developer.apple.com/library/mac/#documentation/QuickTime/QTFF/
|
||||
* QTFFChap1/qtff1.html
|
||||
* http://en.wikipedia.org/wiki/MPEG-4_Part_14
|
||||
* http://atomicparsley.sourceforge.net/mpeg-4files.html
|
||||
*/
|
||||
var MP4Metadata = (function() {
|
||||
// Map MP4 atom names to metadata property names
|
||||
var MP4ATOMS = {
|
||||
'\xa9alb' : 'album',
|
||||
'\xa9art' : 'artist',
|
||||
'\xa9ART' : 'artist',
|
||||
'aART' : 'artist',
|
||||
'\xa9nam' : 'title',
|
||||
'trkn' : 'tracknum',
|
||||
'disk' : 'discnum',
|
||||
'covr' : 'picture'
|
||||
};
|
||||
|
||||
// These MP4 atoms are stored as pairs of integers, so they get mapped to
|
||||
// two metadata fields
|
||||
var MP4INTPAIRATOMS = {
|
||||
'trkn': 'trackcount',
|
||||
'disk': 'disccount'
|
||||
};
|
||||
|
||||
// These are 'ftyp' values that we recognize
|
||||
// See http://www.mp4ra.org/filetype.html
|
||||
// Also see gecko code in /toolkit/components/mediasniffer/nsMediaSniffer.cpp
|
||||
// Gaia will accept the supported compatible brands in gecko as well
|
||||
var MP4Types = {
|
||||
'M4A ' : true, // iTunes audio. Note space in property name.
|
||||
'M4B ' : true, // iTunes audio book. Note space.
|
||||
'mp41' : true, // MP4 version 1
|
||||
'mp42' : true, // MP4 version 2
|
||||
'isom' : true, // ISO base media file format, version 1
|
||||
'iso2' : true // ISO base media file format, version 2
|
||||
};
|
||||
|
||||
// MP4 and 3GP containers both use ISO base media file format.
|
||||
// Also see what audio codecs/formats are supported in 3GPP specification.
|
||||
// Format information:
|
||||
// https://en.wikipedia.org/wiki/ISO_base_media_file_format
|
||||
// http://tools.ietf.org/html/rfc6381
|
||||
// http://www.3gpp.org/ftp/Specs/html-info/26244.htm
|
||||
//
|
||||
var MP4Codecs = {
|
||||
'mp4a' : true, // MPEG-4 audio
|
||||
'samr' : true, // AMR narrow-band speech
|
||||
'sawb' : true, // AMR wide-band speech
|
||||
'sawp' : true // Extended AMR wide-band audio
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
// The MP4 file might be a video or it might be some kind of audio that we
|
||||
// don't support.
|
||||
if (!checkMP4Type(blobview, MP4Types)) {
|
||||
return Promise.reject(new Error('Unknown MP4 file type'));
|
||||
}
|
||||
|
||||
return findMoovAtom(blobview).then((result) => {
|
||||
var metadata = {tag_format: 'mp4'};
|
||||
if (!result) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return parseMoovAtom(result.atom, result.size, metadata);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the type of MP4 file to ensure we should play it. (MP4 files use
|
||||
* 'ftyp' to identify the type of encoding. For more information, see:
|
||||
* <http://www.ftyps.com/what.html>.)
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @param {Object} types A list of types we support.
|
||||
* @return {Boolean} True if the file should be parsed, false otherwise.
|
||||
*/
|
||||
function checkMP4Type(blobview, types) {
|
||||
// The major brand is the four bytes right after 'ftyp'.
|
||||
var majorbrand = blobview.getBinaryText(8, 4);
|
||||
|
||||
if (majorbrand in types) {
|
||||
return true;
|
||||
} else {
|
||||
// Check the rest for the compatible brands. They are every four bytes
|
||||
// after the version of major brand. Usually there are two optional
|
||||
// compatible brands, but an arbitrary number of other compatible brands
|
||||
// are also acceptable, so we will check all the compatible brands until
|
||||
// the header ends.
|
||||
var index = 16;
|
||||
var size = blobview.getUint32(0);
|
||||
|
||||
while (index < size) {
|
||||
var compatiblebrand = blobview.getBinaryText(index, 4);
|
||||
index += 4;
|
||||
if (compatiblebrand in types) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the "moov" atom in the MP4 container.
|
||||
*
|
||||
* @param {BlobView} atom The file we're parsing.
|
||||
* @return {Promise} A promise that resolves when we've found the move atom.
|
||||
*/
|
||||
function findMoovAtom(atom) {
|
||||
// XXX: I think I could probably restructure this somehow. The atoms or
|
||||
// "boxes" we're reading and parsing here for a tree that I need to
|
||||
// traverse. Maybe nextBox() and firstChildBox() functions would be
|
||||
// helpful. Or even make these methods of BlobView? Not sure if it is worth
|
||||
// the time to refactor, though... See also the approach in
|
||||
// shared/js/get_video_rotation.js
|
||||
return new Promise(function(resolve, reject) {
|
||||
var offset = atom.sliceOffset + atom.viewOffset; // position in blob
|
||||
var size = atom.readUnsignedInt();
|
||||
var type = atom.readBinaryText(4);
|
||||
|
||||
if (size === 0) {
|
||||
// A size of 0 means the rest of the file
|
||||
size = atom.blob.size - offset;
|
||||
} else if (size === 1) {
|
||||
// A size of 1 means the size is in bytes 8-15
|
||||
size = atom.readUnsignedInt() * 4294967296 + atom.readUnsignedInt();
|
||||
}
|
||||
|
||||
if (type === 'moov') {
|
||||
// Get the full contents of this atom
|
||||
atom.getMore(offset, size, function(moov) {
|
||||
resolve({atom: moov, size: size});
|
||||
});
|
||||
} else {
|
||||
// Otherwise, get the start of the next atom and recurse
|
||||
// to continue the search for the moov atom.
|
||||
// If we're reached the end of the blob without finding
|
||||
// anything, just call the metadata callback with no metadata
|
||||
if (offset + size + 16 <= atom.blob.size) {
|
||||
atom.getMore(offset + size, 16, function(moov) {
|
||||
try {
|
||||
findMoovAtom(moov).then(function(result) {
|
||||
resolve(result);
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the moov atom. This function, and the ones that follow are all
|
||||
* synchronous. We've read the entire moov atom, so we've got all the bytes
|
||||
* we need and don't have to do an async read again.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
* @return {Metadata} The completed metadata object.
|
||||
*/
|
||||
function parseMoovAtom(data, end, metadata) {
|
||||
data.advance(8); // skip the size and type of this atom
|
||||
|
||||
// Find the udta and trak atoms within the moov atom
|
||||
// There will only be one udta atom, but there may be multiple trak
|
||||
// atoms. In that case, this is probably a movie file and we'll reject
|
||||
// it when we find a track that is not an mp4 audio codec.
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
var nextindex = data.index + size - 8;
|
||||
if (type === 'udta') { // Metadata is inside here
|
||||
parseUdtaAtom(data, end, metadata);
|
||||
} else if (type === 'trak') { // We find the audio format inside here
|
||||
data.advance(-8); // skip back to beginning
|
||||
if (seekChildAtoms(data, ['mdia', 'minf'])) {
|
||||
if (searchChildAtom(data, 'vmhd')) {
|
||||
throw Error('Found video track in MP4 container');
|
||||
}
|
||||
if (searchChildAtom(data, 'smhd')) {
|
||||
if (seekChildAtoms(data, ['stbl', 'stsd'])) {
|
||||
data.advance(20);
|
||||
var codec = data.readBinaryText(4);
|
||||
if (!(codec in MP4Codecs)) {
|
||||
throw Error('Unsupported format in MP4 container: ' + codec);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// There is not enough information for us to identify the MP4
|
||||
throw Error('Not enough metadata in MP4 container!');
|
||||
}
|
||||
}
|
||||
data.seek(nextindex);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the specified hierarchy of atoms and stop at the last one's file
|
||||
* offset.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Array} atoms The names of the atoms to find.
|
||||
* @return {Boolean} True if we found the atoms, false otherwise.
|
||||
*/
|
||||
function seekChildAtoms(data, atoms) {
|
||||
return atoms.every(function(atom) {
|
||||
return seekChildAtom(data, atom);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a child atom and stop at its file offset.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {String} atom The name of the atom to find.
|
||||
* @return {Boolean} True if we found the atom, false otherwise.
|
||||
*/
|
||||
function seekChildAtom(data, atom) {
|
||||
var start = data.index;
|
||||
var length = data.readUnsignedInt();
|
||||
data.advance(4);
|
||||
|
||||
while (data.index < start + length) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
if (type === atom) {
|
||||
data.advance(-8);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
data.advance(size - 8);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a child atom, but reset the file offset to the start after completion.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {String} atom The name of the atom to find.
|
||||
* @return {Boolean} True if we found the atom, false otherwise.
|
||||
*/
|
||||
function searchChildAtom(data, atom) {
|
||||
var start = data.index;
|
||||
var result = seekChildAtom(data, atom);
|
||||
data.seek(start);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the udta atom.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function parseUdtaAtom(data, end, metadata) {
|
||||
// Find the meta atom within the udta atom
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
if (type === 'meta') {
|
||||
parseMetaAtom(data, data.index + size - 8, metadata);
|
||||
data.seek(end);
|
||||
return;
|
||||
} else {
|
||||
data.advance(size - 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the meta atom.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function parseMetaAtom(data, end, metadata) {
|
||||
// The meta atom apparently has a slightly different structure.
|
||||
// Have to skip flag bytes before reading children atoms
|
||||
data.advance(4);
|
||||
|
||||
// Find the ilst atom within the meta atom
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
if (type === 'ilst') {
|
||||
parseIlstAtom(data, data.index + size - 8, metadata);
|
||||
data.seek(end);
|
||||
return;
|
||||
}
|
||||
else {
|
||||
data.advance(size - 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the ilst atom, which (finally) contains all our metadata atom.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function parseIlstAtom(data, end, metadata) {
|
||||
// Now read all child atoms of ilst, looking for metadata
|
||||
// we care about
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
var next = data.index + size - 8;
|
||||
var propname = MP4ATOMS[type];
|
||||
if (propname) {
|
||||
try {
|
||||
var value = getMetadataValue(data, next, type);
|
||||
// Track number and disc number are of the form "x/y".
|
||||
if (type in MP4INTPAIRATOMS) {
|
||||
metadata[propname] = value.number;
|
||||
if (value.count) {
|
||||
metadata[MP4INTPAIRATOMS[type]] = value.count;
|
||||
}
|
||||
} else {
|
||||
metadata[propname] = value;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('skipping', type, ':', e);
|
||||
}
|
||||
}
|
||||
data.seek(next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a single metadata atom.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {String} atomtype The name of this atom.
|
||||
* @return {Object} The parsed value.
|
||||
*/
|
||||
function getMetadataValue(data, end, atomtype) {
|
||||
// Loop until we find a data atom
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
if (type !== 'data') {
|
||||
data.advance(size - 8);
|
||||
continue;
|
||||
}
|
||||
|
||||
// We've found the data atom.
|
||||
// Return its (first) value or throw an error.
|
||||
var datatype = data.readUnsignedInt() & 0xFFFFFF;
|
||||
data.advance(4); // Ignore locale
|
||||
|
||||
var datasize = size - 16; // the rest of the atom is the value
|
||||
|
||||
// Special case for track number and disk number
|
||||
// They have two values: number and count.
|
||||
if (atomtype in MP4INTPAIRATOMS) {
|
||||
data.advance(2);
|
||||
var number = data.readUnsignedShort();
|
||||
var count = data.readUnsignedShort();
|
||||
return { number: number, count: count };
|
||||
}
|
||||
|
||||
switch (datatype) {
|
||||
case 1: // utf8 text
|
||||
return data.readUTF8Text(datasize);
|
||||
case 13: // jpeg
|
||||
return {
|
||||
flavor: 'embedded',
|
||||
start: data.sliceOffset + data.viewOffset + data.index,
|
||||
end: data.sliceOffset + data.viewOffset + data.index + datasize,
|
||||
type: 'image/jpeg'
|
||||
};
|
||||
case 14: // png
|
||||
return {
|
||||
flavor: 'embedded',
|
||||
start: data.sliceOffset + data.viewOffset + data.index,
|
||||
end: data.sliceOffset + data.viewOffset + data.index + datasize,
|
||||
type: 'image/png'
|
||||
};
|
||||
default:
|
||||
throw Error('unexpected type in data atom');
|
||||
}
|
||||
}
|
||||
throw Error('no data atom found');
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
|
||||
})();
|
||||
205
gaia/dev_apps/music-oga/js/metadata/ogg.js
Executable file
|
|
@ -0,0 +1,205 @@
|
|||
/* global LazyLoader, VorbisPictureComment */
|
||||
/* exported OggMetadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse files with Ogg metadata (e.g. Vorbis comments or Opus tags).
|
||||
*
|
||||
* Format information:
|
||||
* http://wiki.xiph.org/VorbisComment
|
||||
*/
|
||||
var OggMetadata = (function() {
|
||||
// Fields that should be stored as integers, not strings
|
||||
var INTFIELDS = [
|
||||
'tracknum', 'trackcount', 'discnum', 'disccount'
|
||||
];
|
||||
|
||||
// Map ogg field names to metadata property names
|
||||
var OGGFIELDS = {
|
||||
title: 'title',
|
||||
artist: 'artist',
|
||||
album: 'album',
|
||||
tracknumber: 'tracknum',
|
||||
tracktotal: 'trackcount',
|
||||
discnumber: 'discnum',
|
||||
disctotal: 'disccount',
|
||||
metadata_block_picture: 'picture'
|
||||
};
|
||||
|
||||
/**
|
||||
* A type thrown if we walk off the end of an Ogg page.
|
||||
*/
|
||||
function EndOfPageError() {}
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
readIdentificationHeader(blobview);
|
||||
return readCommentHeader(blobview);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the identification header of an Ogg container. It's always the first
|
||||
* page.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
*/
|
||||
function readIdentificationHeader(page) {
|
||||
var header = readPageHeader(page);
|
||||
if (header.segment_table.length !== 1) {
|
||||
throw new Error(
|
||||
'ogg identification header expected as only packet of first page'
|
||||
);
|
||||
}
|
||||
// Skip over the identification header.
|
||||
page.advance(header.segment_table[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the comment header of an Ogg container.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
* @return {Promise} A Promise that resolves with the completed metadata.
|
||||
*/
|
||||
function readCommentHeader(page) {
|
||||
var metadata = {};
|
||||
var header = readPageHeader(page);
|
||||
|
||||
var sum = function(a, b) { return a + b; };
|
||||
var comment_length = Array.reduce(header.segment_table, sum, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
page.getMore(page.index, comment_length, (fullpage, err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Look for a comment header from a supported codec
|
||||
var first_byte = fullpage.readByte();
|
||||
var valid = false;
|
||||
switch (first_byte) {
|
||||
case 3:
|
||||
valid = fullpage.readBinaryText(6) === 'vorbis';
|
||||
metadata.tag_format = 'vorbis';
|
||||
break;
|
||||
case 79:
|
||||
valid = fullpage.readBinaryText(7) === 'pusTags';
|
||||
metadata.tag_format = 'opus';
|
||||
break;
|
||||
}
|
||||
if (!valid) {
|
||||
reject('malformed ogg comment packet');
|
||||
return;
|
||||
}
|
||||
|
||||
readAllComments(fullpage, metadata);
|
||||
|
||||
LazyLoader.load('js/metadata/vorbis_picture.js').then(() => {
|
||||
return VorbisPictureComment.parsePictureComment(metadata);
|
||||
}).then(resolve);
|
||||
} catch(e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the header for an Ogg page.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
* @return {Object} An object containing the page's segment table.
|
||||
*/
|
||||
function readPageHeader(page) {
|
||||
var capture_pattern = page.readBinaryText(4);
|
||||
if (capture_pattern !== 'OggS') {
|
||||
throw new Error('malformed ogg page header');
|
||||
}
|
||||
|
||||
// Skip over some header fields until we reach the page segments.
|
||||
page.advance(22);
|
||||
|
||||
var page_segments = page.readUnsignedByte();
|
||||
var segment_table = page.readUnsignedByteArray(page_segments);
|
||||
|
||||
return {
|
||||
segment_table: segment_table
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all the comments in an Ogg container.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function readAllComments(page, metadata) {
|
||||
var vendor_string_length = page.readUnsignedInt(true);
|
||||
page.advance(vendor_string_length); // skip libvorbis vendor string
|
||||
|
||||
var num_comments = page.readUnsignedInt(true);
|
||||
for (var i = 0; i < num_comments; i++) {
|
||||
try {
|
||||
var comment = readComment(page);
|
||||
if (comment) {
|
||||
if (!(comment.field in metadata)) {
|
||||
metadata[comment.field] = comment.value;
|
||||
} else if (comment.field !== 'picture') {
|
||||
// We already have a value, so append this new one.
|
||||
metadata[comment.field] += ' / ' + comment.value;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof EndOfPageError) {
|
||||
return;
|
||||
}
|
||||
console.warn('Error parsing vorbis comment', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single comment field.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
*/
|
||||
function readComment(page) {
|
||||
if (page.remaining() < 4) { // 4 bytes for comment-length variable
|
||||
// TODO: handle metadata that uses multiple pages
|
||||
throw new EndOfPageError();
|
||||
}
|
||||
var comment_length = page.readUnsignedInt(true);
|
||||
if (comment_length > page.remaining()) {
|
||||
// TODO: handle metadata that uses multiple pages
|
||||
throw new EndOfPageError();
|
||||
}
|
||||
|
||||
var comment = page.readUTF8Text(comment_length);
|
||||
var equal = comment.indexOf('=');
|
||||
if (equal === -1) {
|
||||
throw new Error('missing delimiter in comment');
|
||||
}
|
||||
|
||||
var fieldname = comment.substring(0, equal).toLowerCase().replace(' ', '');
|
||||
var propname = OGGFIELDS[fieldname];
|
||||
if (propname) { // Do we care about this field?
|
||||
var value = comment.substring(equal + 1);
|
||||
if (INTFIELDS.indexOf(propname) !== -1) {
|
||||
value = parseInt(value, 10);
|
||||
}
|
||||
return {field: propname, value: value};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
})();
|
||||
131
gaia/dev_apps/music-oga/js/metadata/vorbis_picture.js
Executable file
|
|
@ -0,0 +1,131 @@
|
|||
/* global BlobView */
|
||||
/* exported VorbisPictureComment */
|
||||
'use strict';
|
||||
|
||||
var VorbisPictureComment = (function() {
|
||||
/**
|
||||
* Read the picture frame from the block blob
|
||||
*/
|
||||
function readPicFrame(view) {
|
||||
// Source of the structure:
|
||||
// https://xiph.org/flac/format.html#metadata_block_picture
|
||||
// http://flac.sourceforge.net/format.html#metadata_block_picture
|
||||
//
|
||||
var kind = view.readUnsignedInt();
|
||||
if (kind != 3) {
|
||||
return null;
|
||||
}
|
||||
var mime_len = view.readUnsignedInt();
|
||||
var mimetype = view.readLatin1Text(mime_len);
|
||||
if (mimetype === '-->') {
|
||||
// XXX: We don't support URLs (the spec recommends against them anyway).
|
||||
console.error('URL for cover art is not supported.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip desc, w, h, bpp and indexed colours.
|
||||
var desc_len = view.readUnsignedInt();
|
||||
view.advance(desc_len + 16);
|
||||
|
||||
var pic_len = view.readUnsignedInt();
|
||||
var pic_start = view.sliceOffset + view.viewOffset + view.index;
|
||||
|
||||
// Now return an object that specifies where to pull the image from
|
||||
// The properties of this object can be passed to blob.slice()
|
||||
return {
|
||||
flavor: 'embedded',
|
||||
start: pic_start,
|
||||
end: pic_start + pic_len,
|
||||
type: mimetype
|
||||
};
|
||||
}
|
||||
|
||||
// BEGIN slow base64
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
|
||||
// This is needed as atob doesn't yield the right result.
|
||||
function base64DecToArr(sBase64, nBlocksSize) {
|
||||
|
||||
function b64ToUint6(nChr) {
|
||||
|
||||
return nChr > 64 && nChr < 91 ?
|
||||
nChr - 65
|
||||
: nChr > 96 && nChr < 123 ?
|
||||
nChr - 71
|
||||
: nChr > 47 && nChr < 58 ?
|
||||
nChr + 4
|
||||
: nChr === 43 ?
|
||||
62
|
||||
: nChr === 47 ?
|
||||
63
|
||||
: 0;
|
||||
}
|
||||
|
||||
var sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, '');
|
||||
var nInLen = sB64Enc.length;
|
||||
var nOutLen = nBlocksSize ?
|
||||
Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize :
|
||||
nInLen * 3 + 1 >> 2;
|
||||
var taBytes = new Uint8Array(nOutLen);
|
||||
for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0;
|
||||
nInIdx < nInLen; nInIdx++) {
|
||||
nMod4 = nInIdx & 3;
|
||||
nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4;
|
||||
if (nMod4 === 3 || nInLen - nInIdx === 1) {
|
||||
for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) {
|
||||
taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255;
|
||||
}
|
||||
nUint24 = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return taBytes;
|
||||
}
|
||||
// END
|
||||
|
||||
//
|
||||
// Will parse the vorbis comment for picture from metadata.picture.
|
||||
// If it doesn't exists, it is a no-op.
|
||||
function parsePictureComment(metadata) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!metadata.picture) {
|
||||
resolve(metadata);
|
||||
return;
|
||||
}
|
||||
var value = metadata.picture;
|
||||
try {
|
||||
// Album art in ogg
|
||||
// http://wiki.xiph.org/VorbisComment
|
||||
// http://flac.sourceforge.net/format.html#metadata_block_picture
|
||||
|
||||
// Image block is in base64.
|
||||
var binary = base64DecToArr(value);
|
||||
|
||||
var aBlob = new Blob([binary]);
|
||||
BlobView.get(aBlob, 0, aBlob.size, function(block, err) {
|
||||
|
||||
var prop = readPicFrame(block);
|
||||
if (prop) {
|
||||
// we need to duplicate the blob because we need
|
||||
// to set the MIME type now.
|
||||
metadata.picture = {
|
||||
flavor: 'unsynced',
|
||||
blob: aBlob.slice(prop.start, prop.end, prop.type)
|
||||
};
|
||||
}
|
||||
resolve(metadata);
|
||||
}, BlobView.bigEndian);
|
||||
}
|
||||
catch(e) {
|
||||
console.warn('Error parsing picture comment', e.message);
|
||||
resolve(metadata);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
readPicFrame: readPicFrame,
|
||||
parsePictureComment: parsePictureComment
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
319
gaia/dev_apps/music-oga/js/music.js
Executable file
|
|
@ -0,0 +1,319 @@
|
|||
/* exported App */
|
||||
/* global Database, MusicComms, TitleBar, TabBar, TilesView, ListView,
|
||||
PlayerView, ModeManager, MODE_PICKER, MODE_TILES, MODE_LIST,
|
||||
LazyLoader */
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* This is Music Application of Gaia
|
||||
*/
|
||||
|
||||
var App = (function() {
|
||||
var app;
|
||||
var chromeInteractive = false;
|
||||
// initialize the app object
|
||||
init();
|
||||
|
||||
function init() {
|
||||
navigator.mozL10n.once(function onLocalizationInit() {
|
||||
// Tell performance monitors that our chrome is visible.
|
||||
window.performance.mark('navigationLoaded');
|
||||
|
||||
Database.init();
|
||||
TitleBar.init();
|
||||
TabBar.init();
|
||||
|
||||
setStartMode();
|
||||
|
||||
// Do this now and on each language change in the future
|
||||
navigator.mozL10n.ready(function() {
|
||||
ModeManager.updateTitle();
|
||||
|
||||
if (!chromeInteractive) {
|
||||
chromeInteractive = true;
|
||||
// Tell performance monitors that our chrome is interactive.
|
||||
window.performance.mark('navigationInteractive');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setStartMode() {
|
||||
// If the URL contains '#pick', we will handle the pick activity
|
||||
// or just start the Music app from Mix page
|
||||
if (document.URL.indexOf('#pick') !== -1) {
|
||||
navigator.mozSetMessageHandler('activity', function activityHandler(a) {
|
||||
var activityName = a.source.name;
|
||||
|
||||
if (activityName === 'pick') {
|
||||
// Display the correct ui for the pick activity.
|
||||
document.body.classList.add('picker-mode');
|
||||
app.pendingPick = a;
|
||||
|
||||
// If the overlay is displayed during a pick, let the user get out of
|
||||
// it.
|
||||
document.getElementById('overlay-cancel-button')
|
||||
.addEventListener('click', function() {
|
||||
if (App.pendingPick) {
|
||||
App.pendingPick.postError('pick cancelled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
TabBar.option = 'title';
|
||||
ModeManager.start(MODE_PICKER);
|
||||
});
|
||||
} else {
|
||||
// The done button must be removed when we are not in picker mode
|
||||
// because the rules of the header building blocks
|
||||
TitleBar.doneButton.parentNode.removeChild(TitleBar.doneButton);
|
||||
|
||||
TabBar.option = 'mix';
|
||||
ModeManager.start(MODE_TILES);
|
||||
}
|
||||
}
|
||||
|
||||
function showOverlay(id) {
|
||||
// Blurring the active element to dismiss the keyboard.
|
||||
document.activeElement.blur();
|
||||
//
|
||||
// If id is null then hide the overlay. Otherwise, look up the localized
|
||||
// text for the specified id and display the overlay with that text.
|
||||
// Supported ids include:
|
||||
//
|
||||
// nocard: no sdcard is installed in the phone
|
||||
// pluggedin: the sdcard is being used by USB mass storage
|
||||
// empty: no songs found
|
||||
//
|
||||
// Localization is done using the specified id with "-title" and "-text"
|
||||
// suffixes.
|
||||
//
|
||||
app.currentOverlay = id;
|
||||
|
||||
function setVisibility(visible) {
|
||||
// hide or show the overlay container and toggle aria-hidden on all other
|
||||
// children of <body>
|
||||
Array.forEach(document.body.children, function(elt) {
|
||||
if (elt.id === 'overlay') {
|
||||
elt.classList.toggle('hidden', !visible);
|
||||
} else {
|
||||
elt.setAttribute('aria-hidden', visible);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (id === null) {
|
||||
setVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var menu = document.getElementById('overlay-menu');
|
||||
if (app.pendingPick) {
|
||||
menu.classList.remove('hidden');
|
||||
} else {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
|
||||
var l10nIds = {'title': id + '-title', 'text': id + '-text'};
|
||||
if (id === 'nocard') {
|
||||
l10nIds.title = 'nocard2-title';
|
||||
l10nIds.text = 'nocard3-text';
|
||||
}
|
||||
|
||||
var titleElement = document.getElementById('overlay-title');
|
||||
var textElement = document.getElementById('overlay-text');
|
||||
|
||||
titleElement.dataset.l10nId = l10nIds.title;
|
||||
textElement.dataset.l10nId = l10nIds.text;
|
||||
|
||||
setVisibility(true);
|
||||
}
|
||||
|
||||
function showCorrectOverlay() {
|
||||
// If we don't know about any songs, display the 'empty' overlay.
|
||||
// If we do know about songs and the 'empty overlay is being displayed
|
||||
// then hide it.
|
||||
if (app.knownSongs.length > 0) {
|
||||
if (app.currentOverlay === 'empty' || app.currentOverlay === 'upgrade') {
|
||||
app.showOverlay(null);
|
||||
}
|
||||
} else {
|
||||
app.showOverlay('empty');
|
||||
}
|
||||
}
|
||||
|
||||
function dbEnumerable(callback) {
|
||||
// If we've been upgrading, hide that now
|
||||
if (app.currentOverlay === 'upgrade') {
|
||||
app.showOverlay(null);
|
||||
}
|
||||
|
||||
// Display music that we already know about
|
||||
refreshViews(function() {
|
||||
// Tell performance monitors that the content is displayed and is
|
||||
// ready to interact with. We won't send the final fullyLoaded
|
||||
// mark until we're completely stable and have finished scanning.
|
||||
//
|
||||
// XXX: Maybe we could emit these marks earlier, when we've just
|
||||
// finished the "above the fold" content. That's hard to do on
|
||||
// arbitrary screen resolutions, though.
|
||||
window.performance.mark('visuallyLoaded');
|
||||
window.performance.mark('contentInteractive');
|
||||
|
||||
// For performance optimization, we disable the font-fit logic in
|
||||
// gaia-header to speed up the startup times, and here we have to
|
||||
// remove the no-font-fit attribute to trigger the font-fit logic.
|
||||
TitleBar.view.removeAttribute('no-font-fit');
|
||||
|
||||
// Hide the spinner once we've displayed the initial screen.
|
||||
// The setTimeout is a workaround for Bug 1166500.
|
||||
document.getElementById('spinner').classList.add('hidden');
|
||||
setTimeout(function() {
|
||||
document.getElementById('spinner-overlay').classList.add('hidden');
|
||||
}, 100);
|
||||
|
||||
// Only init the communication when music is not in picker mode.
|
||||
if (document.URL.indexOf('#pick') === -1) {
|
||||
// We need to wait to init the music comms until the UI is fully loaded
|
||||
// because the init of music comms could slow down the startup time.
|
||||
setupCommunications();
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupCommunications() {
|
||||
var files = [
|
||||
'shared/js/bluetooth_helper.js',
|
||||
'shared/js/media/remote_controls.js',
|
||||
'js/communications.js',
|
||||
];
|
||||
|
||||
LazyLoader.load(files).then(() => {
|
||||
MusicComms.init();
|
||||
});
|
||||
}
|
||||
|
||||
function dbReady(refresh, callback) {
|
||||
// Hide the nocard or pluggedin overlay if it is displayed
|
||||
if (app.currentOverlay === 'nocard' || app.currentOverlay === 'pluggedin') {
|
||||
app.showOverlay(null);
|
||||
}
|
||||
|
||||
if (refresh) {
|
||||
refreshViews(callback);
|
||||
} else if (callback) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function dbUnavailable(why) {
|
||||
// Stop and reset the player then back to tiles mode to avoid
|
||||
// crash. We could be smarter here by looking at the currently
|
||||
// playing song and only stopping it if its volume is not in the
|
||||
// list of available volumes. But that could potentially cause
|
||||
// problems if we are playing a playlist and some songs are on one
|
||||
// storage area and some in another. Yanking out an sdcard is
|
||||
// uncommon enough that it should be fine to always stop playing.
|
||||
if (typeof PlayerView !== 'undefined') {
|
||||
PlayerView.stop();
|
||||
}
|
||||
|
||||
// TabBar should be set to "mix" to sync with the tab selection.
|
||||
if (!app.pendingPick) {
|
||||
TabBar.option = 'mix';
|
||||
ModeManager.start(MODE_TILES, function() {
|
||||
TilesView.hideSearch();
|
||||
});
|
||||
}
|
||||
|
||||
if (why === 'nocard') {
|
||||
app.showOverlay('nocard');
|
||||
} else if (why === 'unmounted') {
|
||||
app.showOverlay('pluggedin');
|
||||
}
|
||||
}
|
||||
|
||||
// This tracks if we've automatically hidden the search box on startup yet (it
|
||||
// should only be done once!)
|
||||
var hidSearchBox = false;
|
||||
|
||||
function refreshViews(callback) {
|
||||
function showListView() {
|
||||
var option = TabBar.option;
|
||||
var info = {
|
||||
key: 'metadata.' + option,
|
||||
range: null,
|
||||
direction: (option === 'title') ? 'next' : 'nextunique',
|
||||
option: option
|
||||
};
|
||||
|
||||
ModeManager.waitForView(MODE_PICKER, () => {
|
||||
ListView.activate(info);
|
||||
});
|
||||
}
|
||||
|
||||
// If it's in picking mode, we will just enumerate all the songs. We don't
|
||||
// need to enumerate data for TilesView because the mix page is not needed
|
||||
// in picker mode.
|
||||
if (app.pendingPick) {
|
||||
showListView();
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If music is not in tiles mode and refreshViews is called, that might be
|
||||
// because the user has (un)mounted his SD card and modified the songs.
|
||||
// The database will be updated, and then we should update the list view if
|
||||
// music app is in list mode.
|
||||
if (ModeManager.currentMode === MODE_LIST &&
|
||||
TabBar.option !== 'playlist') {
|
||||
showListView();
|
||||
}
|
||||
|
||||
ModeManager.waitForView(MODE_TILES, () => {
|
||||
TilesView.activate(function(songs) {
|
||||
// If there are no songs, disable the TabBar to prevent users switching
|
||||
// to other pages.
|
||||
TabBar.setDisabled(!songs.length);
|
||||
app.knownSongs = songs;
|
||||
|
||||
app.showCorrectOverlay();
|
||||
if (app.currentOverlay === null && !hidSearchBox) {
|
||||
hidSearchBox = true;
|
||||
|
||||
// After updating the tiles view, hide the search bar. However, we
|
||||
// want to let it stay visible for a short duration so that the user
|
||||
// knows it exists.
|
||||
window.setTimeout(function() { TilesView.hideSearch(); }, 1000);
|
||||
}
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app = {
|
||||
// Pick activity
|
||||
pendingPick: null,
|
||||
// The id of the current overlay or null if none.
|
||||
currentOverlay: null,
|
||||
// To display a correct overlay, record the known songs from the database.
|
||||
knownSongs: [],
|
||||
// Exported functions
|
||||
showOverlay: showOverlay,
|
||||
showCorrectOverlay: showCorrectOverlay,
|
||||
dbEnumerable: dbEnumerable,
|
||||
dbReady: dbReady,
|
||||
dbUnavailable: dbUnavailable,
|
||||
refreshViews: refreshViews
|
||||
};
|
||||
|
||||
return app;
|
||||
})();
|
||||
125
gaia/dev_apps/music-oga/js/open.js
Executable file
|
|
@ -0,0 +1,125 @@
|
|||
/* global AudioMetadata, getStorageIfAvailable, getUnusedFilename, MimeMapper,
|
||||
PlaybackQueue, PlayerView, TYPE_SINGLE */
|
||||
'use strict';
|
||||
|
||||
navigator.mozL10n.once(function onLocalizationInit() {
|
||||
navigator.mozSetMessageHandler('activity', handleOpenActivity);
|
||||
});
|
||||
|
||||
function handleOpenActivity(request) {
|
||||
var data = request.source.data;
|
||||
var blob = request.source.data.blob;
|
||||
var header = document.getElementById('header');
|
||||
var saveButton = document.getElementById('title-save');
|
||||
var banner = document.getElementById('banner');
|
||||
var message = document.getElementById('message');
|
||||
var storage; // A device storage object used by the save button
|
||||
var saved = false; // Did we save it?
|
||||
|
||||
// If the app that initiated this activity wants us to allow the
|
||||
// user to save this blob as a file, and if device storage is available
|
||||
// and if there is enough free space, and if provided file extention
|
||||
// is appropriate for the file type, then display a save button.
|
||||
if (data.allowSave && data.filename && checkFilename()) {
|
||||
saveButton.hidden = false;
|
||||
saveButton.disabled = true;
|
||||
header.runFontFit();
|
||||
|
||||
getStorageIfAvailable('music', blob.size, function(ds) {
|
||||
storage = ds;
|
||||
saveButton.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
playBlob(blob);
|
||||
|
||||
function playBlob(blob) {
|
||||
PlayerView.init(TYPE_SINGLE);
|
||||
PlayerView.stop();
|
||||
|
||||
PlaybackQueue.loadSettings().then(() => {
|
||||
return AudioMetadata.parse(blob);
|
||||
}).then((metadata) => {
|
||||
var fileinfo = {metadata: metadata,
|
||||
name: blob.name,
|
||||
blob: blob};
|
||||
|
||||
PlayerView.activate(new PlaybackQueue.StaticQueue([fileinfo]));
|
||||
PlayerView.start();
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
alert(navigator.mozL10n.get('audioinvalid'));
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
// Set up events for close/save in the single player
|
||||
header.addEventListener('action', done);
|
||||
saveButton.addEventListener('click', save);
|
||||
|
||||
// Terminate music playback when visibility is changed.
|
||||
window.addEventListener('visibilitychange',
|
||||
function onVisibilityChanged() {
|
||||
if (document.hidden) {
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
function done() {
|
||||
PlayerView.stop();
|
||||
request.postResult({saved: saved});
|
||||
}
|
||||
|
||||
function save() {
|
||||
// Hide the menu that holds the save button: we can only save once
|
||||
saveButton.hidden = true;
|
||||
|
||||
// XXX work around bug 870619
|
||||
document.getElementById('title-text').textContent =
|
||||
document.getElementById('title-text').textContent;
|
||||
|
||||
getUnusedFilename(storage, data.filename, function(filename) {
|
||||
var savereq = storage.addNamed(blob, filename);
|
||||
savereq.onsuccess = function() {
|
||||
// Remember that it has been saved so we can pass this back
|
||||
// to the invoking app
|
||||
saved = filename;
|
||||
// And tell the user
|
||||
showBanner(navigator.mozL10n.get('saved', {
|
||||
title: document.getElementById('title-text').textContent
|
||||
}));
|
||||
};
|
||||
savereq.onerror = function(e) {
|
||||
// XXX we don't report this to the user because it is hard to
|
||||
// localize.
|
||||
console.error('Error saving', filename, e);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function checkFilename() {
|
||||
var dotIdx = data.filename.lastIndexOf('.'), ext;
|
||||
|
||||
if (dotIdx > -1) {
|
||||
ext = data.filename.substr(dotIdx + 1);
|
||||
|
||||
// workaround for bug909373 and bug852864, since for audio/ogg files we
|
||||
// get video/ogg for blob.type, we let any file with ogg extention pass
|
||||
if (ext === 'ogg') {
|
||||
return true;
|
||||
} else {
|
||||
return MimeMapper.guessTypeFromExtension(ext) === blob.type;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(msg) {
|
||||
message.textContent = msg;
|
||||
banner.hidden = false;
|
||||
setTimeout(function() {
|
||||
banner.hidden = true;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
146
gaia/dev_apps/music-oga/js/playlist.js
Executable file
|
|
@ -0,0 +1,146 @@
|
|||
/* exported Playlist */
|
||||
/* m3u parsing. */
|
||||
/*
|
||||
* http://tools.ietf.org/html/draft-pantos-http-live-streaming-14
|
||||
* http://en.wikipedia.org/wiki/M3U
|
||||
* http://forums.winamp.com/showthread.php?threadid=65772
|
||||
* http://schworak.com/blog/e39/m3u-play-list-specification/
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
|
||||
/*
|
||||
* API defined:
|
||||
* Playlist a class that encapsulate a playlist as well as its serialisation
|
||||
*
|
||||
* Constructor Playlist(path)
|
||||
*
|
||||
* Playlist.parse(path): contruct and parse a playlist from file.
|
||||
* Return a promise for it.
|
||||
*/
|
||||
function Playlist(path) {
|
||||
this._songs = [];
|
||||
this._directory = null;
|
||||
|
||||
if (path) {
|
||||
this._directory = path;
|
||||
var idx = this._directory.lastIndexOf('/');
|
||||
if (idx !== -1) {
|
||||
this._directory = this._directory.slice(0, idx + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Playlist.parse = function(path) {
|
||||
|
||||
var promise = new Promise(
|
||||
function(resolve, reject) {
|
||||
var storage = navigator.getDeviceStorage('music');
|
||||
|
||||
var request = storage.get(path);
|
||||
|
||||
request.onsuccess = function() {
|
||||
var reader = new FileReader();
|
||||
var file = request.result;
|
||||
reader.readAsText(file);
|
||||
reader.onerror = function() {
|
||||
reject(new Error('Error reading: ' + path + ': ' + this.error));
|
||||
};
|
||||
reader.onload = function() {
|
||||
var pl = new Playlist(path);
|
||||
pl.fromString(reader.result);
|
||||
resolve(pl);
|
||||
};
|
||||
};
|
||||
|
||||
request.onerror = function() {
|
||||
reject(new Error('Couldn\'t get path: ' + path + ': ' + this.error));
|
||||
};
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
Playlist.isUrl = function(path) {
|
||||
var re = /^(https?|ftp|file|mms|rtmp):\//;
|
||||
|
||||
return re.test(path);
|
||||
};
|
||||
|
||||
Playlist.prototype = {
|
||||
|
||||
get songs() {
|
||||
return this._songs;
|
||||
},
|
||||
|
||||
get directory() {
|
||||
return this._directory;
|
||||
},
|
||||
|
||||
// m3u state that path is either absolute or relative to the playlist
|
||||
_makeAbsolutePath: function(path) {
|
||||
if (!this._directory || Playlist.isUrl(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (path[0] === '/') {
|
||||
return path;
|
||||
}
|
||||
|
||||
return this._directory + path;
|
||||
},
|
||||
|
||||
/*
|
||||
* song is a path string.
|
||||
* index is the index to insert the song at. If -1 or undefined
|
||||
* song is added at the end if the array.
|
||||
*/
|
||||
addSong: function(song, index) {
|
||||
if (!song) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
var absPath = this._makeAbsolutePath(song);
|
||||
if (index === undefined || index === -1 || index >= this._songs.length) {
|
||||
this._songs.push(absPath);
|
||||
} else {
|
||||
this._songs.splice(index, 0, absPath);
|
||||
}
|
||||
},
|
||||
|
||||
removeSong: function(path) {
|
||||
var absPath = this._makeAbsolutePath(path);
|
||||
var idx = this._songs.indexOf(absPath);
|
||||
if (idx != -1) {
|
||||
this._songs.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
|
||||
removeSongAt: function(index) {
|
||||
this._songs.splice(index, 1);
|
||||
},
|
||||
|
||||
toString: function() {
|
||||
var content = '';
|
||||
content += '#EXTM3U\n';
|
||||
|
||||
var entry;
|
||||
for (entry of this._songs) {
|
||||
content += entry + '\n';
|
||||
}
|
||||
return content;
|
||||
},
|
||||
|
||||
fromString: function(content) {
|
||||
var lines = content.toString().split('\n');
|
||||
|
||||
for (var i = 0; i < lines.length; i++){
|
||||
var line = lines[i].trim();
|
||||
if (line === '' || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this._songs.push(this._makeAbsolutePath(line));
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
335
gaia/dev_apps/music-oga/js/queue.js
Executable file
|
|
@ -0,0 +1,335 @@
|
|||
/* global asyncStorage, Database */
|
||||
/* exported PlaybackQueue */
|
||||
'use strict';
|
||||
|
||||
var PlaybackQueue = (function() {
|
||||
|
||||
// The key for storing the repeat and shuffle options.
|
||||
var SETTINGS_OPTION_KEY = 'settings_option_key';
|
||||
|
||||
var Repeat = {
|
||||
OFF: 0,
|
||||
LIST: 1,
|
||||
SONG: 2,
|
||||
|
||||
/**
|
||||
* Return the "next" repeat setting in its canonical order; OFF -> LIST ->
|
||||
* SONG -> OFF.
|
||||
*
|
||||
* @param {Repeat} val The current value.
|
||||
* @return {Repeat} The next value.
|
||||
*/
|
||||
next: function(val) {
|
||||
return (val + 1) % 3;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a % b, ensuring that the result is always positive.
|
||||
*
|
||||
* @param {Number} a The dividend.
|
||||
* @param {Number} b The divisor.
|
||||
* @return {Number} The positive remainder.
|
||||
*/
|
||||
function posmod(a, b) {
|
||||
var r = a % b;
|
||||
return r < 0 ? r + b : r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an array with values from 0..n.
|
||||
*
|
||||
* @param {Number} length The length of the array.
|
||||
* @return {Array} The array.
|
||||
*/
|
||||
function fillIndices(length) {
|
||||
var indices = new Array(length);
|
||||
for (var i = 0; i < length; i++) {
|
||||
indices[i] = i;
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle the elements of an array in-place using Yates shuffle. See:
|
||||
* <http://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle>.
|
||||
*
|
||||
* @param {Array} list The array to shuffle.
|
||||
*/
|
||||
function shuffle(list) {
|
||||
for (var i = list.length - 1; i >= 1; i--) {
|
||||
var j = Math.floor(Math.random() * (i + 1));
|
||||
if (j < i) {
|
||||
var tmp = list[j];
|
||||
list[j] = list[i];
|
||||
list[i] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The base for playback queue types.
|
||||
*/
|
||||
function BaseQueue() {
|
||||
this._index = null;
|
||||
}
|
||||
|
||||
BaseQueue.prototype = {
|
||||
/**
|
||||
* Initialize the playback queue.
|
||||
*
|
||||
* @param {Number} [index] The index of the queue to start at.
|
||||
*/
|
||||
_init: function(index) {
|
||||
if (this.length === 0) {
|
||||
throw Error('cannot have an empty queue');
|
||||
}
|
||||
this._index = index || 0;
|
||||
this._shuffle(playbackSettings.shuffle, index !== undefined);
|
||||
},
|
||||
|
||||
/**
|
||||
* Shuffle or unshuffle the playback queue. If the queue is already
|
||||
* shuffled, this will reshuffle it.
|
||||
*
|
||||
* @param {Boolean} enabled True if the queue should be shuffled, false
|
||||
* otherwise.
|
||||
* @param {Boolean} keepIndex True if the current queue index should be
|
||||
* retained, false otherwise.
|
||||
*/
|
||||
_shuffle: function(enabled, keepIndex = true) {
|
||||
this._shuffleTime = Date.now();
|
||||
if (!enabled) {
|
||||
if (keepIndex) {
|
||||
this._index = this.index;
|
||||
}
|
||||
delete this._shuffledList;
|
||||
return;
|
||||
}
|
||||
|
||||
this._shuffledList = fillIndices(this.length);
|
||||
var oldIndex = null;
|
||||
if (keepIndex) {
|
||||
oldIndex = this._shuffledList.splice(this._index, 1)[0];
|
||||
this._index = 0;
|
||||
}
|
||||
|
||||
shuffle(this._shuffledList);
|
||||
|
||||
if (keepIndex) {
|
||||
this._shuffledList.unshift(oldIndex);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Move the queue's current pointer to the next track.
|
||||
*
|
||||
* @param {Boolean} automatic True if this action is happening automatically
|
||||
* (i.e. when a track ends).
|
||||
*/
|
||||
next: function(automatic = false) {
|
||||
return this.advance(1, automatic);
|
||||
},
|
||||
|
||||
/**
|
||||
* Move the queue's current pointer to the previous track.
|
||||
*/
|
||||
previous: function() {
|
||||
this.advance(-1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Move the queue's current pointer to another track.
|
||||
*
|
||||
* @param {Number} offset The offset to move by.
|
||||
* @param {Boolean} automatic True if this action is happening automatically
|
||||
* (i.e. when a track ends).
|
||||
*/
|
||||
advance: function(offset, automatic = false) {
|
||||
var next;
|
||||
var justShuffled = false;
|
||||
|
||||
// If the shuffle setting was changed after we last updated, shuffle
|
||||
// (or unshuffle, as necessary).
|
||||
if (this._shuffleTime < shuffleTime) {
|
||||
this._shuffle(playbackSettings.shuffle);
|
||||
justShuffled = true;
|
||||
}
|
||||
|
||||
switch (PlaybackQueue.repeat) {
|
||||
case Repeat.OFF:
|
||||
next = Math.max(this._index + offset, 0);
|
||||
break;
|
||||
case Repeat.LIST:
|
||||
next = posmod(this._index + offset, this.length);
|
||||
var cycled = next !== this._index + offset;
|
||||
// If we cycled around the list, reshuffle if necessary.
|
||||
if (playbackSettings.shuffle && cycled && !justShuffled) {
|
||||
this._shuffle(true);
|
||||
}
|
||||
break;
|
||||
case Repeat.SONG:
|
||||
next = automatic ? this._index : Math.max(this._index + offset, 0);
|
||||
break;
|
||||
default:
|
||||
throw new Error('unexpected repeat status: ' + this.repeat);
|
||||
}
|
||||
|
||||
// If we hit the end, just invalidate the queue and return.
|
||||
if (next >= this.length) {
|
||||
this._index = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
this._index = next;
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the index of the current track.
|
||||
*/
|
||||
get index() {
|
||||
return this._shuffledList ? this._shuffledList[this._index] : this._index;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the raw index of the current track (i.e. don't shuffle the items).
|
||||
*/
|
||||
get rawIndex() {
|
||||
return this._index;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A playback queue for statically-defined queues.
|
||||
*
|
||||
* @param {Array} fileinfos An array of file infos for the tracks.
|
||||
* @param {Number} [index] The index of the queue to start at.
|
||||
*/
|
||||
function StaticQueue(fileinfos, index) {
|
||||
this._fileinfos = fileinfos;
|
||||
this._init(index);
|
||||
}
|
||||
|
||||
StaticQueue.prototype = new BaseQueue();
|
||||
|
||||
/**
|
||||
* Get the number of tracks in this queue.
|
||||
*/
|
||||
Object.defineProperty(StaticQueue.prototype, 'length', { get: function() {
|
||||
return this._fileinfos.length;
|
||||
}});
|
||||
|
||||
/**
|
||||
* Get the file info for the current track.
|
||||
*
|
||||
* @return {Promise} A Promise resolving to the file info.
|
||||
*/
|
||||
StaticQueue.prototype.current = function() {
|
||||
return Promise.resolve(this._fileinfos[this.index]);
|
||||
};
|
||||
|
||||
/**
|
||||
* A playback queue for statically-defined queues.
|
||||
*
|
||||
* @param {Object} query A Database query to run for each track.
|
||||
* @param {Number} [index] The index of the queue to start at.
|
||||
*/
|
||||
function DynamicQueue(query, index) {
|
||||
this._query = query;
|
||||
this._init(index);
|
||||
}
|
||||
|
||||
DynamicQueue.prototype = new BaseQueue();
|
||||
|
||||
/**
|
||||
* Get the number of tracks in this queue.
|
||||
*/
|
||||
Object.defineProperty(DynamicQueue.prototype, 'length', { get: function() {
|
||||
return this._query.count;
|
||||
}});
|
||||
|
||||
/**
|
||||
* Get the file info for the current track.
|
||||
*
|
||||
* @return {Promise} A Promise resolving to the file info.
|
||||
*/
|
||||
DynamicQueue.prototype.current = function() {
|
||||
var query = this._query;
|
||||
return new Promise((resolve, reject) => {
|
||||
var handle = Database.advancedEnumerate(
|
||||
query.key, query.range, query.direction, this.index, (record) => {
|
||||
Database.cancelEnumeration(handle);
|
||||
resolve(record);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
var playbackSettings = null;
|
||||
var shuffleTime = 0;
|
||||
|
||||
/**
|
||||
* Load the persisted settings for queues (repeat and shuffle).
|
||||
*
|
||||
* @return {Promise} A Promise resolving once the settings have been loaded.
|
||||
*/
|
||||
function loadSettings() {
|
||||
return new Promise((resolve, reject) => {
|
||||
asyncStorage.getItem(SETTINGS_OPTION_KEY, (settings) => {
|
||||
if (settings) {
|
||||
playbackSettings = settings;
|
||||
} else {
|
||||
playbackSettings = {
|
||||
repeat: Repeat.OFF,
|
||||
shuffle: false
|
||||
};
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the persisted settings for queues (repeat and shuffle).
|
||||
*
|
||||
* @return {Promise} A Promise resolving once the settings have been saved.
|
||||
*/
|
||||
function saveSettings() {
|
||||
return new Promise((resolve, reject) => {
|
||||
asyncStorage.setItem(SETTINGS_OPTION_KEY, playbackSettings, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
StaticQueue: StaticQueue,
|
||||
DynamicQueue: DynamicQueue,
|
||||
Repeat: Repeat,
|
||||
|
||||
loadSettings: loadSettings,
|
||||
|
||||
get repeat() {
|
||||
return playbackSettings.repeat;
|
||||
},
|
||||
|
||||
set repeat(val) {
|
||||
playbackSettings.repeat = val;
|
||||
saveSettings();
|
||||
return val;
|
||||
},
|
||||
|
||||
get shuffle() {
|
||||
return playbackSettings.shuffle;
|
||||
},
|
||||
|
||||
set shuffle(val) {
|
||||
playbackSettings.shuffle = val;
|
||||
shuffleTime = Date.now();
|
||||
saveSettings();
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
})();
|
||||
117
gaia/dev_apps/music-oga/js/ui/tab_bar.js
Executable file
|
|
@ -0,0 +1,117 @@
|
|||
/* exported TabBar */
|
||||
/* global AccessibilityHelper, ListView, TilesView,
|
||||
MODE_TILES, MODE_LIST, ModeManager */
|
||||
'use strict';
|
||||
|
||||
var TabBar = {
|
||||
// this array is for automated playlists
|
||||
playlistArray: [
|
||||
{ metadata: { l10nId: 'playlists-shuffle-all' }, option: 'shuffleAll' },
|
||||
{ metadata: { l10nId: 'playlists-highest-rated' }, option: 'rated' },
|
||||
{ metadata: { l10nId: 'playlists-recently-added' }, option: 'date' },
|
||||
{ metadata: { l10nId: 'playlists-most-played' }, option: 'played' },
|
||||
{ metadata: { l10nId: 'playlists-least-played' }, option: 'played' },
|
||||
// update ListView with null result to hide the scan progress
|
||||
null
|
||||
],
|
||||
|
||||
get view() {
|
||||
return document.getElementById('tabs');
|
||||
},
|
||||
|
||||
get tabs() {
|
||||
return this.view.querySelectorAll('[role="tab"]');
|
||||
},
|
||||
|
||||
set option(choice) {
|
||||
var map = {
|
||||
'mix': 'tabs-mix',
|
||||
'playlist': 'tabs-playlists',
|
||||
'artist': 'tabs-artists',
|
||||
'album': 'tabs-albums',
|
||||
'title': 'tabs-songs'
|
||||
};
|
||||
|
||||
var tab = document.getElementById(map[choice]);
|
||||
AccessibilityHelper.setAriaSelected(tab, this.tabs);
|
||||
this._option = choice;
|
||||
},
|
||||
|
||||
get option() {
|
||||
return this._option;
|
||||
},
|
||||
|
||||
init: function tab_init() {
|
||||
this.option = 'mix';
|
||||
this.view.addEventListener('touchend', this);
|
||||
},
|
||||
|
||||
setDisabled: function tab_setDisabled(option) {
|
||||
this.disabled = option;
|
||||
},
|
||||
|
||||
handleEvent: function tab_handleEvent(evt) {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (evt.type) {
|
||||
case 'touchend':
|
||||
var target = evt.target;
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if users click same option, ignore it
|
||||
if (this.option === target.dataset.option) {
|
||||
return;
|
||||
} else {
|
||||
this.option = target.dataset.option;
|
||||
}
|
||||
|
||||
switch (target.id) {
|
||||
case 'tabs-mix':
|
||||
// Assuming the users will switch to ListView later or tap one of
|
||||
// the album on TilesView to play, just cancel the enumeration
|
||||
// because we will start a new one and it can be responsive.
|
||||
ListView.cancelEnumeration();
|
||||
|
||||
ModeManager.start(MODE_TILES);
|
||||
TilesView.hideSearch();
|
||||
|
||||
break;
|
||||
case 'tabs-playlists':
|
||||
ModeManager.start(MODE_LIST, function() {
|
||||
ListView.activate();
|
||||
|
||||
this.playlistArray.forEach(function(playlist) {
|
||||
ListView.update(this.option, playlist);
|
||||
}.bind(this));
|
||||
}.bind(this));
|
||||
|
||||
break;
|
||||
case 'tabs-artists':
|
||||
case 'tabs-albums':
|
||||
case 'tabs-songs':
|
||||
var info = {
|
||||
key: 'metadata.' + this.option,
|
||||
range: null,
|
||||
direction: (this.option === 'title') ? 'next' : 'nextunique',
|
||||
option: this.option
|
||||
};
|
||||
|
||||
ModeManager.start(MODE_LIST, function() {
|
||||
ListView.activate(info);
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
140
gaia/dev_apps/music-oga/js/ui/title_bar.js
Executable file
|
|
@ -0,0 +1,140 @@
|
|||
/* exported TitleBar */
|
||||
/* global LazyLoader, ModeManager, MODE_PICKER, MODE_PLAYER, PlayerView,
|
||||
AlbumArtCache, App */
|
||||
'use strict';
|
||||
|
||||
var TitleBar = {
|
||||
get view() {
|
||||
return document.getElementById('title');
|
||||
},
|
||||
|
||||
get titleText() {
|
||||
return document.querySelector('#title-text bdi');
|
||||
},
|
||||
|
||||
get playerIcon() {
|
||||
return document.getElementById('title-player');
|
||||
},
|
||||
|
||||
get doneButton() {
|
||||
return document.getElementById('title-done');
|
||||
},
|
||||
|
||||
get scanProgress() {
|
||||
return document.getElementById('scan-progress');
|
||||
},
|
||||
|
||||
get scanSpinner() {
|
||||
return document.getElementById('scan-spinner');
|
||||
},
|
||||
|
||||
get scanCount() {
|
||||
return document.getElementById('scan-count');
|
||||
},
|
||||
|
||||
get scanArtist() {
|
||||
return document.getElementById('scan-artist');
|
||||
},
|
||||
|
||||
get scanTitle() {
|
||||
return document.getElementById('scan-title');
|
||||
},
|
||||
|
||||
init: function tb_init() {
|
||||
this.view.addEventListener('click', this);
|
||||
this.view.addEventListener('action', this.onActionBack);
|
||||
},
|
||||
|
||||
changeTitleText: function tb_changeTitleText(content) {
|
||||
this.titleText.textContent = content;
|
||||
},
|
||||
|
||||
showBackArrow: function(show) {
|
||||
if (show) { this.view.setAttribute('action', 'back'); }
|
||||
else { this.view.removeAttribute('action'); }
|
||||
},
|
||||
|
||||
onActionBack: function() {
|
||||
if (App.pendingPick) {
|
||||
if (ModeManager.currentMode === MODE_PICKER) {
|
||||
App.pendingPick.postError('pick cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerView.stop();
|
||||
}
|
||||
|
||||
ModeManager.pop();
|
||||
},
|
||||
|
||||
showScanProgress: function(info) {
|
||||
this.scanProgress.classList.remove('hidden');
|
||||
this.scanSpinner.classList.remove('hidden');
|
||||
this.scanCount.textContent = info.count;
|
||||
this.scanArtist.textContent = info.artist || '';
|
||||
this.scanTitle.textContent = info.title || '';
|
||||
},
|
||||
|
||||
hideScanProgress: function() {
|
||||
this.scanSpinner.classList.add('hidden');
|
||||
// The setTimeout is a workaround for bug 1166500
|
||||
setTimeout(function() {
|
||||
this.scanProgress.classList.add('hidden');
|
||||
}.bind(this), 100);
|
||||
},
|
||||
|
||||
handleEvent: function tb_handleEvent(evt) {
|
||||
var target = evt.target;
|
||||
|
||||
function cleanupPick() {
|
||||
PlayerView.stop();
|
||||
}
|
||||
|
||||
switch (evt.type) {
|
||||
case 'click':
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (target.id) {
|
||||
case 'title-player':
|
||||
// We can't to switch to player mode when there are no songs queued.
|
||||
if (PlayerView.isQueued) {
|
||||
ModeManager.push(MODE_PLAYER);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'title-done':
|
||||
var currentFileInfo = PlayerView.currentFileInfo;
|
||||
LazyLoader.load('js/metadata/album_art_cache.js').then(() => {
|
||||
return AlbumArtCache.getThumbnailBlob(currentFileInfo);
|
||||
}).then((picture) => {
|
||||
var currentMetadata = currentFileInfo.metadata;
|
||||
var playingBlob = PlayerView.playingBlob;
|
||||
|
||||
App.pendingPick.postResult({
|
||||
type: playingBlob.type,
|
||||
blob: playingBlob,
|
||||
name: currentMetadata.title || '',
|
||||
// We only pass some metadata attributes so we don't share
|
||||
// personal details like # of times played and ratings.
|
||||
metadata: {
|
||||
title: currentMetadata.title,
|
||||
artist: currentMetadata.artist,
|
||||
album: currentMetadata.album,
|
||||
picture: picture
|
||||
}
|
||||
});
|
||||
|
||||
cleanupPick();
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
430
gaia/dev_apps/music-oga/js/ui/views/list_view.js
Executable file
|
|
@ -0,0 +1,430 @@
|
|||
/* exported ListView */
|
||||
/* global App, createListElement, Database, IDBKeyRange, ModeManager,
|
||||
MODE_PLAYER, MODE_SUBLIST, MODE_SEARCH_FROM_LIST, PlaybackQueue,
|
||||
PlayerView, SearchView, SubListView, TabBar */
|
||||
'use strict';
|
||||
|
||||
// Assuming the ListView will prepare 5 pages for batch loading.
|
||||
// Each page contains 7 list elements.
|
||||
var LIST_BATCH_SIZE = 7 * 5;
|
||||
// View of List
|
||||
var ListView = {
|
||||
get view() {
|
||||
return document.getElementById('views-list');
|
||||
},
|
||||
|
||||
get anchor() {
|
||||
return document.getElementById('views-list-anchor');
|
||||
},
|
||||
|
||||
get searchBox() {
|
||||
return document.getElementById('views-list-search');
|
||||
},
|
||||
|
||||
get searchInput() {
|
||||
return document.getElementById('views-list-search-input');
|
||||
},
|
||||
|
||||
get dataSource() {
|
||||
return this._dataSource;
|
||||
},
|
||||
|
||||
set dataSource(source) {
|
||||
this._dataSource = source;
|
||||
},
|
||||
|
||||
init: function lv_init() {
|
||||
this.clean();
|
||||
|
||||
this.view.addEventListener('click', this);
|
||||
this.view.addEventListener('touchmove', this);
|
||||
this.view.addEventListener('scroll', this);
|
||||
this.searchInput.addEventListener('focus', this);
|
||||
},
|
||||
|
||||
clean: function lv_clean() {
|
||||
this.cancelEnumeration();
|
||||
|
||||
this.info = null;
|
||||
this.handle = null;
|
||||
this.dataSource = [];
|
||||
|
||||
this.index = 0;
|
||||
this.lastDataIndex = 0;
|
||||
this.firstLetters = [];
|
||||
this.lastFirstLetter = null;
|
||||
this.anchor.innerHTML = '';
|
||||
this.anchor.style.height = 0;
|
||||
this.view.scrollTop = 0;
|
||||
this.hideSearch();
|
||||
this.moveTimer = null;
|
||||
this.scrollTimer = null;
|
||||
},
|
||||
|
||||
cancelEnumeration: function lv_cancelEnumeration() {
|
||||
// Cancel a pending enumeration before start a new one
|
||||
if (this.handle) {
|
||||
Database.cancelEnumeration(this.handle);
|
||||
}
|
||||
},
|
||||
|
||||
hideSearch: function lv_hideSearch() {
|
||||
this.searchInput.value = '';
|
||||
// XXX: we probably want to animate this...
|
||||
if (this.view.scrollTop < this.searchBox.offsetHeight) {
|
||||
this.view.scrollTop = this.searchBox.offsetHeight;
|
||||
}
|
||||
},
|
||||
|
||||
// This function basically create the section header of the list elements.
|
||||
// When we hit a different first letter, this function will use it to
|
||||
// create a new header then keep it, until to hit another different one,
|
||||
// it will create the next header with the new first letter.
|
||||
createHeader: function lv_createHeader(option, result) {
|
||||
var firstLetter = result.metadata[option].charAt(0);
|
||||
var headerLi;
|
||||
|
||||
if (this.lastFirstLetter !== firstLetter) {
|
||||
this.lastFirstLetter = firstLetter;
|
||||
|
||||
headerLi = document.createElement('li');
|
||||
headerLi.className = 'list-header';
|
||||
headerLi.textContent = this.lastFirstLetter || '?';
|
||||
headerLi.setAttribute('role', 'heading');
|
||||
headerLi.setAttribute('aria-level', '2');
|
||||
}
|
||||
|
||||
return headerLi;
|
||||
},
|
||||
|
||||
activate: function lv_activate(info) {
|
||||
// If info is not provided, then we should be displaying playlists,
|
||||
// so it does not need to enumerate from MediaDB.
|
||||
if (!info) {
|
||||
this.clean();
|
||||
return;
|
||||
}
|
||||
|
||||
// Choose one of the indexes to get the count and it should be the
|
||||
// correct count because failed records don't contain metadata, so
|
||||
// here we just pick the album, artist or title as indexes.
|
||||
Database.count('metadata.' + info.option, null, function(count) {
|
||||
this.clean();
|
||||
this.info = info;
|
||||
// Keep the count with the info for later use in PlayerView.
|
||||
this.info.count = count;
|
||||
|
||||
this.handle = Database.enumerate(info.key, info.range, info.direction,
|
||||
function(record) {
|
||||
if (record) {
|
||||
// Check if music is in picker mode because we don't to allow the
|
||||
// user to pick locked music.
|
||||
if (!App.pendingPick || !record.metadata.locked) {
|
||||
this.dataSource.push(record);
|
||||
}
|
||||
|
||||
// Save the current length of the dataSource to lastDataIndex
|
||||
// because we might expand the length to the total count of
|
||||
// the records, since we cannot retrieve all of them in a short
|
||||
// time and the enumeration might be cancelled.
|
||||
// It will also be used to judge the enumeration is end of not.
|
||||
this.lastDataIndex = this.dataSource.length;
|
||||
}
|
||||
|
||||
// When we got the first batch size of the records,
|
||||
// or the total count is less than the batch size,
|
||||
// display it so that users are able to see the first paint
|
||||
// very quickly.
|
||||
if (this.dataSource.length === LIST_BATCH_SIZE || !record) {
|
||||
this.batchUpdate(info.option, LIST_BATCH_SIZE);
|
||||
// If record is null then the enumeration is finished,
|
||||
// so ListView has all the records and is able to adjust
|
||||
// the height.
|
||||
count = record ? count : null;
|
||||
this.adjustHeight(info.option, count);
|
||||
// In picker mode we have to use the ListView's dataSource to
|
||||
// display the correct overlay.
|
||||
if (App.pendingPick) {
|
||||
App.knownSongs = this.dataSource;
|
||||
App.showCorrectOverlay();
|
||||
}
|
||||
}
|
||||
}.bind(this));
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
update: function lv_update(option, result) {
|
||||
if (result === null) {
|
||||
App.showCorrectOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataSource.push(result);
|
||||
|
||||
if (option !== 'playlist') {
|
||||
var header = this.createHeader(option, result);
|
||||
if (header) {
|
||||
this.anchor.appendChild(header);
|
||||
}
|
||||
}
|
||||
|
||||
this.anchor.appendChild(createListElement(option, result, this.index));
|
||||
|
||||
this.index++;
|
||||
},
|
||||
|
||||
// This function is used for judging if the ListView should update and the
|
||||
// range it should update, it sees where the bottom element is and calculates
|
||||
// its position to know how many to update.
|
||||
judgeAndUpdate: function lv_judgeAndUpdate() {
|
||||
// If there is no lastChild then the first paint is not drawn yet.
|
||||
// Also if the info is not provide, we don't have to judge for updating.
|
||||
if (!this.anchor.lastChild || !this.info) {
|
||||
return;
|
||||
}
|
||||
|
||||
var itemHeight = this.anchor.lastChild.offsetHeight;
|
||||
var scrolledHeight = this.view.scrollTop + this.view.offsetHeight;
|
||||
var position = Math.round(scrolledHeight / itemHeight);
|
||||
var last = this.anchor.children.length;
|
||||
var range = position + this.firstLetters.length - last;
|
||||
|
||||
if (range > 0) {
|
||||
this.batchUpdate(TabBar.option, range + LIST_BATCH_SIZE);
|
||||
|
||||
// If list handle is cancelled and the lastDataIndex is not -1,
|
||||
// it means the enumeration was cancelled and the dataSource is incomplete
|
||||
// so that we have to resume it from the last data index we have.
|
||||
if (this.handle.state === 'cancelled' && this.lastDataIndex > -1) {
|
||||
var info = this.info;
|
||||
var index = this.lastDataIndex + 1;
|
||||
|
||||
this.handle = Database.advancedEnumerate(
|
||||
info.key, info.range, info.direction, index, function(record) {
|
||||
if (record) {
|
||||
this.dataSource[index] = record;
|
||||
this.lastDataIndex = index;
|
||||
index++;
|
||||
} else {
|
||||
this.lastDataIndex = -1;
|
||||
}
|
||||
}.bind(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// See where is the last index we have for the existing children, and start
|
||||
// to create the rest elements from it, note that here we use fragment to
|
||||
// update all the new elements at once, this is for reducing the amount of
|
||||
// appending child to the DOM tree.
|
||||
batchUpdate: function lv_batchUpdate(option, range) {
|
||||
var start = this.index;
|
||||
var end = start + range;
|
||||
var fragment = document.createDocumentFragment();
|
||||
|
||||
if (end > this.dataSource.length) {
|
||||
end = this.dataSource.length;
|
||||
}
|
||||
|
||||
for (var i = start; i < end; i++) {
|
||||
var data = this.dataSource[i];
|
||||
if (data) {
|
||||
var header = this.createHeader(option, data);
|
||||
|
||||
if (header) {
|
||||
fragment.appendChild(header);
|
||||
}
|
||||
|
||||
fragment.appendChild(createListElement(option, data, this.index));
|
||||
this.index++;
|
||||
}
|
||||
}
|
||||
|
||||
this.anchor.appendChild(fragment);
|
||||
},
|
||||
|
||||
// Because the correct height of ListView depends on how many records and
|
||||
// how many section headers it got, also we don't want to cause too many
|
||||
// repaints by appending children to the DOM tree and changes the height,
|
||||
// when we got the first count from the MediaDB or the enumeration is end,
|
||||
// we can fake the the height by the first adjustment(count), then fix the
|
||||
// height to correct by the second adjustment(record is null).
|
||||
adjustHeight: function lv_adjustHeight(option, count) {
|
||||
// If it's the first launch, then dataSource will be empty and we don't
|
||||
// need to adjust the height.
|
||||
if (this.dataSource.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!count) {
|
||||
count = this.dataSource.length;
|
||||
this.firstLetters.length = 0;
|
||||
var previousFirstLetter;
|
||||
for (var i = 0; i < this.dataSource.length; i++) {
|
||||
var metadata = this.dataSource[i].metadata;
|
||||
var firstLetter = metadata[option].charAt(0);
|
||||
if (previousFirstLetter !== firstLetter) {
|
||||
this.firstLetters.push(firstLetter);
|
||||
previousFirstLetter = firstLetter;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Assuming we have all the letters from A to Z.
|
||||
this.firstLetters.length = 26;
|
||||
}
|
||||
|
||||
var headerHeight = this.anchor.firstChild.offsetHeight;
|
||||
var itemHeight = this.anchor.lastChild.offsetHeight;
|
||||
var bottomHeight = parseInt(getComputedStyle(this.anchor.lastChild, null).
|
||||
getPropertyValue('margin-bottom'), 10);
|
||||
|
||||
this.anchor.style.height = (
|
||||
headerHeight * this.firstLetters.length +
|
||||
itemHeight * count +
|
||||
bottomHeight
|
||||
) + 'px';
|
||||
},
|
||||
|
||||
playWithShuffleAll: function lv_playWithShuffleAll() {
|
||||
ModeManager.push(MODE_PLAYER, () => {
|
||||
PlayerView.clean();
|
||||
|
||||
Database.count('metadata.title', null, (count) => {
|
||||
var info = {
|
||||
key: 'metadata.title',
|
||||
range: null,
|
||||
direction: 'next',
|
||||
option: 'title',
|
||||
count: count
|
||||
};
|
||||
|
||||
PlaybackQueue.shuffle = true;
|
||||
PlayerView.activate(new PlaybackQueue.DynamicQueue(info));
|
||||
PlayerView.start();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
playWithIndex: function lv_playWithIndex(index) {
|
||||
ModeManager.push(MODE_PLAYER, () => {
|
||||
// Because the ListView might still retrieving the records, and
|
||||
// we are assigning the dataSource to the PlayerView, since
|
||||
// setDBInfo will expand the dataSource length to the total
|
||||
// count we will be retrieved, we must cancel the enumeration
|
||||
// or the length will be expanded to a wrong number.
|
||||
this.cancelEnumeration();
|
||||
|
||||
PlayerView.clean();
|
||||
PlayerView.activate(new PlaybackQueue.DynamicQueue(this.info, index));
|
||||
PlayerView.start();
|
||||
});
|
||||
},
|
||||
|
||||
activateSubListView: function lv_activateSubListView(target) {
|
||||
var option = target.dataset.option;
|
||||
var index = target.dataset.index;
|
||||
var data = this.dataSource[index];
|
||||
var keyRange = (target.dataset.keyRange != 'all') ?
|
||||
IDBKeyRange.only(target.dataset.keyRange) : null;
|
||||
var l10nId = data.metadata.l10nId;
|
||||
var direction =
|
||||
(l10nId === 'playlists-most-played' ||
|
||||
l10nId === 'playlists-recently-added' ||
|
||||
l10nId === 'playlists-highest-rated') ?
|
||||
'prev' : 'next';
|
||||
|
||||
// SubListView needs to prepare the songs data before entering it,
|
||||
// So here we initialize the SubListView before push the view.
|
||||
ModeManager.waitForView(MODE_SUBLIST, () => {
|
||||
SubListView.activate(option, data, index, keyRange, direction, () => {
|
||||
ModeManager.push(MODE_SUBLIST);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
handleEvent: function lv_handleEvent(evt) {
|
||||
var target = evt.target;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (evt.type) {
|
||||
case 'click':
|
||||
if (target.id === 'views-list-search-close') {
|
||||
if (ModeManager.currentMode === MODE_SEARCH_FROM_LIST) {
|
||||
ModeManager.pop();
|
||||
}
|
||||
this.hideSearch();
|
||||
evt.preventDefault();
|
||||
} else {
|
||||
var option = target.dataset.option;
|
||||
// When an user select "Shuffle all"
|
||||
// We just play all songs with shuffle order
|
||||
// or change mode to subList view and list songs
|
||||
if (option === 'shuffleAll') {
|
||||
this.playWithShuffleAll();
|
||||
} else if (option === 'title') {
|
||||
this.playWithIndex(parseInt(target.dataset.index, 10));
|
||||
} else if (option) {
|
||||
this.activateSubListView(target);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'focus':
|
||||
if (target.id === 'views-list-search-input') {
|
||||
if (ModeManager.currentMode !== MODE_SEARCH_FROM_LIST) {
|
||||
ModeManager.start(MODE_SEARCH_FROM_LIST, function() {
|
||||
// Let the search view gets the focus.
|
||||
SearchView.searchInput.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'touchmove':
|
||||
// Start the rest batch updating after the first paint
|
||||
if (this.anchor.children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.moveTimer) {
|
||||
clearTimeout(this.moveTimer);
|
||||
}
|
||||
|
||||
// If the move timer is not cancelled, it should be a suitable time
|
||||
// to update the ui because we don't want to render elements while
|
||||
// the list is scrolling.
|
||||
this.moveTimer = setTimeout(function() {
|
||||
this.judgeAndUpdate();
|
||||
this.moveTimer = null;
|
||||
}.bind(this), 50);
|
||||
break;
|
||||
|
||||
case 'scroll':
|
||||
// Start the rest batch updating after the first paint
|
||||
if (this.anchor.children.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.scrollTimer) {
|
||||
clearTimeout(this.scrollTimer);
|
||||
}
|
||||
|
||||
// If the user try to scroll as possible as it can, after the scrolling
|
||||
// stops, we can see where the position is and try to render the rest
|
||||
// elements that should be displayed on the screen.
|
||||
this.scrollTimer = setTimeout(function() {
|
||||
this.judgeAndUpdate();
|
||||
this.scrollTimer = null;
|
||||
}.bind(this), 500);
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
295
gaia/dev_apps/music-oga/js/ui/views/mode_manager.js
Executable file
|
|
@ -0,0 +1,295 @@
|
|||
/* exported ModeManager */
|
||||
/* global App, LazyLoader, ListView, PlaybackQueue, PlayerView, SearchView,
|
||||
SubListView, TabBar, TilesView, TitleBar, TYPE_LIST, TYPE_SINGLE */
|
||||
'use strict';
|
||||
|
||||
// This Application has five modes: TILES, SEARCH, LIST, SUBLIST, and PLAYER
|
||||
// Search has two "modes", depending on whether we came from TILES or LIST.
|
||||
//
|
||||
// Before the Music app is launched we use display: none to hide the modes so
|
||||
// that Gecko will not try to apply CSS styles on those elements which seems are
|
||||
// actions that slows down the startup time we will remove display: none on
|
||||
// elements when we need to display them.
|
||||
var MODE_TILES = 1;
|
||||
var MODE_LIST = 2;
|
||||
var MODE_SUBLIST = 3;
|
||||
var MODE_PLAYER = 4;
|
||||
var MODE_SEARCH_FROM_TILES = 5;
|
||||
var MODE_SEARCH_FROM_LIST = 6;
|
||||
var MODE_PICKER = 7;
|
||||
|
||||
var ModeManager = {
|
||||
views: {
|
||||
1: {id: 'views-tiles', paths: ['js/ui/views/tiles_view.js']},
|
||||
2: {id: 'views-list', paths: ['js/ui/views/list_view.js']},
|
||||
3: {id: 'views-sublist', paths: ['js/ui/views/subList_view.js']},
|
||||
4: {id: 'views-player', paths: ['js/ui/views/player_view.js',
|
||||
'js/queue.js']},
|
||||
5: {id: 'views-search', paths: ['js/ui/views/search_view.js',
|
||||
'shared/js/text_normalizer.js']},
|
||||
6: {id: 'views-search', paths: ['js/ui/views/search_view.js',
|
||||
'shared/js/text_normalizer.js']},
|
||||
7: {id: 'views-list', paths: ['js/ui/views/list_view.js']}
|
||||
},
|
||||
_modeStack: [],
|
||||
playerTitle: null,
|
||||
|
||||
get currentMode() {
|
||||
return this._modeStack[this._modeStack.length - 1];
|
||||
},
|
||||
|
||||
get previousMode() {
|
||||
return this._modeStack[this._modeStack.length - 2];
|
||||
},
|
||||
|
||||
start: function(mode, callback) {
|
||||
this._modeStack = [];
|
||||
this._resetViews();
|
||||
this._pushMode(mode, false, function() {
|
||||
this._modeStack = [mode];
|
||||
this._updateMode(callback);
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
push: function(mode, callback) {
|
||||
this._pushMode(mode, true, function() {
|
||||
this._modeStack.push(mode);
|
||||
this._updateMode(callback);
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
pop: function() {
|
||||
if (this._modeStack.length <= 1) {
|
||||
return;
|
||||
}
|
||||
this._popMode();
|
||||
this._modeStack.pop();
|
||||
this._updateMode();
|
||||
},
|
||||
|
||||
_resetViews: function() {
|
||||
for (var mode in this.views) {
|
||||
var sheet = document.getElementById(this.views[mode].id);
|
||||
sheet.classList.remove('animated');
|
||||
sheet.classList.remove('current');
|
||||
sheet.classList.add('next');
|
||||
}
|
||||
},
|
||||
|
||||
waitForView: function(mode, callback) {
|
||||
var view = this.views[mode];
|
||||
if (view.isLoaded) {
|
||||
if (callback) {
|
||||
callback(view);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
LazyLoader.load(view.paths).then(() => {
|
||||
// Our view might have been loaded while we were waiting so check again.
|
||||
if (view.isLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the view's hidden style before pushing it.
|
||||
var sheet = document.getElementById(view.id);
|
||||
sheet.classList.remove('hidden');
|
||||
|
||||
switch(view.id) {
|
||||
case 'views-tiles':
|
||||
TilesView.init();
|
||||
break;
|
||||
case 'views-list':
|
||||
ListView.init();
|
||||
break;
|
||||
case 'views-sublist':
|
||||
SubListView.init();
|
||||
break;
|
||||
case 'views-player':
|
||||
if (App.pendingPick) {
|
||||
// Don't autoplay in the pick activity.
|
||||
PlayerView.init(TYPE_SINGLE, false);
|
||||
} else {
|
||||
PlayerView.init(TYPE_LIST);
|
||||
}
|
||||
break;
|
||||
case 'views-search':
|
||||
SearchView.init();
|
||||
break;
|
||||
}
|
||||
|
||||
// The PlaybackQueue needs to load the settings from asyncStorage before
|
||||
// we can use it.
|
||||
if (view.id === 'views-player') {
|
||||
return PlaybackQueue.loadSettings();
|
||||
}
|
||||
}).then(() => {
|
||||
view.isLoaded = true;
|
||||
if (callback) {
|
||||
callback(view);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_pushMode: function(mode, animated, callback) {
|
||||
this.waitForView(mode, (view) => {
|
||||
var nextId = view.id;
|
||||
var currentId = this.currentMode ?
|
||||
this.views[this.currentMode].id : null;
|
||||
var nextSheet = document.getElementById(nextId);
|
||||
var currentSheet = document.getElementById(currentId);
|
||||
|
||||
if (nextSheet) {
|
||||
nextSheet.classList.toggle('animated', animated);
|
||||
|
||||
nextSheet.classList.remove('previous');
|
||||
nextSheet.classList.remove('next');
|
||||
nextSheet.classList.add('current');
|
||||
}
|
||||
|
||||
if (currentSheet) {
|
||||
currentSheet.classList.toggle('animated', animated);
|
||||
|
||||
currentSheet.classList.remove('current');
|
||||
currentSheet.classList.add('previous');
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_popMode: function(callback) {
|
||||
var currentId = this.views[this.currentMode].id;
|
||||
var previousId = this.views[this.previousMode].id;
|
||||
var currentSheet = document.getElementById(currentId);
|
||||
var previousSheet = document.getElementById(previousId);
|
||||
|
||||
currentSheet.classList.remove('current');
|
||||
currentSheet.classList.add('next');
|
||||
|
||||
previousSheet.classList.remove('previous');
|
||||
previousSheet.classList.add('current');
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
|
||||
_updateBackArrow: function() {
|
||||
var noBackArrow = [
|
||||
MODE_TILES,
|
||||
MODE_LIST,
|
||||
MODE_SEARCH_FROM_TILES,
|
||||
MODE_SEARCH_FROM_LIST
|
||||
];
|
||||
|
||||
var hide = noBackArrow.indexOf(this.currentMode) > -1;
|
||||
TitleBar.showBackArrow(!hide);
|
||||
},
|
||||
|
||||
updateTitle: function() {
|
||||
var title;
|
||||
|
||||
switch (this.currentMode) {
|
||||
case MODE_TILES:
|
||||
title = this.playerTitle || navigator.mozL10n.get('music');
|
||||
break;
|
||||
case MODE_LIST:
|
||||
case MODE_SUBLIST:
|
||||
switch (TabBar.option) {
|
||||
case 'playlist':
|
||||
title = navigator.mozL10n.get('playlists');
|
||||
break;
|
||||
case 'artist':
|
||||
title = navigator.mozL10n.get('artists');
|
||||
break;
|
||||
case 'album':
|
||||
title = navigator.mozL10n.get('albums');
|
||||
break;
|
||||
case 'title':
|
||||
title = navigator.mozL10n.get('songs');
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case MODE_PLAYER:
|
||||
title = this.playerTitle || navigator.mozL10n.get('unknownTitle');
|
||||
break;
|
||||
case MODE_PICKER:
|
||||
title = navigator.mozL10n.get('picker-title');
|
||||
break;
|
||||
}
|
||||
|
||||
// if title doesn't exist, that should be the first time launch
|
||||
// so we can just ignore changeTitleText()
|
||||
// because the title is already localized in HTML
|
||||
// And if title does exist, it should be the localized "Music"
|
||||
// so it will be just fine to update changeTitleText() again
|
||||
if (title) {
|
||||
TitleBar.changeTitleText(title);
|
||||
}
|
||||
|
||||
// Hide the title bar when music is in search modes.
|
||||
TitleBar.view.hidden =
|
||||
this.currentMode === MODE_SEARCH_FROM_TILES ||
|
||||
this.currentMode === MODE_SEARCH_FROM_LIST;
|
||||
},
|
||||
|
||||
updatePlayerIcon: function() {
|
||||
var isPlayerMode = (this.currentMode === MODE_PLAYER);
|
||||
var isPlayerQueued = (this.views[MODE_PLAYER].isLoaded &&
|
||||
PlayerView.isQueued);
|
||||
|
||||
TitleBar.playerIcon.hidden = isPlayerMode || !isPlayerQueued;
|
||||
// We have to show the done button when we are in picker mode
|
||||
// and previewing the selecting song
|
||||
if (App.pendingPick) {
|
||||
TitleBar.doneButton.hidden = !isPlayerMode;
|
||||
}
|
||||
},
|
||||
|
||||
_updateTabs: function() {
|
||||
var hideTabs = this.currentMode === MODE_PLAYER ||
|
||||
this.currentMode === MODE_SEARCH_FROM_TILES ||
|
||||
this.currentMode === MODE_SEARCH_FROM_LIST;
|
||||
|
||||
TabBar.view.classList.toggle('away', hideTabs);
|
||||
},
|
||||
|
||||
_updateMode: function(callback) {
|
||||
this._updateBackArrow();
|
||||
this.updateTitle();
|
||||
this.updatePlayerIcon();
|
||||
this._updateTabs();
|
||||
|
||||
// Music only share the playing file when it's in player mode.
|
||||
// Disable the NFC sharing when it's in the other modes.
|
||||
this._enableNFCSharing((this.currentMode === MODE_PLAYER));
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
|
||||
_enableNFCSharing: function(enabled) {
|
||||
if (!navigator.mozNfc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled && !App.pendingPick) {
|
||||
// Assign the sharing function to onpeerready so that it will trigger
|
||||
// the shrinking ui to share the playing file.
|
||||
navigator.mozNfc.onpeerready = function(event) {
|
||||
var peer = event.peer;
|
||||
if (peer) {
|
||||
peer.sendFile(PlayerView.playingBlob);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// The mozNfc api will check onpeerready, if it's null, then it will not
|
||||
// trigger the shrinking ui so it won't be able to share the file.
|
||||
navigator.mozNfc.onpeerready = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
894
gaia/dev_apps/music-oga/js/ui/views/player_view.js
Executable file
|
|
@ -0,0 +1,894 @@
|
|||
/* exported PlayerView */
|
||||
/* global AlbumArtCache, Database, formatTime, LazyLoader, MusicComms,
|
||||
ModeManager, MODE_PLAYER, MozActivity, PlaybackQueue */
|
||||
'use strict';
|
||||
|
||||
// We have two types of the playing sources
|
||||
// These are for player to know which source type is playing
|
||||
var TYPE_LIST = 'list';
|
||||
var TYPE_SINGLE = 'single';
|
||||
|
||||
// AVRCP spec defined the statuses in capitalized and to be simple,
|
||||
// our player just use them instead of defining new constant strings.
|
||||
var PLAYSTATUS_STOPPED = 'STOPPED';
|
||||
var PLAYSTATUS_PLAYING = 'PLAYING';
|
||||
var PLAYSTATUS_PAUSED = 'PAUSED';
|
||||
var PLAYSTATUS_FWD_SEEK = 'FWD_SEEK';
|
||||
var PLAYSTATUS_REV_SEEK = 'REV_SEEK';
|
||||
// Interrupt begin and end are the statuses for audio channel,
|
||||
// they will be used when music app is interrupt by some other channels.
|
||||
var INTERRUPT_BEGIN = 'mozinterruptbegin';
|
||||
|
||||
// View of Player
|
||||
var PlayerView = {
|
||||
_repeatModes: ['off', 'list', 'song'],
|
||||
|
||||
get view() {
|
||||
return document.getElementById('views-player');
|
||||
},
|
||||
|
||||
get audio() {
|
||||
return document.getElementById('player-audio');
|
||||
},
|
||||
|
||||
get foreCoverImage() {
|
||||
return document.querySelector('.cover-image.visible');
|
||||
},
|
||||
|
||||
get backCoverImage() {
|
||||
return document.querySelector('.cover-image:not(.visible)');
|
||||
},
|
||||
|
||||
get playStatus() {
|
||||
return this._playStatus;
|
||||
},
|
||||
|
||||
set playStatus(val) {
|
||||
this._playStatus = val;
|
||||
},
|
||||
|
||||
get isQueued() {
|
||||
return Boolean(this.queue && this.queue.length);
|
||||
},
|
||||
|
||||
init: function pv_init(type = TYPE_LIST, autoplay = true) {
|
||||
this.artist = document.getElementById('player-cover-artist');
|
||||
this.album = document.getElementById('player-cover-album');
|
||||
this.artistText = document.querySelector('#player-cover-artist bdi');
|
||||
this.albumText = document.querySelector('#player-cover-album bdi');
|
||||
|
||||
this.timeoutID;
|
||||
this.cover = document.getElementById('player-cover');
|
||||
this.coverImageURL = null;
|
||||
this.shareButton = document.getElementById('player-cover-share');
|
||||
|
||||
this.repeatButton = document.getElementById('player-album-repeat');
|
||||
this.shuffleButton = document.getElementById('player-album-shuffle');
|
||||
|
||||
this.ratings = document.getElementById('player-album-rating').children;
|
||||
|
||||
this.seekSlider = document.getElementById('player-seek');
|
||||
this.seekRegion = document.getElementById('player-seek-bar');
|
||||
this.seekBar = document.getElementById('player-seek-bar-progress');
|
||||
this.seekIndicator = document.getElementById('player-seek-bar-indicator');
|
||||
this.seekElapsed = document.getElementById('player-seek-elapsed');
|
||||
this.seekRemaining = document.getElementById('player-seek-remaining');
|
||||
|
||||
this.playControl = document.getElementById('player-controls-play');
|
||||
this.previousControl = document.getElementById('player-controls-previous');
|
||||
this.nextControl = document.getElementById('player-controls-next');
|
||||
|
||||
this.banner = document.getElementById('info-banner');
|
||||
|
||||
this.isTouching = false;
|
||||
this.isFastSeeking = false;
|
||||
this.playStatus = PLAYSTATUS_STOPPED;
|
||||
this.pausedPosition = null;
|
||||
this.handle = null;
|
||||
this.currentFileInfo = null;
|
||||
this.playingBlob = null;
|
||||
this.setSeekBar(0, 0); // Set 0 to default seek position
|
||||
this.intervalID = null;
|
||||
|
||||
this.view.addEventListener('click', this);
|
||||
this.view.addEventListener('contextmenu', this);
|
||||
|
||||
// Seeking audio too frequently causes the Desktop build hangs
|
||||
// A related Bug 739094 in Bugzilla
|
||||
this.seekRegion.addEventListener('touchstart', this);
|
||||
this.seekRegion.addEventListener('touchmove', this);
|
||||
this.seekRegion.addEventListener('touchend', this);
|
||||
this.seekSlider.addEventListener('keypress', this);
|
||||
this.previousControl.addEventListener('touchend', this);
|
||||
this.nextControl.addEventListener('touchend', this);
|
||||
|
||||
this.audio.addEventListener('play', this);
|
||||
this.audio.addEventListener('pause', this);
|
||||
this.audio.addEventListener('playing', this);
|
||||
this.audio.addEventListener('durationchange', this);
|
||||
this.audio.addEventListener('timeupdate', this);
|
||||
this.audio.addEventListener('ended', this);
|
||||
// Listen to mozinterruptbegin and mozinterruptend for notifying the system
|
||||
// media playback widget to reflect the playing status.
|
||||
this.audio.addEventListener('mozinterruptbegin', this);
|
||||
this.audio.addEventListener('mozinterruptend', this);
|
||||
|
||||
// Listen to visiblitychange to know when to stop listening to the
|
||||
// 'timeupdate' event.
|
||||
window.addEventListener('visibilitychange', this);
|
||||
|
||||
// XXX: We use localstorage event as a workaround solution in music app
|
||||
// to resolve audio channel competetion between regular mode and pick mode.
|
||||
// This shouldn't have handled by music app itself. Remove the patch of
|
||||
// bug 894744 once we have better solution.
|
||||
window.addEventListener('storage', this._handleInterpageMessage.bind(this));
|
||||
|
||||
// Listen to language changes to update the language direction accordingly
|
||||
navigator.mozL10n.ready(this.updateL10n.bind(this));
|
||||
|
||||
this.type = type;
|
||||
this.autoplay = autoplay;
|
||||
|
||||
if (this.type === TYPE_SINGLE) {
|
||||
this.shuffleButton.disabled = true;
|
||||
this.repeatButton.disabled = true;
|
||||
this.previousControl.disabled = true;
|
||||
this.nextControl.disabled = true;
|
||||
}
|
||||
},
|
||||
|
||||
// When SCO is connected, music is unable to play sounds even it's in the
|
||||
// foreground, this is a limitation for 1.3, see bug 946556. To adapt this,
|
||||
// we regulate the controls to restrict some actions and hope it can give
|
||||
// better ux to the specific scenario.
|
||||
checkSCOStatus: function pv_checkSCOStatus() {
|
||||
if (typeof MusicComms !== 'undefined' && MusicComms.enabled) {
|
||||
var SCOStatus = MusicComms.isSCOEnabled;
|
||||
|
||||
this.playControl.disabled = this.previousControl.disabled =
|
||||
this.nextControl.disabled = SCOStatus;
|
||||
|
||||
this.seekRegion.parentNode.classList.toggle('disabled', SCOStatus);
|
||||
this.banner.classList.toggle('visible', SCOStatus);
|
||||
}
|
||||
},
|
||||
|
||||
clean: function pv_clean() {
|
||||
// Cancel a pending enumeration before start a new one
|
||||
if (this.handle) {
|
||||
Database.cancelEnumeration(this.handle);
|
||||
}
|
||||
|
||||
this.queue = null;
|
||||
this.playingBlob = null;
|
||||
this.coverImageURL = null;
|
||||
this.foreCoverImage.style.backgroundImage = '';
|
||||
},
|
||||
|
||||
activate: function pv_activate(queue) {
|
||||
this.queue = queue;
|
||||
this.shuffleButton.disabled = (this.queue.length < 2);
|
||||
this.setRepeat(PlaybackQueue.repeat);
|
||||
this.setShuffle(PlaybackQueue.shuffle);
|
||||
},
|
||||
|
||||
// This function is for the animation on the album art (cover).
|
||||
// The info (album, artist) will initially show up when a song being played,
|
||||
// if users does not tap the album art (cover) again,
|
||||
// then it will be disappeared after 5 seconds
|
||||
// however, if a user taps before 5 seconds ends,
|
||||
// then the timeout will be cleared to keep the info on screen.
|
||||
showInfo: function pv_showInfo() {
|
||||
this.cover.classList.add('slideOut');
|
||||
|
||||
if (this.timeoutID) {
|
||||
window.clearTimeout(this.timeoutID);
|
||||
}
|
||||
|
||||
this.timeoutID = window.setTimeout(
|
||||
function pv_hideInfo() {
|
||||
this.cover.classList.remove('slideOut');
|
||||
}.bind(this),
|
||||
5000
|
||||
);
|
||||
},
|
||||
|
||||
setInfo: function pv_setInfo(fileinfo) {
|
||||
this.currentFileInfo = fileinfo;
|
||||
var metadata = fileinfo.metadata;
|
||||
|
||||
// Handle the title bar and the share button when the player is not launched
|
||||
// by open activity.
|
||||
if (typeof ModeManager !== 'undefined') {
|
||||
ModeManager.playerTitle = metadata.title;
|
||||
ModeManager.updateTitle();
|
||||
|
||||
// If it is a locked music file, or if we are handling a Pick activity
|
||||
// then we should not give the user the option of sharing the file.
|
||||
if (metadata.locked || this.type === TYPE_SINGLE) {
|
||||
this.shareButton.classList.add('hidden');
|
||||
this.artist.classList.add('hidden-cover-share');
|
||||
this.album.classList.add('hidden-cover-share');
|
||||
} else {
|
||||
this.shareButton.classList.remove('hidden');
|
||||
this.artist.classList.remove('hidden-cover-share');
|
||||
this.album.classList.remove('hidden-cover-share');
|
||||
}
|
||||
} else {
|
||||
// we can't use TitleBar here as if we are in the activity
|
||||
// it will not be initialised.
|
||||
var titleBar = document.querySelector('#title-text bdi');
|
||||
|
||||
titleBar.textContent =
|
||||
metadata.title || navigator.mozL10n.get('unknownTitle');
|
||||
titleBar.dataset.l10nId = metadata.title ? '' : 'unknownTitle';
|
||||
}
|
||||
|
||||
this.artistText.textContent =
|
||||
metadata.artist || navigator.mozL10n.get('unknownArtist');
|
||||
this.artistText.dataset.l10nId = metadata.artist ? '' : 'unknownArtist';
|
||||
this.albumText.textContent =
|
||||
metadata.album || navigator.mozL10n.get('unknownAlbum');
|
||||
this.albumText.dataset.l10nId = metadata.album ? '' : 'unknownAlbum';
|
||||
|
||||
this.setCoverImage(fileinfo);
|
||||
},
|
||||
|
||||
setCoverImage: function pv_setCoverImage(fileinfo) {
|
||||
LazyLoader.load('js/metadata/album_art_cache.js').then(() => {
|
||||
return AlbumArtCache.getFullSizeURL(fileinfo);
|
||||
}).then((url) => {
|
||||
if (this.coverImageURL !== url) {
|
||||
this.coverImageURL = url;
|
||||
|
||||
var back = this.backCoverImage, fore = this.foreCoverImage;
|
||||
back.style.backgroundImage = 'url(' + url + ')';
|
||||
back.classList.add('visible');
|
||||
fore.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getRepeat: function pv_getRepeat() {
|
||||
return this._repeatModes.indexOf(this.repeatButton.value);
|
||||
},
|
||||
|
||||
setRepeat: function pv_setRepeat(value) {
|
||||
this.repeatButton.value = this._repeatModes[value];
|
||||
this.repeatButton.setAttribute(
|
||||
'data-l10n-id', 'repeat-' + this._repeatModes[value]
|
||||
);
|
||||
},
|
||||
|
||||
setShuffle: function pv_setShuffle(value) {
|
||||
this.shuffleButton.classList.toggle('shuffle-on', value);
|
||||
this.shuffleButton.setAttribute('aria-pressed', value);
|
||||
},
|
||||
|
||||
setRatings: function pv_setRatings(rated) {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var rating = this.ratings[i];
|
||||
|
||||
if (i === rated - 1) {
|
||||
rating.setAttribute('aria-checked', true);
|
||||
} else {
|
||||
rating.setAttribute('aria-checked', false);
|
||||
}
|
||||
|
||||
if (i < rated) {
|
||||
rating.classList.add('star-on');
|
||||
} else {
|
||||
rating.classList.remove('star-on');
|
||||
}
|
||||
}
|
||||
},
|
||||
setAudioSrc: function pv_setAudioSrc(file) {
|
||||
var url = URL.createObjectURL(file);
|
||||
this.playingBlob = file;
|
||||
// Reset src before we set a new source to the audio element
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
// Add mozAudioChannelType to the player
|
||||
this.audio.mozAudioChannelType = 'content';
|
||||
this.audio.src = url;
|
||||
this.audio.load();
|
||||
|
||||
this.audio.play();
|
||||
// An object URL must be released by calling URL.revokeObjectURL()
|
||||
// when we no longer need them
|
||||
this.audio.onloadeddata = function(evt) { URL.revokeObjectURL(url); };
|
||||
this.audio.onerror = (function(evt) {
|
||||
if (this.onerror) {
|
||||
this.onerror(evt);
|
||||
}
|
||||
}).bind(this);
|
||||
// when play a new song, reset the seekBar first
|
||||
// this can prevent showing wrong duration
|
||||
// due to b2g cannot get some mp3's duration
|
||||
// and the seekBar can still show 00:00 to -00:00
|
||||
this.setSeekBar(0, 0);
|
||||
},
|
||||
|
||||
updateRemoteMetadata: function pv_updateRemoteMetadata() {
|
||||
// If MusicComms does not exist or queue is empty, we don't have to update
|
||||
// the metadata.
|
||||
if (typeof MusicComms === 'undefined' || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the playing information to AVRCP devices.
|
||||
var metadata = this.currentFileInfo.metadata;
|
||||
var notifyMetadata = {
|
||||
title: metadata.title || navigator.mozL10n.get('unknownTitle'),
|
||||
artist: metadata.artist || navigator.mozL10n.get('unknownArtist'),
|
||||
album: metadata.album || navigator.mozL10n.get('unknownAlbum'),
|
||||
|
||||
// AVRCP expects the duration in ms, so we convert from s to ms.
|
||||
duration: this.audio.duration * 1000,
|
||||
mediaNumber: this.rawIndex + 1,
|
||||
totalMediaCount: this.queue.length
|
||||
};
|
||||
|
||||
// Grab the album art if this is a new song; otherwise, don't bother, since
|
||||
// listeners should already have the album art. Note: if no .picture
|
||||
// attribute is in the metadata, then listeners should reuse the previous
|
||||
// picture. If .picture is null, something went wrong and listeners should
|
||||
// probably use a blank picture (or their own placeholder).
|
||||
if (this.audio.currentTime === 0) {
|
||||
LazyLoader.load('js/metadata/album_art_cache.js').then(() => {
|
||||
return AlbumArtCache.getThumbnailBlob(this.currentFileInfo);
|
||||
}).then((blob) => {
|
||||
notifyMetadata.picture = blob;
|
||||
MusicComms.notifyMetadataChanged(notifyMetadata);
|
||||
});
|
||||
} else {
|
||||
MusicComms.notifyMetadataChanged(notifyMetadata);
|
||||
}
|
||||
},
|
||||
|
||||
updateRemotePlayStatus: function pv_updateRemotePlayStatus() {
|
||||
// If MusicComms does not exist then no need to update the play status.
|
||||
if (typeof MusicComms === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
var position = this.pausedPosition ?
|
||||
this.pausedPosition : this.audio.currentTime;
|
||||
|
||||
var info = {
|
||||
playStatus: this.playStatus,
|
||||
duration: this.audio.duration * 1000,
|
||||
position: position * 1000
|
||||
};
|
||||
|
||||
// Before we resume the player, we need to keep the paused position
|
||||
// because once the connected A2DP device receives different positions
|
||||
// on AFTER paused and BEFORE playing, it will break the play/pause states
|
||||
// that the A2DP device kept.
|
||||
this.pausedPosition = (this.playStatus === PLAYSTATUS_PLAYING) ?
|
||||
null : this.audio.currentTime;
|
||||
|
||||
// Notify the remote device that status is changed.
|
||||
MusicComms.notifyStatusChanged(info);
|
||||
},
|
||||
|
||||
PLAYER_IS_OCCUPIED_BY: 'music-player-is-occupied-by',
|
||||
|
||||
_handleInterpageMessage: function(evt) {
|
||||
if (evt.key === this.PLAYER_IS_OCCUPIED_BY) {
|
||||
// if there is another page (different from the page we are at now)
|
||||
// going to play, stop the current one
|
||||
if (evt.newValue && evt.newValue !== location.href) {
|
||||
this.pause();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_sendInterpageMessage: function() {
|
||||
window.localStorage.setItem(this.PLAYER_IS_OCCUPIED_BY, location.href);
|
||||
},
|
||||
|
||||
_clearInterpageMessage: function() {
|
||||
var whoIsPlaying = window.localStorage.getItem(this.PLAYER_IS_OCCUPIED_BY);
|
||||
if (whoIsPlaying && whoIsPlaying === window.location.href) {
|
||||
window.localStorage.removeItem(this.PLAYER_IS_OCCUPIED_BY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start playing the current song in the queue.
|
||||
*
|
||||
* @param {Boolean} autoplay True if the song should immediately start playing
|
||||
* (unless we can't for some reason), false otherwise.
|
||||
*/
|
||||
start: function pv_start(autoplay = true) {
|
||||
this.checkSCOStatus();
|
||||
this._sendInterpageMessage();
|
||||
this.showInfo();
|
||||
|
||||
this.queue.current().then((songData) => {
|
||||
this.setInfo(songData);
|
||||
|
||||
if (songData.blob) {
|
||||
return songData.blob;
|
||||
}
|
||||
|
||||
this.setRatings(songData.metadata.rated);
|
||||
Database.incrementPlayCount(songData);
|
||||
return Database.getFile(songData, true);
|
||||
}).then((file) => {
|
||||
this.setAudioSrc(file);
|
||||
|
||||
// We pause at the beginning when SCO is enabled. The user can still
|
||||
// select songs to play, but it won't start until the SCO is disconnected.
|
||||
if (!autoplay || !this.autoplay || MusicComms.isSCOEnabled) {
|
||||
this.pause();
|
||||
}
|
||||
}).catch(function(msg) {
|
||||
console.error(msg);
|
||||
});
|
||||
},
|
||||
|
||||
play: function pv_play(targetIndex) {
|
||||
this.checkSCOStatus();
|
||||
this._sendInterpageMessage();
|
||||
this.showInfo();
|
||||
|
||||
// The player must be paused, so let's resume it. However, if we're very
|
||||
// close to the end of the song (and if the song is not really short) then
|
||||
// just skip to the next song rather than finishing this one. (This works
|
||||
// around Bug 1157118 where if we're within a fraction of a second of the
|
||||
// end of a .m4a file, it takes a long time to get an ended event and move
|
||||
// to the next song.)
|
||||
if (this.audio.duration > 20 &&
|
||||
this.audio.duration - this.audio.currentTime < 1) {
|
||||
this.next(true);
|
||||
} else {
|
||||
this.audio.play();
|
||||
}
|
||||
},
|
||||
|
||||
pause: function pv_pause() {
|
||||
this.checkSCOStatus();
|
||||
this._clearInterpageMessage();
|
||||
this.audio.pause();
|
||||
},
|
||||
|
||||
stop: function pv_stop() {
|
||||
this.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
|
||||
this.clean();
|
||||
// Player in open activity does not have ModeManager.
|
||||
if (typeof ModeManager !== 'undefined') {
|
||||
ModeManager.playerTitle = null;
|
||||
// To leave player mode and set the correct title to the TitleBar
|
||||
// we have to decide which mode we should back to when the player stops
|
||||
if (ModeManager.currentMode === MODE_PLAYER) {
|
||||
ModeManager.pop();
|
||||
} else {
|
||||
ModeManager.updateTitle();
|
||||
ModeManager.updatePlayerIcon();
|
||||
}
|
||||
}
|
||||
|
||||
this.playStatus = PLAYSTATUS_STOPPED;
|
||||
this.updateRemotePlayStatus();
|
||||
},
|
||||
|
||||
next: function pv_next(isAutomatic) {
|
||||
if (this.type === TYPE_SINGLE) {
|
||||
this.start(false);
|
||||
} else if(this.queue.next(isAutomatic)) {
|
||||
this.start();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
},
|
||||
|
||||
previous: function pv_previous() {
|
||||
// If a song starts more than 3 (seconds),
|
||||
// when users click skip backward, it will restart the current song
|
||||
// otherwise just skip to the previous song
|
||||
if (this.audio.currentTime <= 3) {
|
||||
this.queue.previous();
|
||||
}
|
||||
|
||||
this.start();
|
||||
},
|
||||
|
||||
startFastSeeking: function pv_startFastSeeking(direction) {
|
||||
// direction can be 1 or -1, 1 means forward and -1 means rewind.
|
||||
this.isTouching = this.isFastSeeking = true;
|
||||
var offset = direction * 2;
|
||||
|
||||
this.prevPlayStatus = this.playStatus;
|
||||
this.playStatus = direction ? PLAYSTATUS_FWD_SEEK : PLAYSTATUS_REV_SEEK;
|
||||
this.updateRemotePlayStatus();
|
||||
|
||||
this.intervalID = window.setInterval(function() {
|
||||
this.seekAudio(this.audio.currentTime + offset);
|
||||
}.bind(this), 15);
|
||||
},
|
||||
|
||||
stopFastSeeking: function pv_stopFastSeeking() {
|
||||
this.isTouching = this.isFastSeeking = false;
|
||||
if (this.intervalID) {
|
||||
window.clearInterval(this.intervalID);
|
||||
}
|
||||
|
||||
this.playStatus = this.prevPlayStatus;
|
||||
this.prevPlayStatus = null;
|
||||
this.updateRemotePlayStatus();
|
||||
},
|
||||
|
||||
updateSeekBar: function pv_updateSeekBar() {
|
||||
// Don't update the seekbar when the user is seeking.
|
||||
if (this.isTouching) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If ModeManager is undefined, then the music app is launched by the open
|
||||
// activity. Otherwise, only seek the audio when the mode is PLAYER because
|
||||
// updating the UI will slow down the other pages, such as the scrolling in
|
||||
// ListView.
|
||||
if (typeof ModeManager === 'undefined' ||
|
||||
ModeManager.currentMode === MODE_PLAYER) {
|
||||
this.seekAudio();
|
||||
}
|
||||
},
|
||||
|
||||
seekAudio: function pv_seekAudio(seekTime) {
|
||||
if (seekTime !== undefined) {
|
||||
// Because of bug 1119186, setting the currentTime to a value as same as
|
||||
// the audio.duration seems corrupts the audio element, so here we floor
|
||||
// the seek time to prevent it.
|
||||
this.audio.currentTime = Math.floor(seekTime);
|
||||
}
|
||||
|
||||
this.setSeekBar(this.audio.duration, this.audio.currentTime);
|
||||
},
|
||||
|
||||
setSeekBar: function pv_setSeekBar(endTime, currentTime) {
|
||||
if (this.seekBar.max != endTime) {
|
||||
// Duration changed, update accessibility label.
|
||||
navigator.mozL10n.setAttributes(this.seekSlider,
|
||||
'playbackSeekBar', {'duration': formatTime(endTime)});
|
||||
}
|
||||
|
||||
this.seekBar.max = isFinite(endTime) ? endTime : 0;
|
||||
this.seekBar.value = currentTime;
|
||||
|
||||
var formattedCurrentTime = formatTime(currentTime);
|
||||
// Adjust values for accessibility
|
||||
this.seekSlider.setAttribute('aria-valuetext', formattedCurrentTime);
|
||||
this.seekSlider.setAttribute('aria-valuemax', this.seekBar.max);
|
||||
this.seekSlider.setAttribute('aria-valuenow', currentTime);
|
||||
|
||||
// if endTime is 0, that's a reset of seekBar
|
||||
var ratio = (isFinite(endTime) && endTime !== 0) ?
|
||||
(currentTime / endTime) : 0;
|
||||
// The width of the seek indicator must be also considered
|
||||
// so we divide the width of seek indicator by 2 to find the center point
|
||||
var x = (ratio * this.seekBar.offsetWidth -
|
||||
this.seekIndicator.offsetWidth / 2);
|
||||
|
||||
if (this.isLTR) {
|
||||
x = x + 'px';
|
||||
} else {
|
||||
if (x < 0) {
|
||||
x = Math.abs(x) + 'px';
|
||||
} else {
|
||||
x = '-' + x + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
this.seekIndicator.style.transform = 'translateX(' + x + ')';
|
||||
|
||||
this.seekElapsed.textContent = formattedCurrentTime;
|
||||
var remainingTime = endTime - currentTime;
|
||||
// Check if there is remaining time to show, avoiding to display "-00:00"
|
||||
// while song is loading (Bug 833710)
|
||||
this.seekRemaining.textContent =
|
||||
(remainingTime > 0) ? '-' + formatTime(remainingTime) : '---:--';
|
||||
},
|
||||
|
||||
share: function pv_shareFile() {
|
||||
// We try to fix Bug 814323 by using current workaround of bluetooth
|
||||
// transfer so we will pass both filenames and filepaths. The filepaths can
|
||||
// be removed after Bug 811615 is fixed.
|
||||
var songData = this.currentFileInfo;
|
||||
|
||||
if (songData.metadata.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
LazyLoader.load('js/metadata/album_art_cache.js').then(() => {
|
||||
return Promise.all([
|
||||
Database.getFile(songData),
|
||||
AlbumArtCache.getThumbnailBlob(songData)
|
||||
]);
|
||||
}).then(function([file, pictureBlob]) {
|
||||
var filename = songData.name,
|
||||
name = filename.substring(filename.lastIndexOf('/') + 1);
|
||||
|
||||
var activityData = {
|
||||
type: 'audio/*',
|
||||
number: 1,
|
||||
blobs: [file],
|
||||
filenames: [name],
|
||||
filepaths: [filename],
|
||||
// We only pass some metadata attributes so we don't share personal
|
||||
// details like # of times played and ratings
|
||||
metadata: [{
|
||||
title: songData.metadata.title,
|
||||
artist: songData.metadata.artist,
|
||||
album: songData.metadata.album,
|
||||
picture: pictureBlob
|
||||
}]
|
||||
};
|
||||
|
||||
if (PlayerView.playStatus !== PLAYSTATUS_PLAYING) {
|
||||
var a = new MozActivity({
|
||||
name: 'share',
|
||||
data: activityData
|
||||
});
|
||||
|
||||
a.onerror = function(e) {
|
||||
console.warn('share activity error:', a.error.name);
|
||||
};
|
||||
}
|
||||
else {
|
||||
// HACK HACK HACK
|
||||
//
|
||||
// Bug 956811: If we are currently playing music and share the
|
||||
// music with an inline activity handler (like the set
|
||||
// ringtone app) that wants to play music itself, we have a
|
||||
// problem because we have two foreground apps playing music
|
||||
// and neither one takes priority over the other. This is an
|
||||
// underlying bug in the way that inline activities are
|
||||
// handled and in our "audio competing policy". See bug
|
||||
// 892371.
|
||||
//
|
||||
// To work around this problem, if the music app is currently
|
||||
// playing anything, then before we launch the activity we start
|
||||
// listening for changes on a property in the settings database.
|
||||
// If the setting changes, we pause our playback and don't resume
|
||||
// until the activity returns. Then we pass the name of this magic
|
||||
// setting as a secret undocumented property of the activity so that
|
||||
// the ringtones app can use it.
|
||||
//
|
||||
// This done as much as possible in a self-invoking function to make
|
||||
// it easier to remove the hack when we have a real bug fix.
|
||||
//
|
||||
// See also the corresponding code in apps/ringtones/js/share.js
|
||||
//
|
||||
// HACK HACK HACK
|
||||
(function() {
|
||||
// This are the magic names we'll use for this hack
|
||||
var hack_activity_property = '_hack_hack_shut_up';
|
||||
var hack_setting_property = 'music._hack.pause_please';
|
||||
|
||||
// Listen for changes to the magic setting
|
||||
navigator.mozSettings.addObserver(hack_setting_property,
|
||||
observer);
|
||||
|
||||
// Pass the magic setting name as part of the activity request
|
||||
activityData[hack_activity_property] = hack_setting_property;
|
||||
|
||||
// Now initiate the activity. This code is the same as the
|
||||
// normal non-hack code in the if clause above.
|
||||
var a = new MozActivity({
|
||||
name: 'share',
|
||||
data: activityData
|
||||
});
|
||||
|
||||
a.onerror = a.onsuccess = cleanup;
|
||||
|
||||
// This is the function that pauses the music if the activity
|
||||
// handler sets the magic settings property.
|
||||
function observer(e) {
|
||||
// If the value of the setting has changed, then we pause the
|
||||
// music. Note that we don't care what the new value of the
|
||||
// setting is. We only care whether it has changed. The
|
||||
// ringtones app will just toggle it back and forth between
|
||||
// true and false.
|
||||
PlayerView.pause();
|
||||
}
|
||||
|
||||
// When the activity is done, we stop observing the setting.
|
||||
// And if we have been paused, then we resume playing.
|
||||
function cleanup() {
|
||||
navigator.mozSettings.removeObserver(hack_setting_property,
|
||||
observer);
|
||||
if (PlayerView.playStatus === PLAYSTATUS_PAUSED) {
|
||||
PlayerView.play();
|
||||
}
|
||||
}
|
||||
}());
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleEvent: function pv_handleEvent(evt) {
|
||||
var target = evt.target;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (evt.type) {
|
||||
case 'click':
|
||||
switch (target.id) {
|
||||
case 'player-cover':
|
||||
case 'player-cover-image-1':
|
||||
case 'player-cover-image-2':
|
||||
this.showInfo();
|
||||
break;
|
||||
case 'player-controls-play':
|
||||
if (this.playControl.classList.contains('is-pause')) {
|
||||
this.play();
|
||||
} else {
|
||||
this.pause();
|
||||
}
|
||||
break;
|
||||
case 'player-album-repeat':
|
||||
this.showInfo();
|
||||
|
||||
var repeat = PlaybackQueue.Repeat.next(this.getRepeat());
|
||||
PlaybackQueue.repeat = repeat;
|
||||
this.setRepeat(repeat);
|
||||
break;
|
||||
case 'player-album-shuffle':
|
||||
this.showInfo();
|
||||
|
||||
var shuffle = !target.classList.contains('shuffle-on');
|
||||
PlaybackQueue.shuffle = shuffle;
|
||||
this.setShuffle(shuffle);
|
||||
break;
|
||||
case 'player-cover-share':
|
||||
this.share();
|
||||
break;
|
||||
}
|
||||
|
||||
if (target.dataset.rating) {
|
||||
this.showInfo();
|
||||
|
||||
var songData = this.currentFileInfo;
|
||||
var targetRating = parseInt(target.dataset.rating, 10);
|
||||
var newRating = (targetRating === songData.metadata.rated) ?
|
||||
targetRating - 1 : targetRating;
|
||||
|
||||
Database.setSongRating(songData, newRating);
|
||||
this.setRatings(newRating);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'play':
|
||||
this.playControl.classList.remove('is-pause');
|
||||
// The play event is fired when audio playback has begun.
|
||||
this.playStatus = PLAYSTATUS_PLAYING;
|
||||
this.playControl.setAttribute('data-l10n-id', 'playbackPause');
|
||||
this.updateRemotePlayStatus();
|
||||
break;
|
||||
case 'pause':
|
||||
this.playControl.classList.add('is-pause');
|
||||
this.playStatus = PLAYSTATUS_PAUSED;
|
||||
this.playControl.setAttribute('data-l10n-id', 'playbackPlay');
|
||||
this.updateRemotePlayStatus();
|
||||
break;
|
||||
case 'touchstart':
|
||||
case 'touchmove':
|
||||
if (evt.type === 'touchstart') {
|
||||
this.isTouching = true;
|
||||
this.seekIndicator.classList.add('highlight');
|
||||
}
|
||||
if (this.isTouching && this.audio.duration > 0) {
|
||||
// target is the seek bar
|
||||
var touch = evt.touches[0];
|
||||
var x = (touch.clientX - target.offsetLeft) / target.offsetWidth;
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
if (x > 1) {
|
||||
x = 1;
|
||||
}
|
||||
|
||||
if (this.isLTR) {
|
||||
this.seekTime = x * this.seekBar.max;
|
||||
} else {
|
||||
this.seekTime = this.audio.duration - x * this.seekBar.max;
|
||||
}
|
||||
this.setSeekBar(this.audio.duration, this.seekTime);
|
||||
}
|
||||
break;
|
||||
case 'touchend':
|
||||
// If isFastSeeking is true then the event is trigger by the long press
|
||||
// of the previous or next buttons, so stop the fast seeking.
|
||||
// Otherwise, check the target id then do the corresponding actions.
|
||||
if (this.isFastSeeking) {
|
||||
this.stopFastSeeking();
|
||||
} else if (target.id === 'player-seek-bar') {
|
||||
this.seekIndicator.classList.remove('highlight');
|
||||
if (this.audio.duration > 0 && this.isTouching) {
|
||||
this.seekAudio(this.seekTime);
|
||||
this.seekTime = 0;
|
||||
}
|
||||
this.isTouching = false;
|
||||
} else if (target.id === 'player-controls-previous') {
|
||||
this.previous();
|
||||
} else if (target.id === 'player-controls-next') {
|
||||
this.next();
|
||||
}
|
||||
break;
|
||||
case 'keypress':
|
||||
// The standard accessible control for sliders is arrow up/down keys.
|
||||
// Our screenreader synthesizes those events on swipe up/down gestures.
|
||||
// Currently, we only allow screen reader users to adjust sliders with a
|
||||
// constant step size (there is no page up/down equivalent). In the case
|
||||
// of music, we make sure that the maximum amount of steps for the
|
||||
// entire duration is 20, or 2 second increments if the duration is less
|
||||
// then 40 seconds.
|
||||
var step = Math.max(this.audio.duration / 20, 2);
|
||||
if (evt.keyCode == evt.DOM_VK_DOWN) {
|
||||
this.seekAudio(this.audio.currentTime - step);
|
||||
} else if (evt.keyCode == evt.DOM_VK_UP) {
|
||||
this.seekAudio(this.audio.currentTime + step);
|
||||
}
|
||||
break;
|
||||
case 'contextmenu':
|
||||
if (target.id === 'player-controls-next') {
|
||||
this.startFastSeeking(1);
|
||||
} else if (target.id === 'player-controls-previous') {
|
||||
this.startFastSeeking(-1);
|
||||
}
|
||||
break;
|
||||
case 'durationchange':
|
||||
case 'timeupdate':
|
||||
this.updateSeekBar();
|
||||
|
||||
// Update the metadata when the new track is really loaded
|
||||
// when it just started to play, or the duration will be 0 then it will
|
||||
// break the duration that the connected A2DP has.
|
||||
if (evt.type === 'durationchange' || this.audio.currentTime === 0) {
|
||||
this.updateRemoteMetadata();
|
||||
}
|
||||
break;
|
||||
case 'ended':
|
||||
this.next(true);
|
||||
break;
|
||||
|
||||
case 'visibilitychange':
|
||||
if (document.hidden) {
|
||||
this.audio.removeEventListener('timeupdate', this);
|
||||
} else {
|
||||
this.audio.addEventListener('timeupdate', this);
|
||||
// Ensure that the scrubber is synced up. It can get out of sync if we
|
||||
// paused while the music app was in the background.
|
||||
this.updateSeekBar();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mozinterruptbegin':
|
||||
this.playStatus = INTERRUPT_BEGIN;
|
||||
this.updateRemotePlayStatus();
|
||||
break;
|
||||
|
||||
case 'mozinterruptend':
|
||||
// After received the mozinterruptend event the player should recover
|
||||
// its status to the status before mozinterruptbegin, it should be
|
||||
// PLAYING because mozinterruptbegin only fires when an audio element
|
||||
// is playing.
|
||||
this.playStatus = PLAYSTATUS_PLAYING;
|
||||
this.updateRemotePlayStatus();
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
updateL10n: function pv_updateL10n() {
|
||||
this.isLTR = navigator.mozL10n.language.direction === 'ltr' ? true : false;
|
||||
}
|
||||
};
|
||||
235
gaia/dev_apps/music-oga/js/ui/views/search_view.js
Executable file
|
|
@ -0,0 +1,235 @@
|
|||
/* exported SearchView */
|
||||
/* global App, createListElement, Database, ListView, ModeManager, MODE_LIST,
|
||||
MODE_PICKER, MODE_PLAYER, MODE_SEARCH_FROM_TILES, MODE_SUBLIST,
|
||||
MODE_TILES, PlaybackQueue, PlayerView, SubListView, TabBar,
|
||||
TilesView */
|
||||
'use strict';
|
||||
|
||||
var SearchView = {
|
||||
context: {
|
||||
ALL: 'ALL',
|
||||
ARTISTS: 'ARTISTS',
|
||||
ALBUMS: 'ALBUMS',
|
||||
SONGS: 'SONGS',
|
||||
},
|
||||
|
||||
searchContext: 'ALL',
|
||||
|
||||
get view() {
|
||||
return document.getElementById('views-search');
|
||||
},
|
||||
|
||||
get searchInput() {
|
||||
return document.getElementById('views-search-input');
|
||||
},
|
||||
|
||||
get searchArtistsView() {
|
||||
return document.getElementById('views-search-artists');
|
||||
},
|
||||
|
||||
get searchAlbumsView() {
|
||||
return document.getElementById('views-search-albums');
|
||||
},
|
||||
|
||||
get searchTitlesView() {
|
||||
return document.getElementById('views-search-titles');
|
||||
},
|
||||
|
||||
showNoResult: function sv_showNoResult(show) {
|
||||
var view = document.getElementById('views-search-no-result');
|
||||
view.classList.toggle('hidden', !show);
|
||||
},
|
||||
|
||||
init: function sv_init() {
|
||||
this.dataSource = [];
|
||||
this.searchHandles = { artist: null, album: null, title: null };
|
||||
|
||||
this.view.addEventListener('click', this);
|
||||
this.view.addEventListener('touchend', this);
|
||||
this.searchInput.addEventListener('focus', this);
|
||||
this.searchInput.addEventListener('input', this);
|
||||
this.searchInput.addEventListener('keypress', this);
|
||||
},
|
||||
|
||||
switchContext: function sv_switchContext() {
|
||||
switch (TabBar.option) {
|
||||
case 'mix':
|
||||
case 'playlist':
|
||||
this.searchContext = SearchView.context.ALL;
|
||||
break;
|
||||
case 'artist':
|
||||
this.searchContext = SearchView.context.ARTISTS;
|
||||
break;
|
||||
case 'album':
|
||||
this.searchContext = SearchView.context.ALBUMS;
|
||||
break;
|
||||
case 'title':
|
||||
this.searchContext = SearchView.context.SONGS;
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
search: function sv_search(query) {
|
||||
this.clear();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
var lists = { artist: this.searchArtistsView,
|
||||
album: this.searchAlbumsView,
|
||||
title: this.searchTitlesView };
|
||||
var numResults = { artist: 0, album: 0, title: 0 };
|
||||
|
||||
function sv_showResult(option, result) {
|
||||
/* jshint validthis:true */
|
||||
if (result === null) {
|
||||
this.searchHandles[option] = null;
|
||||
var totalFound = numResults.artist + numResults.album +
|
||||
numResults.title;
|
||||
this.showNoResult(totalFound === 0);
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataSource.push(result);
|
||||
numResults[option]++;
|
||||
lists[option].classList.remove('hidden');
|
||||
lists[option].getElementsByClassName('search-result-count')[0]
|
||||
.textContent = numResults[option];
|
||||
lists[option].getElementsByClassName('search-results')[0].appendChild(
|
||||
createListElement(option, result, this.dataSource.length - 1, query)
|
||||
);
|
||||
this.showNoResult(false);
|
||||
}
|
||||
|
||||
// Only shows the search results of tracks when it's in picker mode
|
||||
if (!App.pendingPick) {
|
||||
if (this.searchContext === this.context.ALL ||
|
||||
this.searchContext === this.context.ARTISTS) {
|
||||
this.searchHandles.artist = Database.search(
|
||||
'artist', query, sv_showResult.bind(this, 'artist')
|
||||
);
|
||||
}
|
||||
if (this.searchContext === this.context.ALL ||
|
||||
this.searchContext === this.context.ALBUMS) {
|
||||
this.searchHandles.album = Database.search(
|
||||
'album', query, sv_showResult.bind(this, 'album')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.searchContext === this.context.ALL ||
|
||||
this.searchContext === this.context.SONGS) {
|
||||
this.searchHandles.title = Database.search(
|
||||
'title', query, sv_showResult.bind(this, 'title')
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
clear: function sv_clear() {
|
||||
for (var option in this.searchHandles) {
|
||||
var handle = this.searchHandles[option];
|
||||
if (handle) {
|
||||
Database.cancelEnumeration(handle);
|
||||
}
|
||||
}
|
||||
|
||||
var views = [this.searchArtistsView, this.searchAlbumsView,
|
||||
this.searchTitlesView];
|
||||
views.forEach(function(view) {
|
||||
view.getElementsByClassName('search-results')[0].innerHTML = '';
|
||||
view.classList.add('hidden');
|
||||
});
|
||||
this.showNoResult(false);
|
||||
this.dataSource = [];
|
||||
},
|
||||
|
||||
hide: function sv_hide() {
|
||||
this.searchInput.value = '';
|
||||
this.clear();
|
||||
|
||||
if (ModeManager.currentMode === MODE_SEARCH_FROM_TILES) {
|
||||
ModeManager.start(MODE_TILES, () => {
|
||||
TilesView.hideSearch();
|
||||
});
|
||||
} else {
|
||||
ModeManager.start(App.pendingPick ? MODE_PICKER : MODE_LIST, () => {
|
||||
ListView.hideSearch();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
openResult: function sv_openResult(target) {
|
||||
var index = target.dataset.index;
|
||||
|
||||
var option = target.dataset.option;
|
||||
var keyRange = target.dataset.keyRange;
|
||||
var data = this.dataSource[index];
|
||||
|
||||
if (option === 'title') {
|
||||
ModeManager.push(MODE_PLAYER, () => {
|
||||
PlayerView.activate(new PlaybackQueue.StaticQueue([data]));
|
||||
PlayerView.start();
|
||||
});
|
||||
} else {
|
||||
// SubListView needs to prepare the songs data before entering it,
|
||||
// So here we initialize the SubListView before push the view.
|
||||
ModeManager.waitForView(MODE_SUBLIST, () => {
|
||||
SubListView.activate(option, data, index, keyRange, 'next', () => {
|
||||
ModeManager.push(MODE_SUBLIST);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleEvent: function sv_handleEvent(evt) {
|
||||
var target = evt.target;
|
||||
switch (evt.type) {
|
||||
case 'touchend':
|
||||
if (target.id === 'views-search-clear') {
|
||||
evt.preventDefault();
|
||||
this.searchInput.value = '';
|
||||
this.clear();
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'click':
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.id === 'views-search-close') {
|
||||
evt.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
|
||||
if (target.dataset.index) {
|
||||
this.openResult(target);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'focus':
|
||||
this.switchContext();
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
if (target.id === 'views-search-input') {
|
||||
this.search(target.value);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'keypress':
|
||||
if (target.id === 'views-search-input') {
|
||||
if (evt.keyCode === evt.DOM_VK_RETURN) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
186
gaia/dev_apps/music-oga/js/ui/views/subList_view.js
Executable file
|
|
@ -0,0 +1,186 @@
|
|||
/* exported SubListView */
|
||||
/* global AlbumArtCache, createListElement, Database, LazyLoader, ModeManager,
|
||||
MODE_PLAYER, PlaybackQueue, PlayerView, showImage, TabBar */
|
||||
'use strict';
|
||||
|
||||
var SubListView = {
|
||||
unknownNameL10nIds: {
|
||||
'artist': 'unknownArtist',
|
||||
'album': 'unknownAlbum',
|
||||
'title': 'unknownTitle'
|
||||
},
|
||||
|
||||
get view() {
|
||||
return document.getElementById('views-sublist');
|
||||
},
|
||||
|
||||
get dataSource() {
|
||||
return this._dataSource;
|
||||
},
|
||||
|
||||
get anchor() {
|
||||
return document.getElementById('views-sublist-anchor');
|
||||
},
|
||||
|
||||
get headerImage() {
|
||||
return document.getElementById('views-sublist-header-image');
|
||||
},
|
||||
|
||||
get headerName() {
|
||||
return document.getElementById('views-sublist-header-name');
|
||||
},
|
||||
|
||||
get playAllButton() {
|
||||
return document.getElementById('views-sublist-controls-play');
|
||||
},
|
||||
|
||||
get shuffleButton() {
|
||||
return document.getElementById('views-sublist-controls-shuffle');
|
||||
},
|
||||
|
||||
set dataSource(source) {
|
||||
this._dataSource = source;
|
||||
|
||||
// At the same time we also check how many songs in an album
|
||||
// Shuffle button is not necessary when an album only contains one song
|
||||
this.shuffleButton.disabled = (this._dataSource.length < 2);
|
||||
},
|
||||
|
||||
init: function slv_init() {
|
||||
this.handle = null;
|
||||
this.dataSource = [];
|
||||
this.index = 0;
|
||||
|
||||
this.view.addEventListener('click', this);
|
||||
},
|
||||
|
||||
clean: function slv_clean() {
|
||||
// Cancel a pending enumeration before start a new one
|
||||
if (this.handle) {
|
||||
Database.cancelEnumeration(this.handle);
|
||||
}
|
||||
|
||||
this.dataSource = [];
|
||||
this.index = 0;
|
||||
this.anchor.innerHTML = '';
|
||||
this.view.scrollTop = 0;
|
||||
},
|
||||
|
||||
setHeaderImage: function slv_setHeaderImage(fileinfo) {
|
||||
// See if we are viewing the predefined playlists, if so, then replace the
|
||||
// fileinfo with the first record in the dataSource to display the first
|
||||
// album art for every predefined playlist.
|
||||
if (TabBar.playlistArray.indexOf(fileinfo) !== -1) {
|
||||
fileinfo = this.dataSource[0];
|
||||
}
|
||||
|
||||
LazyLoader.load('js/metadata/album_art_cache.js').then(() => {
|
||||
return AlbumArtCache.getThumbnailURL(fileinfo);
|
||||
}).then((url) => {
|
||||
showImage(this.albumImage, url);
|
||||
});
|
||||
},
|
||||
|
||||
setHeaderName: function slv_setHeaderName(name, l10nId) {
|
||||
this.headerName.textContent = name;
|
||||
this.headerName.dataset.l10nId = l10nId;
|
||||
},
|
||||
|
||||
activate: function(option, data, index, keyRange, direction, callback) {
|
||||
var targetOption = (option === 'date') ? option : 'metadata.' + option;
|
||||
this.clean();
|
||||
|
||||
this.handle = Database.enumerateAll(targetOption, keyRange, direction,
|
||||
(dataArray) => {
|
||||
var headerName;
|
||||
var headerNameL10nId = '';
|
||||
var maxDiscNum = 1;
|
||||
|
||||
if (option === 'album') {
|
||||
dataArray.sort(function(e1, e2) {
|
||||
return (e1.metadata.discnum - e2.metadata.discnum) ||
|
||||
(e1.metadata.tracknum - e2.metadata.tracknum);
|
||||
});
|
||||
|
||||
maxDiscNum = Math.max(
|
||||
dataArray[dataArray.length - 1].metadata.disccount,
|
||||
dataArray[dataArray.length - 1].metadata.discnum
|
||||
);
|
||||
}
|
||||
|
||||
var inPlaylist = (option !== 'artist' &&
|
||||
option !== 'album' &&
|
||||
option !== 'title');
|
||||
|
||||
headerName = data.metadata[inPlaylist ? 'title' : option];
|
||||
if (!headerName) {
|
||||
headerNameL10nId = this.unknownNameL10nIds[option];
|
||||
headerName = navigator.mozL10n.get(headerNameL10nId);
|
||||
}
|
||||
|
||||
// Overrides l10nId.
|
||||
if (data.metadata.l10nId) {
|
||||
headerNameL10nId = data.metadata.l10nId;
|
||||
}
|
||||
|
||||
this.dataSource = dataArray;
|
||||
this.setHeaderName(headerName, headerNameL10nId);
|
||||
this.setHeaderImage(data);
|
||||
|
||||
dataArray.forEach((songData) => {
|
||||
songData.multidisc = (maxDiscNum > 1);
|
||||
this.update(songData, inPlaylist);
|
||||
});
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Set inPlaylist to true if you want the index instead of the track #
|
||||
// By default it is the track #
|
||||
update: function slv_update(result, useIndexNumber) {
|
||||
if (result === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var option = useIndexNumber ? 'song-index' : 'song';
|
||||
this.anchor.appendChild(createListElement(option, result, this.index));
|
||||
|
||||
this.index++;
|
||||
},
|
||||
|
||||
handleEvent: function slv_handleEvent(evt) {
|
||||
var target = evt.target;
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (evt.type) {
|
||||
case 'click':
|
||||
if (target === this.shuffleButton || target === this.playAllButton) {
|
||||
ModeManager.push(MODE_PLAYER, () => {
|
||||
PlayerView.clean();
|
||||
PlaybackQueue.shuffle = (target === this.shuffleButton);
|
||||
PlayerView.activate(new PlaybackQueue.StaticQueue(this.dataSource));
|
||||
PlayerView.start();
|
||||
});
|
||||
} else if (target.dataset.index) {
|
||||
ModeManager.push(MODE_PLAYER, () => {
|
||||
PlayerView.clean();
|
||||
var targetIndex = parseInt(target.dataset.index, 10);
|
||||
PlayerView.activate(new PlaybackQueue.StaticQueue(
|
||||
this.dataSource, targetIndex
|
||||
));
|
||||
PlayerView.start();
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
220
gaia/dev_apps/music-oga/js/ui/views/tiles_view.js
Executable file
|
|
@ -0,0 +1,220 @@
|
|||
/* exported TilesView */
|
||||
/* global AlbumArtCache, Database, IDBKeyRange, LazyLoader, ModeManager,
|
||||
MODE_PLAYER, MODE_SEARCH_FROM_TILES, PlaybackQueue, PlayerView,
|
||||
SearchView, showImage */
|
||||
'use strict';
|
||||
|
||||
var TilesView = {
|
||||
get view() {
|
||||
return document.getElementById('views-tiles');
|
||||
},
|
||||
|
||||
get anchor() {
|
||||
return document.getElementById('views-tiles-anchor');
|
||||
},
|
||||
|
||||
get searchBox() {
|
||||
return document.getElementById('views-tiles-search');
|
||||
},
|
||||
|
||||
get searchInput() {
|
||||
return document.getElementById('views-tiles-search-input');
|
||||
},
|
||||
|
||||
get dataSource() {
|
||||
return this._dataSource;
|
||||
},
|
||||
|
||||
set dataSource(source) {
|
||||
this._dataSource = source;
|
||||
},
|
||||
|
||||
init: function tv_init() {
|
||||
this.handle = null;
|
||||
this.dataSource = [];
|
||||
this.index = 0;
|
||||
|
||||
this.view.addEventListener('click', this);
|
||||
this.searchInput.addEventListener('focus', this);
|
||||
},
|
||||
|
||||
clean: function tv_clean() {
|
||||
this.dataSource = [];
|
||||
this.index = 0;
|
||||
this.anchor.innerHTML = '';
|
||||
},
|
||||
|
||||
hideSearch: function tv_hideSearch() {
|
||||
this.searchInput.value = '';
|
||||
// XXX: we probably want to animate this...
|
||||
if (this.view.scrollTop < this.searchBox.offsetHeight) {
|
||||
this.view.scrollTop = this.searchBox.offsetHeight;
|
||||
}
|
||||
},
|
||||
|
||||
activate: function tv_activate(callback) {
|
||||
// Enumerate existing song entries in the database. List them all, and
|
||||
// sort them in ascending order by album. Use enumerateAll() here so that
|
||||
// we get all the results we want and then pass them synchronously to the
|
||||
// _addItem() function. If we did it asynchronously, then we'd get one
|
||||
// redraw for every song.
|
||||
|
||||
// Cancel a pending enumeration before starting a new one.
|
||||
if (this.handle) {
|
||||
Database.cancelEnumeration(this.handle);
|
||||
}
|
||||
|
||||
this.handle = Database.enumerateAll('metadata.album', null, 'nextunique',
|
||||
function(songs) {
|
||||
TilesView.clean();
|
||||
songs.forEach(function(song) {
|
||||
TilesView._addItem(song);
|
||||
});
|
||||
|
||||
// Display the TilesView once the UI has been populated.
|
||||
document.getElementById('views-tiles').classList.remove('hidden');
|
||||
|
||||
if (callback) {
|
||||
callback(songs);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_addItem: function tv_addItem(result) {
|
||||
this.dataSource.push(result);
|
||||
|
||||
var tile = document.createElement('div');
|
||||
tile.className = 'tile';
|
||||
|
||||
var container = document.createElement('div');
|
||||
container.className = 'tile-container';
|
||||
container.setAttribute('role', 'button');
|
||||
|
||||
var albumArt = document.createElement('img');
|
||||
albumArt.className = 'tile-album-art';
|
||||
container.appendChild(albumArt);
|
||||
|
||||
var titleBar = document.createElement('div');
|
||||
titleBar.className = 'tile-title-bar';
|
||||
var artistName = document.createElement('div');
|
||||
artistName.className = 'tile-title-artist';
|
||||
var artistNameText = document.createElement('bdi');
|
||||
artistName.appendChild(artistNameText);
|
||||
var albumName = document.createElement('div');
|
||||
albumName.className = 'tile-title-album';
|
||||
var albumNameText = document.createElement('bdi');
|
||||
albumName.appendChild(albumNameText);
|
||||
artistNameText.textContent =
|
||||
result.metadata.artist || navigator.mozL10n.get('unknownArtist');
|
||||
artistNameText.dataset.l10nId =
|
||||
result.metadata.artist ? '' : 'unknownArtist';
|
||||
albumNameText.textContent =
|
||||
result.metadata.album || navigator.mozL10n.get('unknownAlbum');
|
||||
albumNameText.dataset.l10nId = result.metadata.album ? '' : 'unknownAlbum';
|
||||
titleBar.appendChild(artistName);
|
||||
|
||||
// There are 6 tiles in one group
|
||||
// and the first tile is the main-tile
|
||||
// so we mod 6 to find out who is the main-tile
|
||||
if (this.index % 6 === 0) {
|
||||
tile.classList.add('main-tile');
|
||||
artistName.classList.add('main-tile-title');
|
||||
titleBar.appendChild(albumName);
|
||||
} else {
|
||||
tile.classList.add('sub-tile');
|
||||
artistName.classList.add('sub-tile-title');
|
||||
}
|
||||
|
||||
var index = this.index;
|
||||
LazyLoader.load('js/metadata/album_art_cache.js').then(() => {
|
||||
var NUM_INITIALLY_VISIBLE_TILES = 8;
|
||||
var INITIALLY_HIDDEN_TILE_WAIT_TIME_MS = 1000;
|
||||
|
||||
var setTileBackgroundClosure = function(url) {
|
||||
showImage(albumArt, url);
|
||||
};
|
||||
|
||||
if (index <= NUM_INITIALLY_VISIBLE_TILES) {
|
||||
// Load this tile's background now, because it's visible.
|
||||
AlbumArtCache.getThumbnailURL(result).then(setTileBackgroundClosure);
|
||||
} else {
|
||||
// Defer loading hidden tiles until the visible ones are done.
|
||||
setTimeout(function() {
|
||||
AlbumArtCache.getThumbnailURL(result).then(setTileBackgroundClosure);
|
||||
}, INITIALLY_HIDDEN_TILE_WAIT_TIME_MS);
|
||||
}
|
||||
});
|
||||
|
||||
container.dataset.index = this.index;
|
||||
|
||||
// The tile info(album/artist) shows only when the cover does not exist
|
||||
if (!result.metadata.picture) {
|
||||
container.appendChild(titleBar);
|
||||
} else {
|
||||
container.setAttribute('aria-label', artistName.textContent + ' ' +
|
||||
albumName.textContent);
|
||||
}
|
||||
|
||||
tile.appendChild(container);
|
||||
this.anchor.appendChild(tile);
|
||||
|
||||
this.index++;
|
||||
},
|
||||
|
||||
handleEvent: function tv_handleEvent(evt) {
|
||||
var target = evt.target;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (evt.type) {
|
||||
case 'click':
|
||||
if (target.id === 'views-tiles-search-close') {
|
||||
if (ModeManager.currentMode === MODE_SEARCH_FROM_TILES) {
|
||||
ModeManager.pop();
|
||||
}
|
||||
this.hideSearch();
|
||||
evt.preventDefault();
|
||||
} else if (target.dataset.index) {
|
||||
tv_playAlbum(this.dataSource[target.dataset.index],
|
||||
target.dataset.index);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'focus':
|
||||
if (target.id === 'views-tiles-search-input') {
|
||||
if (ModeManager.currentMode !== MODE_SEARCH_FROM_TILES) {
|
||||
ModeManager.start(MODE_SEARCH_FROM_TILES, function() {
|
||||
// Let the search view gets the focus.
|
||||
SearchView.searchInput.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
function tv_playAlbum(data, index) {
|
||||
var key = 'metadata.album';
|
||||
var range = IDBKeyRange.only(data.metadata.album);
|
||||
var direction = 'next';
|
||||
|
||||
ModeManager.push(MODE_PLAYER, function() {
|
||||
PlayerView.clean();
|
||||
|
||||
// When an user tap an album on the tilesView
|
||||
// we have to get all the song data first
|
||||
// because the shuffle option might be ON
|
||||
// and we have create shuffled list and play in shuffle order
|
||||
Database.enumerateAll(key, range, direction, (dataArray) => {
|
||||
PlayerView.activate(new PlaybackQueue.StaticQueue(dataArray));
|
||||
PlayerView.start();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
216
gaia/dev_apps/music-oga/js/utils.js
Executable file
|
|
@ -0,0 +1,216 @@
|
|||
/* exported formatTime, createListElement */
|
||||
/* global AlbumArtCache, LazyLoader, Normalizer, Sanitizer */
|
||||
'use strict';
|
||||
|
||||
function formatTime(secs) {
|
||||
if (isNaN(secs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
secs = Math.floor(secs);
|
||||
|
||||
var formatedTime;
|
||||
var seconds = secs % 60;
|
||||
var minutes = Math.floor(secs / 60) % 60;
|
||||
var hours = Math.floor(secs / 3600);
|
||||
|
||||
if (hours === 0) {
|
||||
formatedTime =
|
||||
(minutes < 10 ? '0' + minutes : minutes) + ':' +
|
||||
(seconds < 10 ? '0' + seconds : seconds);
|
||||
} else {
|
||||
formatedTime =
|
||||
(hours < 10 ? '0' + hours : hours) + ':' +
|
||||
(minutes < 10 ? '0' + minutes : minutes) + ':' +
|
||||
(seconds < 10 ? '0' + seconds : seconds);
|
||||
}
|
||||
|
||||
return formatedTime;
|
||||
}
|
||||
|
||||
// In Music, visually we have three styles of list
|
||||
// Here we use one function to create different style lists
|
||||
function createListElement(option, data, index, highlight) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'list-item';
|
||||
li.setAttribute('role', 'presentation');
|
||||
|
||||
var a = document.createElement('a');
|
||||
a.dataset.index = index;
|
||||
a.dataset.option = option;
|
||||
a.setAttribute('role', 'option');
|
||||
|
||||
var titleBdi;
|
||||
|
||||
li.appendChild(a);
|
||||
|
||||
function highlightText(result, text) {
|
||||
var textContent = result.textContent;
|
||||
var textLowerCased = textContent.toLocaleLowerCase();
|
||||
var index = Normalizer.toAscii(textLowerCased).indexOf(text);
|
||||
|
||||
if (index >= 0) {
|
||||
var innerHTML = Sanitizer.createSafeHTML`
|
||||
${textContent.substring(0, index)}
|
||||
<span class="search-highlight">
|
||||
${textContent.substring(index, index + text.length)}
|
||||
</span>
|
||||
${textContent.substring(index + text.length)}`;
|
||||
|
||||
result.innerHTML = Sanitizer.unwrapSafeHTML(innerHTML);
|
||||
}
|
||||
}
|
||||
|
||||
switch (option) {
|
||||
case 'playlist':
|
||||
titleBdi = document.createElement('bdi');
|
||||
titleBdi.className = 'list-playlist-title';
|
||||
if (data.metadata.l10nId) {
|
||||
titleBdi.textContent = navigator.mozL10n.get(data.metadata.l10nId);
|
||||
titleBdi.dataset.l10nId = data.metadata.l10nId;
|
||||
} else {
|
||||
titleBdi.textContent =
|
||||
data.metadata.title || navigator.mozL10n.get('unknownTitle');
|
||||
titleBdi.dataset.l10nId =
|
||||
data.metadata.title ? '' : 'unknownTitle';
|
||||
}
|
||||
|
||||
a.dataset.keyRange = 'all';
|
||||
a.dataset.option = data.option;
|
||||
|
||||
a.appendChild(titleBdi);
|
||||
|
||||
if (index === 0) {
|
||||
var shuffleIcon = document.createElement('div');
|
||||
shuffleIcon.className = 'list-playlist-icon';
|
||||
shuffleIcon.dataset.icon = 'shuffle';
|
||||
shuffleIcon.setAttribute('data-l10n-id', 'shuffle-toggle');
|
||||
a.appendChild(shuffleIcon);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'artist':
|
||||
case 'album':
|
||||
case 'title':
|
||||
var artistBdi;
|
||||
var albumImg;
|
||||
|
||||
albumImg = document.createElement('img');
|
||||
albumImg.className = 'list-album-art';
|
||||
li.appendChild(albumImg);
|
||||
|
||||
LazyLoader.load('js/metadata/album_art_cache.js').then(() => {
|
||||
return AlbumArtCache.getThumbnailURL(data);
|
||||
}).then((url) => {
|
||||
showImage(albumImg, url);
|
||||
});
|
||||
|
||||
if (option === 'artist') {
|
||||
artistBdi = document.createElement('bdi');
|
||||
artistBdi.className = 'list-single-title';
|
||||
artistBdi.textContent =
|
||||
data.metadata.artist || navigator.mozL10n.get('unknownArtist');
|
||||
artistBdi.dataset.l10nId =
|
||||
data.metadata.artist ? '' : 'unknownArtist';
|
||||
|
||||
// Highlight the text when the highlight argument is passed
|
||||
// This should only happens when we are creating searched results
|
||||
if (highlight) {
|
||||
highlightText(artistBdi, highlight);
|
||||
}
|
||||
|
||||
a.appendChild(artistBdi);
|
||||
} else {
|
||||
var albumOrTitleBdi = document.createElement('bdi');
|
||||
artistBdi = document.createElement('bdi');
|
||||
albumOrTitleBdi.className = 'list-main-title';
|
||||
artistBdi.className = 'list-sub-title';
|
||||
if (option === 'album') {
|
||||
albumOrTitleBdi.textContent =
|
||||
data.metadata.album || navigator.mozL10n.get('unknownAlbum');
|
||||
albumOrTitleBdi.dataset.l10nId =
|
||||
data.metadata.album ? '' : 'unknownAlbum';
|
||||
} else {
|
||||
albumOrTitleBdi.textContent =
|
||||
data.metadata.title || navigator.mozL10n.get('unknownTitle');
|
||||
albumOrTitleBdi.dataset.l10nId =
|
||||
data.metadata.title ? '' : 'unknownTitle';
|
||||
}
|
||||
artistBdi.textContent =
|
||||
data.metadata.artist || navigator.mozL10n.get('unknownArtist');
|
||||
artistBdi.dataset.l10nId =
|
||||
data.metadata.artist ? '' : 'unknownArtist';
|
||||
|
||||
// Highlight the text when the highlight argument is passed
|
||||
// This should only happens when we are creating searched results
|
||||
if (highlight) {
|
||||
highlightText(albumOrTitleBdi, highlight);
|
||||
}
|
||||
|
||||
a.appendChild(albumOrTitleBdi);
|
||||
a.appendChild(artistBdi);
|
||||
}
|
||||
|
||||
a.dataset.keyRange = data.metadata[option];
|
||||
a.dataset.option = option;
|
||||
|
||||
break;
|
||||
|
||||
case 'song':
|
||||
case 'song-index':
|
||||
var songTitle =
|
||||
data.metadata.title || navigator.mozL10n.get('unknownTitle');
|
||||
|
||||
var indexBdi = document.createElement('bdi');
|
||||
indexBdi.className = 'list-song-index';
|
||||
// 'song-index' mean we want the index and not the track #
|
||||
if (option === 'song-index') {
|
||||
indexBdi.textContent = index + 1;
|
||||
} else {
|
||||
var trackNum = data.metadata.tracknum;
|
||||
if (data.metadata.discnum && data.multidisc) {
|
||||
trackNum = data.metadata.discnum + '.' +
|
||||
(trackNum < 10 ? '0' + trackNum : trackNum);
|
||||
}
|
||||
indexBdi.textContent = trackNum;
|
||||
}
|
||||
|
||||
titleBdi = document.createElement('bdi');
|
||||
titleBdi.className = 'list-song-title';
|
||||
titleBdi.textContent = songTitle;
|
||||
titleBdi.dataset.l10nId = data.metadata.title ? '' : 'unknownTitle';
|
||||
|
||||
var lengthBdi = document.createElement('bdi');
|
||||
lengthBdi.className = 'list-song-length';
|
||||
|
||||
a.appendChild(indexBdi);
|
||||
a.appendChild(titleBdi);
|
||||
a.appendChild(lengthBdi);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function showImage(image, url, option) {
|
||||
// Reset the image element.
|
||||
image.classList.remove(option);
|
||||
image.src = '';
|
||||
// We could probably have several animation options.
|
||||
// Add them here in the future.
|
||||
if (option === 'fadeIn') {
|
||||
image.style.opacity = 0;
|
||||
}
|
||||
|
||||
image.addEventListener('load', handler);
|
||||
|
||||
function handler(evt) {
|
||||
/* jshint validthis:true */
|
||||
evt.target.removeEventListener('load', handler);
|
||||
image.classList.add(option);
|
||||
}
|
||||
|
||||
image.src = url;
|
||||
}
|
||||
18
gaia/dev_apps/music-oga/jsdoc.json
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "music",
|
||||
"tags": {
|
||||
"dictionaries": ["jsdoc"]
|
||||
},
|
||||
"source": {
|
||||
"include": ["apps/music/js"]
|
||||
},
|
||||
"plugins": [
|
||||
"plugins/markdown",
|
||||
"plugins/summarize"
|
||||
],
|
||||
"opts": {
|
||||
"private": true,
|
||||
"recurse": true,
|
||||
"destination": "docs/music"
|
||||
}
|
||||
}
|
||||
75
gaia/dev_apps/music-oga/locales/music.en-US.properties
Executable file
|
|
@ -0,0 +1,75 @@
|
|||
music = Music
|
||||
|
||||
mix = Mix
|
||||
playlists = Playlist
|
||||
artists = Artists
|
||||
albums = Albums
|
||||
songs = Songs
|
||||
more = More
|
||||
|
||||
songArtist = Song artist
|
||||
songAlbum = Song album
|
||||
|
||||
unknownArtist = Unknown artist
|
||||
unknownAlbum = Unknown album
|
||||
unknownTitle = Unknown title
|
||||
|
||||
empty-title = Add songs to get started
|
||||
empty-text = Load songs on to the memory card.
|
||||
|
||||
nocard2-title = No memory card found
|
||||
nocard3-text = Insert a memory card to listen to songs.
|
||||
|
||||
pluggedin-title = Music can not be used while phone is plugged in
|
||||
pluggedin-text = Unplug the phone to play music.
|
||||
|
||||
upgrade-title = Database Upgrade
|
||||
upgrade-text = Please wait while Music updates its database.
|
||||
|
||||
search-music.placeholder = Search Music
|
||||
search-cancel = Cancel
|
||||
search-artists = Artists
|
||||
search-albums = Albums
|
||||
search-titles = Tracks
|
||||
search-close = close
|
||||
search-no-result = No music found
|
||||
|
||||
playlists-shuffle-all = Shuffle all
|
||||
playlists-highest-rated = Highest rated
|
||||
playlists-recently-added = Recently added
|
||||
playlists-most-played = Most played
|
||||
playlists-least-played = Least played
|
||||
|
||||
picker-title = Select a track
|
||||
|
||||
save = Save
|
||||
saved = Saved {{title}} to Music
|
||||
|
||||
audioinvalid = Audio cannot be played: format is unsupported or file is invalid.
|
||||
|
||||
overlay-cancel-button = Cancel
|
||||
|
||||
music-paused-notification = Music paused during the call
|
||||
done = Done
|
||||
|
||||
# The following strings are spoken by screen readers and not shown on the
|
||||
# screen.
|
||||
share-song.ariaLabel = Share
|
||||
player-album-rating.ariaLabel = Rating
|
||||
rating-star.ariaLabel = {[ plural(n) ]}
|
||||
rating-star.ariaLabel[zero] = {{n}} stars
|
||||
rating-star.ariaLabel[one] = {{n}} star
|
||||
rating-star.ariaLabel[two] = {{n}} stars
|
||||
rating-star.ariaLabel[few] = {{n}} stars
|
||||
rating-star.ariaLabel[many] = {{n}} stars
|
||||
rating-star.ariaLabel[other] = {{n}} stars
|
||||
go-to-player-view.ariaLabel = Go to player view
|
||||
repeat-off.ariaLabel = Repeat off
|
||||
repeat-list.ariaLabel = Repeat all
|
||||
repeat-song.ariaLabel = Repeat track
|
||||
shuffle-toggle.ariaLabel = Shuffle
|
||||
mix-tab.ariaLabel = Mix
|
||||
playlists-tab.ariaLabel = Playlists
|
||||
artists-tab.ariaLabel = Artists
|
||||
albums-tab.ariaLabel = Albums
|
||||
songs-tab.ariaLabel = Songs
|
||||
70
gaia/dev_apps/music-oga/manifest.webapp
Executable file
|
|
@ -0,0 +1,70 @@
|
|||
{
|
||||
"name": "Music OGA",
|
||||
"description": "Original Gaia Music app",
|
||||
"type": "certified",
|
||||
"launch_path": "/index.html",
|
||||
"developer": {
|
||||
"name": "The Gaia Team",
|
||||
"url": "https://github.com/mozilla-b2g/gaia"
|
||||
},
|
||||
"permissions": {
|
||||
"themeable":{},
|
||||
"device-storage:music":{ "access": "readwrite" },
|
||||
"device-storage:pictures":{ "access": "readwrite" },
|
||||
"settings":{ "access": "readonly" },
|
||||
"bluetooth":{},
|
||||
"audio-channel-content":{},
|
||||
"nfc-share":{},
|
||||
"moz-extremely-unstable-and-will-change-webcomponents":{}
|
||||
},
|
||||
"activities": {
|
||||
"pick": {
|
||||
"filters": {
|
||||
"type": ["audio/*", "audio/mpeg", "audio/ogg", "audio/mp4",
|
||||
"audio/flac"]
|
||||
},
|
||||
"disposition": "inline",
|
||||
"returnValue": true,
|
||||
"href": "/index.html#pick"
|
||||
},
|
||||
"open": {
|
||||
"filters": {
|
||||
"type": ["audio/mpeg", "audio/ogg", "audio/mp4", "audio/amr",
|
||||
"audio/flac"]
|
||||
},
|
||||
"disposition": "inline",
|
||||
"returnValue": true,
|
||||
"href": "/open.html"
|
||||
}
|
||||
},
|
||||
"locales": {
|
||||
"ar": {
|
||||
"name": "موسيقى OGA",
|
||||
"description": "Gaia موسيقى"
|
||||
},
|
||||
"en-US": {
|
||||
"name": "Music OGA",
|
||||
"description": "Gaia Music"
|
||||
},
|
||||
"fr": {
|
||||
"name": "Musique OGA",
|
||||
"description": "Lecteur audio Gaia"
|
||||
},
|
||||
"zh-TW": {
|
||||
"name": "音樂 OGA",
|
||||
"description": "Gaia 音樂"
|
||||
}
|
||||
},
|
||||
"default_locale": "en-US",
|
||||
"icons": {
|
||||
"84": "/style/icons/default_84.png",
|
||||
"126": "/style/icons/default_126.png",
|
||||
"142": "/style/icons/default_142.png",
|
||||
"189": "/style/icons/default_189.png",
|
||||
"284": "/style/icons/default_284.png"
|
||||
},
|
||||
"orientation": "default",
|
||||
"messages": [
|
||||
{ "media-button": "/index.html" }
|
||||
]
|
||||
}
|
||||
93
gaia/dev_apps/music-oga/open.html
Executable file
|
|
@ -0,0 +1,93 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="theme-color" content="#242d33">
|
||||
<title>Music OGA</title>
|
||||
<link rel="stylesheet" href="style/music.css" type="text/css" />
|
||||
<!-- Include shared building blocks -->
|
||||
<link rel="stylesheet" type="text/css" href="shared/style/status.css" />
|
||||
<link rel="stylesheet" type="text/css" href="shared/style/progress_activity.css"/>
|
||||
<!-- Localization -->
|
||||
<meta name="defaultLanguage" content="en-US">
|
||||
<meta name="availableLanguages" content="en-US">
|
||||
<link rel="localization" href="locales/music.{locale}.properties">
|
||||
<link rel="localization" href="/shared/locales/media/media.{locale}.properties">
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="shared/elements/gaia-theme/gaia-theme.css" />
|
||||
<link rel="stylesheet" type="text/css" href="shared/elements/gaia-icons/gaia-icons.css" />
|
||||
|
||||
<!-- Shared code -->
|
||||
<script type="text/javascript" src="shared/js/l10n.js"></script>
|
||||
<script type="text/javascript" src="shared/js/device_storage/get_storage_if_available.js"></script>
|
||||
<script type="text/javascript" src="shared/js/device_storage/get_unused_filename.js"></script>
|
||||
<script type="text/javascript" defer src="shared/js/lazy_loader.js"></script>
|
||||
<script type="text/javascript" defer src="shared/js/mime_mapper.js"></script>
|
||||
<script type="text/javascript" defer src="shared/js/async_storage.js"></script>
|
||||
<script type="text/javascript" defer src="shared/js/image_utils.js"></script>
|
||||
<script type="text/javascript" defer src="shared/js/font_size_utils.js"></script>
|
||||
|
||||
<!-- Web components -->
|
||||
<script defer src="shared/elements/gaia-header/dist/gaia-header.js"></script>
|
||||
|
||||
<!-- Specific code -->
|
||||
<script type="text/javascript" defer src="js/metadata_scripts.js"></script>
|
||||
<script type="text/javascript" defer src="js/metadata/album_art_cache.js"></script>
|
||||
<script type="text/javascript" defer src="js/utils.js"></script>
|
||||
<script type="text/javascript" defer src="js/queue.js"></script>
|
||||
<script type="text/javascript" defer src="js/ui/views/player_view.js"></script>
|
||||
<script type="text/javascript" defer src="js/open.js"></script>
|
||||
</head>
|
||||
|
||||
<body role="application" class="theme-media">
|
||||
<section id="title" role="region" class="skin-dark">
|
||||
<gaia-header id="header" action="back">
|
||||
<h1 id="title-text"><bdi></bdi></h1>
|
||||
<button id="title-save" data-l10n-id="save" hidden></button>
|
||||
</gaia-header>
|
||||
</section>
|
||||
<div id="views">
|
||||
<div id="views-player" class="view open-mode">
|
||||
<div id="player-cover">
|
||||
<!-- We have two cover image elements so we can crossfade between
|
||||
album art (though this really only matters for index.html). -->
|
||||
<div id="player-cover-image-1" class="cover-image visible"></div>
|
||||
<div id="player-cover-image-2" class="cover-image"></div>
|
||||
<div id="player-cover-caption">
|
||||
<div id="player-cover-artist"><bdi data-l10n-id="songArtist">Song Artist</bdi></div>
|
||||
<div id="player-cover-album"><bdi data-l10n-id="songAlbum">Song Album</bdi></div>
|
||||
</div>
|
||||
<div id="player-cover-buttons" hidden>
|
||||
<button id="player-album-repeat" data-l10n-id="repeat-off" class="player-cover-button repeat-list" data-icon="repeat" disabled></button>
|
||||
<div id="player-album-rating" data-l10n-id="player-album-rating" role="radiogroup">
|
||||
<button class="rating-star" data-rating="1" disabled role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"1"}'></button>
|
||||
<button class="rating-star" data-rating="2" disabled role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"2"}'></button>
|
||||
<button class="rating-star" data-rating="3" disabled role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"3"}'></button>
|
||||
<button class="rating-star" data-rating="4" disabled role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"4"}'></button>
|
||||
<button class="rating-star" data-rating="5" disabled role="radio" data-l10n-id="rating-star" data-l10n-args='{"n":"5"}'></button>
|
||||
</div>
|
||||
<button id="player-album-shuffle" class="player-cover-button shuffle-on" data-icon="shuffle" disabled data-l10n-id="shuffle-toggle"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="player-seek" role="slider" aria-valuemin="0">
|
||||
<span id="player-seek-elapsed">00:00</span>
|
||||
<div id="player-seek-bar">
|
||||
<progress id="player-seek-bar-progress" value="0"></progress>
|
||||
<div id="player-seek-bar-indicator"></div>
|
||||
</div>
|
||||
<span id="player-seek-remaining">00:00</span>
|
||||
</div>
|
||||
<div id="player-controls" dir="ltr">
|
||||
<button id="player-controls-previous" data-l10n-id="playbackPrevious" class="player-controls-button" data-icon="skip-back" disabled></button>
|
||||
<button id="player-controls-play" data-l10n-id="playbackPlay" class="player-controls-button is-pause" data-icon="pause"></button>
|
||||
<button id="player-controls-next" data-l10n-id="playbackNext" class="player-controls-button" data-icon="skip-forward" disabled></button>
|
||||
</div>
|
||||
<audio id="player-audio" class="hidden"></audio>
|
||||
</div>
|
||||
<section id="banner" role="status" hidden>
|
||||
<p id="message"></p>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
10
gaia/dev_apps/music-oga/package.json
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"webide": {
|
||||
"prepackage": {
|
||||
"command": "make",
|
||||
"env": ["APP=music"],
|
||||
"cwd": "../.."
|
||||
},
|
||||
"packageDir": "../../build_stage/music/"
|
||||
}
|
||||
}
|
||||
BIN
gaia/dev_apps/music-oga/style/icons/default_126.png
Executable file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
gaia/dev_apps/music-oga/style/icons/default_142.png
Executable file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
gaia/dev_apps/music-oga/style/icons/default_189.png
Executable file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
gaia/dev_apps/music-oga/style/icons/default_284.png
Executable file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
gaia/dev_apps/music-oga/style/icons/default_84.png
Executable file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt10_small.png
Executable file
|
After Width: | Height: | Size: 42 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt10_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 87 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt10_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 183 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt10_small@2x.png
Executable file
|
After Width: | Height: | Size: 145 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt1_small.png
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt1_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 191 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt1_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 444 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt1_small@2x.png
Executable file
|
After Width: | Height: | Size: 333 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt2_small.png
Executable file
|
After Width: | Height: | Size: 82 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt2_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 163 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt2_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 526 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt2_small@2x.png
Executable file
|
After Width: | Height: | Size: 353 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt3_small.png
Executable file
|
After Width: | Height: | Size: 78 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt3_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 148 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt3_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 461 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt3_small@2x.png
Executable file
|
After Width: | Height: | Size: 306 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt4_small.png
Executable file
|
After Width: | Height: | Size: 60 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt4_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 133 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt4_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 358 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt4_small@2x.png
Executable file
|
After Width: | Height: | Size: 263 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt5_small.png
Executable file
|
After Width: | Height: | Size: 29 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt5_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 67 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt5_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 122 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt5_small@2x.png
Executable file
|
After Width: | Height: | Size: 97 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt6_small.png
Executable file
|
After Width: | Height: | Size: 42 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt6_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 82 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt6_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 187 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt6_small@2x.png
Executable file
|
After Width: | Height: | Size: 142 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt7_small.png
Executable file
|
After Width: | Height: | Size: 97 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt7_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 239 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt7_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 528 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt7_small@2x.png
Executable file
|
After Width: | Height: | Size: 389 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt8_small.png
Executable file
|
After Width: | Height: | Size: 103 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt8_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 251 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt8_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 567 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt8_small@2x.png
Executable file
|
After Width: | Height: | Size: 419 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt9_small.png
Executable file
|
After Width: | Height: | Size: 95 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt9_small@1.5x.png
Executable file
|
After Width: | Height: | Size: 199 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt9_small@2.25x.png
Executable file
|
After Width: | Height: | Size: 516 KiB |
BIN
gaia/dev_apps/music-oga/style/images/AlbumArt9_small@2x.png
Executable file
|
After Width: | Height: | Size: 381 KiB |
BIN
gaia/dev_apps/music-oga/style/images/IconAction_Done_30x30.png
Executable file
|
After Width: | Height: | Size: 479 B |
BIN
gaia/dev_apps/music-oga/style/images/IconAction_Done_30x30@1.5x.png
Executable file
|
After Width: | Height: | Size: 718 B |
BIN
gaia/dev_apps/music-oga/style/images/IconAction_Done_30x30@2.25x.png
Executable file
|
After Width: | Height: | Size: 602 B |
BIN
gaia/dev_apps/music-oga/style/images/IconAction_Done_30x30@2x.png
Executable file
|
After Width: | Height: | Size: 815 B |
BIN
gaia/dev_apps/music-oga/style/images/star_off.png
Executable file
|
After Width: | Height: | Size: 261 B |
BIN
gaia/dev_apps/music-oga/style/images/star_off@1.5x.png
Executable file
|
After Width: | Height: | Size: 364 B |
BIN
gaia/dev_apps/music-oga/style/images/star_off@2.25x.png
Executable file
|
After Width: | Height: | Size: 530 B |
BIN
gaia/dev_apps/music-oga/style/images/star_off@2x.png
Executable file
|
After Width: | Height: | Size: 540 B |
BIN
gaia/dev_apps/music-oga/style/images/star_on.png
Executable file
|
After Width: | Height: | Size: 372 B |
BIN
gaia/dev_apps/music-oga/style/images/star_on@1.5x.png
Executable file
|
After Width: | Height: | Size: 546 B |
BIN
gaia/dev_apps/music-oga/style/images/star_on@2.25x.png
Executable file
|
After Width: | Height: | Size: 824 B |
BIN
gaia/dev_apps/music-oga/style/images/star_on@2x.png
Executable file
|
After Width: | Height: | Size: 670 B |
BIN
gaia/dev_apps/music-oga/style/images/ui/gradient.png
Executable file
|
After Width: | Height: | Size: 13 KiB |
BIN
gaia/dev_apps/music-oga/style/images/ui/gradient@1.5x.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
gaia/dev_apps/music-oga/style/images/ui/gradient@2.25x.png
Executable file
|
After Width: | Height: | Size: 38 KiB |
BIN
gaia/dev_apps/music-oga/style/images/ui/gradient@2x.png
Executable file
|
After Width: | Height: | Size: 32 KiB |
BIN
gaia/dev_apps/music-oga/style/images/ui/pattern.png
Executable file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
gaia/dev_apps/music-oga/style/images/ui/pattern@1.5x.png
Executable file
|
After Width: | Height: | Size: 7 KiB |
BIN
gaia/dev_apps/music-oga/style/images/ui/pattern@2.25x.png
Executable file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
gaia/dev_apps/music-oga/style/images/ui/pattern@2x.png
Executable file
|
After Width: | Height: | Size: 7 KiB |