mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { CoverArtId } from './types';
|
||||
|
||||
export async function invalidateCoverArt(_serverId: string, _coverArtId: CoverArtId): Promise<void> {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Revalidation manifest types — populated in wave 4 */
|
||||
export type CoverRevalidateSchedulerState = {
|
||||
lastRunDate: string;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { CoverArtId, CoverRevalidateReason } from './types';
|
||||
|
||||
export async function coverRevalidateEnqueueIpc(
|
||||
_serverId: string,
|
||||
_coverArtId: CoverArtId,
|
||||
_reason: CoverRevalidateReason,
|
||||
): Promise<void> {}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user