mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +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,59 @@
|
||||
import { useEffect } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import {
|
||||
clearAllDiskSrcCache,
|
||||
forgetDiskSrcPrefix,
|
||||
rememberDiskSrc,
|
||||
} from '../../cover/diskSrcCache';
|
||||
import { notifyCoverDiskReady } from '../../cover/diskHandoff';
|
||||
import { invalidateCacheKey } from '../../utils/imageCache';
|
||||
import { COVER_ART_TIERS } from '../../cover/tiers';
|
||||
import type { CoverArtTier } from '../../cover/types';
|
||||
|
||||
type CoverTierReadyPayload = {
|
||||
serverIndexKey: string;
|
||||
coverArtId: string;
|
||||
tier: CoverArtTier;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type CoverEvictedPayload = {
|
||||
serverIndexKey: string;
|
||||
coverArtId: string;
|
||||
};
|
||||
|
||||
/** Rust → UI: disk `.webp` ready — do not invalidate IDB (that caused webview refetch storms). */
|
||||
export function useCoverArtBridge(): void {
|
||||
useEffect(() => {
|
||||
const unsubs: Array<() => void> = [];
|
||||
void (async () => {
|
||||
unsubs.push(
|
||||
await listen<CoverTierReadyPayload>('cover:tier-ready', ev => {
|
||||
const { serverIndexKey, coverArtId, tier, path } = ev.payload;
|
||||
if (!path) return;
|
||||
const key = `${serverIndexKey}:cover:${coverArtId}:${tier}`;
|
||||
rememberDiskSrc(key, path);
|
||||
notifyCoverDiskReady(key, path);
|
||||
void invalidateCacheKey(key);
|
||||
}),
|
||||
);
|
||||
unsubs.push(
|
||||
await listen('cover:cache-cleared', () => {
|
||||
clearAllDiskSrcCache();
|
||||
}),
|
||||
);
|
||||
unsubs.push(
|
||||
await listen<CoverEvictedPayload>('cover:evicted', ev => {
|
||||
const { serverIndexKey, coverArtId } = ev.payload;
|
||||
forgetDiskSrcPrefix(serverIndexKey, coverArtId);
|
||||
for (const tier of COVER_ART_TIERS) {
|
||||
notifyCoverDiskReady(`${serverIndexKey}:cover:${coverArtId}:${tier}`, '');
|
||||
}
|
||||
}),
|
||||
);
|
||||
})();
|
||||
return () => {
|
||||
for (const u of unsubs) u();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
const EVICT_TICK_MS = 45_000;
|
||||
|
||||
/** LRU eviction when cover disk cache crosses high watermark (spec §2.3). */
|
||||
export function useCoverCacheEvictTick(enabled = true): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const tick = () => {
|
||||
void invoke<number>('cover_cache_evict_tick', {}).catch(() => {});
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, EVICT_TICK_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
coverTrafficBeginNavigation,
|
||||
coverTrafficEndNavigation,
|
||||
} from '../cover/coverTraffic';
|
||||
|
||||
/**
|
||||
* On route change: cancel queued peek/ensure/prefetch from the old page and
|
||||
* briefly pause library backfill so the new page can warm covers first.
|
||||
*/
|
||||
export function useCoverNavigationPriority(): void {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
coverTrafficBeginNavigation();
|
||||
coverTrafficEndNavigation();
|
||||
return () => {
|
||||
coverTrafficBeginNavigation();
|
||||
};
|
||||
}, [pathname]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
coverCacheRestHost,
|
||||
libraryCoverBackfillConfigure,
|
||||
libraryCoverBackfillResetCursor,
|
||||
libraryCoverBackfillRunFullPass,
|
||||
librarySqlServerId,
|
||||
} from '../api/coverCache';
|
||||
import { coverStrategyAllowsLibraryBackfill } from '../utils/library/coverStrategy';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useCoverStrategyStore } from '../store/coverStrategyStore';
|
||||
import { subscribeLibraryCoverBackfillWake } from '../utils/library/coverBackfillWake';
|
||||
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
||||
|
||||
/**
|
||||
* Library cover warm-up — configure session in Rust; full pass runs natively.
|
||||
*
|
||||
* - `library_cover_backfill_run_full_pass` on configure / manual wake
|
||||
* - `library:sync-idle` handled in Rust (not throttled with the webview)
|
||||
*/
|
||||
export function useLibraryCoverBackfill(enabled = true): void {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const strategy = useCoverStrategyStore(s =>
|
||||
s.getStrategyForServer(activeServerId),
|
||||
);
|
||||
const server = useAuthStore(s =>
|
||||
s.activeServerId ? s.servers.find(srv => srv.id === s.activeServerId) : undefined,
|
||||
);
|
||||
const getBaseUrl = useAuthStore(s => s.getBaseUrl);
|
||||
|
||||
useEffect(() => {
|
||||
const kick = () => {
|
||||
void libraryCoverBackfillRunFullPass();
|
||||
};
|
||||
const unsubWake = subscribeLibraryCoverBackfillWake(kick);
|
||||
return unsubWake;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const disable = () => {
|
||||
void libraryCoverBackfillConfigure({
|
||||
enabled: false,
|
||||
serverIndexKey: '',
|
||||
libraryServerId: '',
|
||||
restBaseUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
};
|
||||
|
||||
if (
|
||||
!enabled
|
||||
|| !coverStrategyAllowsLibraryBackfill(strategy)
|
||||
|| !activeServerId
|
||||
|| !server
|
||||
) {
|
||||
disable();
|
||||
return disable;
|
||||
}
|
||||
|
||||
const indexKey = serverIndexKeyForProfile(server);
|
||||
const baseUrl = getBaseUrl();
|
||||
void (async () => {
|
||||
await libraryCoverBackfillConfigure({
|
||||
enabled: true,
|
||||
serverIndexKey: indexKey,
|
||||
libraryServerId: librarySqlServerId(activeServerId),
|
||||
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
});
|
||||
await libraryCoverBackfillResetCursor();
|
||||
await libraryCoverBackfillRunFullPass();
|
||||
})();
|
||||
|
||||
return disable;
|
||||
}, [enabled, strategy, activeServerId, server?.url, server?.username, server?.password, getBaseUrl]);
|
||||
}
|
||||
@@ -57,12 +57,113 @@ function subsonicCacheKey(serverId: string, id: string): string {
|
||||
// mismatched blob in IndexedDB and never recover. See PR #732 for the same
|
||||
// fix inside `NowPlayingInfo.tsx`.
|
||||
type IdSlot<T> = { id: string; value: T } | null;
|
||||
type KeySlot<T> = { key: string; value: T } | null;
|
||||
function seedSlot<T>(id: string, lookup: (id: string) => T | undefined): IdSlot<T> {
|
||||
if (!id) return null;
|
||||
const cached = lookup(id);
|
||||
return cached === undefined ? null : { id, value: cached };
|
||||
}
|
||||
|
||||
function seedKeySlot<T>(key: string, lookup: (key: string) => T | undefined): KeySlot<T> {
|
||||
if (!key) return null;
|
||||
const cached = lookup(key);
|
||||
return cached === undefined ? null : { key, value: cached };
|
||||
}
|
||||
|
||||
export async function prewarmNowPlayingFetchers(
|
||||
deps: NowPlayingFetchersDeps,
|
||||
): Promise<void> {
|
||||
const {
|
||||
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
|
||||
lastfmUsername, currentTrack, subsonicServerId, fetchEnabled = true,
|
||||
} = deps;
|
||||
|
||||
if (!fetchEnabled || !subsonicServerId) return;
|
||||
|
||||
const jobs: Array<Promise<unknown>> = [];
|
||||
|
||||
if (songId) {
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
|
||||
if (songMetaCache.get(cacheKey) === undefined) {
|
||||
jobs.push(
|
||||
getSongForServer(subsonicServerId, songId)
|
||||
.then(v => songMetaCache.set(cacheKey, v ?? null))
|
||||
.catch(() => songMetaCache.set(cacheKey, null)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (artistId) {
|
||||
const artistKey = subsonicCacheKey(subsonicServerId, artistId);
|
||||
if (artistInfoCache.get(artistKey) === undefined) {
|
||||
jobs.push(
|
||||
getArtistInfoForServer(subsonicServerId, artistId, {
|
||||
similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined,
|
||||
})
|
||||
.then(v => artistInfoCache.set(artistKey, v ?? null))
|
||||
.catch(() => artistInfoCache.set(artistKey, null)),
|
||||
);
|
||||
}
|
||||
if (discographyCache.get(artistKey) === undefined) {
|
||||
jobs.push(
|
||||
getArtistForServer(subsonicServerId, artistId)
|
||||
.then(v => discographyCache.set(artistKey, v.albums))
|
||||
.catch(() => discographyCache.set(artistKey, [])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (albumId) {
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
|
||||
if (albumCache.get(cacheKey) === undefined) {
|
||||
jobs.push(
|
||||
getAlbumForServer(subsonicServerId, albumId)
|
||||
.then(v => albumCache.set(cacheKey, v))
|
||||
.catch(() => albumCache.set(cacheKey, null)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (artistName) {
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistName);
|
||||
if (topSongsCache.get(cacheKey) === undefined) {
|
||||
jobs.push(
|
||||
getTopSongsForServer(subsonicServerId, artistName)
|
||||
.then(v => topSongsCache.set(cacheKey, v))
|
||||
.catch(() => topSongsCache.set(cacheKey, [])),
|
||||
);
|
||||
}
|
||||
if (enableBandsintown && tourCache.get(artistName) === undefined) {
|
||||
jobs.push(
|
||||
fetchBandsintownEvents(artistName)
|
||||
.then(v => tourCache.set(artistName, v))
|
||||
.catch(() => tourCache.set(artistName, [])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastfmIsConfigured() && currentTrack) {
|
||||
const trackKey = `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}`;
|
||||
if (lfmTrackCache.get(trackKey) === undefined) {
|
||||
jobs.push(
|
||||
lastfmGetTrackInfo(currentTrack.artist, currentTrack.title, lastfmUsername || undefined)
|
||||
.then(v => lfmTrackCache.set(trackKey, v))
|
||||
.catch(() => lfmTrackCache.set(trackKey, null)),
|
||||
);
|
||||
}
|
||||
const artistKey = `${currentTrack.artist} ${lastfmUsername}`;
|
||||
if (lfmArtistCache.get(artistKey) === undefined) {
|
||||
jobs.push(
|
||||
lastfmGetArtistStats(currentTrack.artist, lastfmUsername || undefined)
|
||||
.then(v => lfmArtistCache.set(artistKey, v))
|
||||
.catch(() => lfmArtistCache.set(artistKey, null)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(jobs);
|
||||
}
|
||||
|
||||
export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingFetchersResult {
|
||||
const {
|
||||
songId, artistId, albumId, artistName, enableBandsintown, audiomuseNavidromeEnabled,
|
||||
@@ -81,12 +182,19 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
seedSlot(artistId && subsonicServerId ? artistId : '', k => discographyCache.get(subsonicCacheKey(subsonicServerId, k))));
|
||||
|
||||
// Name-keyed / global state — no cacheKey/persistence hazard, kept as plain state.
|
||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>(() =>
|
||||
artistName && subsonicServerId ? topSongsCache.get(subsonicCacheKey(subsonicServerId, artistName)) ?? [] : []);
|
||||
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>(() => artistName ? tourCache.get(artistName) ?? [] : []);
|
||||
const topSongsKey = artistName && subsonicServerId ? subsonicCacheKey(subsonicServerId, artistName) : '';
|
||||
const tourKey = enableBandsintown && artistName ? artistName : '';
|
||||
const [topSongsEntry, setTopSongsEntry] = useState<KeySlot<SubsonicSong[]>>(() =>
|
||||
seedKeySlot(topSongsKey, k => topSongsCache.get(k)));
|
||||
const [tourEventsEntry, setTourEventsEntry] = useState<KeySlot<BandsintownEvent[]>>(() =>
|
||||
seedKeySlot(tourKey, k => tourCache.get(k)));
|
||||
const [tourLoading, setTourLoading] = useState(false);
|
||||
const [lfmTrack, setLfmTrack] = useState<LastfmTrackInfo | null>(null);
|
||||
const [lfmArtist, setLfmArtist] = useState<LastfmArtistStats | null>(null);
|
||||
const lfmTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}` : '';
|
||||
const lfmArtistKey = artistName ? `${artistName} ${lastfmUsername}` : '';
|
||||
const [lfmTrackEntry, setLfmTrackEntry] = useState<KeySlot<LastfmTrackInfo | null>>(() =>
|
||||
seedKeySlot(lfmTrackKey, k => lfmTrackCache.get(k)));
|
||||
const [lfmArtistEntry, setLfmArtistEntry] = useState<KeySlot<LastfmArtistStats | null>>(() =>
|
||||
seedKeySlot(lfmArtistKey, k => lfmArtistCache.get(k)));
|
||||
|
||||
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
|
||||
useEffect(() => {
|
||||
@@ -129,28 +237,29 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
}, [fetchEnabled, subsonicServerId, albumId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchEnabled || !subsonicServerId || !artistName) { setTopSongs([]); return; }
|
||||
const cacheKey = subsonicCacheKey(subsonicServerId, artistName);
|
||||
const cached = topSongsCache.get(cacheKey);
|
||||
if (cached !== undefined) { setTopSongs(cached); return; }
|
||||
if (!fetchEnabled || !topSongsKey) { setTopSongsEntry(null); return; }
|
||||
const cached = topSongsCache.get(topSongsKey);
|
||||
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
|
||||
setTopSongsEntry(null);
|
||||
let cancelled = false;
|
||||
getTopSongsForServer(subsonicServerId, artistName)
|
||||
.then(v => { if (!cancelled) { topSongsCache.set(cacheKey, v); setTopSongs(v); } })
|
||||
.catch(() => { if (!cancelled) { topSongsCache.set(cacheKey, []); setTopSongs([]); } });
|
||||
.then(v => { if (!cancelled) { topSongsCache.set(topSongsKey, v); setTopSongsEntry({ key: topSongsKey, value: v }); } })
|
||||
.catch(() => { if (!cancelled) { topSongsCache.set(topSongsKey, []); setTopSongsEntry({ key: topSongsKey, value: [] }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [fetchEnabled, subsonicServerId, artistName]);
|
||||
}, [fetchEnabled, topSongsKey, subsonicServerId, artistName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
|
||||
const cached = tourCache.get(artistName);
|
||||
if (cached !== undefined) { setTourEvents(cached); setTourLoading(false); return; }
|
||||
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
|
||||
const cached = tourCache.get(tourKey);
|
||||
if (cached !== undefined) { setTourEventsEntry({ key: tourKey, value: cached }); setTourLoading(false); return; }
|
||||
let cancelled = false;
|
||||
setTourLoading(true);
|
||||
setTourEventsEntry(null);
|
||||
fetchBandsintownEvents(artistName)
|
||||
.then(v => { if (!cancelled) { tourCache.set(artistName, v); setTourEvents(v); } })
|
||||
.then(v => { if (!cancelled) { tourCache.set(tourKey, v); setTourEventsEntry({ key: tourKey, value: v }); } })
|
||||
.finally(() => { if (!cancelled) setTourLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [enableBandsintown, artistName]);
|
||||
}, [tourKey, artistName]);
|
||||
|
||||
// Discography via getArtist
|
||||
useEffect(() => {
|
||||
@@ -167,28 +276,28 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
}, [fetchEnabled, subsonicServerId, artistId]);
|
||||
|
||||
// Last.fm track info (per-track)
|
||||
const lfmTrackKey = currentTrack ? `${currentTrack.artist} ${currentTrack.title} ${lastfmUsername}` : '';
|
||||
useEffect(() => {
|
||||
if (!lastfmIsConfigured() || !currentTrack) { setLfmTrack(null); return; }
|
||||
if (!lastfmIsConfigured() || !currentTrack || !lfmTrackKey) { setLfmTrackEntry(null); return; }
|
||||
const cached = lfmTrackCache.get(lfmTrackKey);
|
||||
if (cached !== undefined) { setLfmTrack(cached); return; }
|
||||
if (cached !== undefined) { setLfmTrackEntry({ key: lfmTrackKey, value: cached }); return; }
|
||||
setLfmTrackEntry(null);
|
||||
let cancelled = false;
|
||||
lastfmGetTrackInfo(currentTrack.artist, currentTrack.title, lastfmUsername || undefined)
|
||||
.then(v => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, v); setLfmTrack(v); } })
|
||||
.catch(() => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, null); setLfmTrack(null); } });
|
||||
.then(v => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, v); setLfmTrackEntry({ key: lfmTrackKey, value: v }); } })
|
||||
.catch(() => { if (!cancelled) { lfmTrackCache.set(lfmTrackKey, null); setLfmTrackEntry({ key: lfmTrackKey, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [lfmTrackKey, currentTrack, lastfmUsername]);
|
||||
|
||||
// Last.fm artist stats (per-artist — shared across same-artist tracks)
|
||||
const lfmArtistKey = artistName ? `${artistName} ${lastfmUsername}` : '';
|
||||
useEffect(() => {
|
||||
if (!lastfmIsConfigured() || !artistName) { setLfmArtist(null); return; }
|
||||
if (!lastfmIsConfigured() || !artistName || !lfmArtistKey) { setLfmArtistEntry(null); return; }
|
||||
const cached = lfmArtistCache.get(lfmArtistKey);
|
||||
if (cached !== undefined) { setLfmArtist(cached); return; }
|
||||
if (cached !== undefined) { setLfmArtistEntry({ key: lfmArtistKey, value: cached }); return; }
|
||||
setLfmArtistEntry(null);
|
||||
let cancelled = false;
|
||||
lastfmGetArtistStats(artistName, lastfmUsername || undefined)
|
||||
.then(v => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, v); setLfmArtist(v); } })
|
||||
.catch(() => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, null); setLfmArtist(null); } });
|
||||
.then(v => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, v); setLfmArtistEntry({ key: lfmArtistKey, value: v }); } })
|
||||
.catch(() => { if (!cancelled) { lfmArtistCache.set(lfmArtistKey, null); setLfmArtistEntry({ key: lfmArtistKey, value: null }); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [lfmArtistKey, artistName, lastfmUsername]);
|
||||
|
||||
@@ -199,6 +308,10 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
|
||||
const artistInfo = artistInfoEntry && artistInfoEntry.id === artistId ? artistInfoEntry.value : null;
|
||||
const albumData = albumDataEntry && albumDataEntry.id === albumId ? albumDataEntry.value : null;
|
||||
const discography = discographyEntry && discographyEntry.id === artistId ? discographyEntry.value : [];
|
||||
const topSongs = topSongsEntry && topSongsEntry.key === topSongsKey ? topSongsEntry.value : [];
|
||||
const tourEvents = tourEventsEntry && tourEventsEntry.key === tourKey ? tourEventsEntry.value : [];
|
||||
const lfmTrack = lfmTrackEntry && lfmTrackEntry.key === lfmTrackKey ? lfmTrackEntry.value : null;
|
||||
const lfmArtist = lfmArtistEntry && lfmArtistEntry.key === lfmArtistKey ? lfmArtistEntry.value : null;
|
||||
|
||||
return { songMeta, artistInfo, albumData, topSongs, tourEvents, tourLoading, discography, lfmTrack, lfmArtist };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
|
||||
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
||||
import { useNowPlayingPrewarm } from './useNowPlayingPrewarm';
|
||||
import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { makeTrack } from '../test/helpers/factories';
|
||||
import { resetAllStores } from '../test/helpers/storeReset';
|
||||
|
||||
vi.mock('../api/coverCache', () => ({
|
||||
coverCachePeekBatch: vi.fn(async () => ({})),
|
||||
coverCacheEnsure: vi.fn(async () => ({ hit: false, path: '', tier: 800 })),
|
||||
}));
|
||||
|
||||
vi.mock('./useNowPlayingFetchers', () => ({
|
||||
prewarmNowPlayingFetchers: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function seedServers(): { 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.test',
|
||||
username: 'play-user',
|
||||
password: 'play-pass',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(active);
|
||||
return { active, playback };
|
||||
}
|
||||
|
||||
describe('useNowPlayingPrewarm', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('prewarms track data and artwork with playback scope', async () => {
|
||||
const { playback } = seedServers();
|
||||
const track = makeTrack({
|
||||
id: 'song-1',
|
||||
artistId: 'artist-1',
|
||||
albumId: 'album-1',
|
||||
artist: 'Artist One',
|
||||
coverArt: 'cover-1',
|
||||
});
|
||||
usePlayerStore.setState({
|
||||
queue: [track],
|
||||
queueIndex: 0,
|
||||
queueServerId: playback,
|
||||
currentTrack: track,
|
||||
});
|
||||
|
||||
renderHook(() => useNowPlayingPrewarm());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(prewarmNowPlayingFetchers).toHaveBeenCalledTimes(1);
|
||||
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const peekArg = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0];
|
||||
const playbackProfile = useAuthStore.getState().servers.find(s => s.id === playback);
|
||||
expect(playbackProfile).toBeDefined();
|
||||
expect(peekArg?.serverIndexKey).toBe(serverIndexKeyForProfile(playbackProfile!));
|
||||
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
|
||||
expect(ensureRef?.serverScope.kind).toBe('server');
|
||||
});
|
||||
|
||||
it('prewarms radio artwork with active scope (not playback queue scope)', async () => {
|
||||
const { active, playback } = seedServers();
|
||||
const track = makeTrack({ id: 'song-2', coverArt: 'cover-2' });
|
||||
usePlayerStore.setState({
|
||||
queue: [track],
|
||||
queueIndex: 0,
|
||||
queueServerId: playback,
|
||||
currentTrack: null,
|
||||
currentRadio: {
|
||||
id: 'radio-1',
|
||||
name: 'Radio 1',
|
||||
streamUrl: 'https://radio.test/stream',
|
||||
coverArt: 'https://radio.test/art.jpg',
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() => useNowPlayingPrewarm());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const peekArg = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0];
|
||||
const activeProfile = useAuthStore.getState().servers.find(s => s.id === active);
|
||||
expect(activeProfile).toBeDefined();
|
||||
expect(peekArg?.serverIndexKey).toBe(serverIndexKeyForProfile(activeProfile!));
|
||||
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
|
||||
expect(ensureRef?.serverScope).toEqual({ kind: 'active' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect } from 'react';
|
||||
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
|
||||
import { coverArtRef, resolvePlaybackCoverScope } from '../cover/ref';
|
||||
import { getDiskSrc, rememberDiskSrc } from '../cover/diskSrcCache';
|
||||
import { coverIndexKeyFromRef, coverStorageKey } from '../cover/storageKeys';
|
||||
import { resolveCoverDisplayTier } from '../cover/tiers';
|
||||
import { coverArtIdFromRadio } from '../cover/ids';
|
||||
import type { CoverServerScope } from '../cover/types';
|
||||
import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { usePlaybackServerId } from './usePlaybackServerId';
|
||||
|
||||
const NOW_PLAYING_COVER_CSS_PX = 800;
|
||||
|
||||
async function prewarmCoverForScope(
|
||||
coverArtId: string,
|
||||
scope: CoverServerScope,
|
||||
): Promise<void> {
|
||||
if (!coverArtId) return;
|
||||
const tier = resolveCoverDisplayTier(NOW_PLAYING_COVER_CSS_PX, { surface: 'sparse' });
|
||||
const ref = coverArtRef(coverArtId, scope);
|
||||
const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
|
||||
if (getDiskSrc(storageKey)) return;
|
||||
|
||||
const hits = await coverCachePeekBatch([
|
||||
{
|
||||
serverIndexKey: coverIndexKeyFromRef(ref),
|
||||
coverArtId: ref.coverArtId,
|
||||
tier,
|
||||
},
|
||||
]);
|
||||
const hitPath = hits[storageKey];
|
||||
if (hitPath) {
|
||||
rememberDiskSrc(storageKey, hitPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const ensured = await coverCacheEnsure(ref, tier, 'high');
|
||||
if (ensured.hit && ensured.path) {
|
||||
rememberDiskSrc(storageKey, ensured.path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the Now Playing data + key artwork as soon as the playing track changes,
|
||||
* so opening `/now-playing` shows track-correct content instantly.
|
||||
*/
|
||||
export function useNowPlayingPrewarm(): void {
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||
const playbackServerId = usePlaybackServerId();
|
||||
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => (playbackServerId ? Boolean(s.audiomuseNavidromeByServer[playbackServerId]) : false),
|
||||
);
|
||||
const lastfmUsername = useAuthStore(s => s.lastfmUsername);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTrack || !playbackServerId) return;
|
||||
|
||||
void prewarmNowPlayingFetchers({
|
||||
songId: currentTrack.id,
|
||||
artistId: currentTrack.artistId,
|
||||
albumId: currentTrack.albumId,
|
||||
artistName: currentTrack.artist,
|
||||
enableBandsintown,
|
||||
audiomuseNavidromeEnabled,
|
||||
lastfmUsername,
|
||||
currentTrack,
|
||||
subsonicServerId: playbackServerId,
|
||||
fetchEnabled: true,
|
||||
});
|
||||
|
||||
if (currentTrack.coverArt) {
|
||||
void prewarmCoverForScope(currentTrack.coverArt, resolvePlaybackCoverScope());
|
||||
}
|
||||
}, [
|
||||
currentTrack?.id,
|
||||
currentTrack?.artistId,
|
||||
currentTrack?.albumId,
|
||||
currentTrack?.coverArt,
|
||||
currentTrack?.artist,
|
||||
playbackServerId,
|
||||
enableBandsintown,
|
||||
audiomuseNavidromeEnabled,
|
||||
lastfmUsername,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentRadio?.coverArt || !activeServerId) return;
|
||||
const radioCoverArtId = coverArtIdFromRadio(currentRadio.id);
|
||||
void prewarmCoverForScope(radioCoverArtId, { kind: 'active' });
|
||||
}, [currentRadio?.id, currentRadio?.coverArt, activeServerId]);
|
||||
}
|
||||
@@ -1,17 +1,2 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { playbackCoverArtForId } from '../utils/playback/playbackServer';
|
||||
|
||||
/** Cover art for the playing queue — uses {@link queueServerId} when it differs from the browsed server. */
|
||||
export function usePlaybackCoverArt(coverId: string | undefined, size: number) {
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const queueLength = usePlayerStore(s => s.queue.length);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!coverId) return { src: '', cacheKey: '' };
|
||||
return playbackCoverArtForId(coverId, size);
|
||||
}, [coverId, size, queueServerId, queueLength, activeServerId, servers]);
|
||||
}
|
||||
/** @deprecated Import from `src/cover/usePlaybackCoverArt` — `displayCssPx` is CSS px, not tier. */
|
||||
export { usePlaybackCoverArt } from '../cover/usePlaybackCoverArt';
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import type { CoverArtId } from '../cover/types';
|
||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
|
||||
const PLAYLIST_HERO_BG_CSS_PX = 200;
|
||||
const PLAYLIST_MAIN_COVER_CSS_PX = 200;
|
||||
|
||||
export interface PlaylistCovers {
|
||||
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
|
||||
customCoverFetchUrl: string | null;
|
||||
customCoverCacheKey: string | null;
|
||||
resolvedBgUrl: string | null;
|
||||
coverQuadIds: (CoverArtId | null)[];
|
||||
bgCoverId: CoverArtId | null;
|
||||
resolvedBgUrl: string;
|
||||
}
|
||||
|
||||
export function usePlaylistCovers(songs: SubsonicSong[], customCoverId: string | null): PlaylistCovers {
|
||||
@@ -24,31 +28,30 @@ export function usePlaylistCovers(songs: SubsonicSong[], customCoverId: string |
|
||||
return result;
|
||||
}, [songs]);
|
||||
|
||||
// Stable fetch URLs + cache keys for the 2×2 grid and blurred background.
|
||||
// buildCoverArtUrl generates a new crypto salt on every call, so these MUST
|
||||
// be memoized — otherwise every render produces new URLs, useCachedUrl
|
||||
// re-triggers, state updates, another render → infinite flicker loop.
|
||||
const coverQuadUrls = useMemo(() =>
|
||||
Array.from({ length: 4 }, (_, i) => {
|
||||
const coverId = coverQuad[i % Math.max(1, coverQuad.length)];
|
||||
if (!coverId) return null;
|
||||
return { src: buildCoverArtUrl(coverId, 200), cacheKey: coverArtCacheKey(coverId, 200) };
|
||||
}),
|
||||
[coverQuad]);
|
||||
|
||||
const effectiveBgId = customCoverId ?? coverQuad[0] ?? '';
|
||||
const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]);
|
||||
const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]);
|
||||
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
|
||||
|
||||
const customCoverFetchUrl = useMemo(
|
||||
() => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null,
|
||||
[customCoverId],
|
||||
);
|
||||
const customCoverCacheKey = useMemo(
|
||||
() => customCoverId ? coverArtCacheKey(customCoverId, 300) : null,
|
||||
[customCoverId],
|
||||
const coverQuadIds = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 4 }, (_, i) => {
|
||||
const coverId = coverQuad[i % Math.max(1, coverQuad.length)];
|
||||
return coverId ?? null;
|
||||
}),
|
||||
[coverQuad],
|
||||
);
|
||||
|
||||
return { coverQuadUrls, customCoverFetchUrl, customCoverCacheKey, resolvedBgUrl };
|
||||
const bgCoverId = customCoverId ?? coverQuad[0] ?? null;
|
||||
const { src: resolvedBgUrl } = useCoverArt(bgCoverId, PLAYLIST_HERO_BG_CSS_PX, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const refs = coverQuadIds
|
||||
.filter((id): id is CoverArtId => !!id)
|
||||
.map(id => coverArtRef(id));
|
||||
if (bgCoverId) refs.push(coverArtRef(bgCoverId));
|
||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'middle' });
|
||||
}, [coverQuadIds, bgCoverId]);
|
||||
|
||||
return { coverQuadIds, bgCoverId, resolvedBgUrl };
|
||||
}
|
||||
|
||||
export { PLAYLIST_MAIN_COVER_CSS_PX };
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useLayoutEffect, useMemo } from 'react';
|
||||
import { GRID_COVER_PRIME_ALL_MAX } from '../cover/layoutSizes';
|
||||
import {
|
||||
collectAlbumCoverWarmItems,
|
||||
ensureAlbumCoverMisses,
|
||||
warmCoverDiskSrcBatch,
|
||||
} from '../cover/warmDiskPeek';
|
||||
import type { CoverSurfaceKind } from '../cover/types';
|
||||
|
||||
const DEFAULT_LIMIT = 120;
|
||||
|
||||
/**
|
||||
* Peek before paint; for small grids (≤48) queue ensures only for disk misses.
|
||||
*/
|
||||
export function useWarmGridCovers(
|
||||
items: ReadonlyArray<{ coverArt?: string | null }>,
|
||||
displayCssPx: number,
|
||||
opts?: {
|
||||
limit?: number;
|
||||
surface?: CoverSurfaceKind;
|
||||
enabled?: boolean;
|
||||
/** Precomputed fingerprint — avoids re-peeking when parent re-renders with a huge list. */
|
||||
warmKey?: string;
|
||||
},
|
||||
): void {
|
||||
const limit = opts?.limit ?? DEFAULT_LIMIT;
|
||||
const surface = opts?.surface ?? 'dense';
|
||||
const enabled = opts?.enabled ?? true;
|
||||
|
||||
const warmKey = useMemo(() => {
|
||||
if (opts?.warmKey !== undefined) {
|
||||
return `${displayCssPx}:${opts.warmKey}`;
|
||||
}
|
||||
const slice = items.slice(0, limit);
|
||||
return `${displayCssPx}:${slice.map(a => a.coverArt ?? '').join('\u0001')}`;
|
||||
}, [items, displayCssPx, limit, opts?.warmKey]);
|
||||
|
||||
const primeAllMisses = items.length > 0 && items.length <= GRID_COVER_PRIME_ALL_MAX;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || displayCssPx <= 0) return;
|
||||
const batch = collectAlbumCoverWarmItems(items, displayCssPx, surface, limit);
|
||||
if (batch.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await warmCoverDiskSrcBatch(batch);
|
||||
if (cancelled) return;
|
||||
if (primeAllMisses) {
|
||||
await ensureAlbumCoverMisses(items, displayCssPx, { surface, limit });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, warmKey, items, displayCssPx, limit, surface, primeAllMisses]);
|
||||
}
|
||||
Reference in New Issue
Block a user