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