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

392
gaia/apps/music/js/app.js Executable file
View file

@ -0,0 +1,392 @@
/* exported onSearchOpen, onSearchClose */
/* global SERVICE_WORKERS, bridge */
'use strict';
function perfMark(marker) {
window.performance.mark(marker);
perfMark[marker] = Date.now();
}
perfMark.get = (marker) => {
var start = window.performance.timing.fetchStart; // domLoading?
return perfMark[marker] - start;
};
perfMark.log = () => Object.keys(perfMark).forEach((marker) => {
if (typeof perfMark[marker] !== 'function') {
console.log('[Performance] ' + marker + ': ' + perfMark.get(marker) + 'ms');
}
});
// PERFORMANCE MARKER (1): navigationLoaded
// Designates that the app's *core* chrome or navigation interface
// exists in the DOM and is marked as ready to be displayed.
perfMark('navigationLoaded');
const VIEWS = {
ALBUM_DETAIL: {TAB: 'albums', URL: '/views/album-detail/index.html'},
ALBUMS: {TAB: 'albums', URL: '/views/albums/index.html'},
ARTIST_DETAIL: {TAB: 'artists', URL: '/views/artist-detail/index.html'},
ARTISTS: {TAB: 'artists', URL: '/views/artists/index.html'},
HOME: {TAB: 'home', URL: '/views/home/index.html'},
PLAYER: {TAB: 'home', URL: '/views/player/index.html'},
PLAYLIST_DETAIL: {TAB: 'playlists', URL: '/views/playlist-detail/index.html'},
PLAYLISTS: {TAB: 'playlists', URL: '/views/playlists/index.html'},
SONGS: {TAB: 'songs', URL: '/views/songs/index.html'}
};
var $id = document.getElementById.bind(document);
var isPlaying = false;
var activity = null;
if (navigator.mozSetMessageHandler) {
navigator.mozSetMessageHandler('activity', activity => onActivity(activity));
}
var client = bridge.client({
service: 'music-service',
endpoint: window,
timeout: false
});
client.on('play', () => isPlaying = true);
client.on('stop', () => {
isPlaying = false;
var isPlayerView = viewStack.activeView &&
viewStack.activeView.url === VIEWS.PLAYER.URL;
if (isPlayerView) {
viewStack.popView(true);
window.history.back();
}
});
client.on('databaseChange', () => updateOverlays());
client.on('databaseUpgrade', () => {
if (upgradeOverlay) {
upgradeOverlay.hidden = false;
}
});
client.on('databaseUnavailable', (reason) => {
if (noCardOverlay) {
noCardOverlay.hidden = reason !== 'nocard';
}
if (pluggedInOverlay) {
pluggedInOverlay.hidden = reason !== 'pluggedin';
}
});
client.on('databaseEnumerable', () => {
if (upgradeOverlay) {
upgradeOverlay.hidden = true;
}
});
client.on('databaseReady', () => {
if (noCardOverlay) {
noCardOverlay.hidden = true;
}
if (pluggedInOverlay) {
pluggedInOverlay.hidden = true;
}
});
// scanProgress and scanStopped must always act in the order they're received.
// If you add a Promise to either of these handlers, be careful!
client.on('scanProgress', (detail) => {
scanProgress.update({
value: detail.count,
heading: detail.artist,
subheading: detail.title
});
});
client.on('scanStopped', () => scanProgress.clear());
client.connect();
var header = $id('header');
var headerTitle = $id('header-title');
var playerButton = $id('player-button');
var activityDoneButton = $id('activity-done-button');
var viewStack = $id('view-stack');
var tabBar = $id('tab-bar');
var emptyOverlay = $id('empty-overlay');
var noCardOverlay = $id('no-card-overlay');
var pluggedInOverlay = $id('plugged-in-overlay');
var upgradeOverlay = $id('upgrade-overlay');
var scanProgress = $id('scan-progress');
updateOverlays();
header.addEventListener('action', (evt) => {
if (evt.detail.type !== 'back') {
return;
}
var isPlayerView = viewStack.activeView &&
viewStack.activeView.url === VIEWS.PLAYER.URL;
if (viewStack.views.length > 1) {
// Don't destroy the popped view if it is the "Player" view.
viewStack.popView(!isPlayerView);
window.history.back();
return;
}
cancelActivity();
});
playerButton.addEventListener('click', () => navigateToURL('/player'));
activityDoneButton.addEventListener('click', () => {
switch (activity && activity.source.name) {
case 'open':
// TODO: Implement 'Save' functionality
activity.postResult({ saved: false });
break;
case 'pick':
client.method('getPlaybackStatus').then((status) => {
var filePath = status.filePath;
var getSong = client.method('getSong', filePath);
var getSongFile = client.method('getSongFile', filePath);
var getSongThumbnail = client.method('getSongThumbnail', filePath);
Promise.all([getSong, getSongFile, getSongThumbnail])
.then(([song, file, thumbnail]) => {
activity.postResult({
type: file.type,
blob: file,
name: song.metadata.title || '',
// We only pass some metadata attributes so we don't share
// personal details like # of times played and ratings.
metadata: {
title: song.metadata.title,
artist: song.metadata.artist,
album: song.metadata.album,
picture: thumbnail
}
});
});
});
break;
}
});
viewStack.addEventListener('change', (evt) => {
var viewUrl = evt.detail.url;
var tab = getTabByViewURL(viewUrl);
if (tab) {
tabBar.selectedElement = tab;
}
document.body.dataset.activeViewUrl = viewUrl;
var showingPlayer = viewUrl === VIEWS.PLAYER.URL;
playerButton.hidden = showingPlayer || !isPlaying;
activityDoneButton.hidden = !showingPlayer || !activity;
client.method('enableNFC', showingPlayer);
setBackButtonHidden(!activity && viewStack.views.length < 2);
setHeaderTitle(evt.detail.title);
});
viewStack.addEventListener('titlechange', (evt) => {
setHeaderTitle(evt.detail);
});
viewStack.addEventListener('loaded', onVisuallyLoaded);
viewStack.addEventListener('rendered', onFullyLoaded);
tabBar.addEventListener('change', (evt) => {
var tab = evt.detail.selectedElement;
navigateToURL(tab.dataset.url, true);
});
if (emptyOverlay) {
emptyOverlay.addEventListener('action', () => cancelActivity());
}
if (noCardOverlay) {
noCardOverlay.addEventListener('action', () => cancelActivity());
}
if (pluggedInOverlay) {
pluggedInOverlay.addEventListener('action', () => cancelActivity());
}
if (upgradeOverlay) {
upgradeOverlay.addEventListener('action', () => cancelActivity());
}
if (SERVICE_WORKERS) {
navigator.serviceWorker.getRegistration().then((registration) => {
if (registration && registration.active) {
console.log('ServiceWorker already registered');
boot();
return;
}
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(() => {
console.log('ServiceWorker registered successfully');
window.location.reload();
})
.catch((error) => {
console.error('ServiceWorker registration failed', error);
});
});
}
else {
boot();
}
function setHeaderTitle(title) {
if (viewStack.activeView) {
viewStack.activeView.title = title;
}
window.requestAnimationFrame(() => headerTitle.textContent = title);
}
function setBackButtonHidden(hidden) {
header.els.actionButton.style.visibility = hidden ? 'hidden' : 'visible';
scanProgress.hidden = !hidden;
}
function getTabByViewURL(url) {
for (var key in VIEWS) {
if (url === VIEWS[key].URL) {
return tabBar.querySelector('button[value="' + VIEWS[key].TAB + '"]');
}
}
return null;
}
function navigateToURL(url, replaceRoot) {
var path = url.substring(1);
var parts = path.split('?');
var viewUrl = '/views/' + parts.shift() + '/index.html';
if (replaceRoot) {
viewStack.setRootView(viewUrl);
}
else {
viewStack.pushView(viewUrl);
}
if (!SERVICE_WORKERS) {
url = '#' + url;
}
window.history.pushState(null, null, url);
}
function updateOverlays() {
if (emptyOverlay) {
client.method('getSongCount').then((count) => {
emptyOverlay.hidden = count > 0;
});
}
client.method('getDatabaseStatus').then((status) => {
if (noCardOverlay) {
noCardOverlay.hidden = status.unavailable !== 'nocard';
}
if (pluggedInOverlay) {
pluggedInOverlay.hidden = status.unavailable !== 'pluggedin';
}
if (upgradeOverlay) {
upgradeOverlay.hidden = !status.upgrading;
}
});
}
function cancelActivity() {
switch (activity && activity.source.name) {
case 'open':
activity.postResult({ saved: false });
break;
case 'pick':
activity.postError('pick cancelled');
break;
}
}
function onActivity(activity) {
window.activity = activity;
if (activity.source.name === 'open') {
client.method('openExternalFile', activity.source.data.blob,
activity.source.data.filename);
}
setBackButtonHidden(false);
}
function onSearchOpen() {
document.body.dataset.search = true;
}
function onSearchClose() {
document.body.removeAttribute('data-search');
}
function onVisuallyLoaded() {
viewStack.removeEventListener('loaded', onVisuallyLoaded);
// PERFORMANCE MARKER (3): visuallyLoaded
// Designates that the app is visually loaded (e.g.: all of the
// "above-the-fold" content exists in the DOM and is marked as
// ready to be displayed).
perfMark('visuallyLoaded');
// PERFORMANCE MARKER (4): contentInteractive
// Designates that the app has its events bound for the minimum
// set of functionality to allow the user to interact with the
// "above-the-fold" content.
perfMark('contentInteractive');
}
function onFullyLoaded() {
viewStack.removeEventListener('rendered', onFullyLoaded);
// PERFORMANCE MARKER (5): fullyLoaded
// Designates that the app is *completely* loaded and all relevant
// "below-the-fold" content exists in the DOM, is marked visible,
// has its events bound and is ready for user interaction. All
// required startup background processing should be complete.
perfMark('fullyLoaded');
perfMark.log();
}
function boot() {
var url = SERVICE_WORKERS ?
window.location.href.substring(window.location.origin.length) :
window.location.hash.substring(1) || '/';
if (url === '/' || url === '/index.html') {
url = tabBar.firstElementChild.dataset.url;
}
header.action = 'back';
setBackButtonHidden(true);
navigateToURL(url, true);
// PERFORMANCE MARKER (2): navigationInteractive
// Designates that the app's *core* chrome or navigation interface
// has its events bound and is ready for user interaction.
perfMark('navigationInteractive');
}

7
gaia/apps/music/js/config.js Executable file
View file

@ -0,0 +1,7 @@
/* exported SERVICE_WORKERS */
/**
* This file will be automatically generated
* and overwritten at build time.
*/
var SERVICE_WORKERS = false;

580
gaia/apps/music/js/db.js Executable file
View file

@ -0,0 +1,580 @@
/* exported Database */
/* global AlbumArt, AudioMetadata, ForwardLock, IDBKeyRange, IntlHelper,
LazyLoader, MediaDB, Normalizer, service */
'use strict';
var Database = (function() {
var playlists = [
{
id: 'shuffle-all',
index: 'metadata.title',
direction: 'next',
shuffle: true
},
{
id: 'highest-rated',
index: 'metadata.rated',
direction: 'prev',
shuffle: false
},
{
id: 'recently-added',
index: 'date',
direction: 'prev',
shuffle: false
},
{
id: 'most-played',
index: 'metadata.played',
direction: 'prev',
shuffle: false
},
{
id: 'least-played',
index: 'metadata.played',
direction: 'next',
shuffle: false
}
];
var status = {
upgrading: false,
unavailable: false,
enumerable: false,
ready: false
};
var resolveEnumerable;
var enumerable = new Promise((resolve) => {
resolveEnumerable = resolve;
});
var resolveReady;
var ready = new Promise((resolve) => {
resolveReady = resolve;
});
var dbChange = debounce(() => service.broadcast('databaseChange'), 500);
document.addEventListener('DOMRetranslated', dbChange);
function debounce(fn, ms) {
var timeout;
return () => {
var args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), ms);
};
}
// 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 scripts = [
'/js/metadata/metadata_scripts.js',
'/js/metadata/album_art.js'
];
LazyLoader.load(scripts).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;
service.broadcast('databaseUpgrade');
}
}
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) {
status.upgrading = true;
status.enumerable = false;
status.ready = false;
service.broadcast('databaseUpgrade');
};
// 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 reason = event.detail === MediaDB.UNMOUNTED ?
'pluggedin' : event.detail;
onUnavailable(reason);
};
// 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() {
service.broadcast('databaseChange');
};
function onUnavailable(reason) {
status.unavailable = reason;
status.enumerable = false;
status.ready = false;
enumerable = new Promise((resolve) => {
resolveEnumerable = resolve;
});
ready = new Promise((resolve) => {
resolveReady = resolve;
});
service.broadcast('databaseUnavailable', reason);
}
musicdb.onenumerable = onEnumerable;
// Don't refresh the UI on the first onready event, since onenumerable will
// have already handled the refresh.
var refreshOnReady = false;
function onEnumerable() {
if (musicdb.state === MediaDB.READY) {
onReady();
} else {
musicdb.onready = onReady;
}
status.upgrading = false;
status.enumerable = true;
resolveEnumerable();
service.broadcast('databaseEnumerable');
}
function onReady() {
// Start scanning for new music
musicdb.scan();
status.unavailable = false;
status.enumerable = true;
status.ready = true;
resolveEnumerable();
resolveReady();
service.broadcast('databaseReady', refreshOnReady);
// 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;
service.broadcast('scanStopped');
if (filesFoundBatch > 0 || filesDeletedWhileScanning > 0) {
filesFoundWhileScanning = 0;
filesFoundBatch = 0;
filesDeletedWhileScanning = 0;
dbChange();
}
// 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;
service.broadcast('scanProgress', {
count: filesFoundWhileScanning,
artist: metadata.artist,
title: metadata.title
});
if (filesFoundBatch > SCAN_UPDATE_BATCH_SIZE) {
filesFoundBatch = 0;
dbChange();
}
}
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
dbChange();
}
};
// 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;
dbChange();
}, DELETE_BATCH_TIMEOUT);
}
};
}
function incrementPlayCount(fileinfo) {
return new Promise((resolve) => {
fileinfo.metadata.played++;
musicdb.updateMetadata(fileinfo.name, {
played: fileinfo.metadata.played
}, resolve);
});
}
function setSongRating(fileinfo, rated) {
return new Promise((resolve) => {
fileinfo.metadata.rated = rated;
musicdb.updateMetadata(fileinfo.name, {
rated: fileinfo.metadata.rated
}, resolve);
});
}
function getFile(fileinfo, decrypt = false) {
return new Promise((resolve, reject) => {
ready.then(() => {
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) {
LazyLoader.load('/shared/js/omadrm/fl.js').then(() => {
ForwardLock.getKey(function(secret) {
ForwardLock.unlockBlob(secret, blob, resolve, null, reject);
});
});
});
});
}
function getFileInfo(filename) {
return new Promise(function(resolve, reject) {
ready
.then(() => musicdb.getFileInfo(
filename, (r) => {
r ? resolve(r) :
reject('Undefined result for ' + filename);
}, reject))
.catch((e) => reject(e));
});
}
/* [deprecated] Low-level database functions */
function enumerate(...args) {
return enumerable.then(() => musicdb.enumerate(...args));
}
function enumerateAll(...args) {
return enumerable.then(() => musicdb.enumerateAll(...args));
}
function advancedEnumerate(...args) {
return enumerable.then(() => musicdb.advancedEnumerate(...args));
}
function count(...args) {
return enumerable.then(() => musicdb.count(...args));
}
function cancelEnumeration(handle) {
musicdb.cancelEnumeration(handle);
}
/* Sorting helpers */
IntlHelper.define('titleSorter', 'collator', {
usage: 'sort', sensitivity: 'base', numeric: true, ignorePunctuation: true
});
function _localeSort(collator, a, b) {
return collator.compare(a, b);
}
function _sortArtist(collator, a, b) {
return _localeSort(collator, a.metadata.artist, b.metadata.artist);
}
function _sortAlbum(collator, a, b) {
return _localeSort(collator, a.metadata.album, b.metadata.album);
}
function _sortTitle(collator, a, b) {
return _localeSort(collator, a.metadata.title, b.metadata.title);
}
function _sortTrack(collator, a, b) {
return (a.metadata.discnum - b.metadata.discnum) ||
(a.metadata.tracknum - b.metadata.tracknum) ||
_sortTitle(collator, a, b);
}
/* High-level database functions */
/**
* Get the list of artists in the music library.
*
* @return {Promise} A Promise resolving to an array of artists (represented
* as fileinfos of a song from each artist).
*/
function artists() {
return new Promise((resolve) => {
enumerateAll('metadata.artist', null, 'nextunique', (artists) => {
artists.sort(_sortArtist.bind(null, IntlHelper.get('titleSorter')));
resolve(artists);
});
});
}
/**
* Get the list of albums in the music library.
*
* @return {Promise} A Promise resolving to an array of albums (represented
* as fileinfos of a song from each album).
*/
function albums() {
return new Promise((resolve) => {
enumerateAll('metadata.album', null, 'nextunique', (albums) => {
albums.sort(_sortAlbum.bind(null, IntlHelper.get('titleSorter')));
resolve(albums);
});
});
}
/**
* Get the list of songs in the music library.
*
* @return {Promise} A Promise resolving to an array of songs (represented
* as fileinfos).
*/
function songs() {
return new Promise((resolve) => {
enumerateAll('metadata.title', null, 'next', (songs) => {
songs.sort(_sortTitle.bind(null, IntlHelper.get('titleSorter')));
resolve(songs);
});
});
}
/**
* Get the total number of songs in the music library.
*
* @return {Promise} A Promise resolving to the number of songs in the
* database.
*/
function totalCount() {
return new Promise((resolve) => {
count('metadata.title', null, (count) => resolve(count));
});
}
/**
* Get all the songs for a given artist in the music library.
*
* @param {String} name The name of the artist to look up.
* @return {Promise} A Promise resolving to an array of songs for that artist
* (represented as fileinfos).
*/
function artist(name) {
return new Promise((resolve) => {
var range = IDBKeyRange.only(name);
enumerateAll('metadata.artist', range, 'next', (songs) => {
var collator = IntlHelper.get('titleSorter');
songs.sort((a, b) => {
return _sortAlbum(collator, a, b) || _sortTrack(collator, a, b);
});
resolve(songs);
});
});
}
/**
* Get all the songs for a given album in the music library.
*
* @param {String} name The name of the album to look up.
* @return {Promise} A Promise resolving to an array of songs for that album
* (represented as fileinfos).
*/
function album(name) {
return new Promise((resolve) => {
var range = IDBKeyRange.only(name);
enumerateAll('metadata.album', range, 'next', (songs) => {
songs.sort(_sortTrack.bind(null, IntlHelper.get('titleSorter')));
resolve(songs);
});
});
}
/**
* 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) {
return enumerable.then(() => {
if (!query) {
callback(null);
return;
}
return LazyLoader.load('/shared/js/text_normalizer.js').then(() => {
// 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);
}
});
});
});
}
return {
init: init,
incrementPlayCount: incrementPlayCount,
setSongRating: setSongRating,
getFile: getFile,
getFileInfo: getFileInfo,
enumerate: enumerate,
enumerateAll: enumerateAll,
advancedEnumerate: advancedEnumerate,
count: count,
cancelEnumeration: cancelEnumeration,
artists: artists,
albums: albums,
songs: songs,
totalCount: totalCount,
artist: artist,
album: album,
search: search,
playlists: playlists,
status: status,
// This is just here for testing.
get initialScanComplete() {
return musicdb.initialScanComplete;
}
};
})();

550
gaia/apps/music/js/endpoint.js Executable file
View file

@ -0,0 +1,550 @@
/* global AlbumArtCache, AudioMetadata, Database, LazyLoader, NFCShare,
PlaybackQueue, Remote, bridge, navigateToURL, onSearchOpen,
onSearchClose */
'use strict';
var audio = null;
var queueSettings = null;
var remote = null;
var nfcShare = null;
var currentFilePath = null;
var currentQueue = null;
var isInterrupted = false;
var isFastSeeking = false;
var isStopped = true;
var externalFile = null;
var service = bridge.service('music-service')
.method('play', play)
.method('pause', pause)
.method('seek', seek)
.method('startFastSeek', startFastSeek)
.method('stopFastSeek', stopFastSeek)
.method('getPlaybackStatus', getPlaybackStatus)
.method('currentSong', currentSong)
.method('previousSong', previousSong)
.method('nextSong', nextSong)
.method('queueAlbum', queueAlbum)
.method('queueArtist', queueArtist)
.method('queuePlaylist', queuePlaylist)
.method('queueSong', queueSong)
.method('setRepeatSetting', setRepeatSetting)
.method('setShuffleSetting', setShuffleSetting)
.method('getAlbums', getAlbums)
.method('getAlbum', getAlbum)
.method('getArtists', getArtists)
.method('getArtist', getArtist)
.method('getPlaylists', getPlaylists)
.method('getPlaylist', getPlaylist)
.method('getSongs', getSongs)
.method('getSongCount', getSongCount)
.method('getSong', getSong)
.method('getSongFile', getSongFile)
.method('setSongRating', setSongRating)
.method('search', search)
.method('getSongArtwork', getSongArtwork)
.method('getSongThumbnail', getSongThumbnail)
.method('getSongArtworkURL', getSongArtworkURL)
.method('getSongThumbnailURL', getSongThumbnailURL)
.method('share', share)
.method('openExternalFile', openExternalFile)
.method('enableNFC', enableNFC)
.method('getDatabaseStatus', getDatabaseStatus)
.method('navigate', navigate)
.method('searchOpen', searchOpen)
.method('searchClose', searchClose)
.listen()
.listen(new BroadcastChannel('music-service'));
document.addEventListener('DOMContentLoaded', function() {
audio = document.getElementById('audio');
audio.addEventListener('loadeddata', function() {
URL.revokeObjectURL(audio.src);
});
audio.addEventListener('play', function() {
service.broadcast('play');
});
audio.addEventListener('pause', function() {
service.broadcast('pause');
});
audio.addEventListener('durationchange', function() {
service.broadcast('durationChange', audio.duration);
});
audio.addEventListener('timeupdate', function() {
service.broadcast('elapsedTimeChange', audio.currentTime);
});
audio.addEventListener('ended', function() {
nextSong(true);
});
audio.addEventListener('mozinterruptbegin', function() {
isInterrupted = true;
service.broadcast('interruptBegin');
});
audio.addEventListener('mozinterruptend', function() {
isInterrupted = false;
service.broadcast('interruptEnd');
});
});
function play(filePath) {
loadRemote();
if (!filePath) {
audio.play();
return;
}
getSongFile(filePath).then((file) => {
if (isStopped) {
return;
}
currentFilePath = filePath;
audio.src = null;
audio.load();
audio.mozAudioChannelType = 'content';
audio.src = URL.createObjectURL(file);
audio.load();
audio.play();
getSong(filePath).then((song) => {
Database.incrementPlayCount(song);
});
service.broadcast('songChange');
});
}
function pause() {
audio.pause();
}
function stop() {
isStopped = true;
audio.pause();
currentFilePath = null;
currentQueue = null;
audio.src = null;
audio.load();
service.broadcast('stop');
}
var seekTimeout;
function seek(time) {
if (audio.seeking) {
clearTimeout(seekTimeout);
seekTimeout = setTimeout(() => seek(time), 50);
return;
}
audio.fastSeek(parseInt(time, 10));
}
function startFastSeek(reverse) {
if (isFastSeeking) {
return;
}
reverse = reverse === true || reverse === 'reverse';
isFastSeeking = true;
function fastSeek() {
if (!isFastSeeking) {
audio.volume = 1;
return;
}
audio.volume = 0.5;
seek(audio.currentTime + (reverse ? -5 : 5));
setTimeout(fastSeek, 100);
}
fastSeek();
}
function stopFastSeek() {
isFastSeeking = false;
}
function getPlaybackStatus() {
return loadQueueSettings().then(() => {
return {
queueIndex: currentQueue ? currentQueue.index : -1,
queueRawIndex: currentQueue ? currentQueue.rawIndex : -1,
queueLength: currentQueue ? currentQueue.length : -1,
repeat: PlaybackQueue.repeat,
shuffle: PlaybackQueue.shuffle ? 1 : 0,
filePath: currentFilePath,
stopped: isStopped,
paused: audio.paused,
duration: audio.duration,
elapsedTime: audio.currentTime,
isInterrupted: isInterrupted,
isFastSeeking: isFastSeeking
};
});
}
function currentSong() {
if (!currentQueue) {
return Promise.reject();
}
return currentQueue.current();
}
function previousSong() {
if (!currentQueue) {
return Promise.reject();
}
currentQueue.previous();
return currentSong().then(song => play(song.name));
}
function nextSong(automatic = false) {
if (!currentQueue) {
return Promise.reject();
}
var hasNextSong = currentQueue.next(automatic);
if (!hasNextSong && !externalFile) {
return Promise.resolve(stop());
}
return currentSong().then(song => play(song.name));
}
function loadQueueSettings() {
if (!queueSettings) {
queueSettings = LazyLoader.load('/js/queue.js').then(() => {
return PlaybackQueue.loadSettings();
});
}
return queueSettings;
}
function loadRemote() {
if (!remote) {
remote = LazyLoader.load('/js/remote.js').then(() => {
return Remote;
});
}
return remote;
}
function loadNFCShare() {
if (!nfcShare) {
nfcShare = LazyLoader.load('/js/nfc_share.js').then(() => {
return NFCShare;
});
}
return nfcShare;
}
function queueArtist(filePath) {
return loadQueueSettings().then(() => {
return getArtist(filePath).then((songs) => {
var index = songs.findIndex(song => song.name === filePath);
currentQueue = new PlaybackQueue.StaticQueue(songs, index);
return currentSong().then((song) => {
isStopped = false;
play(song.name);
});
});
});
}
function queueAlbum(filePath) {
return loadQueueSettings().then(() => {
return getAlbum(filePath).then((songs) => {
var index = songs.findIndex(song => song.name === filePath);
currentQueue = new PlaybackQueue.StaticQueue(songs, index);
return currentSong().then((song) => {
isStopped = false;
play(song.name);
});
});
});
}
function queuePlaylist(id, filePath) {
return loadQueueSettings().then(() => {
return getPlaylist(id).then((songs) => {
var playlist = Database.playlists.find(playlist => playlist.id === id);
return setShuffleSetting(playlist.shuffle).then(() => {
var index = filePath ?
songs.findIndex(song => song.name === filePath) :
(playlist.shuffle ? Math.floor(Math.random() * songs.length) : 0);
currentQueue = new PlaybackQueue.StaticQueue(songs, index);
return currentSong().then((song) => {
isStopped = false;
play(song.name);
});
});
});
});
}
function queueSong(filePath) {
return loadQueueSettings().then(() => {
return getSongs().then((songs) => {
var index = songs.findIndex(song => song.name === filePath);
currentQueue = new PlaybackQueue.StaticQueue(songs, index);
return currentSong().then((song) => {
isStopped = false;
play(song.name);
});
});
});
}
function setRepeatSetting(repeat) {
repeat = parseInt(repeat, 10) || 0;
return loadQueueSettings().then(() => PlaybackQueue.repeat = repeat);
}
function setShuffleSetting(shuffle) {
if (typeof shuffle !== 'boolean') {
shuffle = shuffle !== 'false' && parseInt(shuffle || 0, 10) !== 0;
}
return loadQueueSettings().then(() => PlaybackQueue.shuffle = shuffle);
}
function getPlaylists() {
return Promise.resolve(Database.playlists);
}
function getPlaylist(id) {
var playlist = Database.playlists.find(playlist => playlist.id === id);
return new Promise((resolve) => {
Database.enumerateAll(playlist.index, null, playlist.direction, (songs) => {
resolve(songs);
});
});
}
function getArtists() {
return Database.artists();
}
function getAlbums() {
return Database.albums();
}
function getSongs() {
return Database.songs();
}
function getSongCount() {
return Database.totalCount();
}
function getArtist(filePath) {
return getSong(filePath).then((song) => {
return Database.artist(song.metadata.artist);
});
}
function getAlbum(filePath) {
return getSong(filePath).then((song) => {
return Database.album(song.metadata.album);
});
}
function getSong(filePath) {
if (externalFile && filePath === externalFile.name) {
return Promise.resolve(externalFile);
}
return Database.getFileInfo(filePath);
}
function getSongFile(filePath) {
if (externalFile && filePath === externalFile.name) {
return Promise.resolve(externalFile.file);
}
return getSong(filePath).then((song) => {
return Database.getFile(song);
});
}
function getSongArtwork(filePath) {
return LazyLoader.load('/js/metadata/album_art_cache.js').then(() => {
return getSong(filePath).then((song) => {
return AlbumArtCache.getFullSizeBlob(song);
});
});
}
function getSongThumbnail(filePath) {
return LazyLoader.load('/js/metadata/album_art_cache.js').then(() => {
return getSong(filePath).then((song) => {
return AlbumArtCache.getThumbnailBlob(song);
});
});
}
function getSongArtworkURL(filePath) {
return LazyLoader.load('/js/metadata/album_art_cache.js').then(() => {
return getSong(filePath).then((song) => {
return AlbumArtCache.getFullSizeURL(song);
});
});
}
function getSongThumbnailURL(filePath) {
return LazyLoader.load('/js/metadata/album_art_cache.js').then(() => {
return getSong(filePath).then((song) => {
return AlbumArtCache.getThumbnailURL(song);
});
});
}
function setSongRating(rating, filePath) {
rating = parseInt(rating, 10) || 0;
return getSong(filePath).then((song) => {
return Database.setSongRating(song, rating);
});
}
function search(key, query) {
return new Promise((resolve) => {
var results = [];
Database.search(key, query, (result) => {
if (result === null) {
resolve(results);
} else {
results.push(result);
}
});
});
}
function getDatabaseStatus() {
return Promise.resolve(Database.status);
}
function navigate(url) {
navigateToURL(url);
}
function searchOpen() {
onSearchOpen();
}
function searchClose() {
onSearchClose();
}
function share(filePath) {
return getSong(filePath).then((song) => {
if (song.metadata.locked || !window.MozActivity) {
return;
}
return Promise.all([
getSongFile(filePath),
getSongThumbnail(filePath)
]).then(([file, thumbnail]) => {
var path = song.name;
var filename = path.substring(path.lastIndexOf('/') + 1);
return new window.MozActivity({
name: 'share',
data: {
type: 'audio/*',
number: 1,
blobs: [file],
filenames: [filename],
filepaths: [path],
metadata: [{
title: song.metadata.title,
artist: song.metadata.artist,
album: song.metadata.album,
picture: thumbnail
}]
}
});
});
});
}
function openExternalFile(file, filename = null) {
var scripts = [
'/js/metadata/metadata_scripts.js',
'/js/metadata/album_art.js'
];
return loadQueueSettings().then(() => {
return LazyLoader.load(scripts);
}).then(() => {
return AudioMetadata.parse(file, filename);
}).then((metadata) => {
externalFile = {
file: file,
name: file.name || URL.createObjectURL(file),
metadata: metadata
};
currentQueue = new PlaybackQueue.StaticQueue([externalFile]);
return currentSong();
}).then((song) => {
isStopped = false;
play(song.name);
});
}
function enableNFC(enabled) {
// This function is a no-op until we enable NFC once.
if (!nfcShare && !enabled) {
return;
}
loadNFCShare().then(() => {
NFCShare.enabled = enabled;
});
}
Database.init();

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,358 @@
/* 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;
function ThumbnailCache() {
this._pendingThumbnails = {};
}
ThumbnailCache.prototype = {
/**
* Check the thumbnail cache (asyncStorage) to see if we already have a
* thumbnailized version of the album art. If not, create a thumbnail and
* return it.
*
* @param {Object} fileinfo The file's info.
* @param {String} cacheKey The cache key generated by makeCacheKey.
* @return {Promise} A promise returning the Blob for the thumbnail.
*/
get: function(fileinfo, cacheKey) {
if (!cacheKey) {
return this._makeThumbnail(fileinfo, cacheKey);
}
// If we've already requested this thumbnail from asyncStorage, just
// return the existing Promise.
if (cacheKey in this._pendingThumbnails) {
return this._pendingThumbnails[cacheKey];
}
// 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 promise = new Promise((resolve, reject) => {
LazyLoader.load('/shared/js/async_storage.js').then(() => {
asyncStorage.getItem(cacheKey, (blob) => { resolve(blob); });
});
}).then((cachedBlob) => {
return cachedBlob || this._makeThumbnail(fileinfo, cacheKey);
});
this._pendingThumbnails[cacheKey] = promise;
return promise;
},
/**
* Create a thumbnail for the album art and store the Blob in our thumbnail
* cache.
*
* @param {Object} fileinfo The file's info.
* @param {String} cacheKey The cache key generated by makeCacheKey.
* @return {Promise} A promise returning the thumbnailized Blob.
*/
_makeThumbnail: function(fileinfo, cacheKey) {
// We don't have a saved blob yet, so grab it, thumbnailize it, and store
// it in asyncStorage.
return getAlbumArtBlob(fileinfo).then((blob) => {
return LazyLoader.load([
'/shared/js/async_storage.js',
'/shared/js/image_utils.js'
]).then(() => {
return ImageUtils.resizeAndCropToCover(
blob, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT
);
});
}).then((thumbnailBlob) => {
if (cacheKey) {
asyncStorage.setItem(cacheKey, thumbnailBlob);
}
return thumbnailBlob;
});
}
};
// The session caches for thumbnails and full-size images; maps a cache key
// (see below) to a Promise (to a blob: URL). There's also a long-term cache
// for thumbnails, backed by asyncStorage.
var sessionCache = {'thumbnail': {}, 'fullsize': {}};
var thumbnailCache = new ThumbnailCache();
/**
* 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, 'fullsize')
);
}
// See if we've already requested a URL for this album. If so, return it.
var cacheKey = makeCacheKey(fileinfo);
if (cacheKey && cacheKey in sessionCache.fullsize) {
return sessionCache.fullsize[cacheKey];
}
// Otherwise, get the blob from the album art, create a URL for it, and
// return it in the Promise.
var promise = getAlbumArtBlob(fileinfo).then((blob) => {
return URL.createObjectURL(blob);
});
if (cacheKey) {
sessionCache.fullsize[cacheKey] = promise;
}
return promise;
}
/**
* 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, 'fullsize'));
}
// Skip the session 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, 'thumbnail')
);
}
// See if we've already requested a URL for this album. If so, return it.
var cacheKey = makeCacheKey(fileinfo);
if (cacheKey && cacheKey in sessionCache.thumbnail) {
return sessionCache.thumbnail[cacheKey];
}
// Otherwise, get the blob from the thumbnail cache, create a URL for it,
// and return it in the Promise.
var promise = thumbnailCache.get(fileinfo, cacheKey).then((blob) => {
return URL.createObjectURL(blob);
});
if (cacheKey) {
sessionCache.thumbnail[cacheKey] = promise;
}
return promise;
}
/**
* 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, 'thumbnail'));
}
// Skip the session cache, since we want to return a blob, not a URL.
return thumbnailCache.get(fileinfo, makeCacheKey(fileinfo));
}
function getDefaultCoverURL(fileinfo, size) {
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;
var dir = '/img/artwork/' + (size === 'thumbnail' ? 'thumbnails/' : '');
return dir + 'artwork-' + hashedNumber + '.jpg';
}
/**
* 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;
}
/**
* 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((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,178 @@
/* 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.
* @param {String} [filename] The name of the file, used as a fallback if
* the metadata has no title field.
* @return {Promise} A Promise returning the parsed metadata object.
*/
function parse(blob, filename = null) {
if (!filename) {
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,58 @@
/* global AudioMetadata, ForwardLock, LazyLoader */
/* 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) {
LazyLoader.load('/shared/js/omadrm/fl.js').then(() => {
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,454 @@
/* 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:
// Round down to the nearest 16 bits.
return view.readNullTerminatedUTF16Text(size - (size % 2), undefined);
case 2:
// Round down to the nearest 16 bits.
return view.readNullTerminatedUTF16Text(size - (size % 2), 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
};
})();

46
gaia/apps/music/js/nfc_share.js Executable file
View file

@ -0,0 +1,46 @@
/* exported NFCShare */
/* global client */
var NFCShare = (function() {
var _NFCShare = {
_isEnabled: false,
get enabled() {
return this._isEnabled;
},
set enabled(value) {
if (typeof value !== 'boolean') {
throw Error('expected true or false');
}
if (!navigator.mozNfc || value === this._isEnabled) {
return;
}
if (value) {
navigator.mozNfc.onpeerready = (event) => {
var peer = event.peer;
if (!peer) {
return;
}
client.method('getPlaybackStatus').then((status) => {
return client.method('getSongFile', status.filePath);
}).then((blob) => {
peer.sendFile(blob);
});
};
} else {
navigator.mozNfc.onpeerready = null;
}
this._isEnabled = value;
return this._isEnabled;
}
};
return _NFCShare;
})();

339
gaia/apps/music/js/queue.js Executable file
View file

@ -0,0 +1,339 @@
/* global asyncStorage, Database, LazyLoader */
/* 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) => {
LazyLoader.load('/shared/js/async_storage.js').then(() => {
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) => {
LazyLoader.load('/shared/js/async_storage.js').then(() => {
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;
},
};
})();

179
gaia/apps/music/js/remote.js Executable file
View file

@ -0,0 +1,179 @@
/* exported Remote */
/* global LazyLoader, MediaRemoteControls, bridge */
'use strict';
var Remote = (function() {
const PLAY_STATUS_INTERRUPTED = 'mozinterruptbegin';
const PLAYSTATUS_STOPPED = 'STOPPED';
const PLAY_STATUS_PAUSED = 'PAUSED';
const PLAY_STATUS_PLAYING = 'PLAYING';
var Remote = {
enabled: false,
isSCOEnabled: false,
updateMetadata: function() {
if (Remote.enabled) {
client.method('getPlaybackStatus').then((status) => {
client.method('getSong', status.filePath).then((song) => {
if (!song) {
return;
}
client.method('getSongThumbnail', status.filePath).then((blob) => {
document.l10n.formatValues(
'unknownTitle', 'unknownArtist', 'unknownAlbum'
).then(([unknownTitle, unknownArtist, unknownAlbum]) => {
mrc.notifyMetadataChanged({
title: song.metadata.title || unknownTitle,
artist: song.metadata.artist || unknownArtist,
album: song.metadata.album || unknownAlbum,
mediaNumber: status.queueRawIndex,
totalMediaCount: status.queueLength,
duration: status.duration,
picture: blob
});
});
});
});
});
}
},
updatePlaybackStatus: function() {
if (Remote.enabled) {
client.method('getPlaybackStatus').then((status) => {
var playStatus = status.stopped ? PLAYSTATUS_STOPPED :
(status.paused ? PLAY_STATUS_PAUSED : PLAY_STATUS_PLAYING);
if (status.isInterrupted) {
playStatus = PLAY_STATUS_INTERRUPTED;
}
mrc.notifyStatusChanged({
playStatus: playStatus,
duration: status.duration,
position: status.elapsedTime
});
});
}
}
};
var mrc; // Lazy-initialized
var client = bridge.client({
service: 'music-service',
endpoint: window,
timeout: false
});
var commands = {
play: function(evt) {
commands.playpause(evt);
},
// 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(evt) {
var isResumedBySCO = Remote.isSCOEnabled;
Remote.isSCOEnabled = evt.detail.isSCOConnected;
client.method('getPlaybackStatus').then((status) => {
// Check if it's resumed by the SCO disconnection, if so, we need to
// recover the player to the original status.
if (isResumedBySCO) {
client.method(status.paused ? 'play' : 'pause');
} else {
// Play in shuffle order if music app is launched remotely.
if (!status.filePath) {
client.method('queuePlaylist', 'shuffle-all');
} else {
client.method(status.paused ? 'play' : 'pause');
}
}
});
},
pause: function(evt) {
Remote.isSCOEnabled = evt.detail.isSCOConnected;
client.method('pause');
},
stop: function() {
client.method('pause');
},
next: function() {
client.method('nextSong');
},
previous: function() {
client.method('previousSong');
},
seekpress: function(evt) {
client.method('startFastSeek', evt.detail.direction === -1);
},
seekrelease: function() {
client.method('stopFastSeek');
},
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.
Remote.updateMetadata();
},
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.
Remote.updatePlaybackStatus();
}
};
var scripts = [
'/shared/js/bluetooth_helper.js',
'/shared/js/media/remote_controls.js'
];
LazyLoader.load(scripts).then(() => {
mrc = new MediaRemoteControls();
for (var command in commands) {
mrc.addCommandListener(command, commands[command]);
}
mrc.start(() => {
if (Remote.enabled) {
mrc.getSCOStatus(status => Remote.isSCOEnabled = status);
}
});
mrc.notifyAppInfo({
origin: window.location.origin,
icon: window.location.origin + '/style/icons/music_84.png'
});
client.on('songChange', Remote.updateMetadata);
client.on('play', Remote.updatePlaybackStatus);
client.on('pause', Remote.updatePlaybackStatus);
client.on('stop', Remote.updatePlaybackStatus);
client.on('elapsedTimeChange', Remote.updatePlaybackStatus);
client.on('interruptBegin', Remote.updatePlaybackStatus);
client.on('interruptEnd', Remote.updatePlaybackStatus);
Remote.enabled = true;
});
return Remote;
})();

View file

@ -0,0 +1,31 @@
/* exported ActivitiesService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function ActivitiesService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/activities/share/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('share', filePath)
.then(() => resolve(respond({ success: true })))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,38 @@
/* exported AlbumsService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function AlbumsService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/albums/list', stopAfter((request) => {
return new Promise((resolve) => {
client.method('getAlbums')
.then(albums => resolve(respond(albums)));
});
}));
worker.get('/api/albums/info/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('getAlbum', filePath)
.then(songs => resolve(respond(songs)))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,38 @@
/* exported ArtistsService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function ArtistsService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/artists/list', stopAfter((request) => {
return new Promise((resolve) => {
client.method('getArtists')
.then(artists => resolve(respond(artists)));
});
}));
worker.get('/api/artists/info/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('getArtist', filePath)
.then(songs => resolve(respond(songs)))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,64 @@
/* exported ArtworkService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function ArtworkService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/artwork/original/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('getSongArtwork', filePath)
.then(file => resolve(respond(file)))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
worker.get('/api/artwork/thumbnail/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('getSongThumbnail', filePath)
.then(file => resolve(respond(file)))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
worker.get('/api/artwork/url/original/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('getSongArtworkURL', filePath)
.then(file => resolve(respond(file)))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
worker.get('/api/artwork/url/thumbnail/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('getSongThumbnailURL', filePath)
.then(file => resolve(respond(file)))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': response.type || 'application/octet-stream' }
});
}
}

View file

@ -0,0 +1,67 @@
/* exported AudioService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function AudioService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/audio/play', stopAfter((request) => {
return new Promise((resolve) => {
client.method('play')
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/audio/pause', stopAfter((request) => {
return new Promise((resolve) => {
client.method('pause')
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/audio/seek/:time', stopAfter((request) => {
return new Promise((resolve) => {
client.method('seek', request.parameters.time)
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/audio/fastseek/start/:direction', stopAfter((request) => {
return new Promise((resolve) => {
client.method('startFastSeek', request.parameters.direction === 'reverse')
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/audio/fastseek/stop', stopAfter((request) => {
return new Promise((resolve) => {
client.method('stopFastSeek')
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/audio/status', stopAfter((request) => {
return new Promise((resolve) => {
client.method('getPlaybackStatus')
.then(status => resolve(respond(status)));
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,27 @@
/* exported DatabaseService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function DatabaseService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/database/status', stopAfter((request) => {
return new Promise((resolve) => {
client.method('getDatabaseStatus')
.then(status => resolve(respond(status)));
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,31 @@
/* exported NavigationService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function NavigationService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/navigation/:url', stopAfter((request) => {
return new Promise((resolve) => {
var url = decodeURIComponent(request.parameters.url);
client.method('navigate', url)
.then(() => resolve(respond({ success: true })))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,37 @@
/* exported PlaylistService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function PlaylistService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/playlists/list', stopAfter((request) => {
return new Promise((resolve) => {
client.method('getPlaylists')
.then(playlists => resolve(respond(playlists)));
});
}));
worker.get('/api/playlists/info/:id', stopAfter((request) => {
return new Promise((resolve) => {
client.method('getPlaylist', request.parameters.id)
.then(songs => resolve(respond(songs)))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,107 @@
/* exported QueueService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function QueueService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/queue/current', stopAfter((request) => {
return new Promise((resolve) => {
client.method('currentSong')
.then(song => resolve(respond(song)))
.catch(() => {
resolve(new Response('', { status: 404 }));
});
});
}));
worker.get('/api/queue/previous', stopAfter((request) => {
return new Promise((resolve) => {
client.method('previousSong')
.then(success => resolve(respond({ success: success })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/queue/next', stopAfter((request) => {
return new Promise((resolve) => {
client.method('nextSong')
.then(success => resolve(respond({ success: success })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/queue/album/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('queueAlbum', filePath)
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/queue/artist/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('queueArtist', filePath)
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/queue/playlist/:id/shuffle', stopAfter((request) => {
return new Promise((resolve) => {
client.method('queuePlaylist', request.parameters.id)
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/queue/playlist/:id/song/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var id = request.parameters.id;
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('queuePlaylist', id, filePath)
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/queue/song/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('queueSong', filePath)
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/queue/repeat/:repeat', stopAfter((request) => {
return new Promise((resolve) => {
client.method('setRepeatSetting', request.parameters.repeat)
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
worker.get('/api/queue/shuffle/:shuffle', stopAfter((request) => {
return new Promise((resolve) => {
client.method('setShuffleSetting', request.parameters.shuffle)
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,34 @@
/* exported SearchService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function SearchService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/search/:key/', stopAfter((request) => {
return new Promise((resolve) => {
client.method('search', request.parameters.key, '')
.then(songs => resolve(respond(songs)));
});
}));
worker.get('/api/search/:key/:query', stopAfter((request) => {
return new Promise((resolve) => {
client.method('search', request.parameters.key, request.parameters.query)
.then(songs => resolve(respond(songs)));
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,55 @@
/* exported SongsService */
/* global Response, ServiceWorkerWare, bridge */
'use strict';
importScripts('components/bridge/client.js');
var client = bridge.client({
service: 'music-service',
endpoint: new BroadcastChannel('music-service')
});
function SongsService(worker) {
var stopAfter = ServiceWorkerWare.decorators.stopAfter;
worker.get('/api/songs/list', stopAfter((request) => {
return new Promise((resolve) => {
client.method('getSongs')
.then(songs => resolve(respond(songs)));
});
}));
worker.get('/api/songs/count', stopAfter((request) => {
return new Promise((resolve) => {
client.method('getSongCount')
.then(count => resolve(respond(count)));
});
}));
worker.get('/api/songs/info/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('getSong', filePath)
.then(song => resolve(respond(song)))
.catch((error) => {
resolve(new Response('', { status: 404 }));
});
});
}));
worker.get('/api/songs/rating/:rating/:filePath', stopAfter((request) => {
return new Promise((resolve) => {
var rating = request.parameters.rating;
var filePath = decodeURIComponent(request.parameters.filePath);
client.method('setSongRating', rating, filePath)
.then(() => resolve(respond({ success: true })))
.catch(() => resolve(respond({ success: false })));
});
}));
function respond(response) {
return new Response(JSON.stringify(response), {
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,392 @@
/* global fetch */
(function() {
'use strict';
if (navigator.getDeviceStorages &&
navigator.getDeviceStorage) {
return;
}
const PRELOAD_FILES = [
'/media/1-01%20Whole%20Lotta%20Love.mp3',
'/media/1-02%20What%20Is%20and%20What%20Should%20Never%20Be.mp3',
'/media/1-03%20The%20Lemon%20Song.mp3',
'/media/1-04%20Thank%20You.mp3',
'/media/1-05%20Heartbreaker.mp3',
'/media/1-06%20Living%20Loving%20Maid%20(She\'s%20Just%20a%20Woman).mp3',
'/media/1-07%20Ramble%20On.mp3',
'/media/1-08%20Moby%20Dick.mp3',
'/media/1-09%20Bring%20It%20On%20Home.mp3',
'/media/soundgarden/1-01%20Let%20Me%20Drown.mp3',
'/media/soundgarden/1-02%20My%20Wave.mp3',
'/media/soundgarden/1-03%20Fell%20On%20Black%20Days.mp3',
'/media/soundgarden/1-04%20Mailman.mp3',
'/media/soundgarden/1-05%20Superunknown.mp3',
'/media/soundgarden/1-06%20Head%20Down.mp3',
'/media/soundgarden/1-07%20Black%20Hole%20Sun.mp3',
'/media/soundgarden/1-08%20Spoonman.mp3',
'/media/soundgarden/1-09%20Limo%20Wreck.mp3',
'/media/soundgarden/1-10%20The%20Day%20I%20Tried%20to%20Live.mp3',
'/media/soundgarden/1-11%20Kickstand.mp3',
'/media/soundgarden/1-12%20Fresh%20Tendrils.mp3',
'/media/soundgarden/1-13%204th%20of%20July.mp3',
'/media/soundgarden/1-14%20Half.mp3',
'/media/soundgarden/1-15%20Like%20Suicide.mp3',
'/media/soundgarden/1-16%20She%20Likes%20Surprises.mp3'
];
navigator.getDeviceStorages = navigator.getDeviceStorages || getDeviceStorages;
navigator.getDeviceStorage = navigator.getDeviceStorage || getDeviceStorage;
var deviceStorages = {};
function getDeviceStorages(storageName) {
return [getDeviceStorage(storageName)];
}
function getDeviceStorage(storageName) {
if (deviceStorages[storageName]) {
return deviceStorages[storageName];
}
return (deviceStorages[storageName] = new DeviceStorage(storageName));
}
function DeviceStorage(storageName) {
this.storageName = storageName;
this.canBeFormatted = false;
this.canBeMounted = false;
this.canBeShared = false;
this.default = true;
this.isRemovable = false;
this.onchange = null;
this.addEventListener('change', (evt) => {
if (typeof this.onchange === 'function') {
this.onchange(evt);
}
});
this._files = [];
var preloadNextFile = (function(index) {
if (index >= PRELOAD_FILES.length) {
return;
}
var path = PRELOAD_FILES[index];
fetch(path)
.then((result) => {
result.blob().then((file) => {
preloadNextFile(++index);
file.name = decodeURIComponent(path);
file.lastModifiedDate = new Date();
this._files.push(file);
this.dispatchEvent('change', {
reason: 'created',
path: file.name
});
this.dispatchEvent('change', {
reason: 'modified',
path: file.name
});
});
})
.catch((error) => {
preloadNextFile(++index);
console.log('Unable to retrieve preloaded file', path, error);
});
}).bind(this);
preloadNextFile(0);
}
DeviceStorage.prototype = new EventTarget();
DeviceStorage.prototype.constructor = DeviceStorage;
DeviceStorage.prototype.add = function(file) {
if (file instanceof Blob) {
return this.addNamed(file, '/' + this.storageName + '/' +
generateRandomFileName());
}
throw 'TypeError: Argument 1 of DeviceStorage.add does not implement ' +
'interface Blob.';
};
DeviceStorage.prototype.addNamed = function(file, name) {
if (file instanceof Blob) {
return new DOMRequest((success, error) => {
var exists = !!this._files.find(f => f.name === name);
if (exists) {
return error({ name: 'Unknown', message: '' });
}
file.lastModifiedDate = new Date();
file.name = name;
this._files.push(file);
this.dispatchEvent('change', {
reason: 'created',
path: file.name
});
this.dispatchEvent('change', {
reason: 'modified',
path: file.name
});
success(name);
});
}
throw 'TypeError: Argument 1 of DeviceStorage.addNamed does not implement '+
'interface Blob.';
};
DeviceStorage.prototype.appendNamed = function() {
};
DeviceStorage.prototype.available = function() {
return new DOMRequest(success => success('available'));
};
DeviceStorage.prototype.delete = function(fileName) {
return new DOMRequest((success, error) => {
var file = this._files.find(f => f.name === fileName);
if (file) {
this._files.splice(this._files.indexOf(file), 1);
this.dispatchEvent('change', {
reason: 'deleted',
path: file.name
});
success();
return;
}
error({ name: 'Unknown', message: '' });
});
};
DeviceStorage.prototype.enumerate = function(path, options) {
return new DOMRequest((success, error) => {
var files = [];
if (typeof path === 'object') {
options = path;
path = '';
}
if (!path) {
options = options || {};
path = '';
}
this._files.forEach((file) => {
if (options.since instanceof Date &&
file.lastModifiedDate < options.since) {
return;
}
if (file.name.startsWith(path)) {
files.push(file);
}
});
if (files.length === 0) {
return error({ name: 'Unknown', message: '' });
}
success(files);
});
};
DeviceStorage.prototype.enumerateEditable = function() {
// NOOP
};
DeviceStorage.prototype.format = function() {
return new DOMRequest(success => success('formatting'));
};
DeviceStorage.prototype.freeSpace = function() {
return new DOMRequest(success => success(1000000000));
};
DeviceStorage.prototype.get = function(fileName) {
return new DOMRequest((success, error) => {
var file = this._files.find(f => f.name === fileName);
if (file) {
success(file);
return;
}
error({ name: 'Unknown', message: '' });
});
};
DeviceStorage.prototype.getEditable = function() {
// NOOP
};
DeviceStorage.prototype.getRoot = function() {
// NOOP
};
DeviceStorage.prototype.mount = function() {
return new DOMRequest(success => success('mounting'));
};
DeviceStorage.prototype.storageStatus = function() {
return new DOMRequest(success => success('Mounted'));
};
DeviceStorage.prototype.unmount = function() {
return new DOMRequest(success => success('unmounting'));
};
DeviceStorage.prototype.usedSpace = function() {
return new DOMRequest(success => success(
this._files.reduce((a, b) => (a.size || a) + b.size))
);
};
function DOMRequest(callback) {
var success = (result) => {
this.readyState = 'done';
this.result = result;
if (typeof this.onsuccess === 'function') {
this.onsuccess();
}
};
var error = (error) => {
this.readyState = 'done';
this.error = error;
if (typeof this.onerror === 'function') {
this.onerror();
}
};
this.readyState = 'pending';
this.onsuccess = null;
this.onerror = null;
if (typeof callback === 'function') {
setTimeout(() => callback(success, error));
}
}
DOMRequest.prototype.constructor = DOMRequest;
function DOMCursor(callback) {
var success = (results) => {
this.readyState = 'done';
this._results = results;
this._index = -1;
this.continue();
};
var error = (error) => {
this.readyState = 'done';
this.error = error;
if (typeof this.onerror === 'function') {
this.onerror();
}
};
this.readyState = 'pending';
this.done = false;
this.onsuccess = null;
this.onerror = null;
if (typeof callback === 'function') {
setTimeout(() => callback(success, error));
}
}
DOMCursor.prototype = new DOMRequest();
DOMCursor.prototype.constructor = DOMCursor;
DOMCursor.prototype.continue = function() {
if (this._index >= this._results.length - 1) {
return;
}
this.result = this._results[++this._index];
if (this._index >= this._results.length - 1) {
this.done = true;
}
if (typeof this.onsuccess === 'function') {
this.onsuccess();
}
};
function EventTarget(object) {
if (typeof object !== 'object') {
return;
}
for (var property in object) {
this[property] = object[property];
}
}
EventTarget.prototype.constructor = EventTarget;
EventTarget.prototype.dispatchEvent = function(name, data) {
var events = this._events || {};
var listeners = events[name] || [];
listeners.forEach(listener => listener.call(this, data));
};
EventTarget.prototype.addEventListener = function(name, listener) {
var events = this._events = this._events || {};
var listeners = events[name] = events[name] || [];
if (listeners.find(fn => fn === listener)) {
return;
}
listeners.push(listener);
};
EventTarget.prototype.removeEventListener = function(name, listener) {
var events = this._events || {};
var listeners = events[name] || [];
for (var i = listeners.length - 1; i >= 0; i--) {
if (listeners[i] === listener) {
listeners.splice(i, 1);
return;
}
}
};
function generateRandomFileName() {
var fileName = '';
for (var i = 0; i < 8; i++) {
fileName += Math.floor((1 + Math.random()) * 0x10000)
.toString(16).substr(1);
}
return fileName;
}
})();

249
gaia/apps/music/js/view.js Executable file
View file

@ -0,0 +1,249 @@
/* exported View */
/* global SERVICE_WORKERS, bridge */
window.View = (function() {
'use strict';
var debug = 0 ? (...args) => console.log('[View]', ...args) : () => {};
if (!SERVICE_WORKERS) (function() {
window.ROUTES = {
'/api/activities/share/:filePath': 'share',
'/api/albums/list': 'getAlbums',
'/api/albums/info/:filePath': 'getAlbum',
'/api/artists/list': 'getArtists',
'/api/artists/info/:filePath': 'getArtist',
'/api/artwork/original/:filePath': 'getSongArtwork',
'/api/artwork/thumbnail/:filePath': 'getSongThumbnail',
'/api/artwork/url/original/:filePath': 'getSongArtworkURL',
'/api/artwork/url/thumbnail/:filePath': 'getSongThumbnailURL',
'/api/audio/play': 'play',
'/api/audio/pause': 'pause',
'/api/audio/seek/:time': 'seek',
'/api/audio/fastseek/start/:direction': 'startFastSeek',
'/api/audio/fastseek/stop': 'stopFastSeek',
'/api/audio/status': 'getPlaybackStatus',
'/api/database/status': 'getDatabaseStatus',
'/api/playlists/list': 'getPlaylists',
'/api/playlists/info/:id': 'getPlaylist',
'/api/queue/current': 'currentSong',
'/api/queue/previous': 'previousSong',
'/api/queue/next': 'nextSong',
'/api/queue/album/:filePath': 'queueAlbum',
'/api/queue/artist/:filePath': 'queueArtist',
'/api/queue/playlist/:id/shuffle': 'queuePlaylist',
'/api/queue/playlist/:id/song/:filePath': 'queuePlaylist',
'/api/queue/song/:filePath': 'queueSong',
'/api/queue/repeat/:repeat': 'setRepeatSetting',
'/api/queue/shuffle/:shuffle': 'setShuffleSetting',
'/api/songs/list': 'getSongs',
'/api/songs/count': 'getSongCount',
'/api/songs/info/:filePath': 'getSong',
'/api/songs/rating/:rating/:filePath': 'setSongRating',
'/api/search/:key/': 'search',
'/api/search/:key/:query': 'search'
};
for (var path in window.ROUTES) {
var method = window.ROUTES[path];
window.ROUTES[path] = parseSimplePath(path);
window.ROUTES[path].method = method;
}
})();
function View() {
this.client = bridge.client({
service: 'music-service',
endpoint: window.parent,
timeout: false
});
this.params = {};
var parts = window.parent.location.href.split('?');
parts.shift();
var query = parts.join('?');
query.split('&').forEach((param) => {
var parts = param.split('=');
this.params[parts[0]] = parts[1];
});
window.addEventListener('click', (evt) => {
var link = evt.target.closest('a');
if (link) {
evt.preventDefault();
this.client.method('navigate', link.getAttribute('href'));
}
});
window.addEventListener('viewdestroy', () => this.destroy());
document.addEventListener('DOMRetranslated', () => {
this.title = document.title;
});
}
View.prototype.setupSearch = function(params) {
this.searchBox.addEventListener('search', (evt) => this.search(evt.detail));
this.searchResults.addEventListener('open', () => {
this.client.method('searchOpen');
});
this.searchResults.addEventListener('close', () => {
this.client.method('searchClose');
this.list.scrollTop = this.searchBox.HEIGHT;
});
this.searchResults.addEventListener('resultclick', (evt) => {
var link = evt.detail;
if (link) {
this.client.method('navigate', link.getAttribute('href'));
}
});
this.searchResults.getItemImageSrc = (item) => this.getThumbnail(item.name);
};
View.prototype.setupList = function() {
this.list.minScrollHeight = `calc(100% + ${this.searchBox.HEIGHT}px)`;
this.list.offset = this.searchBox.HEIGHT;
this.list.configure({
getItemImageSrc: (item) => this.getThumbnail(item.name)
});
// Show the view only when list has something
// rendered, this prevents Gecko painting unnecessarily.
this.list.rendered.then(() => document.body.hidden = false);
this.list.scrollTo(this.searchBox.HEIGHT);
};
View.prototype.destroy = function() {
Object.getOwnPropertyNames(this).forEach(prop => this[prop] = null);
debug('Destroyed');
};
View.prototype.render = function() {
// No-op in the base class.
};
View.prototype.onRenderDone = function() {
if (window.frameElement) {
window.frameElement.dispatchEvent(new CustomEvent('rendered'));
}
debug('Rendered');
};
View.prototype.fetch = SERVICE_WORKERS ?
function(url) {
return window.fetch(encodeURI(url));
} :
function(url) {
for (var path in window.ROUTES) {
var route = window.ROUTES[path];
var match = url.match(route.regexp);
if (match) {
return new Promise((resolve) => {
setTimeout(() => {
var args = [route.method].concat(match.splice(1));
this.client.method.apply(this.client, args).then((result) => {
resolve({
blob: () => Promise.resolve(result),
json: () => Promise.resolve(result)
});
});
});
});
}
}
return Promise.reject();
};
Object.defineProperty(View.prototype, 'title', {
get: function() {
return document.title;
},
set: function(value) {
document.title = value;
window.frameElement.dispatchEvent(new CustomEvent('titlechange', {
detail: document.title
}));
}
});
View.extend = function(subclass) {
subclass.prototype = Object.create(View.prototype, {
constructor: {
value: subclass,
enumerable: false,
writable: true,
configurable: true
}
});
// subclass.__proto__ = View;
return subclass;
};
/**
* Route parser from components/serviceworkerware/dist/sww.js
*/
function parseSimplePath(path) {
// Check for named placeholder crowding
if (/\:[a-zA-Z0-9]+\:[a-zA-Z0-9]+/g.test(path)) {
throw new Error('Invalid usage of named placeholders');
}
// Check for mixed placeholder crowdings
var mixedPlaceHolders =
/(\*\:[a-zA-Z0-9]+)|(\:[a-zA-Z0-9]+\:[a-zA-Z0-9]+)|(\:[a-zA-Z0-9]+\*)/g;
if (mixedPlaceHolders.test(path.replace(/\\\*/g,''))) {
throw new Error('Invalid usage of named placeholders');
}
// Try parsing the string and converting special characters into regex
try {
// Parsing anonymous placeholders with simple backslash-escapes
path = path.replace(/(.|^)[*]+/g, function(m,escape) {
return escape==='\\' ? '\\*' : (escape+'(?:.*?)');
});
// Parsing named placeholders with backslash-escapes
var tags = [];
path = path.replace(/(.|^)\:([a-zA-Z0-9]+)/g, function (m, escape, tag) {
if (escape === '\\') { return ':' + tag; }
tags.push(tag);
return escape + '(.+?)';
});
return { regexp: RegExp(path + '$'), tags: tags };
}
// Failed to parse final path as a RegExp
catch (ex) {
throw new Error('Invalid path specified');
}
}
return View;
})();