feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870)

* chore(cover): scaffold cover module and rust cover_cache stub

Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands
in cover_cache/mod.rs (no-op returns until phase B).

* feat(cover): add unified cover module and tier resolver (phase A)

Wave 1A: tiers, storage keys, resolveJs with cold/sibling races,
useCoverArt, CoverArtImage, layoutSizes, playback scope helpers,
coverSiblings tier ladder, deprecated shims on subsonicStreamUrl.

* feat(cover): rust disk cache and tier-ready events (phase B)

Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch,
cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests.

* feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C)

Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB
cover key clear, prefetch registry drain, MainApp wiring.

* feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D)

Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense,
coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid.

* feat(cover): migrate sparse surfaces and integrations (phase E sparse)

Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover,
MPRIS/Discord/export integrations, playback chrome and detail heroes.

* feat(cover): revalidation scheduler and disk pressure gate (phase E+)

Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure,
useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks.

* docs: CHANGELOG and credits for cover art pipeline PR #869

* fix(cover): stop webview getCoverArt storm on dense grids (429)

Dense surfaces no longer put rotating getCoverArt URLs in img src; load
disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead
of invalidating IDB. Throttle background prefetch and cap Home registry.

* fix(cover): omit empty img src until cover URL is ready

React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB
resolves; queue current track shows placeholder when src is still empty.

* fix(cover): disk cache by host index key, parallel ensure, asset protocol

Bind cover storage to serverIndexKey (library host), rename cover IPC/events,
fix REST base URL and Tauri flat args, enable protocol-asset for disk paths,
add prioritized ensure queue, and wipe legacy profile-UUID cache once.
Limit Vite dep scan to index.html so research/target HTML is ignored.

* fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage

Encode lossy WebP (~82), write only missing tiers, library cover backfill,
and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP
asset protocol; no IDB fallback when server is up. Session Home feed cache
with warm peek on return; BecauseYouLike deduped cover hook and high prefetch.

* feat(cover): per-server cache strategy and native library backfill

Move cover disk cache settings to Offline & cache with Lazy/Aggressive
per server, per-server clear, and no size cap. Run full-catalog backfill
on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes
without flooding the webview). Drop global prefetch limits from auth store
and waveform clear from the offline storage block.

* fix(build): CSP connect-src for Subsonic API; quieter prod nix build

Prod webview blocked axios ping after cover CSP (missing connect-src).
Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo.

* fix(cover): complete WebP ladder in library bulk backfill

Aggressive backfill now writes all derived tiers (128–800), skips IDs
only when the full ladder exists (not 800 alone), avoids fetch-failed
markers on bulk HTTP errors, and stops the pass when the active server
changes.

* fix(cover,home): navigation-priority backfill and Because You Like UX

Pause library cover backfill while navigating; split peek/ensure traffic
so grids and rails win over bulk work. Disk src lookup, grid warm hooks,
and non-blocking mainstage prime for faster visible covers.

Because You Like: session snapshot, staggered horizontal skeleton row,
text hidden until cover is ready, and layout aligned with loaded cards.

* feat(random-albums,library): local-first album fetch + cover art pipeline

Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM()
LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер
исчезает практически мгновенно; сеть используется только как фолбэк.

- advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок
- browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums
- RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра
  и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve
  прогревает следующий батч в фоне после каждого Refresh

Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue,
diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers,
useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие
компоненты/хуки).

* fix(random-albums): prevent double-load on Zustand rehydration

useEffect([selectedGenres, load]) fired twice on every visit: first with
default store values, then again ~50 ms later when Zustand rehydrated
mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage.

Previously this was invisible because the first network fetch took ~1.5 s,
so loadingRef.current was still true on the second fire. With the new
local-first SQLite path the first load completes in ~50 ms, leaving the
guard cleared before rehydration triggers a second random batch.

Fix: ref-pattern — keep loadRef.current fresh on every render, effect
depends only on selectedGenres. Manual Refresh and genre-filter changes
still call the latest closure correctly.

* fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash

fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что
вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило
всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку
примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает
сетевые запросы к рейтингам артистов).

- fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при
  consume резерва через primeAlbumCoversForDisplay
- reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums
  (аналогично non-reserve пути; при уже прогретом кэше — мгновенно)

* feat(because-you-like): reserve-first pattern — instant display on return visits

Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку
мгновенно, вместо spinner → сетевые запросы → контент.

Архитектура:
- resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из
  замыкания useEffect); читают текущий localStorage, возвращают
  { anchor, recs, nextAnchorHistory, nextPicksHistory }
- fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу
  после отображения результата, кладёт следующий батч в _becauseReserve.
  Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не
  нужен); они прогреваются через primeAlbumCoversForDisplay при consume.
- useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton
  (контент появляется без мигания)
- useEffect: reserve-first path — consume → primeCovers → setState → fill;
  full-fetch path сохранён как fallback при первом визите или промахе

Поведение:
- Визит 1: full fetch (как раньше) → показ → fillReserve R1
- Визит 2+: consume R1 → мгновенный показ → fillReserve R2
- При сетевом сбое: restore из session cache (как раньше)

* fix(because-you-like): initialise state from reserve — no skeleton flash on remount

При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и
показывал skeleton на один тик до того как useEffect отработает.

Теперь useState() использует lazy initializers, которые читают _becauseReserve
прямо в первом рендере: если reserve валиден — state сразу refreshing=false,
anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache
(из предыдущего показа) и появляются без дополнительных запросов.

useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton
только если reserve отсутствует (для случая navigation без ремаунта).

* fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival

Lazy initializers не могли применить reserve при первом рендере, потому что
mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[]
на первом рендере, poolKey не совпадает с reserve.

useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает):
когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно
до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false.
При отсутствии reserve — сбрасывает в skeleton как прежде.

* fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount

Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect,
поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным
poolKey → mismatch → lazy initializers запускали skeleton.

Теперь двухуровневый fallback без зависимости от poolKey:
1. reserve (serverId + poolKey совпадают) → мгновенный новый батч
2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент
   доступен сразу с mount, обновляется тихо в фоне
3. skeleton → только при полном отсутствии данных (первый визит)

Применяется одинаково в lazy useState initializers, useLayoutEffect и
full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент).

* fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool

Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey=''
→ mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал
mostPlayed.

Исправления:
- BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния
  на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать
  reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms
- hasValidReserve: проверяет только serverId
- fillBecauseReserve: убран poolKey из сигнатуры и хранилища
- useEffect: guard pool.length === 0 → возврат без fetch/consume;
  effect перезапустится когда pool заполнится (реальные deps изменятся)
  → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша

Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate)
> skeleton (только первый визит)

* fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch

Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps
useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId,
потом mix-rating значения. Это вызывало двойной запуск эффекта:
- Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра
- Второй запуск (после rehydration): cache miss или повторный fetch с
  реальными mix-настройками → ~500ms → новый набор

Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор
первые ~500ms при каждом возврате на Mainstage.

Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth()
читается внутри эффекта через getState() — всегда актуальные значения без
пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch,
но не вызывают двойной запуск при rehydration.

* feat(home): local-first discover songs via SQLite ORDER BY RANDOM()

Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков)
в browseTextSearch.ts — использует libraryAdvancedSearch с sort random,
field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку.

В Home.tsx: discoverSongs теперь сначала пробует локальный индекс,
и только при недоступности (индекс не готов, ошибка) падает обратно
на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки
берутся из SSD вместо сети.

* fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits

Причина: Home.tsx размонтируется при навигации. При возврате первый рендер
всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом
useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым
состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]).

Решение: getInitialHomeFeed() читает homeFeedCache синхронно через
useAuthStore.getState() (не hook) в lazy useState initializers. К моменту
повторного визита store уже rehydrated — все state получают кэшированные
данные до первого рендера.

Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot
в useEffect когда state уже заполнен — иначе новые ссылки на массивы
вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными.

* fix(mainstage): keep refresh without return flicker

Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation.

* fix(mainstage): stabilize because rail and hero background framing

Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount.

* fix(now-playing): prewarm track data and prevent stale carry-over

Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track.

* fix(cover,test): refresh playback scope and default tauri cover mocks

Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI.

* fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks

Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs.

* test(cover): hoist mocked useCoverArt and clean EOF whitespace

Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line.

* fix(cover): align playback ensure auth and harden backfill retry flow

Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch.

* fix(ci): resolve clippy lint and update frontend node runtime

Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20.

* chore(settings): simplify cover and analytics strategy copy

Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
This commit is contained in:
cucadmuh
2026-05-26 19:35:08 +03:00
committed by GitHub
parent 91e7195e0f
commit 418b25914a
160 changed files with 7551 additions and 914 deletions
+112
View File
@@ -0,0 +1,112 @@
import type { ImgHTMLAttributes } from 'react';
import { useEffect, useRef, useState } from 'react';
import { DEFAULT_CACHED_IMAGE_PREPARE_MARGIN } from '../components/CachedImage';
import { resolveIntersectionScrollRoot } from '../utils/ui/resolveIntersectionScrollRoot';
import { coverEnsureBump } from './ensureQueue';
import { coverPrefetchBumpPriority } from './prefetchRegistry';
import { coverArtRef } from './ref';
import { coverStorageKey } from './storageKeys';
import { resolveCoverDisplayTier } from './tiers';
import { coverImgSrc } from './imgSrc';
import { useCoverArt } from './useCoverArt';
import type { CoverArtId, CoverPrefetchPriority, CoverServerScope, CoverSurfaceKind } from './types';
export type CoverArtImageProps = {
coverArtId: CoverArtId | null | undefined;
displayCssPx: number;
serverScope?: CoverServerScope;
surface?: CoverSurfaceKind;
fullRes?: boolean;
className?: string;
alt?: string;
fetchQueueBias?: number;
observeRootMargin?: string;
observeScrollRootId?: string;
/** Initial ensure tier — use `high` for hero / above-the-fold cells. */
ensurePriority?: CoverPrefetchPriority;
} & Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'>;
export function CoverArtImage({
coverArtId,
displayCssPx,
serverScope,
surface,
fullRes,
className,
alt,
fetchQueueBias: _fetchQueueBias,
observeRootMargin = DEFAULT_CACHED_IMAGE_PREPARE_MARGIN,
observeScrollRootId,
ensurePriority: ensurePriorityProp,
onError: restOnError,
...rest
}: CoverArtImageProps) {
const scope = serverScope ?? { kind: 'active' };
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
ensurePriorityProp ?? 'middle',
);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
if (ensurePriorityProp) setEnsurePriority(ensurePriorityProp);
}, [ensurePriorityProp]);
useEffect(() => {
const el = imgRef.current;
if (!el || !coverArtId) return;
const root =
(observeScrollRootId
? (document.getElementById(observeScrollRootId) as Element | null)
: null) ?? resolveIntersectionScrollRoot(el);
const ref = coverArtRef(coverArtId, scope);
const tier = resolveCoverDisplayTier(displayCssPx, { surface, fullRes });
const storageKey = coverStorageKey(scope, coverArtId, tier);
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
setEnsurePriority('high');
coverPrefetchBumpPriority(ref, 'high');
coverEnsureBump(storageKey, 'high');
}
}
},
{
root: root ?? undefined,
rootMargin: observeRootMargin,
threshold: [0, 0.05, 0.15],
},
);
observer.observe(el);
return () => observer.disconnect();
}, [coverArtId, scope, displayCssPx, surface, fullRes, observeRootMargin, observeScrollRootId]);
const { src, provisional, onImgError } = useCoverArt(coverArtId, displayCssPx, {
serverScope: scope,
surface,
fullRes,
ensurePriority,
alt,
});
const imgSrc = coverImgSrc(src);
return (
<img
ref={imgRef}
src={imgSrc}
className={className}
alt={alt ?? ''}
data-cover-provisional={provisional ? 'true' : undefined}
data-observe-root-margin={observeRootMargin}
data-observe-scroll-root={observeScrollRootId}
{...rest}
onError={e => {
onImgError?.();
restOnError?.(e);
}}
/>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useMemo, useState, type CSSProperties, type ImgHTMLAttributes } from 'react';
import type { SubsonicArtistInfo } from '../api/subsonicTypes';
import { isRealArtistImage } from '../utils/componentHelpers/nowPlayingHelpers';
import { CoverArtImage } from './CoverArtImage';
import type { CoverArtRef, CoverSurfaceKind } from './types';
export type ArtistHeroCoverProps = {
artistId: string;
artistInfo: SubsonicArtistInfo | null;
coverFallback?: CoverArtRef | null;
displayCssPx: number;
surface?: CoverSurfaceKind;
className?: string;
alt?: string;
style?: CSSProperties;
onError?: () => void;
} & Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'style' | 'onError'>;
/** Artist hero — external Last.fm/Subsonic URLs with getCoverArt fallback (v1). */
export function ArtistHeroCover({
artistId,
artistInfo,
coverFallback,
displayCssPx,
surface = 'sparse',
className,
alt,
style,
onError,
...rest
}: ArtistHeroCoverProps) {
const [externalUrl, setExternalUrl] = useState('');
const [externalFailed, setExternalFailed] = useState(false);
const candidateUrl = useMemo(() => {
const rawLarge = artistInfo?.largeImageUrl;
const rawMed = artistInfo?.mediumImageUrl;
if (isRealArtistImage(rawLarge)) return rawLarge!;
if (isRealArtistImage(rawMed)) return rawMed!;
return '';
}, [artistInfo?.largeImageUrl, artistInfo?.mediumImageUrl]);
useEffect(() => {
setExternalFailed(false);
setExternalUrl('');
if (!candidateUrl) return;
let cancelled = false;
const probe = new Image();
probe.onload = () => { if (!cancelled) setExternalUrl(candidateUrl); };
probe.onerror = () => { if (!cancelled) setExternalFailed(true); };
probe.src = candidateUrl;
return () => {
cancelled = true;
probe.onload = probe.onerror = null;
};
}, [candidateUrl, artistId]);
if (externalUrl && !externalFailed) {
return (
<img
src={externalUrl}
className={className}
alt={alt ?? ''}
style={style}
onError={() => {
setExternalFailed(true);
onError?.();
}}
{...rest}
/>
);
}
if (coverFallback?.coverArtId) {
return (
<CoverArtImage
coverArtId={coverFallback.coverArtId}
serverScope={coverFallback.serverScope}
displayCssPx={displayCssPx}
surface={surface}
className={className}
alt={alt}
style={style}
onError={onError}
{...rest}
/>
);
}
return null;
}
+87
View File
@@ -0,0 +1,87 @@
import {
libraryCoverBackfillConfigure,
libraryCoverBackfillSetUiPriority,
} from '../api/coverCache';
import { coverEnsureCancelPending } from './ensureQueue';
import { coverPeekCancelPending } from './peekQueue';
import { coverPrefetchClearRegistry } from './prefetchRegistry';
function cancelVisibleCoverWork(): void {
coverEnsureCancelPending();
coverPeekCancelPending();
coverPrefetchClearRegistry();
}
let navigationHoldDepth = 0;
let serverSwitchHold = false;
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
let serverSwitchEndTimer: ReturnType<typeof setTimeout> | null = null;
const NAVIGATION_QUIET_MS = 400;
const SERVER_SWITCH_QUIET_MS = 700;
function syncBackfillUiPriority(): void {
const hold = navigationHoldDepth > 0 || serverSwitchHold;
void libraryCoverBackfillSetUiPriority(hold);
}
function pauseLibraryBackfillSession(): void {
void libraryCoverBackfillConfigure({
enabled: false,
serverIndexKey: '',
libraryServerId: '',
restBaseUrl: '',
username: '',
password: '',
});
}
/** Route change — drop queued peek/ensure/prefetch so the new page is not behind the old one. */
export function coverTrafficBeginNavigation(): void {
navigationHoldDepth += 1;
cancelVisibleCoverWork();
syncBackfillUiPriority();
}
export function coverTrafficEndNavigation(): void {
navigationHoldDepth = Math.max(0, navigationHoldDepth - 1);
scheduleNavigationResume();
}
/** Active server change — stop all cover IPC so ping + menu stay responsive. */
export function coverTrafficBeginServerSwitch(): void {
serverSwitchHold = true;
if (serverSwitchEndTimer) {
clearTimeout(serverSwitchEndTimer);
serverSwitchEndTimer = null;
}
cancelVisibleCoverWork();
pauseLibraryBackfillSession();
syncBackfillUiPriority();
}
export function coverTrafficEndServerSwitch(): void {
if (serverSwitchEndTimer) clearTimeout(serverSwitchEndTimer);
serverSwitchEndTimer = setTimeout(() => {
serverSwitchHold = false;
serverSwitchEndTimer = null;
syncBackfillUiPriority();
}, SERVER_SWITCH_QUIET_MS);
}
export function coverTrafficBackgroundPaused(): boolean {
return navigationHoldDepth > 0 || serverSwitchHold;
}
/** Hard stop for ensure/peek pumps (includes visible `high` grid jobs). */
export function coverTrafficServerSwitchPaused(): boolean {
return serverSwitchHold;
}
function scheduleNavigationResume(): void {
if (resumeTimer) clearTimeout(resumeTimer);
resumeTimer = setTimeout(() => {
resumeTimer = null;
syncBackfillUiPriority();
}, NAVIGATION_QUIET_MS);
}
+37
View File
@@ -0,0 +1,37 @@
/** Listeners for Rust `cover:tier-ready` — keyed by IDB storage key. */
type DiskReadyListener = (path: string) => void;
const listeners = new Map<string, Set<DiskReadyListener>>();
export function subscribeCoverDiskReady(storageKey: string, onReady: DiskReadyListener): () => void {
let set = listeners.get(storageKey);
if (!set) {
set = new Set();
listeners.set(storageKey, set);
}
set.add(onReady);
return () => {
const s = listeners.get(storageKey);
if (!s) return;
s.delete(onReady);
if (s.size === 0) listeners.delete(storageKey);
};
}
export function hasCoverDiskReadyListeners(storageKey: string): boolean {
const set = listeners.get(storageKey);
return !!set && set.size > 0;
}
export function notifyCoverDiskReady(storageKey: string, path: string): void {
const set = listeners.get(storageKey);
if (!set) return;
for (const fn of [...set]) {
try {
fn(path);
} catch {
/* ignore */
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import { convertFileSrc, isTauri } from '@tauri-apps/api/core';
/** Stable asset URLs for disk `.webp` tiers — survives route unmount. */
const diskSrcByStorageKey = new Map<string, string>();
let cacheGeneration = 0;
const cacheListeners = new Set<() => void>();
function bumpDiskSrcCache(): void {
cacheGeneration += 1;
for (const fn of cacheListeners) {
try {
fn();
} catch {
/* ignore */
}
}
}
/** Re-render `useCoverArt` when warm/peek seeds this map (no wait for ensure queue). */
export function subscribeDiskSrcCache(onStoreChange: () => void): () => void {
cacheListeners.add(onStoreChange);
return () => cacheListeners.delete(onStoreChange);
}
export function getDiskSrcCacheGeneration(): number {
return cacheGeneration;
}
/** True when `convertFileSrc` failed and returned the filesystem path unchanged. */
function isRawFsPath(url: string, fsPath: string): boolean {
return url === fsPath || (url.startsWith('/') && fsPath.startsWith('/'));
}
/**
* Turn a Rust disk path into a webview-loadable URL.
* Returns empty when not in Tauri or path is outside asset scope (never put raw paths in `<img src>`).
*/
export function coverDiskUrl(fsPath: string): string {
if (!fsPath || !isTauri()) return '';
const src = convertFileSrc(fsPath);
if (isRawFsPath(src, fsPath)) {
if (import.meta.env.DEV) {
console.warn('[cover] convertFileSrc out of asset scope — check tauri.conf assetProtocol', fsPath);
}
return '';
}
return src;
}
export function rememberDiskSrc(storageKey: string, fsPath: string): string {
if (!storageKey || !fsPath) return '';
const src = coverDiskUrl(fsPath);
if (!src) return '';
const prev = diskSrcByStorageKey.get(storageKey);
if (prev === src) return src;
diskSrcByStorageKey.set(storageKey, src);
bumpDiskSrcCache();
return src;
}
export function getDiskSrc(storageKey: string): string {
return diskSrcByStorageKey.get(storageKey) ?? '';
}
export function forgetDiskSrc(storageKey: string): void {
if (diskSrcByStorageKey.delete(storageKey)) bumpDiskSrcCache();
}
export function forgetDiskSrcPrefix(serverIndexKey: string, coverArtId: string): void {
const prefix = `${serverIndexKey}:cover:${coverArtId}:`;
let changed = false;
for (const key of diskSrcByStorageKey.keys()) {
if (key.startsWith(prefix)) {
diskSrcByStorageKey.delete(key);
changed = true;
}
}
if (changed) bumpDiskSrcCache();
}
export function clearAllDiskSrcCache(): void {
if (diskSrcByStorageKey.size === 0) return;
diskSrcByStorageKey.clear();
bumpDiskSrcCache();
}
export function clearDiskSrcCacheForServer(serverIndexKey: string): void {
const prefix = `${serverIndexKey}:cover:`;
let changed = false;
for (const key of [...diskSrcByStorageKey.keys()]) {
if (key.startsWith(prefix)) {
diskSrcByStorageKey.delete(key);
changed = true;
}
}
if (changed) bumpDiskSrcCache();
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('./diskSrcCache', () => ({
rememberDiskSrc: vi.fn(() => 'asset://cover.webp'),
getDiskSrc: vi.fn(() => ''),
}));
vi.mock('./diskHandoff', () => ({
hasCoverDiskReadyListeners: vi.fn(() => true),
notifyCoverDiskReady: vi.fn(),
}));
import { rememberDiskSrc } from './diskSrcCache';
import { notifyCoverDiskReady } from './diskHandoff';
import { gridDiskSrcLookupOrder, rememberGridDiskSrc } from './diskSrcLookup';
describe('gridDiskSrcLookupOrder', () => {
it('prefers 800 right after 512 when 512 is wanted', () => {
expect(gridDiskSrcLookupOrder(512)).toEqual([512, 800, 256, 128]);
});
it('prefers 800 for 256 display tier', () => {
expect(gridDiskSrcLookupOrder(256)[1]).toBe(800);
});
});
describe('rememberGridDiskSrc', () => {
beforeEach(() => {
vi.mocked(rememberDiskSrc).mockClear();
vi.mocked(notifyCoverDiskReady).mockClear();
vi.mocked(rememberDiskSrc).mockReturnValue('asset://x');
});
it('seeds 512 and 800 keys from one on-disk path (800.webp fallback)', () => {
const hit = rememberGridDiskSrc({ kind: 'active' }, 'al-1', 512, '/data/800.webp');
expect(hit).toBe(true);
expect(vi.mocked(rememberDiskSrc).mock.calls.length).toBeGreaterThanOrEqual(2);
expect(vi.mocked(notifyCoverDiskReady)).toHaveBeenCalledTimes(1);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
import { hasCoverDiskReadyListeners, notifyCoverDiskReady } from './diskHandoff';
import { coverStorageKey } from './storageKeys';
import type { CoverArtId, CoverArtTier, CoverServerScope } from './types';
/** Dense grids: prefer a larger on-disk tier (800) before tiny thumbs when the ideal tier is missing. */
export function gridDiskSrcLookupOrder(want: CoverArtTier): CoverArtTier[] {
const out: CoverArtTier[] = [want];
if (want >= 256 && want < 800) out.push(800);
const ladder: CoverArtTier[] = [128, 256, 512, 800];
for (let i = ladder.length - 1; i >= 0; i -= 1) {
const t = ladder[i]!;
if (t !== want && t < want && !out.includes(t)) out.push(t);
}
if (want < 800 && !out.includes(800)) out.push(800);
return out;
}
/** Synchronous hit from `diskSrcCache` — any tier already warmed/peeked for this cover. */
export function getDiskSrcForGrid(
scope: CoverServerScope,
coverArtId: CoverArtId,
wantTier: CoverArtTier,
): string {
for (const tier of gridDiskSrcLookupOrder(wantTier)) {
const src = getDiskSrc(coverStorageKey(scope, coverArtId, tier));
if (src) return src;
}
return '';
}
/** Seed lookup-order tier keys (512 + 800 fallback path, etc.) — no subscriber wakeups. */
export function seedGridDiskSrcCache(
scope: CoverServerScope,
coverArtId: CoverArtId,
wantTier: CoverArtTier,
fsPath: string,
): boolean {
if (!fsPath) return false;
let hit = false;
for (const tier of gridDiskSrcLookupOrder(wantTier)) {
if (rememberDiskSrc(coverStorageKey(scope, coverArtId, tier), fsPath)) hit = true;
}
return hit;
}
/**
* After peek/ensure: seed cache and wake mounted cells once (avoids 4× notify / re-render storms).
*/
export function rememberGridDiskSrc(
scope: CoverServerScope,
coverArtId: CoverArtId,
wantTier: CoverArtTier,
fsPath: string,
): boolean {
const hit = seedGridDiskSrcCache(scope, coverArtId, wantTier, fsPath);
if (!hit) return false;
const wantKey = coverStorageKey(scope, coverArtId, wantTier);
if (hasCoverDiskReadyListeners(wantKey)) {
notifyCoverDiskReady(wantKey, fsPath);
}
return true;
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
const { ensureImpl } = vi.hoisted(() => ({
ensureImpl: vi.fn(
async (_ref: { coverArtId: string }, _tier: number, _priority: string) => {
await new Promise(r => setTimeout(r, 2));
return { hit: true, path: `/tmp/${_ref.coverArtId}.webp`, tier: 128 };
},
),
}));
vi.mock('../api/coverCache', () => ({
coverCacheEnsure: ensureImpl,
libraryCoverBackfillConfigure: vi.fn(async () => {}),
libraryCoverBackfillSetUiPriority: vi.fn(async () => {}),
}));
import { coverArtRef } from './ref';
import { coverTrafficBeginServerSwitch, coverTrafficEndServerSwitch } from './coverTraffic';
import {
__test_queuedCoverIds,
__test_resetCoverEnsureQueue,
coverEnsureBump,
coverEnsureQueued,
coverEnsureRelease,
} from './ensureQueue';
describe('coverEnsureQueued', () => {
beforeEach(() => {
__test_resetCoverEnsureQueue();
ensureImpl.mockClear();
});
it('dedupes concurrent ensures for the same storage key', async () => {
const ref = coverArtRef('al-1');
const [a, b] = await Promise.all([
coverEnsureQueued('s:cover:al-1:128', ref, 128, 'high'),
coverEnsureQueued('s:cover:al-1:128', ref, 128, 'low'),
]);
expect(a.path).toBe('/tmp/al-1.webp');
expect(b.path).toBe('/tmp/al-1.webp');
expect(ensureImpl).toHaveBeenCalledTimes(1);
});
it('bumps a queued job ahead of older high-priority work', () => {
coverTrafficBeginServerSwitch();
const refA = coverArtRef('al-a');
const refB = coverArtRef('al-b');
const refC = coverArtRef('al-c');
void coverEnsureQueued('s:cover:al-a:128', refA, 128, 'high');
void coverEnsureQueued('s:cover:al-b:128', refB, 128, 'high');
void coverEnsureQueued('s:cover:al-c:128', refC, 128, 'high');
coverEnsureBump('s:cover:al-c:128', 'high');
expect(__test_queuedCoverIds()[0]).toBe('al-c');
coverTrafficEndServerSwitch();
});
it('release drops a pending job so a remount can re-queue', async () => {
coverTrafficBeginServerSwitch();
const ref = coverArtRef('al-drop');
const pending = coverEnsureQueued('s:cover:al-drop:128', ref, 128, 'middle');
coverEnsureRelease('s:cover:al-drop:128');
const result = await pending;
expect(result.path).toBe('');
expect(ensureImpl).not.toHaveBeenCalled();
coverTrafficEndServerSwitch();
await new Promise(r => setTimeout(r, 800));
await coverEnsureQueued('s:cover:al-drop:128', ref, 128, 'high');
expect(ensureImpl).toHaveBeenCalledTimes(1);
});
});
+215
View File
@@ -0,0 +1,215 @@
import { coverCacheEnsure } from '../api/coverCache';
import { getDiskSrc } from './diskSrcCache';
import { getDiskSrcForGrid } from './diskSrcLookup';
import { coverIndexKeyFromRef } from './storageKeys';
import type { CoverArtRef, CoverArtTier, CoverPrefetchPriority } from './types';
type EnsureJob = {
storageKey: string;
ref: CoverArtRef;
tier: CoverArtTier;
priority: CoverPrefetchPriority;
/** Larger = closer to viewport / more recently bumped — dequeued first within the same priority band. */
orderKey: number;
resolve: (r: { hit: boolean; path: string }) => void;
};
import {
coverTrafficBackgroundPaused,
coverTrafficServerSwitchPaused,
} from './coverTraffic';
/** Parallel Rust cover ensures (visible UI; library backfill is native-only). */
export const COVER_ENSURE_MAX_INFLIGHT = 10;
const MAX_INFLIGHT = COVER_ENSURE_MAX_INFLIGHT;
/** Drop stale scroll-ahead work so the queue cannot grow without bound. */
const MAX_QUEUE = 96;
let inflight = 0;
let queue: EnsureJob[] = [];
let nextOrderKey = 0;
const inflightStorageKeys = new Set<string>();
function priorityRank(p: CoverPrefetchPriority): number {
return p === 'high' ? 0 : p === 'middle' ? 1 : 2;
}
function sortQueue(): void {
queue.sort((a, b) => {
const pr = priorityRank(a.priority) - priorityRank(b.priority);
if (pr !== 0) return pr;
return b.orderKey - a.orderKey;
});
}
function trimQueue(): void {
while (queue.length > MAX_QUEUE) {
let worstIdx = 0;
for (let i = 1; i < queue.length; i += 1) {
const a = queue[worstIdx]!;
const b = queue[i]!;
const rankA = priorityRank(a.priority);
const rankB = priorityRank(b.priority);
if (rankB > rankA || (rankB === rankA && b.orderKey < a.orderKey)) {
worstIdx = i;
}
}
const [job] = queue.splice(worstIdx, 1);
job.resolve({ hit: false, path: '' });
ensureInflight.delete(job.storageKey);
}
}
function coverInflightKey(ref: CoverArtRef): string {
return `${coverIndexKeyFromRef(ref)}:${ref.coverArtId}`;
}
/** Serialize ensures per cover ID so we do not re-download for every tier. */
const coverDownloadTail = new Map<string, Promise<unknown>>();
function ensureForCover(
ref: CoverArtRef,
tier: CoverArtTier,
priority: CoverPrefetchPriority,
) {
const key = coverInflightKey(ref);
const tail = coverDownloadTail.get(key) ?? Promise.resolve();
const run = tail.then(() => coverCacheEnsure(ref, tier, priority));
coverDownloadTail.set(key, run.catch(() => {}));
return run;
}
function findQueuedJob(storageKey: string): EnsureJob | undefined {
return queue.find(j => j.storageKey === storageKey);
}
function bumpJob(job: EnsureJob, priority?: CoverPrefetchPriority): void {
if (priority && priorityRank(priority) < priorityRank(job.priority)) {
job.priority = priority;
}
job.orderKey = ++nextOrderKey;
sortQueue();
}
function pump(): void {
if (coverTrafficServerSwitchPaused()) return;
while (inflight < MAX_INFLIGHT && queue.length > 0) {
const next = queue[0]!;
if (coverTrafficBackgroundPaused() && next.priority !== 'high') {
break;
}
const job = queue.shift()!;
inflight += 1;
inflightStorageKeys.add(job.storageKey);
void ensureForCover(job.ref, job.tier, job.priority)
.then(r => job.resolve({ hit: r.hit, path: r.path }))
.catch(() => job.resolve({ hit: false, path: '' }))
.finally(() => {
inflight -= 1;
inflightStorageKeys.delete(job.storageKey);
pump();
});
}
}
const ensureInflight = new Map<string, Promise<{ hit: boolean; path: string }>>();
/** Move a queued job ahead of older scroll-ahead work (viewport / prefetch bump). */
export function coverEnsureBump(
storageKey: string,
priority: CoverPrefetchPriority = 'high',
): void {
const job = findQueuedJob(storageKey);
if (!job) return;
bumpJob(job, priority);
pump();
}
/** Drop queued ensures (route/server change) — in-flight jobs finish on their own. */
export function coverEnsureCancelPending(): void {
const dropped = queue;
queue = [];
for (const job of dropped) {
job.resolve({ hit: false, path: '' });
ensureInflight.delete(job.storageKey);
}
}
/** Cell unmounted or deferred — drop pending work so the viewport can jump the queue. */
export function coverEnsureRelease(storageKey: string): void {
const idx = queue.findIndex(j => j.storageKey === storageKey);
if (idx >= 0) {
const [job] = queue.splice(idx, 1);
job.resolve({ hit: false, path: '' });
ensureInflight.delete(storageKey);
} else if (!inflightStorageKeys.has(storageKey)) {
ensureInflight.delete(storageKey);
}
}
/** Queued + active ensure jobs (for library backfill watermark). */
export function coverEnsureQueueBacklog(): number {
return queue.length + inflight;
}
/** @internal Vitest-only — module singleton queue. */
export function __test_resetCoverEnsureQueue(): void {
queue = [];
inflight = 0;
nextOrderKey = 0;
inflightStorageKeys.clear();
ensureInflight.clear();
coverDownloadTail.clear();
}
/** @internal Vitest-only — queued cover art IDs front-to-back. */
export function __test_queuedCoverIds(): string[] {
return queue.map(j => j.ref.coverArtId);
}
function ensureMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean {
if (getDiskSrc(storageKey)) return true;
return Boolean(getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier));
}
/** Rust disk ensure — parallel slots; one download chain per cover art ID. */
export function coverEnsureQueued(
storageKey: string,
ref: CoverArtRef,
tier: CoverArtTier,
priority: CoverPrefetchPriority,
): Promise<{ hit: boolean; path: string }> {
if (ensureMemoryHit(storageKey, ref, tier)) {
return Promise.resolve({ hit: true, path: '' });
}
const existing = ensureInflight.get(storageKey);
if (existing) {
const queued = findQueuedJob(storageKey);
if (queued) bumpJob(queued, priority);
return existing;
}
const p = new Promise<{ hit: boolean; path: string }>(resolve => {
const orderKey = ++nextOrderKey;
const prev = findQueuedJob(storageKey);
if (prev) {
bumpJob(prev, priority);
const chain = prev.resolve;
prev.resolve = r => {
chain(r);
resolve(r);
};
trimQueue();
pump();
return;
}
queue.push({ storageKey, ref, tier, priority, orderKey, resolve });
sortQueue();
trimQueue();
pump();
}).finally(() => ensureInflight.delete(storageKey));
ensureInflight.set(storageKey, p);
return p;
}
+38
View File
@@ -0,0 +1,38 @@
import {
buildCoverArtUrl,
buildCoverArtUrlForServer,
} from '../api/subsonicStreamUrl';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import type { CoverArtRef, CoverArtTier } from './types';
/** Builds ephemeral getCoverArt URL — NOT a cache key */
export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): string {
const { coverArtId, serverScope } = ref;
if (serverScope.kind === 'server') {
return buildCoverArtUrlForServer(
serverScope.url,
serverScope.username,
serverScope.password,
coverArtId,
tier,
);
}
if (serverScope.kind === 'playback') {
const playbackSid = getPlaybackServerId();
const activeSid = useAuthStore.getState().activeServerId;
if (playbackSid && activeSid && playbackSid !== activeSid) {
const server = useAuthStore.getState().servers.find(s => s.id === playbackSid);
if (server) {
return buildCoverArtUrlForServer(
server.url,
server.username,
server.password,
coverArtId,
tier,
);
}
}
}
return buildCoverArtUrl(coverArtId, tier);
}
+17
View File
@@ -0,0 +1,17 @@
import type { CoverArtId } from './types';
export function coverArtIdFromAlbum(album: { coverArt?: string }): CoverArtId | null {
return album.coverArt ?? null;
}
export function coverArtIdFromSong(song: { coverArt?: string; id?: string }): CoverArtId | null {
return song.coverArt ?? null;
}
export function coverArtIdFromArtist(artist: { coverArt?: string; id: string }): CoverArtId {
return artist.coverArt ?? artist.id;
}
export function coverArtIdFromRadio(stationId: string): CoverArtId {
return `ra-${stationId}`;
}
+4
View File
@@ -0,0 +1,4 @@
/** Omit `src` until URL is ready — React 19 rejects `src=""` on `<img>`. */
export function coverImgSrc(url: string): string | undefined {
return url.length > 0 ? url : undefined;
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Unified cover art pipeline — see workdocs tasks/2026-05-cover-art-pipeline/contracts.md
*/
export * from './types';
export * from './tiers';
export * from './ids';
export * from './storageKeys';
export * from './reachability';
export * from './layoutSizes';
export * from './ref';
export { useCoverArt } from './useCoverArt';
export { CoverArtImage } from './CoverArtImage';
export {
clearAllDiskSrcCache,
forgetDiskSrc,
forgetDiskSrcPrefix,
getDiskSrc,
rememberDiskSrc,
} from './diskSrcCache';
export { usePlaybackCoverArt } from './usePlaybackCoverArt';
export { ensureCoverTierJs } from './resolveJs';
export { ensureCoverTierDiskSrc, ensureCoverTierDiskBlob } from './resolveDisk';
export { buildCoverArtFetchUrl } from './fetchUrl';
export {
coverStorageKey,
coverIndexKeyFromScope,
coverIndexKeyFromRef,
} from './storageKeys';
+23
View File
@@ -0,0 +1,23 @@
import { coverCacheEnsure } from '../../api/coverCache';
import { buildCoverArtFetchUrl } from '../fetchUrl';
import type { CoverArtRef } from '../types';
function fileUrlFromDiskPath(path: string): string {
if (!path) return '';
if (path.startsWith('file://')) return path;
return `file://${path}`;
}
/** Discord large image — disk 800 path or HTTPS fetch URL. */
export async function coverArtUrlForDiscord(ref: CoverArtRef): Promise<string | null> {
try {
const result = await coverCacheEnsure(ref, 800);
if (result.hit && result.path) {
return fileUrlFromDiskPath(result.path);
}
} catch {
/* fall through */
}
const url = buildCoverArtFetchUrl(ref, 800);
return url || null;
}
+13
View File
@@ -0,0 +1,13 @@
import { ensureCoverTierJs } from '../resolveJs';
import { resolveCoverDisplayTier } from '../tiers';
import type { CoverArtRef } from '../types';
/** Canvas/export helper — resolves tier from CSS px then returns a Blob. */
export async function loadCoverBlobForExport(
ref: CoverArtRef,
displayCssPx: number,
signal?: AbortSignal,
): Promise<Blob | null> {
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' });
return ensureCoverTierJs(ref, tier, signal);
}
+22
View File
@@ -0,0 +1,22 @@
import { coverCacheEnsure } from '../../api/coverCache';
import { buildCoverArtFetchUrl } from '../fetchUrl';
import type { CoverArtRef } from '../types';
function fileUrlFromDiskPath(path: string): string {
if (!path) return '';
if (path.startsWith('file://')) return path;
return `file://${path}`;
}
/** MPRIS cover — prefer on-disk 800.webp, else ephemeral HTTPS 800 URL. */
export async function coverArtUrlForMpris(ref: CoverArtRef): Promise<string> {
try {
const result = await coverCacheEnsure(ref, 800);
if (result.hit && result.path) {
return fileUrlFromDiskPath(result.path);
}
} catch {
/* fall through to fetch URL */
}
return buildCoverArtFetchUrl(ref, 800);
}
+3
View File
@@ -0,0 +1,3 @@
import type { CoverArtId } from './types';
export async function invalidateCoverArt(_serverId: string, _coverArtId: CoverArtId): Promise<void> {}
+28
View File
@@ -0,0 +1,28 @@
import { computeCardGridColumnCount, computeCellWidthPx } from '../utils/cardGridLayout';
export const COVER_DENSE_SEARCH_CSS_PX = 40;
export const COVER_DENSE_ARTIST_LIST_CSS_PX = 64;
export const COVER_DENSE_RAIL_CELL_CSS_PX = 180;
export const COVER_DENSE_GRID_MIN_CELL_CSS_PX = 140;
export function coverDisplayCssPxForAlbumGrid(containerWidthPx: number, maxColumns: number): number {
const cols = computeCardGridColumnCount(containerWidthPx, maxColumns);
return Math.round(computeCellWidthPx(containerWidthPx, cols));
}
export const GRID_COVER_WARM_LIMIT = 120;
/** Bounded album grids (Random Albums, paginated slice, …) — prime HTTP ensures after peek. */
export const GRID_COVER_PRIME_ALL_MAX = 48;
/** Props for `VirtualCardGrid` `warmGridCovers` on album-style pages. */
export function albumGridWarmCovers<T extends { coverArt?: string | null }>(
displayCssPx: number = COVER_DENSE_GRID_MIN_CELL_CSS_PX,
limit: number = GRID_COVER_WARM_LIMIT,
) {
return {
pickCoverArtId: (item: T) => item.coverArt,
displayCssPx,
limit,
};
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import CoverLightbox from '../components/CoverLightbox';
import { buildCoverArtFetchUrl } from './fetchUrl';
import { coverImgSrc } from './imgSrc';
import { ensureCoverTierDiskSrc } from './resolveDisk';
import type { CoverArtRef } from './types';
export function useCoverLightboxSrc(
ref: CoverArtRef | null,
opts?: { alt?: string },
): { open: () => void; lightbox: ReactNode; src: string; loading: boolean } {
const [open, setOpen] = useState(false);
const [src, setSrc] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !ref) return;
let cancelled = false;
setLoading(true);
void (async () => {
const diskSrc = await ensureCoverTierDiskSrc(ref, 2000);
if (cancelled) return;
if (diskSrc) {
setSrc(diskSrc);
} else {
setSrc(buildCoverArtFetchUrl(ref, 2000));
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [open, ref?.coverArtId, ref?.serverScope]);
useEffect(() => {
if (open) return;
setSrc('');
setLoading(false);
}, [open]);
const handleClose = useCallback(() => setOpen(false), []);
const handleOpen = useCallback(() => setOpen(true), []);
const lightbox = open && coverImgSrc(src) && !loading ? (
<CoverLightbox src={src} alt={opts?.alt ?? ''} onClose={handleClose} />
) : null;
return { open: handleOpen, lightbox, src, loading };
}
+4
View File
@@ -0,0 +1,4 @@
/** Revalidation manifest types — populated in wave 4 */
export type CoverRevalidateSchedulerState = {
lastRunDate: string;
};
+114
View File
@@ -0,0 +1,114 @@
import { coverCachePeekBatch } from '../api/coverCache';
import { getDiskSrc } from './diskSrcCache';
import { getDiskSrcForGrid } from './diskSrcLookup';
import { coverTrafficServerSwitchPaused } from './coverTraffic';
import { rememberGridDiskSrc } from './diskSrcLookup';
import { coverIndexKeyFromRef } from './storageKeys';
import type { CoverArtRef, CoverArtTier } from './types';
function peekMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean {
if (getDiskSrc(storageKey)) return true;
return Boolean(getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier));
}
type PeekJob = {
storageKey: string;
ref: CoverArtRef;
tier: CoverArtTier;
resolve: (hit: boolean) => void;
};
let flushScheduled = false;
const pending = new Map<string, PeekJob>();
const inflight = new Map<string, Promise<boolean>>();
function scheduleFlush(): void {
if (flushScheduled) return;
flushScheduled = true;
queueMicrotask(() => {
flushScheduled = false;
void flush();
});
}
async function flush(): Promise<void> {
if (coverTrafficServerSwitchPaused()) {
coverPeekCancelPending();
return;
}
const jobs = [...pending.values()];
pending.clear();
if (jobs.length === 0) return;
const needDisk: PeekJob[] = [];
for (const job of jobs) {
if (peekMemoryHit(job.storageKey, job.ref, job.tier)) {
job.resolve(true);
inflight.delete(job.storageKey);
} else {
needDisk.push(job);
}
}
if (needDisk.length === 0) return;
const hits = await coverCachePeekBatch(
needDisk.map(job => ({
serverIndexKey: coverIndexKeyFromRef(job.ref),
coverArtId: job.ref.coverArtId,
tier: job.tier,
})),
);
for (const job of needDisk) {
const path = hits[job.storageKey];
const hit = Boolean(
path
&& rememberGridDiskSrc(job.ref.serverScope, job.ref.coverArtId, job.tier, path),
);
job.resolve(hit);
inflight.delete(job.storageKey);
}
}
/** Disk-only peek batched per microtask — seeds `diskSrcCache` without `cover_cache_ensure`. */
export function coverPeekQueued(
storageKey: string,
ref: CoverArtRef,
tier: CoverArtTier,
): Promise<boolean> {
if (peekMemoryHit(storageKey, ref, tier)) {
return Promise.resolve(true);
}
const running = inflight.get(storageKey);
if (running) return running;
const p = new Promise<boolean>(resolve => {
const prev = pending.get(storageKey);
if (prev) {
const chain = prev.resolve;
prev.resolve = hit => {
chain(hit);
resolve(hit);
};
return;
}
pending.set(storageKey, { storageKey, ref, tier, resolve });
scheduleFlush();
}).finally(() => {
if (inflight.get(storageKey) === p) inflight.delete(storageKey);
});
inflight.set(storageKey, p);
return p;
}
/** Drop batched peeks (server switch) — callers get `false`. */
export function coverPeekCancelPending(): void {
const jobs = [...pending.values()];
pending.clear();
for (const job of jobs) {
job.resolve(false);
inflight.delete(job.storageKey);
}
}
+77
View File
@@ -0,0 +1,77 @@
import { coverCacheMayBackgroundDownload as ipcMayDownload } from '../api/coverCache';
import { coverIndexKeyFromRef } from './storageKeys';
import { coverServerReachable } from './reachability';
import type { CoverArtRef, CoverArtTier, CoverPrefetchPriority, CoverSurfaceKind } from './types';
const MAX_REGISTRY = 120;
const registry = new Map<string, { ref: CoverArtRef; priority: CoverPrefetchPriority }>();
function registryKey(ref: CoverArtRef): string {
return `${coverIndexKeyFromRef(ref)}:${ref.coverArtId}`;
}
export function coverPrefetchRegister(
refs: CoverArtRef[],
opts: {
surface: CoverSurfaceKind;
priority: CoverPrefetchPriority;
deriveTiers?: CoverArtTier[];
},
): () => void {
if (opts.surface !== 'dense') return () => {};
if (!coverCacheMayBackgroundDownload()) return () => {};
const keys: string[] = [];
for (const ref of refs) {
if (!ref.coverArtId || !coverServerReachable(ref.serverScope)) continue;
const key = registryKey(ref);
if (registry.size >= MAX_REGISTRY && !registry.has(key)) {
const drop = [...registry.entries()].find(([, v]) => v.priority === 'low');
if (drop) registry.delete(drop[0]);
}
registry.set(key, { ref, priority: opts.priority });
keys.push(key);
}
return () => {
for (const key of keys) registry.delete(key);
};
}
export function coverCacheMayBackgroundDownload(): boolean {
return ipcMayDownload();
}
/** Drop all page-registered prefetch targets (route change). */
export function coverPrefetchClearRegistry(): void {
registry.clear();
}
/** Drain registered IDs for background ensure (viewport / page batches). */
export function coverPrefetchDrainBatch(limit: number): CoverArtRef[] {
const sorted = [...registry.entries()].sort((a, b) => {
const rank = (p: CoverPrefetchPriority) =>
p === 'high' ? 0 : p === 'middle' ? 1 : 2;
return rank(a[1].priority) - rank(b[1].priority);
});
return sorted.slice(0, limit).map(([, v]) => v.ref);
}
/** Raise priority when a cover enters the viewport (e.g. horizontal album row). */
export function coverPrefetchBumpPriority(
ref: CoverArtRef,
priority: CoverPrefetchPriority,
): void {
if (!ref.coverArtId || !coverServerReachable(ref.serverScope)) return;
const key = registryKey(ref);
const existing = registry.get(key);
if (!existing) {
registry.set(key, { ref, priority });
return;
}
const rank = (p: CoverPrefetchPriority) =>
p === 'high' ? 0 : p === 'middle' ? 1 : 2;
if (rank(priority) < rank(existing.priority)) {
registry.set(key, { ref, priority });
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { CoverCacheStrategy } from '../utils/library/coverStrategy';
import {
coverStrategyAllowsLibraryBackfill,
coverStrategyAllowsRoutePrefetch,
} from '../utils/library/coverStrategy';
/** @deprecated Use `coverStrategyAllowsRoutePrefetch` */
export function coverPrefetchStrategyAllowsRoutePrefetch(
strategy: CoverCacheStrategy,
): boolean {
return coverStrategyAllowsRoutePrefetch(strategy);
}
/** @deprecated Use `coverStrategyAllowsLibraryBackfill` */
export function coverPrefetchStrategyAllowsLibraryBackfill(
strategy: CoverCacheStrategy,
): boolean {
return coverStrategyAllowsLibraryBackfill(strategy);
}
+13
View File
@@ -0,0 +1,13 @@
import { useAuthStore } from '../store/authStore';
import type { CoverServerScope } from './types';
/** Per-server reachability — active/playback use navigator + configured server */
export function coverServerReachable(scope: CoverServerScope): boolean {
if (scope.kind === 'server') {
return !!scope.url && !!scope.username;
}
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
const active = useAuthStore.getState().getActiveServer();
const baseUrl = useAuthStore.getState().getBaseUrl();
return !!(active && baseUrl);
}
+28
View File
@@ -0,0 +1,28 @@
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import type { CoverArtId, CoverArtRef, CoverServerScope } from './types';
export function coverArtRef(
coverArtId: CoverArtId,
serverScope: CoverServerScope = { kind: 'active' },
): CoverArtRef {
return { coverArtId, serverScope };
}
export function resolvePlaybackCoverScope(): CoverServerScope {
const playbackSid = getPlaybackServerId();
const activeSid = useAuthStore.getState().activeServerId;
if (playbackSid && activeSid && playbackSid !== activeSid) {
const server = useAuthStore.getState().servers.find(s => s.id === playbackSid);
if (server) {
return {
kind: 'server',
serverId: server.id,
url: server.url,
username: server.username,
password: server.password,
};
}
}
return { kind: 'playback' };
}
+57
View File
@@ -0,0 +1,57 @@
import { isTauri } from '@tauri-apps/api/core';
import { coverCacheEnsure } from '../api/coverCache';
import { invalidateCacheKey } from '../utils/imageCache';
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
import { coverStorageKey } from './storageKeys';
import type { CoverArtRef, CoverArtTier } from './types';
/**
* Full-res / lightbox — Rust WebP on disk (`cover-cache/…/2000.webp`), not IndexedDB.
*/
export async function ensureCoverTierDiskSrc(
ref: CoverArtRef,
tier: CoverArtTier,
): Promise<string> {
if (!ref.coverArtId || !isTauri()) return '';
const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
const cached = getDiskSrc(storageKey);
if (cached) return cached;
const result = await coverCacheEnsure(ref, tier, 'high');
if (!result.hit || !result.path) return '';
const src = rememberDiskSrc(storageKey, result.path);
if (src) {
void invalidateCacheKey(storageKey);
}
return src;
}
/** Blob consumers (export) — read back from disk asset URL after ensure. */
export async function ensureCoverTierDiskBlob(
ref: CoverArtRef,
tier: CoverArtTier,
signal?: AbortSignal,
): Promise<Blob | null> {
const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
const existing = getDiskSrc(storageKey);
if (existing) {
try {
const resp = await fetch(existing, { signal });
if (resp.ok) return resp.blob();
} catch {
/* fall through to ensure */
}
}
const src = await ensureCoverTierDiskSrc(ref, tier);
if (!src) return null;
try {
const resp = await fetch(src, { signal });
if (!resp.ok) return null;
return resp.blob();
} catch {
return null;
}
}
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureCoverTierJs } from './resolveJs';
import type { CoverArtRef } from './types';
vi.mock('../utils/imageCache', () => ({
getCachedBlob: vi.fn(async () => new Blob(['x'], { type: 'image/jpeg' })),
}));
vi.mock('../utils/imageCache/coverSiblings', () => ({
parseCoverCacheKey: (key: string) => {
const m = key.match(/^(.+):cover:(.+):(\d+)$/);
if (!m) return null;
return { stem: `${m[1]}:cover:${m[2]}`, size: Number(m[3]) };
},
probeSiblingCoverBlobInMemory: () => null,
probeSiblingCoverBlobFromIDB: async () => null,
scheduleSiblingVersusNetworkRace: vi.fn(),
}));
const { blobMap } = vi.hoisted(() => ({ blobMap: new Map<string, Blob>() }));
vi.mock('../utils/imageCache/blobCache', () => ({
blobCache: blobMap,
rememberBlob: (key: string, blob: Blob) => {
blobMap.set(key, blob);
},
}));
vi.mock('../utils/imageCache/idbStore', () => ({
putBlob: vi.fn(),
}));
vi.mock('./reachability', () => ({
coverServerReachable: () => true,
}));
vi.mock('./fetchUrl', () => ({
buildCoverArtFetchUrl: () => 'https://example.test/cover',
}));
const ref: CoverArtRef = {
coverArtId: 'al-1',
serverScope: { kind: 'active' },
};
describe('ensureCoverTierJs', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns null when cover id missing', async () => {
expect(await ensureCoverTierJs({ ...ref, coverArtId: '' }, 128)).toBeNull();
});
it('fetches via getCachedBlob on cold path', async () => {
const { getCachedBlob } = await import('../utils/imageCache');
vi.mocked(getCachedBlob).mockImplementation(async (_url, key) => {
const b = new Blob(['x'], { type: 'image/jpeg' });
blobMap.set(key, b);
return b;
});
const blob = await ensureCoverTierJs(ref, 128);
expect(blob).toBeTruthy();
expect(getCachedBlob).toHaveBeenCalled();
});
});
+141
View File
@@ -0,0 +1,141 @@
import { isTauri } from '@tauri-apps/api/core';
import { getCachedBlob } from '../utils/imageCache';
import { ensureCoverTierDiskBlob } from './resolveDisk';
import {
parseCoverCacheKey,
probeSiblingCoverBlobFromIDB,
probeSiblingCoverBlobInMemory,
scheduleSiblingVersusNetworkRace,
} from '../utils/imageCache/coverSiblings';
import { blobCache } from '../utils/imageCache/blobCache';
import { downscaleCoverBlob } from '../utils/cover/coverBlobDownscale';
import { rememberBlob } from '../utils/imageCache/blobCache';
import { putBlob } from '../utils/imageCache/idbStore';
import { buildCoverArtFetchUrl } from './fetchUrl';
import { coverServerReachable } from './reachability';
import { coverStorageKey } from './storageKeys';
import type { CoverArtRef, CoverArtTier } from './types';
const CANONICAL_TIER = 800 as CoverArtTier;
async function commitNormalizedTier(
cacheKey: string,
blob: Blob,
tier: CoverArtTier,
signal?: AbortSignal,
): Promise<Blob> {
const normalized = await downscaleCoverBlob(blob, tier, signal);
const out = normalized && normalized.size < blob.size * 0.92 ? normalized : blob;
putBlob(cacheKey, out);
rememberBlob(cacheKey, out);
return out;
}
/**
* Cold resolve: race canonical 800 chain vs direct tier fetch (§5.3).
*/
async function scheduleColdCoverRace(
ref: CoverArtRef,
tier: CoverArtTier,
signal: AbortSignal | undefined,
getPriority?: () => number,
): Promise<Blob | null> {
const targetKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
const canonicalKey = coverStorageKey(ref.serverScope, ref.coverArtId, CANONICAL_TIER);
const chainCtl = new AbortController();
const directCtl = new AbortController();
let winner = false;
const kill = () => {
chainCtl.abort();
directCtl.abort();
};
signal?.addEventListener('abort', kill, { once: true });
const tryWin = async (blob: Blob | null, key: string, normalizeTo: CoverArtTier) => {
if (!blob || winner || signal?.aborted) return;
winner = true;
kill();
const committed = await commitNormalizedTier(key, blob, normalizeTo, signal);
if (key !== targetKey && normalizeTo === tier) {
await commitNormalizedTier(targetKey, committed, tier, signal);
}
if (key === canonicalKey || normalizeTo === CANONICAL_TIER) {
rememberBlob(canonicalKey, committed);
}
return committed;
};
const chainBranch = (async () => {
const url = buildCoverArtFetchUrl(ref, CANONICAL_TIER);
const blob = await getCachedBlob(url, canonicalKey, signal, getPriority);
if (!blob || winner || signal?.aborted) return;
await tryWin(blob, canonicalKey, CANONICAL_TIER);
if (tier !== CANONICAL_TIER && !winner) {
const derived = await downscaleCoverBlob(blob, tier, signal);
if (derived) await tryWin(derived, targetKey, tier);
}
})();
const directBranch = (async () => {
const url = buildCoverArtFetchUrl(ref, tier);
const blob = await getCachedBlob(url, targetKey, signal, getPriority);
if (blob) await tryWin(blob, targetKey, tier);
})();
await Promise.allSettled([chainBranch, directBranch]);
return blobCache.get(targetKey) ?? null;
}
/**
* Ensure tier blob exists; run §5 races from implementation-spec.
*/
export async function ensureCoverTierJs(
ref: CoverArtRef,
tier: CoverArtTier,
signal?: AbortSignal,
getPriority?: () => number,
): Promise<Blob | null> {
if (!ref.coverArtId || signal?.aborted) return null;
const cacheKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
const mem = blobCache.get(cacheKey);
if (mem) return mem;
const fetchUrl = buildCoverArtFetchUrl(ref, tier);
const parsed = parseCoverCacheKey(cacheKey);
if (parsed) {
const provisional =
probeSiblingCoverBlobInMemory(parsed.stem, parsed.size) ??
(await probeSiblingCoverBlobFromIDB(parsed.stem, parsed.size));
if (provisional && !signal?.aborted) {
rememberBlob(cacheKey, provisional);
if (coverServerReachable(ref.serverScope)) {
scheduleSiblingVersusNetworkRace(fetchUrl, cacheKey, provisional, signal, getPriority);
}
return provisional;
}
}
if (!coverServerReachable(ref.serverScope)) {
return null;
}
if (tier === 2000) {
if (isTauri()) {
return ensureCoverTierDiskBlob(ref, tier, signal);
}
return getCachedBlob(buildCoverArtFetchUrl(ref, 2000), cacheKey, signal, getPriority);
}
const canonicalKey = coverStorageKey(ref.serverScope, ref.coverArtId, CANONICAL_TIER);
const hasChain =
blobCache.has(canonicalKey) ||
(parsed && (await probeSiblingCoverBlobFromIDB(parsed.stem, parsed.size)) !== null);
if (!hasChain && tier !== CANONICAL_TIER) {
return scheduleColdCoverRace(ref, tier, signal, getPriority);
}
return getCachedBlob(fetchUrl, cacheKey, signal, getPriority);
}
+7
View File
@@ -0,0 +1,7 @@
import type { CoverArtId, CoverRevalidateReason } from './types';
export async function coverRevalidateEnqueueIpc(
_serverId: string,
_coverArtId: CoverArtId,
_reason: CoverRevalidateReason,
): Promise<void> {}
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { coverIndexKeyFromScope, coverStorageKey } from './storageKeys';
const mockState = {
activeServerId: 'profile-uuid-1',
servers: [
{
id: 'profile-uuid-1',
url: 'http://music.local:4533',
username: 'u',
password: 'p',
name: 'Home',
},
{
id: 'profile-uuid-2',
url: 'https://nav.example.com/navidrome',
username: 'u2',
password: 'p2',
name: 'Remote',
},
],
getActiveServer: () => mockState.servers.find(s => s.id === mockState.activeServerId),
};
vi.mock('../store/authStore', () => ({
useAuthStore: { getState: () => mockState },
}));
vi.mock('../utils/playback/playbackServer', () => ({
getPlaybackServerId: () => null,
}));
describe('coverStorageKey', () => {
beforeEach(() => {
mockState.activeServerId = 'profile-uuid-1';
});
it('uses host index key for active scope (not profile uuid)', () => {
expect(coverStorageKey({ kind: 'active' }, 'al-42', 128)).toBe(
'music.local:4533:cover:al-42:128',
);
});
it('uses host index key from explicit server url', () => {
expect(
coverStorageKey(
{
kind: 'server',
serverId: 'profile-uuid-2',
url: 'https://nav.example.com/navidrome',
username: 'u',
password: 'p',
},
'ar-1',
512,
),
).toBe('nav.example.com/navidrome:cover:ar-1:512');
});
it('coverIndexKeyFromScope matches library-style keys', () => {
expect(coverIndexKeyFromScope({ kind: 'active' })).toBe('music.local:4533');
});
});
+45
View File
@@ -0,0 +1,45 @@
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import {
serverIndexKeyForProfile,
serverIndexKeyFromUrl,
} from '../utils/server/serverIndexKey';
import type { CoverArtId, CoverArtRef, CoverArtTier, CoverServerScope } from './types';
/**
* Stable server bucket for cover disk + IDB — same host index key as library SQLite (`server_id` column).
* Not the auth profile UUID; URL aliases (LAN vs public) will map to one key later.
*/
export function coverIndexKeyFromScope(scope: CoverServerScope): string {
if (scope.kind === 'server') {
return serverIndexKeyFromUrl(scope.url) || scope.serverId;
}
if (scope.kind === 'playback') {
const playbackSid = getPlaybackServerId();
const activeSid = useAuthStore.getState().activeServerId;
const sid = playbackSid || activeSid;
const server = sid
? useAuthStore.getState().servers.find(s => s.id === sid)
: undefined;
if (server) return serverIndexKeyForProfile(server);
return '_';
}
const server = useAuthStore.getState().getActiveServer();
if (server) return serverIndexKeyForProfile(server);
return '_';
}
export function coverIndexKeyFromRef(ref: CoverArtRef): string {
return coverIndexKeyFromScope(ref.serverScope);
}
/** @deprecated Use `coverIndexKeyFromScope` */
export const serverIdFromScope = coverIndexKeyFromScope;
export function coverStorageKey(
serverScope: CoverServerScope,
coverArtId: CoverArtId,
tier: CoverArtTier,
): string {
return `${coverIndexKeyFromScope(serverScope)}:cover:${coverArtId}:${tier}`;
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { resolveCoverDisplayTier } from './tiers';
describe('resolveCoverDisplayTier', () => {
it('caps dense grids at 512', () => {
expect(resolveCoverDisplayTier(300, { dpr: 2, surface: 'dense' })).toBe(512);
expect(resolveCoverDisplayTier(300, { dpr: 1, surface: 'dense' })).toBe(512);
});
it('allows 800 on sparse surfaces', () => {
expect(resolveCoverDisplayTier(300, { dpr: 2, surface: 'sparse' })).toBe(800);
});
it('returns 2000 for full-res', () => {
expect(resolveCoverDisplayTier(40, { fullRes: true })).toBe(2000);
});
it('picks smallest tier >= needed px', () => {
expect(resolveCoverDisplayTier(40, { dpr: 2, surface: 'dense' })).toBe(128);
expect(resolveCoverDisplayTier(64, { dpr: 2, surface: 'dense' })).toBe(128);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { COVER_ART_DENSE_MAX_TIER, COVER_ART_TIERS, type CoverArtTier, type CoverSurfaceKind } from './types';
export { COVER_ART_TIERS, COVER_ART_DENSE_MAX_TIER };
export function resolveCoverDisplayTier(
displayCssPx: number,
opts?: { dpr?: number; fullRes?: boolean; surface?: CoverSurfaceKind },
): CoverArtTier {
if (opts?.fullRes) return 2000;
const dpr = opts?.dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
const neededPx = Math.ceil(displayCssPx * dpr);
let tier = COVER_ART_TIERS.find(t => t !== 2000 && t >= neededPx) ?? 800;
if (opts?.surface === 'dense' && tier > COVER_ART_DENSE_MAX_TIER) {
tier = COVER_ART_DENSE_MAX_TIER;
}
return tier;
}
+39
View File
@@ -0,0 +1,39 @@
/** Subsonic / Navidrome cover art ID passed to getCoverArt.view */
export type CoverArtId = string;
/** Fixed storage / server-request tiers */
export const COVER_ART_TIERS = [64, 128, 256, 512, 800, 2000] as const;
/** Max tier for dense grids — decode perf */
export const COVER_ART_DENSE_MAX_TIER = 512 as const;
export type CoverArtTier = (typeof COVER_ART_TIERS)[number];
export type CoverServerScope =
| { kind: 'active' }
| { kind: 'playback' }
| { kind: 'server'; serverId: string; url: string; username: string; password: string };
export type CoverSurfaceKind = 'dense' | 'sparse';
export type CoverPrefetchPriority = 'high' | 'middle' | 'low';
export type CoverArtRef = {
coverArtId: CoverArtId;
serverScope: CoverServerScope;
};
export type CoverArtHandle = {
src: string;
storageKey: string;
/** Alias for {@link storageKey} — migration shim for legacy `cacheKey` consumers */
cacheKey: string;
tier: CoverArtTier;
provisional: boolean;
/** Retry disk ensure after a broken/stale `src` (e.g. post cache clear). */
onImgError?: () => void;
};
export type CoverFullResIntent = { kind: 'tier2000' };
export type CoverRevalidateReason = 'library_delta' | 'scheduled' | 'upload' | 'manual';
+151
View File
@@ -0,0 +1,151 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react';
import { coverEnsureQueued, coverEnsureRelease } from './ensureQueue';
import { coverPeekQueued } from './peekQueue';
import { getDiskSrcForGrid, seedGridDiskSrcCache } from './diskSrcLookup';
import {
forgetDiskSrc,
getDiskSrc,
getDiskSrcCacheGeneration,
rememberDiskSrc,
subscribeDiskSrcCache,
} from './diskSrcCache';
import { subscribeCoverDiskReady } from './diskHandoff';
import { coverArtRef } from './ref';
import { coverServerReachable } from './reachability';
import { coverStorageKey } from './storageKeys';
import { resolveCoverDisplayTier } from './tiers';
import type {
CoverArtHandle,
CoverArtId,
CoverPrefetchPriority,
CoverServerScope,
CoverSurfaceKind,
} from './types';
/**
* Disk cache in Rust (WebP tiers) — no webview `getCoverArt` fetch when server is reachable.
*/
export function useCoverArt(
coverArtId: CoverArtId | null | undefined,
displayCssPx: number,
opts?: {
serverScope?: CoverServerScope;
surface?: CoverSurfaceKind;
fullRes?: boolean;
fetchQueueBias?: number;
observeRootMargin?: string;
alt?: string;
/** Download / ensure ordering — visible cells should pass `high`. */
ensurePriority?: CoverPrefetchPriority;
},
): CoverArtHandle {
const serverScope = opts?.serverScope ?? { kind: 'active' };
const surface = opts?.surface ?? 'sparse';
const reachable = coverServerReachable(serverScope);
const tier = useMemo(
() =>
coverArtId
? resolveCoverDisplayTier(displayCssPx, {
surface,
fullRes: opts?.fullRes,
})
: 128,
[coverArtId, displayCssPx, surface, opts?.fullRes],
);
const ref = useMemo(
() => (coverArtId ? coverArtRef(coverArtId, serverScope) : null),
[coverArtId, serverScope],
);
const storageKey = useMemo(
() => (ref ? coverStorageKey(ref.serverScope, ref.coverArtId, tier) : ''),
[ref, tier],
);
const ensurePriority: CoverPrefetchPriority = opts?.ensurePriority ?? 'middle';
/** Dense grids: peek on mount; HTTP ensure only when IO marks the cell `high`. */
const deferEnsureUntilVisible = surface === 'dense' && ensurePriority !== 'high';
const readCachedSrc = useCallback(() => {
if (!ref) return '';
if (surface === 'dense') {
return getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier);
}
return getDiskSrc(storageKey);
}, [ref, storageKey, surface, tier]);
useSyncExternalStore(subscribeDiskSrcCache, getDiskSrcCacheGeneration);
const cachedSrc = readCachedSrc();
const applyDiskPath = useCallback((path: string) => {
if (!ref || !storageKey) return;
if (!path) {
forgetDiskSrc(storageKey);
return;
}
if (surface === 'dense') {
seedGridDiskSrcCache(ref.serverScope, ref.coverArtId, tier, path);
} else {
rememberDiskSrc(storageKey, path);
}
}, [ref, storageKey, tier, surface, readCachedSrc]);
useEffect(() => {
if (!ref || !storageKey) return;
if (readCachedSrc()) return;
let cancelled = false;
void (async () => {
const peekHit = await coverPeekQueued(storageKey, ref, tier);
if (cancelled) return;
if (peekHit || readCachedSrc()) return;
if (reachable && !deferEnsureUntilVisible) {
const result = await coverEnsureQueued(storageKey, ref, tier, ensurePriority);
if (cancelled) return;
if (result.hit && result.path) {
applyDiskPath(result.path);
}
}
})();
const unsubDisk = subscribeCoverDiskReady(storageKey, path => {
if (!cancelled && path) applyDiskPath(path);
});
return () => {
cancelled = true;
unsubDisk();
coverEnsureRelease(storageKey);
};
}, [
ref,
storageKey,
tier,
reachable,
ensurePriority,
deferEnsureUntilVisible,
applyDiskPath,
readCachedSrc,
]);
const src = cachedSrc;
const provisional = Boolean(ref && storageKey && !src);
const onImgError = useCallback(() => {
forgetDiskSrc(storageKey);
if (ref && reachable) {
void coverEnsureQueued(storageKey, ref, tier, 'high').then(result => {
if (result.hit && result.path) applyDiskPath(result.path);
});
}
}, [storageKey, ref, tier, reachable, applyDiskPath]);
return { src, storageKey, cacheKey: storageKey, tier, provisional, onImgError };
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { coverCacheStats } from '../api/coverCache';
import { coverStrategyAllowsRoutePrefetch } from '../utils/library/coverStrategy';
import { useCoverStrategyStore } from '../store/coverStrategyStore';
import { useAuthStore } from '../store/authStore';
import { coverPrefetchDrainBatch } from './prefetchRegistry';
import { coverTrafficBackgroundPaused } from './coverTraffic';
import { coverEnsureQueued } from './ensureQueue';
import { coverStorageKey } from './storageKeys';
import { resolveCoverDisplayTier } from './tiers';
import type { CoverArtTier } from './types';
const STEADY_POLL_MS = 1500;
const BATCH_LIMIT = 12;
/** Match dense card thumbs (~160 CSS px) — prefetch 128 wasted a full re-ensure for 512. */
const DENSE_PREFETCH_TIER = resolveCoverDisplayTier(160, { surface: 'dense' }) as CoverArtTier;
/**
* Background cover warm-up — low rate; Rust HTTP only (never competes with webview grid fetches).
*/
export function useCoverArtPrefetch(enabled = true): void {
const activeServerId = useAuthStore(s => s.activeServerId);
const strategy = useCoverStrategyStore(s => s.getStrategyForServer(activeServerId));
useEffect(() => {
if (!enabled || !activeServerId || !coverStrategyAllowsRoutePrefetch(strategy)) return;
let cancelled = false;
void (async () => {
while (!cancelled) {
if (coverTrafficBackgroundPaused()) {
await new Promise(r => setTimeout(r, STEADY_POLL_MS));
continue;
}
const stats = await coverCacheStats().catch(() => null);
if (stats && !stats.autoDownloadEnabled) {
await new Promise(r => setTimeout(r, STEADY_POLL_MS * 2));
continue;
}
const batch = coverPrefetchDrainBatch(BATCH_LIMIT);
if (batch.length > 0) {
await Promise.all(
batch.map(ref => {
const key = coverStorageKey(ref.serverScope, ref.coverArtId, DENSE_PREFETCH_TIER);
return coverEnsureQueued(key, ref, DENSE_PREFETCH_TIER, 'low');
}),
);
}
await new Promise(r => setTimeout(r, STEADY_POLL_MS));
}
})();
return () => {
cancelled = true;
};
}, [enabled, activeServerId, strategy]);
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
const TICK_MS = 60_000;
/** Scheduled cover revalidate slices (Rust); pauses while streaming. */
export function useCoverRevalidateScheduler(enabled = true): void {
const cycleDays = useAuthStore(s => s.coverRevalidateCycleDays);
const isPlaying = usePlayerStore(s => s.isPlaying);
useEffect(() => {
if (!enabled || isPlaying) return;
let cancelled = false;
const tick = () => {
if (cancelled || usePlayerStore.getState().isPlaying) return;
void invoke<number>('cover_revalidate_tick', { cycleDays }).catch(() => {});
};
tick();
const id = window.setInterval(tick, TICK_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [enabled, cycleDays, isPlaying]);
}
+91
View File
@@ -0,0 +1,91 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CoverArtHandle } from './types';
import { usePlaybackCoverArt } from './usePlaybackCoverArt';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { makeTrack } from '../test/helpers/factories';
import { resetAllStores } from '../test/helpers/storeReset';
const hoisted = vi.hoisted(() => ({
useCoverArtMock: vi.fn(
(_coverArtId?: unknown, _displayCssPx?: unknown, _opts?: unknown): CoverArtHandle => ({
src: '',
storageKey: '',
cacheKey: '',
tier: 128,
provisional: false,
}),
),
}));
vi.mock('./useCoverArt', () => ({
useCoverArt: hoisted.useCoverArtMock,
}));
function seedPlaybackState(): { active: string; playback: string } {
const active = useAuthStore.getState().addServer({
name: 'Active',
url: 'https://active.test',
username: 'active-user',
password: 'active-pass',
});
const playback = useAuthStore.getState().addServer({
name: 'Playback',
url: 'https://playback-a.test',
username: 'play-user',
password: 'play-pass',
});
useAuthStore.getState().setActiveServer(active);
const track = makeTrack({ id: 'song-1', coverArt: 'cover-1' });
usePlayerStore.setState({
queue: [track],
queueIndex: 0,
queueServerId: playback,
currentTrack: track,
});
return { active, playback };
}
describe('usePlaybackCoverArt', () => {
beforeEach(() => {
resetAllStores();
hoisted.useCoverArtMock.mockClear();
});
it('recomputes server scope when playback server credentials change', async () => {
const { playback } = seedPlaybackState();
const { rerender } = renderHook(() => usePlaybackCoverArt('cover-1', 300));
const calls = hoisted.useCoverArtMock.mock.calls as Array<
[unknown, unknown, { serverScope?: Record<string, unknown> }]
>;
const firstScope = calls[0]?.[2]?.serverScope;
expect(firstScope).toMatchObject({
kind: 'server',
serverId: playback,
url: 'https://playback-a.test',
username: 'play-user',
password: 'play-pass',
});
const updatedServers = useAuthStore.getState().servers.map(server =>
server.id === playback
? { ...server, url: 'https://playback-b.test', password: 'play-pass-2' }
: server,
);
useAuthStore.setState({ servers: updatedServers });
rerender();
await waitFor(() => {
const latestScope = calls[calls.length - 1]?.[2]?.serverScope;
expect(latestScope).toMatchObject({
kind: 'server',
serverId: playback,
url: 'https://playback-b.test',
username: 'play-user',
password: 'play-pass-2',
});
});
});
});
+30
View File
@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import { resolvePlaybackCoverScope } from './ref';
import type { CoverArtHandle, CoverArtId } from './types';
import { useCoverArt } from './useCoverArt';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
/** Cover art for playback queue — uses queue server when it differs from browsed server. */
export function usePlaybackCoverArt(
coverArtId: CoverArtId | undefined,
displayCssPx: number,
): CoverArtHandle {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueLength = usePlayerStore(s => s.queue.length);
const activeServerId = useAuthStore(s => s.activeServerId);
const serversFingerprint = useAuthStore(s =>
s.servers
.map(srv => `${srv.id}\u0001${srv.url}\u0001${srv.username}\u0001${srv.password}`)
.join('\u0002'),
);
const scope = useMemo(
() => resolvePlaybackCoverScope(),
[queueServerId, queueLength, activeServerId, serversFingerprint],
);
return useCoverArt(coverArtId, displayCssPx, {
serverScope: scope,
surface: 'sparse',
});
}
+204
View File
@@ -0,0 +1,204 @@
import { coverCachePeekBatch } from '../api/coverCache';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { coverEnsureQueued } from './ensureQueue';
import { getDiskSrcForGrid, rememberGridDiskSrc } from './diskSrcLookup';
import { coverArtRef } from './ref';
import { coverIndexKeyFromRef, coverStorageKey } from './storageKeys';
import { resolveCoverDisplayTier } from './tiers';
import type { CoverArtRef, CoverArtTier, CoverSurfaceKind } from './types';
export type CoverWarmItem = {
ref: CoverArtRef;
tier: CoverArtTier;
storageKey: string;
};
export function coverWarmItem(
coverArtId: string,
displayCssPx: number,
surface: CoverSurfaceKind = 'dense',
): CoverWarmItem {
const ref = coverArtRef(coverArtId);
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
return {
ref,
tier,
storageKey: coverStorageKey(ref.serverScope, ref.coverArtId, tier),
};
}
export function collectAlbumCoverWarmItems(
albums: ReadonlyArray<{ coverArt?: string | null }>,
displayCssPx: number,
surface: CoverSurfaceKind = 'dense',
limit = 96,
): CoverWarmItem[] {
const out: CoverWarmItem[] = [];
for (const a of albums) {
if (!a.coverArt || out.length >= limit) break;
out.push(coverWarmItem(a.coverArt, displayCssPx, surface));
}
return out;
}
/**
* One IPC round-trip: seed `diskSrcCache` from existing `.webp` before cells hit the ensure queue.
*/
export async function warmCoverDiskSrcBatch(items: CoverWarmItem[]): Promise<number> {
if (items.length === 0) return 0;
const hits = await coverCachePeekBatch(
items.map(item => ({
serverIndexKey: coverIndexKeyFromRef(item.ref),
coverArtId: item.ref.coverArtId,
tier: item.tier,
})),
);
let warmed = 0;
for (const item of items) {
const path = hits[item.storageKey];
if (
path
&& rememberGridDiskSrc(item.ref.serverScope, item.ref.coverArtId, item.tier, path)
) {
warmed += 1;
}
}
return warmed;
}
/** High-priority ensure for albums still missing disk `src` after peek. */
export async function ensureAlbumCoverMisses(
albums: ReadonlyArray<{ coverArt?: string | null }>,
displayCssPx: number,
opts?: { surface?: CoverSurfaceKind; limit?: number },
): Promise<void> {
const surface = opts?.surface ?? 'dense';
const limit = opts?.limit ?? albums.length;
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
const slice = albums.slice(0, limit);
const needEnsure = slice.filter(album => {
if (!album.coverArt) return false;
return !getDiskSrcForGrid({ kind: 'active' }, album.coverArt, tier);
});
if (needEnsure.length === 0) return;
const PRIME_CHUNK = 8;
for (let i = 0; i < needEnsure.length; i += PRIME_CHUNK) {
const chunk = needEnsure.slice(i, i + PRIME_CHUNK);
await Promise.all(
chunk.map(async album => {
const id = album.coverArt!;
const ref = coverArtRef(id);
const key = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
const result = await coverEnsureQueued(key, ref, tier, 'high');
if (result.hit && result.path) {
rememberGridDiskSrc(ref.serverScope, ref.coverArtId, tier, result.path);
}
}),
);
}
}
/**
* Peek + high-priority ensure so cards paint with `src` on first frame.
*/
export async function primeAlbumCoversForDisplay(
albums: ReadonlyArray<{ coverArt?: string | null }>,
displayCssPx: number,
opts?: { surface?: CoverSurfaceKind; limit?: number; disabled?: boolean },
): Promise<void> {
if (opts?.disabled) return;
const surface = opts?.surface ?? 'dense';
const limit = opts?.limit ?? albums.length;
const items = collectAlbumCoverWarmItems(albums, displayCssPx, surface, limit);
if (items.length === 0) return;
await warmCoverDiskSrcBatch(items);
await ensureAlbumCoverMisses(albums, displayCssPx, { surface, limit });
}
function dedupeWarmItems(items: CoverWarmItem[]): CoverWarmItem[] {
const seen = new Set<string>();
const out: CoverWarmItem[] = [];
for (const item of items) {
if (seen.has(item.storageKey)) continue;
seen.add(item.storageKey);
out.push(item);
}
return out;
}
export async function warmHomeMainstageCovers(snapshot: {
heroAlbums: SubsonicAlbum[];
recent: SubsonicAlbum[];
random: SubsonicAlbum[];
mostPlayed: SubsonicAlbum[];
recentlyPlayed: SubsonicAlbum[];
starred: SubsonicAlbum[];
discoverSongs?: Array<{ coverArt?: string | null }>;
}): Promise<void> {
const items = dedupeWarmItems([
...collectAlbumCoverWarmItems(snapshot.heroAlbums, 220, 'dense', 12),
...collectAlbumCoverWarmItems(snapshot.recent, 300, 'dense', 24),
...collectAlbumCoverWarmItems(snapshot.random, 300, 'dense', 24),
...collectAlbumCoverWarmItems(snapshot.mostPlayed, 300, 'dense', 20),
...collectAlbumCoverWarmItems(snapshot.recentlyPlayed, 300, 'dense', 20),
...collectAlbumCoverWarmItems(snapshot.starred, 300, 'dense', 20),
...collectAlbumCoverWarmItems(snapshot.discoverSongs ?? [], 200, 'dense', 20),
]);
await warmCoverDiskSrcBatch(items);
// Prepare above-the-fold mainstage covers ahead of return navigation:
// if a refreshed snapshot introduces new albums not yet on disk, ensure them
// now in background so Hero / first rows don't wait on per-cell ensure.
await Promise.allSettled([
ensureAlbumCoverMisses(snapshot.heroAlbums, 220, { surface: 'dense', limit: 8 }),
ensureAlbumCoverMisses(snapshot.recent, 300, { surface: 'dense', limit: 14 }),
ensureAlbumCoverMisses(snapshot.random, 300, { surface: 'dense', limit: 10 }),
]);
// Fire-and-forget decode warmup to reduce first-paint "from cache" delay.
void predecodeWarmAlbums(snapshot.heroAlbums, 220, 8);
void predecodeWarmAlbums(snapshot.recent, 300, 10);
void predecodeWarmAlbums(snapshot.random, 300, 8);
}
async function predecodeWarmAlbums(
albums: ReadonlyArray<{ coverArt?: string | null }>,
displayCssPx: number,
limit: number,
): Promise<void> {
if (typeof window === 'undefined') return;
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
const urls: string[] = [];
for (const album of albums) {
if (!album.coverArt || urls.length >= limit) continue;
const src = getDiskSrcForGrid({ kind: 'active' }, album.coverArt, tier);
if (!src) continue;
urls.push(src);
}
if (urls.length === 0) return;
await Promise.allSettled(
urls.map(
src =>
new Promise<void>(resolve => {
const img = new Image();
img.decoding = 'async';
img.src = src;
if (img.complete) {
resolve();
return;
}
img.onload = () => resolve();
img.onerror = () => resolve();
if ('decode' in img) {
void (img as HTMLImageElement).decode().then(resolve).catch(resolve);
}
}),
),
);
}