VulpesOS/gaia/apps/music/js/metadata/album_art_cache.js
OverSu b38dea0597 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.
2026-09-30 23:13:03 +02:00

358 lines
12 KiB
JavaScript
Executable file

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