/**
* Thumbnails this browser can show (#317). A thumbnail is only a grid or cover placeholder; every other view uses the
* original.
*
* - `detectJxlSupport()` decodes a tiny embedded JPEG XL image once per page (`JXL_PROBE`) and remembers the answer
* (`jxlSupport()`: undefined until it answered, then true or false).
* - `thumbnailSource(image, supported)`: the URL a card or the inspector cover loads: the listing's thumbnail, or the
* original when the thumbnail is JPEG XL and this browser cannot decode it. While the answer is unknown the
* thumbnail is tried; `fallBackToOriginal` then catches a failure.
* - `fallBackToOriginal(img, url)`: a thumbnail `
` that fails to load shows the original instead (once).
* - `showsThumbnail(image)`: false for a video whose thumbnail is missing or one this browser cannot show (its URL
* would be the video itself): the card keeps the video's own element or label, never a broken image.
* - `replaceOnError(img, next)`: a video thumbnail `
` that fails to load is replaced by `next()` (once).
*/
// A 1×1 JPEG XL image (cjxl -d 1), decoded once to learn whether this browser shows JPEG XL.
export const JXL_PROBE = 'data:image/jxl;base64,/woAkAEAE4gCALQAtZ8gAAAVKqOMG7yc6/nyQ4fFtI3rDG21bWEJY7O9MEhIOGwY4s3xwRCTShIA';
let probe = null;
let answer;
/** Whether `url` names a JPEG XL file (its path, not its query or fragment). */
export function isJxlUrl(url) {
if (typeof url !== 'string' || !url) return false;
const path = url.split(/[?#]/, 1)[0];
return path.toLowerCase().endsWith('.jxl');
}
/** The remembered answer: undefined until the probe decoded or failed. */
export const jxlSupport = () => answer;
/** Forget the answer (tests). */
export function resetJxlSupport() {
probe = null;
answer = undefined;
}
/** Decode the embedded JPEG XL once; resolves true when this browser shows it. */
export function detectJxlSupport({ createImage = () => new Image() } = {}) {
probe ??= new Promise(resolve => {
let image;
try {
image = createImage();
} catch {
resolve(false);
return;
}
image.onload = () => resolve(true);
image.onerror = () => resolve(false);
image.src = JXL_PROBE;
}).then(supported => {
answer = supported;
return supported;
});
return probe;
}
/** The URL a card or cover loads for `image` (`{url, thumbnail_url}`), given the JPEG XL answer. */
export function thumbnailSource(image, supported = answer) {
const thumbnail = image?.thumbnail_url;
if (typeof thumbnail !== 'string' || !thumbnail) return image?.url;
if (supported === false && isJxlUrl(thumbnail)) return image.url;
return thumbnail;
}
/** Whether a card shows `image`'s thumbnail (always for an image: its original at worst). */
export function showsThumbnail(image, supported = answer) {
if (!image?.is_video) return true;
const source = thumbnailSource(image, supported);
return Boolean(source) && source !== image.url;
}
/** A video thumbnail `
` that fails to load is replaced by `next()`, once (`listen` as in fallBackToOriginal). */
export function replaceOnError(node, next, listen = null) {
const swap = () => node.replaceWith(next());
if (listen) listen(node, 'error', swap, { once: true });
else node.addEventListener('error', swap, { once: true });
}
/**
* A thumbnail `
` that fails to load shows `original` instead, once. `listen(target, type, handler, options)` is the
* caller's scoped listener (a scope's `listen`); without one a plain once-listener is used.
*/
export function fallBackToOriginal(node, original, listen = null) {
if (!original || node.src === original) return;
const swap = () => { if (node.src !== original) node.src = original; };
if (listen) listen(node, 'error', swap, { once: true });
else node.addEventListener('error', swap, { once: true });
}