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.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View 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);
};

View 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>

View 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
View 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;
}
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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
};
})();

View 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;
})();

View 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);
}
}

View 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));
}
}
};

View 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;
},
};
})();

View 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;
}
}
};

View 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;
}
}
};

View 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;
}
}
};

View 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;
}
}
};

View 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;
}
};

View 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;
}
}
};

View 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;
}
}
};

View 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();
});
});
}
}
};

View 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;
}

View 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"
}
}

View 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

View 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" }
]
}

View 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>

View file

@ -0,0 +1,10 @@
{
"webide": {
"prepackage": {
"command": "make",
"env": ["APP=music"],
"cwd": "../.."
},
"packageDir": "../../build_stage/music/"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

File diff suppressed because it is too large Load diff

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more