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:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
231
gaia/dev_apps/music-oga/js/metadata/album_art.js
Executable file
231
gaia/dev_apps/music-oga/js/metadata/album_art.js
Executable file
|
|
@ -0,0 +1,231 @@
|
|||
/* exported AlbumArt */
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Metadata describing the cover art for a track. Cover art comes in one of
|
||||
* three flavors: embedded (stored in the audio track's metadata), unsynced
|
||||
* (like embedded, but stored in an unsynchronized block of ID3 data; we
|
||||
* ultimately save it in an external file), or external (stored as a separate
|
||||
* file in the same directory as the audio track).
|
||||
*
|
||||
* @typedef {Object} Picture
|
||||
* @property {String} flavor How the art was stored; one of "embedded",
|
||||
* "unsynced", or "external".
|
||||
* @property {Number} [start] The offset in bytes to where the picture is
|
||||
* stored in the audio file; only applies when flavor="embedded".
|
||||
* @property {Number} [end] The offset in bytes to the end of where the
|
||||
* picture is stored in the audio file; only applies when flavor="embedded".
|
||||
* @property {Number} [type] The mimetype of the picture; only applies when
|
||||
* flavor="embedded".
|
||||
* @property {String} [filename] The path on the filesystem to the original
|
||||
* (full-size) picture; only applies when flavor="external" or "unsynced".
|
||||
*/
|
||||
|
||||
var AlbumArt = (function() {
|
||||
var pictureStorage = navigator.getDeviceStorage('pictures');
|
||||
|
||||
// A cache of metadata Picture objects for albums with external cover art. The
|
||||
// key is the directory name for the song in question.
|
||||
var externalCoverCache = {};
|
||||
|
||||
// A cache of filenames for saved cover art (generated from unsynced ID3
|
||||
// tags). The value is the absolute path name.
|
||||
var savedCoverCache = new Set();
|
||||
|
||||
/**
|
||||
* Perform additional processing for cover art, such as checking for external
|
||||
* artwork and saving temporary image blobs (for unsynchronized ID3 data).
|
||||
*
|
||||
* @param {Blob} blob The audio file.
|
||||
* @param {Metadata} metadata The metadata for the file.
|
||||
* @return {Promise} A Promise resolving to the metadata object with any
|
||||
* additional cover art fields added as necessary.
|
||||
*/
|
||||
function process(blob, metadata) {
|
||||
// If a blob isn't backed by an actual file, we can't do anything here
|
||||
// except return what we have.
|
||||
var filename = blob.name;
|
||||
if (!filename) {
|
||||
return Promise.resolve(metadata);
|
||||
}
|
||||
|
||||
if (!metadata.picture) {
|
||||
// We don't have any embedded album art; see if there's any external album
|
||||
// art in the same directory.
|
||||
var lastSlash = filename.lastIndexOf('/');
|
||||
var dirName = filename.substring(0, lastSlash + 1);
|
||||
|
||||
// First, check if we've recently fetched this cover; if so, just use the
|
||||
// cached results so we're not going out to the disk more than necessary.
|
||||
if (dirName in externalCoverCache) {
|
||||
metadata.picture = externalCoverCache[dirName];
|
||||
return Promise.resolve(metadata);
|
||||
}
|
||||
|
||||
// Try to find external album art using a handful of common names. The
|
||||
// possibilities listed here appear to be the most common, based on a
|
||||
// brief survey of what people use in the wild.
|
||||
return new Promise(function(resolve, reject) {
|
||||
var possibleFilenames = ['folder.jpg', 'cover.jpg', 'front.jpg'];
|
||||
var tryFetchExternalCover = function(index) {
|
||||
if (index === possibleFilenames.length) {
|
||||
// We couldn't find any external album art.
|
||||
externalCoverCache[dirName] = null;
|
||||
resolve(metadata);
|
||||
return;
|
||||
}
|
||||
|
||||
var externalCoverFilename = dirName + possibleFilenames[index];
|
||||
var getcoverrequest = pictureStorage.get(externalCoverFilename);
|
||||
getcoverrequest.onsuccess = function() {
|
||||
metadata.picture = { flavor: 'external',
|
||||
filename: externalCoverFilename };
|
||||
// Cache the picture object we generated to make things faster.
|
||||
externalCoverCache[dirName] = metadata.picture;
|
||||
resolve(metadata);
|
||||
};
|
||||
getcoverrequest.onerror = function() {
|
||||
tryFetchExternalCover(index + 1);
|
||||
};
|
||||
};
|
||||
tryFetchExternalCover(0);
|
||||
});
|
||||
} else if (metadata.picture.blob) {
|
||||
// We have album art in a separate blob that we need to save somewhere.
|
||||
// Two reasons :
|
||||
// - we had an unsynced ID3 frame with the art and needed to de-unsync
|
||||
// it.
|
||||
// - we had album art from a VorbisComment.
|
||||
//
|
||||
// First, try to make a unique filename for the image based on the artist,
|
||||
// album, and file size. The file size is essentially a poor man's
|
||||
// checksum to make sure we aren't reusing an inappropriate image, such as
|
||||
// if the user updated the embedded art.
|
||||
var albumKey;
|
||||
if (metadata.artist || metadata.album) {
|
||||
// Don't let the artist or album strings be too long; VFAT has a maximum
|
||||
// of 255 characters for the path, and we need some room for the rest of
|
||||
// it!
|
||||
var artist = (metadata.artist || '').substr(0, 64);
|
||||
var album = (metadata.album || '').substr(0, 64);
|
||||
albumKey = artist + '.' + album;
|
||||
} else {
|
||||
// If there's no album or artist, use the path to the file instead; we
|
||||
// truncate from the end to help ensure uniqueness.
|
||||
albumKey = filename.substr(-128);
|
||||
}
|
||||
|
||||
var coverBlob = metadata.picture.blob;
|
||||
delete metadata.picture.blob;
|
||||
|
||||
// coverBlob is always a JPEG or PNG.
|
||||
var extension = coverBlob.type === 'image/jpeg' ? '.jpg' : '.png';
|
||||
var imageFilename = vfatEscape(albumKey) + '.' + coverBlob.size +
|
||||
extension;
|
||||
var storageName = getStorageName(filename);
|
||||
return checkSaveCover(coverBlob, imageFilename, storageName).then(
|
||||
function(savedFile) {
|
||||
metadata.picture.filename = savedFile;
|
||||
return metadata;
|
||||
}, function(err) {
|
||||
delete metadata.picture;
|
||||
return metadata;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// We have embedded album art! All the processing is already done, so we
|
||||
// can just return.
|
||||
return Promise.resolve(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape any characters that are illegal on the VFAT filesystem.
|
||||
*
|
||||
* @param {string} str The string to escape.
|
||||
* @return {string} The escaped string.
|
||||
*/
|
||||
function vfatEscape(str) {
|
||||
return str.replace(/["*\/:<>?\|]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the storage area for an absolute filename, with leading
|
||||
* and trailing slashes (e.g. '/sdcard/'). If the filename is relative,
|
||||
* return the empty string (i.e. use the default storage area.)
|
||||
*
|
||||
* @param {string} filename The path to the file.
|
||||
* @return {string} The file's storage area, or the empty string if the path
|
||||
* was relative.
|
||||
*/
|
||||
function getStorageName(filename) {
|
||||
// `filename` is usually a fully qualified name (perhaps something like
|
||||
// /sdcard/Music/file.mp3). On desktop, it's a relative name, but desktop
|
||||
// only has one storage area anyway.
|
||||
if (filename[0] === '/') {
|
||||
var slashIndex = filename.indexOf('/', 1);
|
||||
if (slashIndex < 0) {
|
||||
var err = Error('handleCoverArt: Bad filename: "' + filename + '"');
|
||||
console.error(err);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
// Get the storage name, e.g. /sdcard/
|
||||
return filename.substring(0, slashIndex + 1);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for the existence of an image on the audio file's storage area,
|
||||
* and if it's not present, try to save it.
|
||||
*
|
||||
* @param {Blob} coverBlob The blob for the full-size cover art.
|
||||
* @param {string} imageFilename A relative filename for the image.
|
||||
* @param {string} storageName The name of the storage area too use for
|
||||
* saving the image. May be an empty string or a path with trailing '/'.
|
||||
* For instance, to store on the SD card, pass '/sdcard/'.
|
||||
* @return {Promise} A Promise that resolves when finished, providing the
|
||||
* filename of the saved image.
|
||||
*/
|
||||
function checkSaveCover(coverBlob, imageFilename, storageName) {
|
||||
// We want to put the image in the same storage area as the audio track it's
|
||||
// for. Since the audio track could be in any storage area, we examine its
|
||||
// filename to get the storage name; the storage name is always the first
|
||||
// part of the (absolute) filename, so we grab that and build an absolute
|
||||
// path for the image. This will ensure that the generic deviceStorage we
|
||||
// use for pictures ("pictureStorage") puts the image where we want it.
|
||||
var imageAbsPath = storageName + '.music/covers/' + imageFilename;
|
||||
if (savedCoverCache.has(imageAbsPath)) {
|
||||
return Promise.resolve(imageAbsPath);
|
||||
}
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
var getrequest = pictureStorage.get(imageAbsPath);
|
||||
|
||||
// We already have the image. We're done!
|
||||
getrequest.onsuccess = function() {
|
||||
savedCoverCache.add(imageAbsPath);
|
||||
resolve(imageAbsPath);
|
||||
};
|
||||
|
||||
// We don't have the image yet. Let's save it.
|
||||
getrequest.onerror = function() {
|
||||
var saverequest = pictureStorage.addNamed(coverBlob, imageAbsPath);
|
||||
saverequest.onerror = function() {
|
||||
console.error('Could not save cover image', imageFilename);
|
||||
};
|
||||
|
||||
// Don't bother waiting for saving to finish. Just return.
|
||||
savedCoverCache.add(imageAbsPath);
|
||||
resolve(imageAbsPath);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
process: process,
|
||||
pictureStorage: pictureStorage
|
||||
};
|
||||
})();
|
||||
350
gaia/dev_apps/music-oga/js/metadata/album_art_cache.js
Executable file
350
gaia/dev_apps/music-oga/js/metadata/album_art_cache.js
Executable file
|
|
@ -0,0 +1,350 @@
|
|||
/* global AlbumArt, asyncStorage, Database, ImageUtils, LazyLoader */
|
||||
/* exported AlbumArtCache */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Handle fetching album art and thumbnail management for already-parsed audio
|
||||
* files.
|
||||
*/
|
||||
var AlbumArtCache = (function() {
|
||||
|
||||
// When we generate our own thumbnails, aim for this size. XXX: This should be
|
||||
// improved to pick a size based on the resolution of our device.
|
||||
var THUMBNAIL_WIDTH = 300;
|
||||
var THUMBNAIL_HEIGHT = 300;
|
||||
|
||||
// The level one caches for thumbnails and full-size images; maps a cache key
|
||||
// (see below) to a blob: URL. There's also a level two cache for thumbnails,
|
||||
// backed by asyncStorage.
|
||||
var L1Cache = {'thumbnail': {}, 'fullsize': {}};
|
||||
|
||||
/**
|
||||
* Get a URL for a full-sized version of the cover art for a given file (if
|
||||
* any).
|
||||
*
|
||||
* @param {Object} fileinfo The info for the file we want album art for.
|
||||
* @param {Boolean} noPlaceholder True if this function should only return an
|
||||
* image if there's cover art for the file. Otherwise, this function will
|
||||
* return a placeholder image if there's no cover art.
|
||||
* @return {Promise} A promise returning either the URL for the cover art, or
|
||||
* null.
|
||||
*/
|
||||
function getFullSizeURL(fileinfo, noPlaceholder) {
|
||||
// If there's no picture info in the metadata, return null or a placeholder.
|
||||
if (!fileinfo.metadata.picture) {
|
||||
return Promise.resolve(
|
||||
noPlaceholder ? null : getDefaultCoverURL(fileinfo)
|
||||
);
|
||||
}
|
||||
|
||||
// See if we've already made a URL for this album. If so, return it.
|
||||
var cacheKey = makeCacheKey(fileinfo);
|
||||
if (cacheKey && cacheKey in L1Cache.fullsize) {
|
||||
return Promise.resolve(L1Cache.fullsize[cacheKey]);
|
||||
}
|
||||
|
||||
// Otherwise, get the blob from the album art, create a URL for it, and
|
||||
// return it in the Promise.
|
||||
return getAlbumArtBlob(fileinfo).then(function(blob) {
|
||||
return makeAndCacheURL(cacheKey, blob, 'fullsize');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Blob for a full-sized version of the cover art for a given file (if
|
||||
* any).
|
||||
*
|
||||
* @param {Object} fileinfo The info for the file we want album art for.
|
||||
* @param {Boolean} noPlaceholder True if this function should only return an
|
||||
* image if there's cover art for the file. Otherwise, this function will
|
||||
* return a placeholder image if there's no cover art.
|
||||
* @return {Promise} A promise returning either the Blob for the cover art, or
|
||||
* null.
|
||||
*/
|
||||
function getFullSizeBlob(fileinfo, noPlaceholder) {
|
||||
// If there's no picture info in the metadata, return null or a placeholder.
|
||||
if (!fileinfo.metadata.picture) {
|
||||
if (noPlaceholder) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return getBlobFromURL(getDefaultCoverURL(fileinfo));
|
||||
}
|
||||
|
||||
// Skip the L1 cache, since we want to return a blob, not a URL.
|
||||
|
||||
return getAlbumArtBlob(fileinfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a URL for a thumbnailized version of the cover art for a given file
|
||||
* (if any).
|
||||
*
|
||||
* @param {Object} fileinfo The info for the file we want album art for.
|
||||
* @param {Boolean} noPlaceholder True if this function should only return an
|
||||
* image if there's cover art for the file. Otherwise, this function will
|
||||
* return a placeholder image if there's no cover art.
|
||||
* @return {Promise} A promise returning either the URL for the thumbnail, or
|
||||
* null.
|
||||
*/
|
||||
function getThumbnailURL(fileinfo, noPlaceholder) {
|
||||
// If there's no picture info in the metadata, return null or a placeholder.
|
||||
if (!fileinfo.metadata.picture) {
|
||||
return Promise.resolve(
|
||||
noPlaceholder ? null : getDefaultCoverURL(fileinfo)
|
||||
);
|
||||
}
|
||||
|
||||
// See if we've already made a URL for this album. If so, return it.
|
||||
var cacheKey = makeCacheKey(fileinfo);
|
||||
if (cacheKey && cacheKey in L1Cache.thumbnail) {
|
||||
return Promise.resolve(L1Cache.thumbnail[cacheKey]);
|
||||
}
|
||||
|
||||
// Otherwise, see if we've saved a blob in asyncStorage. If not, create a
|
||||
// thumbnail blob and store it in the cache. Finally, create a URL for the
|
||||
// blob and return it in the Promise.
|
||||
//
|
||||
// XXX: There's a (minor) race condition here. If two requests for the same
|
||||
// album art get here, the slower one will overwrite the faster one's cached
|
||||
// blob URL, meaning that not all instances of a piece of album art will
|
||||
// have the same URL. This only really matters to tests, though.
|
||||
return checkL2Cache(cacheKey).then(function(cachedBlob) {
|
||||
return cachedBlob || createThumbnail(cacheKey, fileinfo);
|
||||
}).then(function(blob) {
|
||||
return makeAndCacheURL(cacheKey, blob, 'thumbnail');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Blob for a thumbnailized version of the cover art for a given file
|
||||
* (if any).
|
||||
*
|
||||
* @param {Object} fileinfo The info for the file we want album art for.
|
||||
* @param {Boolean} noPlaceholder True if this function should only return an
|
||||
* image if there's cover art for the file. Otherwise, this function will
|
||||
* return a placeholder image if there's no cover art.
|
||||
* @return {Promise} A promise returning either the Blob for the thumbnail, or
|
||||
* null.
|
||||
*/
|
||||
function getThumbnailBlob(fileinfo, noPlaceholder) {
|
||||
// If there's no picture info in the metadata, return null or a placeholder.
|
||||
if (!fileinfo.metadata.picture) {
|
||||
if (noPlaceholder) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return getBlobFromURL(getDefaultCoverURL(fileinfo));
|
||||
}
|
||||
|
||||
// Skip the L1 cache, since we want to return a blob, not a URL.
|
||||
|
||||
// See if we've saved a blob in asyncStorage. If not, create a
|
||||
// thumbnail blob and store it in the cache. In either case, return the
|
||||
// blob.
|
||||
var cacheKey = makeCacheKey(fileinfo);
|
||||
return checkL2Cache(cacheKey).then(function(cachedBlob) {
|
||||
return cachedBlob || createThumbnail(cacheKey, fileinfo);
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultCoverURL(fileinfo) {
|
||||
var metadata = fileinfo.metadata;
|
||||
// If metadata does not contain both album and artist, then use title
|
||||
// instead.
|
||||
var infoForHash = (!metadata.album && !metadata.artist) ?
|
||||
metadata.title : metadata.album + metadata.artist;
|
||||
var hashedNumber = (Math.abs(hash(infoForHash)) % 10) + 1;
|
||||
|
||||
return '/style/images/AlbumArt' + hashedNumber + '_small.png';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a string into an integral value. (This is a Javascript implementation
|
||||
* of Java's String.hashCode() method.
|
||||
*
|
||||
* @param {String} str The string to hash.
|
||||
* @return {Number} The hashed value.
|
||||
*/
|
||||
function hash(str) {
|
||||
var hashCode = 0;
|
||||
if (str.length === 0) { return hashCode; }
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var c = str.charCodeAt(i);
|
||||
hashCode = ((hashCode << 5) - hashCode) + c;
|
||||
hashCode = hashCode & hashCode; // Convert to 32-bit integer
|
||||
}
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cache key for this file. The cache key should be unique for each
|
||||
* album, and (hopefully) the same for tracks in a given album.
|
||||
*
|
||||
* @param {Object} fileinfo The file's info.
|
||||
* @return {String} A cache key for the file, or null if we couldn't generate
|
||||
* one.
|
||||
*/
|
||||
function makeCacheKey(fileinfo) {
|
||||
var metadata = fileinfo.metadata;
|
||||
if (metadata.picture.filename) {
|
||||
return 'external.' + metadata.picture.filename;
|
||||
} else if (metadata.picture.flavor === 'embedded') {
|
||||
var album = metadata.album;
|
||||
var artist = metadata.artist;
|
||||
var size = metadata.picture.end - metadata.picture.start;
|
||||
|
||||
if (album || artist) {
|
||||
return 'thumbnail.' + album + '.' + artist + '.' + size;
|
||||
} else {
|
||||
return 'thumbnail.' + (fileinfo.name || fileinfo.blob.name);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the L2 cache (asyncStorage) to see if we already have a thumbnailized
|
||||
* version of the album art.
|
||||
*
|
||||
* @param {String} cacheKey The cache key generated by makeCacheKey.
|
||||
* @return {Promise} A promise returning either the Blob for the thumbnail, or
|
||||
* null.
|
||||
*/
|
||||
function checkL2Cache(cacheKey) {
|
||||
if (!cacheKey) {
|
||||
return Promise.resolve(null);
|
||||
} else {
|
||||
return new Promise(function(resolve, reject) {
|
||||
asyncStorage.getItem(cacheKey, function(blob) {
|
||||
// Note that this can resolve to null if the blob wasn't cached yet.
|
||||
resolve(blob);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a blob: URL for a Blob and store the URL in our L1 cache.
|
||||
*
|
||||
* @param {String} cacheKey The cache key generated by makeCacheKey.
|
||||
* @param {Blob} blob The blob.
|
||||
* @param {String} type Either 'thumbnail' or 'fullsize'.
|
||||
* @return {String} The blob: URL.
|
||||
*/
|
||||
function makeAndCacheURL(cacheKey, blob, type) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
if (cacheKey) {
|
||||
L1Cache[type][cacheKey] = url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thumbnail for the album art and store the Blob in our L2 cache.
|
||||
*
|
||||
* @param {String} cacheKey The cache key generated by makeCacheKey.
|
||||
* @param {Object} fileinfo The file's info.
|
||||
* @return {Promise} A promise returning the thumbnailized Blob.
|
||||
*/
|
||||
function createThumbnail(cacheKey, fileinfo) {
|
||||
// We don't have a saved blob yet, so grab it, thumbnailize it, and store
|
||||
// it in asyncStorage.
|
||||
return getAlbumArtBlob(fileinfo).then(function(blob) {
|
||||
return ImageUtils.resizeAndCropToCover(
|
||||
blob, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT
|
||||
);
|
||||
}).then(function(thumbnailBlob) {
|
||||
if (cacheKey) {
|
||||
asyncStorage.setItem(cacheKey, thumbnailBlob);
|
||||
}
|
||||
return thumbnailBlob;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the (full-size) Blob for this file's album art.
|
||||
*
|
||||
* @param {Object} fileinfo The file's info.
|
||||
* @return {Promise} A promise returning the full-size Blob.
|
||||
*/
|
||||
function getAlbumArtBlob(fileinfo) {
|
||||
var picture = fileinfo.metadata.picture;
|
||||
if (picture.blob) {
|
||||
// We must have an unsynced picture that came from a temporary blob
|
||||
// (i.e. from the open activity or a unit test).
|
||||
return Promise.resolve(picture.blob);
|
||||
} else if (picture.filename) {
|
||||
// Some audio tracks have an external file for their album art, so we
|
||||
// need to grab it from deviceStorage. This could also be an unsynced
|
||||
// picture that came from a regular file.
|
||||
return LazyLoader.load('/js/metadata/album_art.js').then(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
var getreq = AlbumArt.pictureStorage.get(picture.filename);
|
||||
getreq.onsuccess = function() {
|
||||
resolve(this.result);
|
||||
};
|
||||
getreq.onerror = function() {
|
||||
reject(this.error);
|
||||
};
|
||||
});
|
||||
});
|
||||
} else if (picture.start) {
|
||||
// Other audio tracks have the album art embedded in the file, so we
|
||||
// just need to splice out the part we want.
|
||||
return getSongBlob(fileinfo).then((blob) => {
|
||||
return blob.slice(picture.start, picture.end, picture.type);
|
||||
});
|
||||
} else {
|
||||
// If we got here, something strange happened...
|
||||
var err = new Error('unknown picture flavor: ' + picture.flavor);
|
||||
console.error(err);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Blob for this file (i.e. the audio track itself).
|
||||
*
|
||||
* @param {Object} fileinfo The file's info.
|
||||
* @return {Promise} A promise returning the audio track's Blob.
|
||||
*/
|
||||
function getSongBlob(fileinfo) {
|
||||
if (fileinfo.blob) {
|
||||
// This can happen for the open activity.
|
||||
return Promise.resolve(fileinfo.blob);
|
||||
} else {
|
||||
// This is the normal case.
|
||||
return Database.getFile(fileinfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Blob for a URL.
|
||||
*
|
||||
* @param {String} url The URL to fetch.
|
||||
* @return {Promise} A promise returning the Blob.
|
||||
*/
|
||||
function getBlobFromURL(url) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.responseType = 'blob';
|
||||
|
||||
xhr.onload = function() {
|
||||
resolve(xhr.response);
|
||||
};
|
||||
// I don't think onerror usually gets called, but let's play it safe.
|
||||
xhr.onerror = function() {
|
||||
reject(null);
|
||||
};
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getFullSizeURL: getFullSizeURL,
|
||||
getFullSizeBlob: getFullSizeBlob,
|
||||
getThumbnailURL: getThumbnailURL,
|
||||
getThumbnailBlob: getThumbnailBlob
|
||||
};
|
||||
})();
|
||||
174
gaia/dev_apps/music-oga/js/metadata/core.js
Executable file
174
gaia/dev_apps/music-oga/js/metadata/core.js
Executable file
|
|
@ -0,0 +1,174 @@
|
|||
/* global BlobView, MetadataFormats */
|
||||
/* exported AudioMetadata */
|
||||
'use strict';
|
||||
|
||||
// XXX We're hiding the fact that these are JSdoc comments because our linter
|
||||
// refuses to accept "property" as a valid tag. Grumble grumble.
|
||||
|
||||
/*
|
||||
* Metadata for a track.
|
||||
*
|
||||
* @typedef {Object} Metadata
|
||||
* @property {String} tag_format The format of the tag (e.g. id3v2.4).
|
||||
* @property {String} artist The track's artist.
|
||||
* @property {String} album The track's album.
|
||||
* @property {String} title The track's title.
|
||||
* @property {Number} [tracknum] The number of the track on the album.
|
||||
* @property {Number} [trackcount] The total number of tracks in the album.
|
||||
* @property {Number} [discnum] The number of the disc on the album.
|
||||
* @property {Number} [disccount] The total number of discs in the album.
|
||||
* @property {Picture} [picture] The cover art, if any.
|
||||
* @property {Number} rated The track's rating; starts at 0.
|
||||
* @property {Number} played The track's play count; starts at 0.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse the metadata for an audio file.
|
||||
*/
|
||||
var AudioMetadata = (function() {
|
||||
/**
|
||||
* Parse the specified blob and return a Promise with the metadata.
|
||||
*
|
||||
* @param {Blob} blob The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blob) {
|
||||
var filename = blob.name;
|
||||
|
||||
// If blob.name exists, it should be an audio file from system
|
||||
// otherwise it should be an audio blob that probably from network/process
|
||||
// we can still parse it but we don't need to care about the filename
|
||||
if (filename) {
|
||||
// If the file is in the DCIM/ directory and has a .3gp extension
|
||||
// then it is a video, not a music file and we ignore it
|
||||
if (filename.slice(0, 5) === 'DCIM/' &&
|
||||
filename.slice(-4).toLowerCase() === '.3gp') {
|
||||
return Promise.reject('skipping 3gp video file');
|
||||
}
|
||||
}
|
||||
|
||||
// If the file is too small to be a music file then ignore it
|
||||
if (blob.size < 128) {
|
||||
return Promise.reject('file is empty or too small');
|
||||
}
|
||||
|
||||
// Read the start of the file, figure out what kind it is, and call
|
||||
// the appropriate parser. Start off with an 64kb chunk of data.
|
||||
// If the metadata is in that initial chunk we won't have to read again.
|
||||
return new Promise(function(resolve, reject) {
|
||||
var headersize = Math.min(64 * 1024, blob.size);
|
||||
BlobView.get(blob, 0, headersize, function(header, error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var parser = MetadataFormats.findParser(header);
|
||||
var promise;
|
||||
if (parser) {
|
||||
promise = parser.parse(header);
|
||||
} else {
|
||||
// This is some kind of file that we don't know about.
|
||||
// Let's see if we can play it.
|
||||
promise = checkPlayability(blob);
|
||||
}
|
||||
|
||||
resolve(promise.then(function(metadata) {
|
||||
return addDefaultMetadata(metadata || {}, filename);
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('AudioMetadata.parse:', e, e.stack);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in any default metadata fields, such as a fallback for the title, and
|
||||
* the rating/playcount.
|
||||
*
|
||||
* @param {Object} metadata The metadata from one of our parsers.
|
||||
* @param {String} filename The name of the underlying file, if any.
|
||||
* @return {Object} The updated metdata object.
|
||||
*/
|
||||
function addDefaultMetadata(metadata, filename) {
|
||||
if (!metadata.artist) {
|
||||
metadata.artist = '';
|
||||
}
|
||||
if (!metadata.album) {
|
||||
metadata.album = '';
|
||||
}
|
||||
if (!metadata.title) {
|
||||
// If the blob has a name, use that as a default title in case
|
||||
// we can't find one in the file
|
||||
if (filename) {
|
||||
var p1 = filename.lastIndexOf('/');
|
||||
var p2 = filename.lastIndexOf('.');
|
||||
if (p2 <= p1) {
|
||||
p2 = filename.length;
|
||||
}
|
||||
metadata.title = filename.substring(p1 + 1, p2);
|
||||
} else {
|
||||
metadata.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
metadata.rated = metadata.played = 0;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a blob can be played as an audio file.
|
||||
*
|
||||
* @param {Blob} blob The file to test.
|
||||
* @return {Promise} A promise that resolves successfully if the file is
|
||||
* playable.
|
||||
*/
|
||||
function checkPlayability(blob) {
|
||||
var player = new Audio();
|
||||
player.mozAudioChannelType = 'content';
|
||||
var canplay = blob.type && player.canPlayType(blob.type);
|
||||
if (canplay === 'probably') {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
player.src = url;
|
||||
|
||||
// XXX: There seems to have been a gecko regression, and error events
|
||||
// are no longer being fired when we try to play some invalid audio
|
||||
// files. To work around this, we use a timeout to assume the file is
|
||||
// unplayable if we nave not gotten any events within a reasonable
|
||||
// amount of time. See also bugs 1208331 and 1198169.
|
||||
const CANPLAY_TIMEOUT = 3000;
|
||||
var timeoutId = setTimeout(() => {
|
||||
console.error('No oncanplay or error events seen yet.',
|
||||
'Assuming file is corrupt:', blob.name);
|
||||
player.onerror();
|
||||
}, CANPLAY_TIMEOUT);
|
||||
|
||||
player.onerror = function() {
|
||||
clearTimeout(timeoutId);
|
||||
URL.revokeObjectURL(url);
|
||||
player.removeAttribute('src');
|
||||
player.load();
|
||||
reject('Unplayable music file');
|
||||
};
|
||||
|
||||
player.oncanplay = function() {
|
||||
clearTimeout(timeoutId);
|
||||
URL.revokeObjectURL(url);
|
||||
player.removeAttribute('src');
|
||||
player.load();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
})();
|
||||
175
gaia/dev_apps/music-oga/js/metadata/flac.js
Executable file
175
gaia/dev_apps/music-oga/js/metadata/flac.js
Executable file
|
|
@ -0,0 +1,175 @@
|
|||
/* global LazyLoader, VorbisPictureComment */
|
||||
/* exported FLACMetadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse metadata for FLAC files (this is very similar to Ogg files, but with a
|
||||
* different container format).
|
||||
*
|
||||
* Format information:
|
||||
* http://flac.sourceforge.net/format.html
|
||||
*/
|
||||
var FLACMetadata = (function() {
|
||||
// Fields that should be stored as integers, not strings
|
||||
var INTFIELDS = [
|
||||
'tracknum', 'trackcount', 'discnum', 'disccount'
|
||||
];
|
||||
|
||||
// Map FLAC field names to metadata property names
|
||||
var FLACFIELDS = {
|
||||
title: 'title',
|
||||
artist: 'artist',
|
||||
album: 'album',
|
||||
tracknumber: 'tracknum',
|
||||
tracktotal: 'trackcount',
|
||||
discnumber: 'discnum',
|
||||
disctotal: 'disccount'
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
// First four bytes are "fLaC" or we wouldn't be here.
|
||||
blobview.seek(4);
|
||||
|
||||
var metadata = {tag_format: 'flac'};
|
||||
var has_vorbis_comment = false;
|
||||
var has_picture = false;
|
||||
|
||||
function processBlock(block) {
|
||||
if (block.block_type === 4) {
|
||||
readAllComments(block.view, metadata);
|
||||
has_vorbis_comment = true;
|
||||
} else if (block.block_type === 6) {
|
||||
metadata.picture = VorbisPictureComment.readPicFrame(block.view);
|
||||
has_picture = true;
|
||||
}
|
||||
|
||||
return (!has_vorbis_comment || !has_picture);
|
||||
}
|
||||
|
||||
return LazyLoader.load('js/metadata/vorbis_picture.js').then(() => {
|
||||
return findMetadataBlocks(blobview, processBlock);
|
||||
}).then(() => {
|
||||
return metadata;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Step over metadata blocks until we find the proper metadata block.
|
||||
*
|
||||
* @param {BlobView} blobview The BlobView for the file.
|
||||
* @param {function} callback The callback to process the block with.
|
||||
* @return {Promise} A Promise that resolves when all relevant metadata blocks
|
||||
* have been processed.
|
||||
*/
|
||||
function findMetadataBlocks(blobview, callback) {
|
||||
return readMetadataBlockHeader(blobview).then((block) => {
|
||||
if (callback(block) && !block.last) {
|
||||
block.view.advance(block.length - block.view.index);
|
||||
findMetadataBlocks(block.view, callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a metadata block header and fetch its contents from the Blob, plus
|
||||
* enough extra data to read the next block's header.
|
||||
*
|
||||
* @param {BlobView} blobview The BlobView for the file.
|
||||
* @return {Promise} A Promise resolving to an object with the following
|
||||
* fields:
|
||||
* {Boolean} last True if this is the last metadata block.
|
||||
* {Number} block_type The block's type, as an integer.
|
||||
* {Number} length The size in bytes of the block's body (excluding the
|
||||
* header).
|
||||
* {BlobView} view The BlobView with the block's data, starting at the
|
||||
* beginning of the metadata block's content (not the header).
|
||||
*/
|
||||
function readMetadataBlockHeader(blobview) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var header = blobview.readUnsignedByte();
|
||||
var last = (header & 0x80) === 0x80;
|
||||
var block_type = header & 0x7F;
|
||||
var length = blobview.readUint24(false);
|
||||
|
||||
// Get the contents of this block, plus enough to read the next block's
|
||||
// header if necessary.
|
||||
blobview.getMore(blobview.viewOffset + blobview.index, length + 4,
|
||||
function(more, err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
last: last,
|
||||
block_type: block_type,
|
||||
length: length,
|
||||
view: more
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all the comments in a metadata header block.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function readAllComments(page, metadata) {
|
||||
var vendor_string_length = page.readUnsignedInt(true);
|
||||
page.advance(vendor_string_length); // skip vendor string
|
||||
|
||||
var num_comments = page.readUnsignedInt(true);
|
||||
for (var i = 0; i < num_comments; i++) {
|
||||
try {
|
||||
var comment = readComment(page);
|
||||
if (comment) {
|
||||
if (!(comment.field in metadata)) {
|
||||
metadata[comment.field] = comment.value;
|
||||
} else {
|
||||
// We already have a value, so append this new one.
|
||||
metadata[comment.field] += ' / ' + comment.value;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error parsing vorbis comment', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single comment field.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
*/
|
||||
function readComment(page) {
|
||||
var comment_length = page.readUnsignedInt(true);
|
||||
var comment = page.readUTF8Text(comment_length);
|
||||
var equal = comment.indexOf('=');
|
||||
if (equal === -1) {
|
||||
throw new Error('missing delimiter in comment');
|
||||
}
|
||||
|
||||
var fieldname = comment.substring(0, equal).toLowerCase().replace(' ', '');
|
||||
var propname = FLACFIELDS[fieldname];
|
||||
if (propname) { // Do we care about this field?
|
||||
var value = comment.substring(equal + 1);
|
||||
if (INTFIELDS.indexOf(propname) !== -1) {
|
||||
value = parseInt(value, 10);
|
||||
}
|
||||
return {field: propname, value: value};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
})();
|
||||
115
gaia/dev_apps/music-oga/js/metadata/formats.js
Executable file
115
gaia/dev_apps/music-oga/js/metadata/formats.js
Executable file
|
|
@ -0,0 +1,115 @@
|
|||
/* global FLACMetadata, ForwardLockMetadata, ID3v1Metadata, ID3v2Metadata,
|
||||
LazyLoader, MP4Metadata, OggMetadata */
|
||||
/* exported MetadataFormats */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Delegates metadata parsing to the appropriate parser based on magic header
|
||||
* values.
|
||||
*/
|
||||
var MetadataFormats = (function() {
|
||||
|
||||
/*
|
||||
* This is the list of formats that we know how to parse. Each format has
|
||||
* three properties:
|
||||
*
|
||||
* @property {String} file The path to the file for parsing this metadata
|
||||
* format.
|
||||
* @property {Object} module A getter that returns the module object for this
|
||||
* parser. Note: It *must* be a getter because it needs to be evaluated
|
||||
* *after* the file is loaded.
|
||||
* @property {Function} match A function that takes a BlobView of the file
|
||||
* and returns true if the file uses this metadata format.
|
||||
*/
|
||||
var formats = [
|
||||
{
|
||||
file: 'js/metadata/forward_lock.js',
|
||||
get module() { return ForwardLockMetadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(0, 9) === 'LOCKED 1 ';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/id3v2.js',
|
||||
get module() { return ID3v2Metadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(0, 3) === 'ID3';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/ogg.js',
|
||||
get module() { return OggMetadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(0, 4) === 'OggS';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/flac.js',
|
||||
get module() { return FLACMetadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(0, 4) === 'fLaC';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/mp4.js',
|
||||
get module() { return MP4Metadata; },
|
||||
match: function(header) {
|
||||
return header.getBinaryText(4, 4) === 'ftyp';
|
||||
}
|
||||
},
|
||||
{
|
||||
file: 'js/metadata/id3v1.js',
|
||||
get module() { return ID3v1Metadata; },
|
||||
match: function(header) {
|
||||
return (header.getUint16(0, false) & 0xFFFE) === 0xFFFA;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a new metadata parser for a particular file format.
|
||||
*
|
||||
* @param {Object} formatInfo A description of the file format containing a
|
||||
* `file` attribute for the file to load, and a `module` attribute returning
|
||||
* the module that contains the parse() method we should call.
|
||||
*/
|
||||
function MetadataParser(formatInfo) {
|
||||
this._formatInfo = formatInfo;
|
||||
}
|
||||
|
||||
MetadataParser.prototype = {
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} header The file in question.
|
||||
* @return {Promise} A Promise that resolves with the completed metadata
|
||||
* object.
|
||||
*/
|
||||
parse: function(header) {
|
||||
var info = this._formatInfo;
|
||||
return LazyLoader.load(info.file).then(() => {
|
||||
return info.module.parse(header);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the appropriate metadata parser for a given file.
|
||||
*
|
||||
* @param {BlobView} header The file in question.
|
||||
* @return {MetadataParser} The metadata parser to use for this file.
|
||||
*/
|
||||
function findParser(header) {
|
||||
for (var i = 0; i < formats.length; i++) {
|
||||
if (formats[i].match(header)) {
|
||||
return new MetadataParser(formats[i]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
findParser: findParser
|
||||
};
|
||||
|
||||
})();
|
||||
56
gaia/dev_apps/music-oga/js/metadata/forward_lock.js
Executable file
56
gaia/dev_apps/music-oga/js/metadata/forward_lock.js
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
/* global AudioMetadata, ForwardLock */
|
||||
/* exported ForwardLockMetadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse metadata for ForwardLocked files. Generally, this will forward on to
|
||||
* the metadata parser for the underlying format.
|
||||
*/
|
||||
var ForwardLockMetadata = (function() {
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} locked The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(locked) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
ForwardLock.getKey(function(secret) {
|
||||
ForwardLock.unlockBlob(secret, locked.blob, callback, reject);
|
||||
|
||||
function callback(unlocked, unlockedMetadata) {
|
||||
// Now that we have the unlocked content of the locked file, pass it
|
||||
// back to AudioMetadata.parse to get the metadata. When we're done,
|
||||
// add some extra metadata to indicate that this is locked content (so
|
||||
// it isn't shared) and to specify the vendor that locked it.
|
||||
resolve(AudioMetadata.parse(unlocked).then(function(metadata) {
|
||||
metadata.locked = true;
|
||||
if (unlockedMetadata.vendor) {
|
||||
metadata.vendor = unlockedMetadata.vendor;
|
||||
}
|
||||
if (!metadata.title) {
|
||||
metadata.title = unlockedMetadata.name;
|
||||
}
|
||||
if (metadata.picture && metadata.picture.flavor === 'embedded') {
|
||||
// Grab the slice for the embedded album art while we still have
|
||||
// the decrypted blob to look at!
|
||||
metadata.picture = {
|
||||
flavor: 'unsynced',
|
||||
blob: unlocked.slice(metadata.picture.start,
|
||||
metadata.picture.end,
|
||||
metadata.picture.type)
|
||||
};
|
||||
}
|
||||
return metadata;
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
|
||||
})();
|
||||
79
gaia/dev_apps/music-oga/js/metadata/id3v1.js
Executable file
79
gaia/dev_apps/music-oga/js/metadata/id3v1.js
Executable file
|
|
@ -0,0 +1,79 @@
|
|||
/* global BlobView */
|
||||
/* exported ID3v1Metadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse files with ID3v1 metadata. Metadata includes title, artist, album,
|
||||
* and possibly the track number. Year, comment and genre are ignored.
|
||||
*
|
||||
* Format information:
|
||||
* http://www.id3.org/ID3v1
|
||||
* http://en.wikipedia.org/wiki/ID3
|
||||
*/
|
||||
var ID3v1Metadata = (function() {
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var blob = blobview.blob;
|
||||
BlobView.get(blob, blob.size - 128, 128, function(footer, error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var magic = footer.getBinaryText(0, 3);
|
||||
if (magic === 'TAG') {
|
||||
// It's an MP3 file with an ID3v1 tag.
|
||||
resolve(parseID3v1Metadata(footer));
|
||||
} else {
|
||||
// It's an MP3 file with no metadata. Just resolve to an empty
|
||||
// metatada object.
|
||||
resolve({});
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ID3v1 metadata from the 128 bytes footer at the end of a file.
|
||||
*
|
||||
* @param {BlobView} footer The last 128 bytes of the file.
|
||||
* @return {Metadata} The parsed metadata object.
|
||||
*/
|
||||
function parseID3v1Metadata(footer) {
|
||||
footer.seek(3); // Skip the "TAG" prefix.
|
||||
var title = footer.readNullTerminatedLatin1Text(30, true);
|
||||
var artist = footer.readNullTerminatedLatin1Text(30, true);
|
||||
var album = footer.readNullTerminatedLatin1Text(30, true);
|
||||
|
||||
footer.advance(32); // Skip year and comment.
|
||||
var zerobyte = footer.readUnsignedByte();
|
||||
var track = footer.readUnsignedByte();
|
||||
|
||||
var metadata = {
|
||||
tag_format: 'id3v1',
|
||||
title: title || undefined,
|
||||
artist: artist || undefined,
|
||||
album: album || undefined
|
||||
};
|
||||
if (zerobyte === 0 && track !== 0) {
|
||||
metadata.tracknum = track;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
|
||||
})();
|
||||
452
gaia/dev_apps/music-oga/js/metadata/id3v2.js
Executable file
452
gaia/dev_apps/music-oga/js/metadata/id3v2.js
Executable file
|
|
@ -0,0 +1,452 @@
|
|||
/* global BlobView, ID3v1Metadata, LazyLoader */
|
||||
/* exported ID3v2Metadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse files with ID3v2 metadata.
|
||||
*
|
||||
* Format information:
|
||||
* http://id3.org/Developer%20Information
|
||||
*/
|
||||
var ID3v2Metadata = (function() {
|
||||
// Map id3v2 frame ids to metadata property names. Each line has two mappings:
|
||||
// one for id3v2.3+ and one for id3v2.2.
|
||||
var ID3V2FRAMES = {
|
||||
TPE1: 'artist', TP1: 'artist',
|
||||
TALB: 'album', TAL: 'album',
|
||||
TIT2: 'title', TT2: 'title',
|
||||
TRCK: 'tracknum', TRK: 'tracknum',
|
||||
TPOS: 'discnum', TPA: 'discnum',
|
||||
APIC: 'picture', PIC: 'picture'
|
||||
};
|
||||
var ID3V2SECONDARYFRAMES = {
|
||||
TRCK: 'trackcount', TRK: 'trackcount',
|
||||
TPOS: 'disccount', TPA: 'disccount'
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
return new Promise((resolve, reject) => {
|
||||
var header = parseHeader(blobview);
|
||||
if (header.version > 4) {
|
||||
console.warn('mp3 file with unknown metadata version');
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
blobview.getMore(blobview.index, header.length, (moreview, err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(parseFrames(header, moreview));
|
||||
} catch(e) {
|
||||
console.warn('Invalid ID3v2 data for', blobview.blob.name, ':', e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}).catch((e) => {
|
||||
return LazyLoader.load('js/metadata/id3v1.js').then(() => {
|
||||
return ID3v1Metadata.parse(blobview);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the header of an ID3 tag.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Object} An object describing the header's values.
|
||||
*/
|
||||
function parseHeader(blobview) {
|
||||
// First three bytes are "ID3" or we wouldn't be here
|
||||
blobview.seek(3);
|
||||
|
||||
var header = {
|
||||
get versionString() {
|
||||
return 'id3v2.' + this.version + '.' + this.revision;
|
||||
},
|
||||
|
||||
get tag_unsynchronized() {
|
||||
return (this.flags & 0x80) !== 0;
|
||||
},
|
||||
|
||||
get has_extended_header() {
|
||||
return (this.flags & 0x40) !== 0;
|
||||
}
|
||||
};
|
||||
|
||||
header.version = blobview.readUnsignedByte();
|
||||
header.revision = blobview.readUnsignedByte();
|
||||
|
||||
header.flags = blobview.readUnsignedByte();
|
||||
header.length = blobview.readID3Uint28BE();
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all the ID3 frames in this tag.
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Object} The metadata extracted from the ID3 tag.
|
||||
*/
|
||||
function parseFrames(header, blobview) {
|
||||
var metadata = {};
|
||||
metadata.tag_format = header.versionString;
|
||||
|
||||
// In id3v2.3, the "unsynchronized" flag in the tag header applies to
|
||||
// the whole tag (excluding the tag header). In id3v2.4, the flag is just
|
||||
// an indicator that we should expect to see the "unsynchronized" flag
|
||||
// set on all the frames.
|
||||
if (header.tag_unsynchronized && header.version === 3) {
|
||||
blobview = deunsync(blobview, header.length);
|
||||
}
|
||||
|
||||
// Skip the extended header, if there is one.
|
||||
if (header.has_extended_header) {
|
||||
var extended_header_size;
|
||||
if (header.version === 4) {
|
||||
// In id3v2.4, the size includes itself, i.e. the rest of the header
|
||||
// is |extended_header_size - 4|.
|
||||
extended_header_size = blobview.readID3Uint28BE() - 4;
|
||||
} else { // id3version === 3
|
||||
// In id3v2.3, the size *excludes* itself, i.e. the rest of the header
|
||||
// is |extended_header_size|.
|
||||
extended_header_size = blobview.readUnsignedInt();
|
||||
}
|
||||
blobview.advance(extended_header_size);
|
||||
}
|
||||
|
||||
while (blobview.index < blobview.byteLength) {
|
||||
// If there is a null byte here, then we've found padding and we're done.
|
||||
if (blobview.getUint8(blobview.index) === 0) {
|
||||
break;
|
||||
}
|
||||
parseFrame(header, blobview, metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an individual ID3 frame.
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} blobview The audio file being parsed.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function parseFrame(header, blobview, metadata) {
|
||||
var frameid, framesize, frame_format_flags, frame_unsynchronized = false,
|
||||
data_len_indicator = false, unsupported_flags = false;
|
||||
|
||||
switch (header.version) {
|
||||
case 2:
|
||||
frameid = blobview.readBinaryText(3);
|
||||
framesize = blobview.readUint24();
|
||||
break;
|
||||
case 3:
|
||||
frameid = blobview.readBinaryText(4);
|
||||
framesize = blobview.readUnsignedInt();
|
||||
// Ignore the frame status flags, since they only apply when modifying the
|
||||
// file/tag.
|
||||
blobview.readUnsignedByte();
|
||||
frame_format_flags = blobview.readUnsignedByte();
|
||||
|
||||
unsupported_flags = (frame_format_flags !== 0);
|
||||
break;
|
||||
case 4:
|
||||
frameid = blobview.readBinaryText(4);
|
||||
framesize = blobview.readID3Uint28BE();
|
||||
blobview.readUnsignedByte(); // As above, ignore the frame status flags.
|
||||
frame_format_flags = blobview.readUnsignedByte();
|
||||
|
||||
data_len_indicator = ((frame_format_flags & 0b00000001) !== 0);
|
||||
frame_unsynchronized = ((frame_format_flags & 0b00000010) !== 0);
|
||||
unsupported_flags = ((frame_format_flags & 0b11111100) !== 0);
|
||||
break;
|
||||
}
|
||||
|
||||
var nextframe = blobview.index + framesize;
|
||||
var propname = ID3V2FRAMES[frameid];
|
||||
|
||||
// Skip frames we don't care about.
|
||||
if (!propname) {
|
||||
blobview.seek(nextframe);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip frames with flags we don't understand.
|
||||
if (unsupported_flags) {
|
||||
console.warn('Skipping', frameid, 'frame with unsupported format flags',
|
||||
'0b' + frame_format_flags.toString(2));
|
||||
blobview.seek(nextframe);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var frameview, framevalue, data_len;
|
||||
|
||||
if (data_len_indicator) {
|
||||
data_len = blobview.readID3Uint28BE();
|
||||
framesize -= 4;
|
||||
}
|
||||
|
||||
if (frame_unsynchronized) {
|
||||
frameview = deunsync(blobview, framesize);
|
||||
framesize = frameview.sliceLength;
|
||||
if (data_len !== undefined && framesize != data_len) {
|
||||
console.warn('de-unsynced frame size (' + framesize + ') doesn\'t' +
|
||||
'match data length indicator (' + data_len + ')');
|
||||
}
|
||||
} else {
|
||||
frameview = blobview;
|
||||
}
|
||||
|
||||
switch (frameid) {
|
||||
case 'TPE1': case 'TP1': // artist
|
||||
case 'TALB': case 'TAL': // album
|
||||
case 'TIT2': case 'TT2': // title
|
||||
framevalue = readTextFrame(header, frameview, framesize);
|
||||
break;
|
||||
case 'TRCK': case 'TRK': // track
|
||||
case 'TPOS': case 'TPA': // disc
|
||||
framevalue = readNumericPairFrame(header, frameview, framesize);
|
||||
if (framevalue[1] !== null) {
|
||||
metadata[ID3V2SECONDARYFRAMES[frameid]] = framevalue[1];
|
||||
}
|
||||
framevalue = framevalue[0];
|
||||
break;
|
||||
case 'APIC': case 'PIC': // picture
|
||||
framevalue = readPicFrame(header, frameview, framesize);
|
||||
break;
|
||||
default:
|
||||
console.error('Should not have gotten here');
|
||||
}
|
||||
|
||||
if (framevalue !== null) {
|
||||
metadata[propname] = framevalue;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error parsing mp3 metadata frame', frameid, ':', e);
|
||||
}
|
||||
|
||||
// Make sure we're at the start of the next frame before continuing
|
||||
blobview.seek(nextframe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the ID3 unsynchronization so that we get the unescaped data. This
|
||||
* will advance the view to the end of the unsynchronized block so that you
|
||||
* can continue to use the original view once you're finished with the
|
||||
* deunsynced view.
|
||||
*
|
||||
* @param {BlobView} view The original BlobView for the ID3 tag.
|
||||
* @param {Number} framesize The size of the unsynchronized frame.
|
||||
* @return {BlobView} A new BlobView with the deunsynced data.
|
||||
*/
|
||||
function deunsync(view, framesize) {
|
||||
// To de-unsychronize a frame, we need to convert all instances of
|
||||
// |0xff 00| to |0xff|.
|
||||
var data = new Uint8Array(framesize), was0xff = false, dataIndex = 0;
|
||||
for (var i = 0; i < framesize; i++) {
|
||||
var b = view.readUnsignedByte();
|
||||
if (was0xff && b === 0x00) {
|
||||
continue;
|
||||
}
|
||||
was0xff = (b === 0xff);
|
||||
data[dataIndex++] = b;
|
||||
}
|
||||
|
||||
// Create a new BlobView with the de-unsynchronized data.
|
||||
var deunsynced_view = new BlobView.getFromArrayBuffer(
|
||||
data.buffer, 0, dataIndex, view.littleEndian
|
||||
);
|
||||
deunsynced_view.deunsynced = true;
|
||||
return deunsynced_view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from a textual ID3 frame (one whose name starts with "T",
|
||||
* and isn't "TXXX").
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Number} encoding The encoding for this string (0: latin-1,
|
||||
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
||||
* the first byte of the value.
|
||||
* @param {String} The value of this frame.
|
||||
*/
|
||||
function readTextFrame(header, view, size, encoding) {
|
||||
if (encoding === undefined) {
|
||||
encoding = view.readUnsignedByte();
|
||||
size = size - 1;
|
||||
}
|
||||
if (header.version === 4) {
|
||||
// In id3v2.4, all text frames can have multiple values, separated by
|
||||
// the null character. For now, we join them together to make one big
|
||||
// string.
|
||||
return readEncodedTextArray(view, size, encoding);
|
||||
} else {
|
||||
// In id3v2.3, some text frames can have multiple values, separated by
|
||||
// '/', so we don't need to do anything special here. In fact, even if
|
||||
// we start treating multi-value text frames as an array, we probably
|
||||
// shouldn't do anything, since '/' is a bad delimiter.
|
||||
return readEncodedText(view, size, encoding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from a numeric pair frame (track number or disc number).
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Number} encoding The encoding for this string (0: latin-1,
|
||||
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
||||
* the first byte of the value.
|
||||
* @param {Array} The value of this frame, as two Numbers.
|
||||
*/
|
||||
function readNumericPairFrame(header, view, size, encoding) {
|
||||
var text = readTextFrame(header, view, size, encoding);
|
||||
var pair = text.split('/', 2).map(function(val) {
|
||||
var i = parseInt(val, 10);
|
||||
return isNaN(i) || !isFinite(i) ? null : i;
|
||||
});
|
||||
if (pair.length === 1) {
|
||||
pair.push(null);
|
||||
}
|
||||
return pair;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from a picture frame.
|
||||
*
|
||||
* @param {Object} header The header object for this ID3 tag.
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Picture} The value of this frame.
|
||||
*/
|
||||
function readPicFrame(header, view, size) {
|
||||
var start = view.index;
|
||||
var encoding = view.readUnsignedByte();
|
||||
var mimetype;
|
||||
// mimetype is different for old PIC frames and new APIC frames
|
||||
if (header.version === 2) {
|
||||
mimetype = view.readLatin1Text(3);
|
||||
if (mimetype === 'JPG') {
|
||||
mimetype = 'image/jpeg';
|
||||
} else if (mimetype === 'PNG') {
|
||||
mimetype = 'image/png';
|
||||
}
|
||||
} else {
|
||||
mimetype = view.readNullTerminatedLatin1Text(size - 1);
|
||||
if (mimetype === '-->') {
|
||||
// XXX: We don't support URLs (the spec recommends against them anyway).
|
||||
console.error('URL for cover art is not supported.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// We ignore these next two fields.
|
||||
view.readUnsignedByte(); // kind
|
||||
readEncodedText(view, size - (view.index - start), encoding); // desc
|
||||
|
||||
var picstart = view.sliceOffset + view.viewOffset + view.index;
|
||||
var picsize = size - (view.index - start);
|
||||
var picend = picstart + picsize;
|
||||
|
||||
if (view.deunsynced) {
|
||||
return {
|
||||
flavor: 'unsynced',
|
||||
blob: new Blob(
|
||||
[view.buffer.slice(picstart, picend)],
|
||||
{type: mimetype}
|
||||
)
|
||||
};
|
||||
} else {
|
||||
// Now return an object that specifies where to pull the image from
|
||||
// The properties of this object can be passed to blob.slice()
|
||||
return {
|
||||
flavor: 'embedded',
|
||||
start: picstart,
|
||||
end: picend,
|
||||
type: mimetype
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an array of strings from a frame.
|
||||
*
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Number} encoding The encoding for this string (0: latin-1,
|
||||
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
||||
* the first byte of the value.
|
||||
* @param {String} The strings, joined by " / ".
|
||||
*/
|
||||
function readEncodedTextArray(view, size, encoding) {
|
||||
var text;
|
||||
switch (encoding) {
|
||||
case 0:
|
||||
text = view.readLatin1Text(size);
|
||||
break;
|
||||
case 1:
|
||||
text = view.readUTF16Text(size, undefined);
|
||||
break;
|
||||
case 2:
|
||||
text = view.readUTF16Text(size, false);
|
||||
break;
|
||||
case 3:
|
||||
text = view.readUTF8Text(size);
|
||||
break;
|
||||
default:
|
||||
throw Error('unknown text encoding');
|
||||
}
|
||||
|
||||
// Remove any trailing nulls and replace nulls in the middle with ' / '.
|
||||
// XXX: When the database supports arrays of values for each field, we
|
||||
// should split this into a proper array!
|
||||
return text.replace(/\0+$/, '').replace('\0', ' / ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single string from a frame.
|
||||
*
|
||||
* @param {BlobView} view The audio file being parsed.
|
||||
* @param {Number} size The size in bytes of this frame's value.
|
||||
* @param {Number} encoding The encoding for this string (0: latin-1,
|
||||
* 1: utf16, 2: utf16-be, 3: utf-8). If null, determine the encoding from
|
||||
* the first byte of the value.
|
||||
* @param {String} The string.
|
||||
*/
|
||||
function readEncodedText(view, size, encoding) {
|
||||
switch (encoding) {
|
||||
case 0:
|
||||
return view.readNullTerminatedLatin1Text(size);
|
||||
case 1:
|
||||
return view.readNullTerminatedUTF16Text(size, undefined);
|
||||
case 2:
|
||||
return view.readNullTerminatedUTF16Text(size, false);
|
||||
case 3:
|
||||
return view.readNullTerminatedUTF8Text(size);
|
||||
default:
|
||||
throw Error('unknown text encoding');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
|
||||
})();
|
||||
421
gaia/dev_apps/music-oga/js/metadata/mp4.js
Executable file
421
gaia/dev_apps/music-oga/js/metadata/mp4.js
Executable file
|
|
@ -0,0 +1,421 @@
|
|||
/* exported MP4Metadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse files with MP4 metadata.
|
||||
*
|
||||
* Format information:
|
||||
* https://developer.apple.com/library/mac/#documentation/QuickTime/QTFF/
|
||||
* QTFFChap1/qtff1.html
|
||||
* http://en.wikipedia.org/wiki/MPEG-4_Part_14
|
||||
* http://atomicparsley.sourceforge.net/mpeg-4files.html
|
||||
*/
|
||||
var MP4Metadata = (function() {
|
||||
// Map MP4 atom names to metadata property names
|
||||
var MP4ATOMS = {
|
||||
'\xa9alb' : 'album',
|
||||
'\xa9art' : 'artist',
|
||||
'\xa9ART' : 'artist',
|
||||
'aART' : 'artist',
|
||||
'\xa9nam' : 'title',
|
||||
'trkn' : 'tracknum',
|
||||
'disk' : 'discnum',
|
||||
'covr' : 'picture'
|
||||
};
|
||||
|
||||
// These MP4 atoms are stored as pairs of integers, so they get mapped to
|
||||
// two metadata fields
|
||||
var MP4INTPAIRATOMS = {
|
||||
'trkn': 'trackcount',
|
||||
'disk': 'disccount'
|
||||
};
|
||||
|
||||
// These are 'ftyp' values that we recognize
|
||||
// See http://www.mp4ra.org/filetype.html
|
||||
// Also see gecko code in /toolkit/components/mediasniffer/nsMediaSniffer.cpp
|
||||
// Gaia will accept the supported compatible brands in gecko as well
|
||||
var MP4Types = {
|
||||
'M4A ' : true, // iTunes audio. Note space in property name.
|
||||
'M4B ' : true, // iTunes audio book. Note space.
|
||||
'mp41' : true, // MP4 version 1
|
||||
'mp42' : true, // MP4 version 2
|
||||
'isom' : true, // ISO base media file format, version 1
|
||||
'iso2' : true // ISO base media file format, version 2
|
||||
};
|
||||
|
||||
// MP4 and 3GP containers both use ISO base media file format.
|
||||
// Also see what audio codecs/formats are supported in 3GPP specification.
|
||||
// Format information:
|
||||
// https://en.wikipedia.org/wiki/ISO_base_media_file_format
|
||||
// http://tools.ietf.org/html/rfc6381
|
||||
// http://www.3gpp.org/ftp/Specs/html-info/26244.htm
|
||||
//
|
||||
var MP4Codecs = {
|
||||
'mp4a' : true, // MPEG-4 audio
|
||||
'samr' : true, // AMR narrow-band speech
|
||||
'sawb' : true, // AMR wide-band speech
|
||||
'sawp' : true // Extended AMR wide-band audio
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
// The MP4 file might be a video or it might be some kind of audio that we
|
||||
// don't support.
|
||||
if (!checkMP4Type(blobview, MP4Types)) {
|
||||
return Promise.reject(new Error('Unknown MP4 file type'));
|
||||
}
|
||||
|
||||
return findMoovAtom(blobview).then((result) => {
|
||||
var metadata = {tag_format: 'mp4'};
|
||||
if (!result) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return parseMoovAtom(result.atom, result.size, metadata);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the type of MP4 file to ensure we should play it. (MP4 files use
|
||||
* 'ftyp' to identify the type of encoding. For more information, see:
|
||||
* <http://www.ftyps.com/what.html>.)
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @param {Object} types A list of types we support.
|
||||
* @return {Boolean} True if the file should be parsed, false otherwise.
|
||||
*/
|
||||
function checkMP4Type(blobview, types) {
|
||||
// The major brand is the four bytes right after 'ftyp'.
|
||||
var majorbrand = blobview.getBinaryText(8, 4);
|
||||
|
||||
if (majorbrand in types) {
|
||||
return true;
|
||||
} else {
|
||||
// Check the rest for the compatible brands. They are every four bytes
|
||||
// after the version of major brand. Usually there are two optional
|
||||
// compatible brands, but an arbitrary number of other compatible brands
|
||||
// are also acceptable, so we will check all the compatible brands until
|
||||
// the header ends.
|
||||
var index = 16;
|
||||
var size = blobview.getUint32(0);
|
||||
|
||||
while (index < size) {
|
||||
var compatiblebrand = blobview.getBinaryText(index, 4);
|
||||
index += 4;
|
||||
if (compatiblebrand in types) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the "moov" atom in the MP4 container.
|
||||
*
|
||||
* @param {BlobView} atom The file we're parsing.
|
||||
* @return {Promise} A promise that resolves when we've found the move atom.
|
||||
*/
|
||||
function findMoovAtom(atom) {
|
||||
// XXX: I think I could probably restructure this somehow. The atoms or
|
||||
// "boxes" we're reading and parsing here for a tree that I need to
|
||||
// traverse. Maybe nextBox() and firstChildBox() functions would be
|
||||
// helpful. Or even make these methods of BlobView? Not sure if it is worth
|
||||
// the time to refactor, though... See also the approach in
|
||||
// shared/js/get_video_rotation.js
|
||||
return new Promise(function(resolve, reject) {
|
||||
var offset = atom.sliceOffset + atom.viewOffset; // position in blob
|
||||
var size = atom.readUnsignedInt();
|
||||
var type = atom.readBinaryText(4);
|
||||
|
||||
if (size === 0) {
|
||||
// A size of 0 means the rest of the file
|
||||
size = atom.blob.size - offset;
|
||||
} else if (size === 1) {
|
||||
// A size of 1 means the size is in bytes 8-15
|
||||
size = atom.readUnsignedInt() * 4294967296 + atom.readUnsignedInt();
|
||||
}
|
||||
|
||||
if (type === 'moov') {
|
||||
// Get the full contents of this atom
|
||||
atom.getMore(offset, size, function(moov) {
|
||||
resolve({atom: moov, size: size});
|
||||
});
|
||||
} else {
|
||||
// Otherwise, get the start of the next atom and recurse
|
||||
// to continue the search for the moov atom.
|
||||
// If we're reached the end of the blob without finding
|
||||
// anything, just call the metadata callback with no metadata
|
||||
if (offset + size + 16 <= atom.blob.size) {
|
||||
atom.getMore(offset + size, 16, function(moov) {
|
||||
try {
|
||||
findMoovAtom(moov).then(function(result) {
|
||||
resolve(result);
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the moov atom. This function, and the ones that follow are all
|
||||
* synchronous. We've read the entire moov atom, so we've got all the bytes
|
||||
* we need and don't have to do an async read again.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
* @return {Metadata} The completed metadata object.
|
||||
*/
|
||||
function parseMoovAtom(data, end, metadata) {
|
||||
data.advance(8); // skip the size and type of this atom
|
||||
|
||||
// Find the udta and trak atoms within the moov atom
|
||||
// There will only be one udta atom, but there may be multiple trak
|
||||
// atoms. In that case, this is probably a movie file and we'll reject
|
||||
// it when we find a track that is not an mp4 audio codec.
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
var nextindex = data.index + size - 8;
|
||||
if (type === 'udta') { // Metadata is inside here
|
||||
parseUdtaAtom(data, end, metadata);
|
||||
} else if (type === 'trak') { // We find the audio format inside here
|
||||
data.advance(-8); // skip back to beginning
|
||||
if (seekChildAtoms(data, ['mdia', 'minf'])) {
|
||||
if (searchChildAtom(data, 'vmhd')) {
|
||||
throw Error('Found video track in MP4 container');
|
||||
}
|
||||
if (searchChildAtom(data, 'smhd')) {
|
||||
if (seekChildAtoms(data, ['stbl', 'stsd'])) {
|
||||
data.advance(20);
|
||||
var codec = data.readBinaryText(4);
|
||||
if (!(codec in MP4Codecs)) {
|
||||
throw Error('Unsupported format in MP4 container: ' + codec);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// There is not enough information for us to identify the MP4
|
||||
throw Error('Not enough metadata in MP4 container!');
|
||||
}
|
||||
}
|
||||
data.seek(nextindex);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the specified hierarchy of atoms and stop at the last one's file
|
||||
* offset.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Array} atoms The names of the atoms to find.
|
||||
* @return {Boolean} True if we found the atoms, false otherwise.
|
||||
*/
|
||||
function seekChildAtoms(data, atoms) {
|
||||
return atoms.every(function(atom) {
|
||||
return seekChildAtom(data, atom);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a child atom and stop at its file offset.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {String} atom The name of the atom to find.
|
||||
* @return {Boolean} True if we found the atom, false otherwise.
|
||||
*/
|
||||
function seekChildAtom(data, atom) {
|
||||
var start = data.index;
|
||||
var length = data.readUnsignedInt();
|
||||
data.advance(4);
|
||||
|
||||
while (data.index < start + length) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
if (type === atom) {
|
||||
data.advance(-8);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
data.advance(size - 8);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a child atom, but reset the file offset to the start after completion.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {String} atom The name of the atom to find.
|
||||
* @return {Boolean} True if we found the atom, false otherwise.
|
||||
*/
|
||||
function searchChildAtom(data, atom) {
|
||||
var start = data.index;
|
||||
var result = seekChildAtom(data, atom);
|
||||
data.seek(start);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the udta atom.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function parseUdtaAtom(data, end, metadata) {
|
||||
// Find the meta atom within the udta atom
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
if (type === 'meta') {
|
||||
parseMetaAtom(data, data.index + size - 8, metadata);
|
||||
data.seek(end);
|
||||
return;
|
||||
} else {
|
||||
data.advance(size - 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the meta atom.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function parseMetaAtom(data, end, metadata) {
|
||||
// The meta atom apparently has a slightly different structure.
|
||||
// Have to skip flag bytes before reading children atoms
|
||||
data.advance(4);
|
||||
|
||||
// Find the ilst atom within the meta atom
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
if (type === 'ilst') {
|
||||
parseIlstAtom(data, data.index + size - 8, metadata);
|
||||
data.seek(end);
|
||||
return;
|
||||
}
|
||||
else {
|
||||
data.advance(size - 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the ilst atom, which (finally) contains all our metadata atom.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function parseIlstAtom(data, end, metadata) {
|
||||
// Now read all child atoms of ilst, looking for metadata
|
||||
// we care about
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
var next = data.index + size - 8;
|
||||
var propname = MP4ATOMS[type];
|
||||
if (propname) {
|
||||
try {
|
||||
var value = getMetadataValue(data, next, type);
|
||||
// Track number and disc number are of the form "x/y".
|
||||
if (type in MP4INTPAIRATOMS) {
|
||||
metadata[propname] = value.number;
|
||||
if (value.count) {
|
||||
metadata[MP4INTPAIRATOMS[type]] = value.count;
|
||||
}
|
||||
} else {
|
||||
metadata[propname] = value;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('skipping', type, ':', e);
|
||||
}
|
||||
}
|
||||
data.seek(next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a single metadata atom.
|
||||
*
|
||||
* @param {BlobView} data The data for the moov atom.
|
||||
* @param {Number} end The offset at which the moov atom ends.
|
||||
* @param {String} atomtype The name of this atom.
|
||||
* @return {Object} The parsed value.
|
||||
*/
|
||||
function getMetadataValue(data, end, atomtype) {
|
||||
// Loop until we find a data atom
|
||||
while (data.index < end) {
|
||||
var size = data.readUnsignedInt();
|
||||
var type = data.readBinaryText(4);
|
||||
if (type !== 'data') {
|
||||
data.advance(size - 8);
|
||||
continue;
|
||||
}
|
||||
|
||||
// We've found the data atom.
|
||||
// Return its (first) value or throw an error.
|
||||
var datatype = data.readUnsignedInt() & 0xFFFFFF;
|
||||
data.advance(4); // Ignore locale
|
||||
|
||||
var datasize = size - 16; // the rest of the atom is the value
|
||||
|
||||
// Special case for track number and disk number
|
||||
// They have two values: number and count.
|
||||
if (atomtype in MP4INTPAIRATOMS) {
|
||||
data.advance(2);
|
||||
var number = data.readUnsignedShort();
|
||||
var count = data.readUnsignedShort();
|
||||
return { number: number, count: count };
|
||||
}
|
||||
|
||||
switch (datatype) {
|
||||
case 1: // utf8 text
|
||||
return data.readUTF8Text(datasize);
|
||||
case 13: // jpeg
|
||||
return {
|
||||
flavor: 'embedded',
|
||||
start: data.sliceOffset + data.viewOffset + data.index,
|
||||
end: data.sliceOffset + data.viewOffset + data.index + datasize,
|
||||
type: 'image/jpeg'
|
||||
};
|
||||
case 14: // png
|
||||
return {
|
||||
flavor: 'embedded',
|
||||
start: data.sliceOffset + data.viewOffset + data.index,
|
||||
end: data.sliceOffset + data.viewOffset + data.index + datasize,
|
||||
type: 'image/png'
|
||||
};
|
||||
default:
|
||||
throw Error('unexpected type in data atom');
|
||||
}
|
||||
}
|
||||
throw Error('no data atom found');
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
|
||||
})();
|
||||
205
gaia/dev_apps/music-oga/js/metadata/ogg.js
Executable file
205
gaia/dev_apps/music-oga/js/metadata/ogg.js
Executable file
|
|
@ -0,0 +1,205 @@
|
|||
/* global LazyLoader, VorbisPictureComment */
|
||||
/* exported OggMetadata */
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Parse files with Ogg metadata (e.g. Vorbis comments or Opus tags).
|
||||
*
|
||||
* Format information:
|
||||
* http://wiki.xiph.org/VorbisComment
|
||||
*/
|
||||
var OggMetadata = (function() {
|
||||
// Fields that should be stored as integers, not strings
|
||||
var INTFIELDS = [
|
||||
'tracknum', 'trackcount', 'discnum', 'disccount'
|
||||
];
|
||||
|
||||
// Map ogg field names to metadata property names
|
||||
var OGGFIELDS = {
|
||||
title: 'title',
|
||||
artist: 'artist',
|
||||
album: 'album',
|
||||
tracknumber: 'tracknum',
|
||||
tracktotal: 'trackcount',
|
||||
discnumber: 'discnum',
|
||||
disctotal: 'disccount',
|
||||
metadata_block_picture: 'picture'
|
||||
};
|
||||
|
||||
/**
|
||||
* A type thrown if we walk off the end of an Ogg page.
|
||||
*/
|
||||
function EndOfPageError() {}
|
||||
|
||||
/**
|
||||
* Parse a file and return a Promise with the metadata.
|
||||
*
|
||||
* @param {BlobView} blobview The audio file to parse.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
* @return {Promise} A Promise returning the parsed metadata object.
|
||||
*/
|
||||
function parse(blobview) {
|
||||
readIdentificationHeader(blobview);
|
||||
return readCommentHeader(blobview);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the identification header of an Ogg container. It's always the first
|
||||
* page.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
*/
|
||||
function readIdentificationHeader(page) {
|
||||
var header = readPageHeader(page);
|
||||
if (header.segment_table.length !== 1) {
|
||||
throw new Error(
|
||||
'ogg identification header expected as only packet of first page'
|
||||
);
|
||||
}
|
||||
// Skip over the identification header.
|
||||
page.advance(header.segment_table[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the comment header of an Ogg container.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
* @return {Promise} A Promise that resolves with the completed metadata.
|
||||
*/
|
||||
function readCommentHeader(page) {
|
||||
var metadata = {};
|
||||
var header = readPageHeader(page);
|
||||
|
||||
var sum = function(a, b) { return a + b; };
|
||||
var comment_length = Array.reduce(header.segment_table, sum, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
page.getMore(page.index, comment_length, (fullpage, err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Look for a comment header from a supported codec
|
||||
var first_byte = fullpage.readByte();
|
||||
var valid = false;
|
||||
switch (first_byte) {
|
||||
case 3:
|
||||
valid = fullpage.readBinaryText(6) === 'vorbis';
|
||||
metadata.tag_format = 'vorbis';
|
||||
break;
|
||||
case 79:
|
||||
valid = fullpage.readBinaryText(7) === 'pusTags';
|
||||
metadata.tag_format = 'opus';
|
||||
break;
|
||||
}
|
||||
if (!valid) {
|
||||
reject('malformed ogg comment packet');
|
||||
return;
|
||||
}
|
||||
|
||||
readAllComments(fullpage, metadata);
|
||||
|
||||
LazyLoader.load('js/metadata/vorbis_picture.js').then(() => {
|
||||
return VorbisPictureComment.parsePictureComment(metadata);
|
||||
}).then(resolve);
|
||||
} catch(e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the header for an Ogg page.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
* @return {Object} An object containing the page's segment table.
|
||||
*/
|
||||
function readPageHeader(page) {
|
||||
var capture_pattern = page.readBinaryText(4);
|
||||
if (capture_pattern !== 'OggS') {
|
||||
throw new Error('malformed ogg page header');
|
||||
}
|
||||
|
||||
// Skip over some header fields until we reach the page segments.
|
||||
page.advance(22);
|
||||
|
||||
var page_segments = page.readUnsignedByte();
|
||||
var segment_table = page.readUnsignedByteArray(page_segments);
|
||||
|
||||
return {
|
||||
segment_table: segment_table
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all the comments in an Ogg container.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
* @param {Metadata} metadata The (partially filled-in) metadata object.
|
||||
*/
|
||||
function readAllComments(page, metadata) {
|
||||
var vendor_string_length = page.readUnsignedInt(true);
|
||||
page.advance(vendor_string_length); // skip libvorbis vendor string
|
||||
|
||||
var num_comments = page.readUnsignedInt(true);
|
||||
for (var i = 0; i < num_comments; i++) {
|
||||
try {
|
||||
var comment = readComment(page);
|
||||
if (comment) {
|
||||
if (!(comment.field in metadata)) {
|
||||
metadata[comment.field] = comment.value;
|
||||
} else if (comment.field !== 'picture') {
|
||||
// We already have a value, so append this new one.
|
||||
metadata[comment.field] += ' / ' + comment.value;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof EndOfPageError) {
|
||||
return;
|
||||
}
|
||||
console.warn('Error parsing vorbis comment', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single comment field.
|
||||
*
|
||||
* @param {BlobView} page The audio file being parsed.
|
||||
*/
|
||||
function readComment(page) {
|
||||
if (page.remaining() < 4) { // 4 bytes for comment-length variable
|
||||
// TODO: handle metadata that uses multiple pages
|
||||
throw new EndOfPageError();
|
||||
}
|
||||
var comment_length = page.readUnsignedInt(true);
|
||||
if (comment_length > page.remaining()) {
|
||||
// TODO: handle metadata that uses multiple pages
|
||||
throw new EndOfPageError();
|
||||
}
|
||||
|
||||
var comment = page.readUTF8Text(comment_length);
|
||||
var equal = comment.indexOf('=');
|
||||
if (equal === -1) {
|
||||
throw new Error('missing delimiter in comment');
|
||||
}
|
||||
|
||||
var fieldname = comment.substring(0, equal).toLowerCase().replace(' ', '');
|
||||
var propname = OGGFIELDS[fieldname];
|
||||
if (propname) { // Do we care about this field?
|
||||
var value = comment.substring(equal + 1);
|
||||
if (INTFIELDS.indexOf(propname) !== -1) {
|
||||
value = parseInt(value, 10);
|
||||
}
|
||||
return {field: propname, value: value};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
parse: parse
|
||||
};
|
||||
})();
|
||||
131
gaia/dev_apps/music-oga/js/metadata/vorbis_picture.js
Executable file
131
gaia/dev_apps/music-oga/js/metadata/vorbis_picture.js
Executable file
|
|
@ -0,0 +1,131 @@
|
|||
/* global BlobView */
|
||||
/* exported VorbisPictureComment */
|
||||
'use strict';
|
||||
|
||||
var VorbisPictureComment = (function() {
|
||||
/**
|
||||
* Read the picture frame from the block blob
|
||||
*/
|
||||
function readPicFrame(view) {
|
||||
// Source of the structure:
|
||||
// https://xiph.org/flac/format.html#metadata_block_picture
|
||||
// http://flac.sourceforge.net/format.html#metadata_block_picture
|
||||
//
|
||||
var kind = view.readUnsignedInt();
|
||||
if (kind != 3) {
|
||||
return null;
|
||||
}
|
||||
var mime_len = view.readUnsignedInt();
|
||||
var mimetype = view.readLatin1Text(mime_len);
|
||||
if (mimetype === '-->') {
|
||||
// XXX: We don't support URLs (the spec recommends against them anyway).
|
||||
console.error('URL for cover art is not supported.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip desc, w, h, bpp and indexed colours.
|
||||
var desc_len = view.readUnsignedInt();
|
||||
view.advance(desc_len + 16);
|
||||
|
||||
var pic_len = view.readUnsignedInt();
|
||||
var pic_start = view.sliceOffset + view.viewOffset + view.index;
|
||||
|
||||
// Now return an object that specifies where to pull the image from
|
||||
// The properties of this object can be passed to blob.slice()
|
||||
return {
|
||||
flavor: 'embedded',
|
||||
start: pic_start,
|
||||
end: pic_start + pic_len,
|
||||
type: mimetype
|
||||
};
|
||||
}
|
||||
|
||||
// BEGIN slow base64
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
|
||||
// This is needed as atob doesn't yield the right result.
|
||||
function base64DecToArr(sBase64, nBlocksSize) {
|
||||
|
||||
function b64ToUint6(nChr) {
|
||||
|
||||
return nChr > 64 && nChr < 91 ?
|
||||
nChr - 65
|
||||
: nChr > 96 && nChr < 123 ?
|
||||
nChr - 71
|
||||
: nChr > 47 && nChr < 58 ?
|
||||
nChr + 4
|
||||
: nChr === 43 ?
|
||||
62
|
||||
: nChr === 47 ?
|
||||
63
|
||||
: 0;
|
||||
}
|
||||
|
||||
var sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, '');
|
||||
var nInLen = sB64Enc.length;
|
||||
var nOutLen = nBlocksSize ?
|
||||
Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize :
|
||||
nInLen * 3 + 1 >> 2;
|
||||
var taBytes = new Uint8Array(nOutLen);
|
||||
for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0;
|
||||
nInIdx < nInLen; nInIdx++) {
|
||||
nMod4 = nInIdx & 3;
|
||||
nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4;
|
||||
if (nMod4 === 3 || nInLen - nInIdx === 1) {
|
||||
for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) {
|
||||
taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255;
|
||||
}
|
||||
nUint24 = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return taBytes;
|
||||
}
|
||||
// END
|
||||
|
||||
//
|
||||
// Will parse the vorbis comment for picture from metadata.picture.
|
||||
// If it doesn't exists, it is a no-op.
|
||||
function parsePictureComment(metadata) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!metadata.picture) {
|
||||
resolve(metadata);
|
||||
return;
|
||||
}
|
||||
var value = metadata.picture;
|
||||
try {
|
||||
// Album art in ogg
|
||||
// http://wiki.xiph.org/VorbisComment
|
||||
// http://flac.sourceforge.net/format.html#metadata_block_picture
|
||||
|
||||
// Image block is in base64.
|
||||
var binary = base64DecToArr(value);
|
||||
|
||||
var aBlob = new Blob([binary]);
|
||||
BlobView.get(aBlob, 0, aBlob.size, function(block, err) {
|
||||
|
||||
var prop = readPicFrame(block);
|
||||
if (prop) {
|
||||
// we need to duplicate the blob because we need
|
||||
// to set the MIME type now.
|
||||
metadata.picture = {
|
||||
flavor: 'unsynced',
|
||||
blob: aBlob.slice(prop.start, prop.end, prop.type)
|
||||
};
|
||||
}
|
||||
resolve(metadata);
|
||||
}, BlobView.bigEndian);
|
||||
}
|
||||
catch(e) {
|
||||
console.warn('Error parsing picture comment', e.message);
|
||||
resolve(metadata);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
readPicFrame: readPicFrame,
|
||||
parsePictureComment: parsePictureComment
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
Loading…
Reference in a new issue