Files
psysonic/src/utils/coverBlobDownscale.ts
T
cucadmuh c3d37546cf Feat/search improvements (#470)
* feat(covers): race sibling downscale vs fetch, search thumb priorities

Run getCoverArt and client downscale in parallel when another size of the
same cover is cached; first successful result wins and aborts the other path.
Await both branches so inflight bookkeeping does not detach early.

Extend the cover cache size roster so provisional siblings resolve for sizes
used in the UI (e.g. 400/600/800, 48/96).

CachedImage: fetchQueueBias for live/mobile search (artist thumbnails ahead of
albums in fetch-slot ordering); configurable observeRootMargin with a wider
default to prepare priority slightly before elements enter view.

Mobile search adds round artist-thumb styling; add shared cover blob downscale
helper.

* perf(image-cache): batch sibling IDB reads and guard cover size registry

Use one read transaction when probing IndexedDB for sibling cover keys.
Extract COVER_ART_REGISTERED_SIZES and add Vitest coverage so every literal
coverArtCacheKey(_, size) in src stays aligned with sibling invalidation.
Honor AbortSignal during JPEG encode in downscaleCoverBlob.
2026-05-06 01:26:30 +03:00

63 lines
1.8 KiB
TypeScript

/**
* Client-side thumbnail path when a larger cover blob is already in cache:
* avoids a second HTTP getCoverArt for a smaller `size=` variant.
*/
export async function downscaleCoverBlob(
blob: Blob,
maxPx: number,
signal?: AbortSignal,
): Promise<Blob | null> {
if (typeof document === 'undefined' || signal?.aborted || maxPx < 16) return null;
let bmp: ImageBitmap | undefined;
try {
bmp = await createImageBitmap(blob);
if (signal?.aborted) return null;
const w = bmp.width;
const h = bmp.height;
if (!w || !h || !Number.isFinite(w) || !Number.isFinite(h)) return null;
const maxDim = Math.max(w, h);
/** Skip encode work when already suitable for tiny list thumbs. */
if (maxDim <= maxPx * 1.02) return null;
const scale = maxPx / maxDim;
const dw = Math.max(1, Math.round(w * scale));
const dh = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = dw;
canvas.height = dh;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bmp, 0, 0, dw, dh);
if (signal?.aborted) return null;
return await new Promise<Blob | null>(resolve => {
if (signal?.aborted) {
resolve(null);
return;
}
const finish = (b: Blob | null) => {
signal?.removeEventListener('abort', onAbort);
resolve(signal?.aborted ? null : b);
};
const onAbort = () => finish(null);
signal?.addEventListener('abort', onAbort, { once: true });
canvas.toBlob(b => finish(b ?? null), 'image/jpeg', 0.88);
});
} catch {
return null;
} finally {
try {
bmp?.close();
} catch {
/* noop */
}
}
}