mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +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:
@@ -38,9 +38,9 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: vitest
|
||||
@@ -51,9 +51,9 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- name: tsc
|
||||
@@ -69,9 +69,9 @@ jobs:
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: 'lts/*'
|
||||
cache: 'npm'
|
||||
- name: install jq
|
||||
run: sudo apt-get update && sudo apt-get install -y jq
|
||||
|
||||
@@ -63,3 +63,4 @@ result-*
|
||||
dev.sh
|
||||
shell.nix
|
||||
prod.sh
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -92,6 +92,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Cover art — tier ladder, disk cache, and grid prefetch
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#869](https://github.com/Psychotoxical/psysonic/pull/869)**
|
||||
|
||||
* Album and artist grids load sharper repeat visits from a dedicated WebP disk cache (separate from the general image cache budget in **Settings → Storage**).
|
||||
* Dense lists prefetch smaller tiers first, then steady-state grid quality capped at 512px for scroll performance; detail and player chrome still resolve up to 800px on demand.
|
||||
* One-time upgrade clears legacy multi-size IndexedDB cover keys; offline-first when the server is unreachable.
|
||||
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Linux — session GDK, WebKitGTK mitigations, and Wayland text
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME=$(mktemp -d)
|
||||
(cd src-tauri && cargo tauri build --no-bundle -v)
|
||||
(cd src-tauri && cargo tauri build --no-bundle)
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
|
||||
Generated
+63
@@ -2216,6 +2216,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -2456,9 +2462,22 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2805,6 +2824,16 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libwebp-sys"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.3.8"
|
||||
@@ -4014,6 +4043,7 @@ dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
"id3",
|
||||
"image",
|
||||
"libc",
|
||||
"lofty",
|
||||
"md5",
|
||||
@@ -4049,6 +4079,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-nvidia-quirk",
|
||||
"webp",
|
||||
"windows 0.62.2",
|
||||
"zbus 5.15.0",
|
||||
"zip 0.6.6",
|
||||
@@ -4173,6 +4204,12 @@ version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.3"
|
||||
@@ -5672,6 +5709,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"image",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
@@ -6907,6 +6945,16 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webp"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"libwebp-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
@@ -8083,6 +8131,21 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "3.15.2"
|
||||
|
||||
@@ -42,7 +42,7 @@ psysonic-audio = { path = "crates/psysonic-audio" }
|
||||
psysonic-library = { path = "crates/psysonic-library" }
|
||||
psysonic-syncfs = { path = "crates/psysonic-syncfs" }
|
||||
psysonic-integration = { path = "crates/psysonic-integration" }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
@@ -74,6 +74,8 @@ rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
dasp_sample = "0.11.0"
|
||||
zip = "0.6.6"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "webp"] }
|
||||
webp = "0.3"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1094,6 +1094,9 @@ fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> {
|
||||
("year", EntityKind::Album) => Some("a.year"),
|
||||
("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"),
|
||||
("name", EntityKind::Artist) => Some("ar.name COLLATE NOCASE"),
|
||||
// SQLite built-in: ORDER BY RANDOM() LIMIT N — fast pseudo-random sample,
|
||||
// no index scan needed beyond the row-id range. Direction is ignored.
|
||||
("random", _) => Some("RANDOM()"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Library cursor scan for background cover disk warm-up.
|
||||
//!
|
||||
//! Cover IDs for backfill come from **track** + **album** rows using
|
||||
//! `COALESCE(cover_art_id, album_id)` (album table id as fallback).
|
||||
//! Artist IDs are excluded — `getCoverArt` with `artist_id` often 404s and stalled the queue.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
const DEFAULT_BATCH: u32 = 32;
|
||||
const MAX_BATCH: u32 = 48;
|
||||
const SCAN_PAGE: i64 = 256;
|
||||
const MAX_SCAN_PAGES: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryCoverBackfillBatchDto {
|
||||
pub cover_ids: Vec<String>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub exhausted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryCoverProgressDto {
|
||||
pub total_distinct: i64,
|
||||
pub pending: i64,
|
||||
pub done: i64,
|
||||
}
|
||||
|
||||
const COVER_ID_SUBQUERY: &str = "
|
||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(album_id), ''), NULLIF(TRIM(cover_art_id), '')) AS id
|
||||
FROM track
|
||||
WHERE server_id = ?1 AND deleted = 0
|
||||
AND (
|
||||
NULLIF(TRIM(album_id), '') IS NOT NULL
|
||||
OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL
|
||||
)
|
||||
UNION
|
||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(id), ''), NULLIF(TRIM(cover_art_id), '')) AS id
|
||||
FROM album
|
||||
WHERE server_id = ?1
|
||||
AND (
|
||||
NULLIF(TRIM(id), '') IS NOT NULL
|
||||
OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL
|
||||
)";
|
||||
|
||||
pub const COVER_FETCH_FAIL_MARKER: &str = ".fetch-failed";
|
||||
|
||||
/// Recent HTTP failure — skip in backfill cursor so slots go to fetchable album art.
|
||||
pub fn cover_fetch_recently_failed(cover_dir: &Path) -> bool {
|
||||
let marker = cover_dir.join(COVER_FETCH_FAIL_MARKER);
|
||||
let Ok(meta) = std::fs::metadata(&marker) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(modified) = meta.modified() else {
|
||||
return true;
|
||||
};
|
||||
modified
|
||||
.elapsed()
|
||||
.map(|e| e < std::time::Duration::from_secs(30 * 60))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Remove `.fetch-failed` markers so the next library pass retries HTTP.
|
||||
pub fn clear_cover_fetch_failures(cover_root: &Path, server_index_key: &str) -> u32 {
|
||||
let server_dir = cover_root.join(server_index_key);
|
||||
let Ok(entries) = std::fs::read_dir(&server_dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut cleared = 0u32;
|
||||
for id_dir in entries.flatten() {
|
||||
let marker = id_dir.path().join(COVER_FETCH_FAIL_MARKER);
|
||||
if marker.is_file() && std::fs::remove_file(&marker).is_ok() {
|
||||
cleared += 1;
|
||||
}
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
fn fetch_cover_id_page(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
after: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<String>, String> {
|
||||
store.with_read_conn(|conn| {
|
||||
let sql = format!(
|
||||
"SELECT id FROM ({COVER_ID_SUBQUERY})
|
||||
WHERE id > ?2
|
||||
ORDER BY id ASC
|
||||
LIMIT ?3"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let ids = stmt
|
||||
.query_map(rusqlite::params![library_server_id, after, limit], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ids)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count_distinct_cover_ids(store: &LibraryStore, library_server_id: &str) -> Result<i64, String> {
|
||||
store.with_read_conn(|conn| {
|
||||
let sql = format!("SELECT COUNT(*) FROM ({COVER_ID_SUBQUERY})");
|
||||
conn.query_row(&sql, rusqlite::params![library_server_id], |row| row.get(0))
|
||||
})
|
||||
}
|
||||
|
||||
/// Library warm-up target tier — HTTP fetch size and progress heuristic.
|
||||
pub const LIBRARY_COVER_CANONICAL_TIER: u32 = 800;
|
||||
|
||||
/// WebP ladder written by aggressive backfill (must match `cover_cache::DERIVE_TIERS`).
|
||||
pub const LIBRARY_COVER_DERIVE_TIERS: [u32; 4] = [128, 256, 512, 800];
|
||||
|
||||
fn tier_file_ready(dir: &Path, tier: u32) -> bool {
|
||||
let path = dir.join(format!("{tier}.webp"));
|
||||
path.is_file() && path.metadata().map(|m| m.len() > 0).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn cover_ladder_complete_on_disk(dir: &Path) -> bool {
|
||||
LIBRARY_COVER_DERIVE_TIERS
|
||||
.iter()
|
||||
.all(|&tier| tier_file_ready(dir, tier))
|
||||
}
|
||||
|
||||
pub fn cover_canonical_cached_on_disk(
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
cover_art_id: &str,
|
||||
) -> bool {
|
||||
let dir = cover_root.join(server_index_key).join(cover_art_id);
|
||||
tier_file_ready(&dir, LIBRARY_COVER_CANONICAL_TIER)
|
||||
}
|
||||
|
||||
pub fn cover_ladder_cached_on_disk(
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
cover_art_id: &str,
|
||||
) -> bool {
|
||||
let dir = cover_root.join(server_index_key).join(cover_art_id);
|
||||
cover_ladder_complete_on_disk(&dir)
|
||||
}
|
||||
|
||||
pub fn collect_cover_backfill_batch(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<LibraryCoverBackfillBatchDto, String> {
|
||||
let want = limit.unwrap_or(DEFAULT_BATCH).min(MAX_BATCH) as usize;
|
||||
let mut after = cursor.map(str::to_string).unwrap_or_default();
|
||||
let mut pending = Vec::with_capacity(want);
|
||||
let mut sql_exhausted = false;
|
||||
|
||||
for _ in 0..MAX_SCAN_PAGES {
|
||||
if pending.len() >= want {
|
||||
break;
|
||||
}
|
||||
let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?;
|
||||
if page.is_empty() {
|
||||
sql_exhausted = true;
|
||||
break;
|
||||
}
|
||||
for id in &page {
|
||||
after.clone_from(id);
|
||||
let dir = cover_root.join(server_index_key).join(id);
|
||||
if cover_canonical_cached_on_disk(cover_root, server_index_key, id)
|
||||
|| cover_fetch_recently_failed(&dir)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
pending.push(id.clone());
|
||||
if pending.len() >= want {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (page.len() as i64) < SCAN_PAGE {
|
||||
sql_exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LibraryCoverBackfillBatchDto {
|
||||
cover_ids: pending,
|
||||
next_cursor: if sql_exhausted { None } else { Some(after) },
|
||||
exhausted: sql_exhausted,
|
||||
})
|
||||
}
|
||||
|
||||
/// Distinct library cover IDs still missing canonical `800.webp` (not raw dir count on disk).
|
||||
pub fn count_pending_canonical_covers(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
) -> Result<i64, String> {
|
||||
let mut after = String::new();
|
||||
let mut pending = 0i64;
|
||||
loop {
|
||||
let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?;
|
||||
if page.is_empty() {
|
||||
break;
|
||||
}
|
||||
for id in &page {
|
||||
after.clone_from(id);
|
||||
if !cover_canonical_cached_on_disk(cover_root, server_index_key, id) {
|
||||
pending += 1;
|
||||
}
|
||||
}
|
||||
if (page.len() as i64) < SCAN_PAGE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
/// UI progress — fast approximate counts (no full-library disk walk).
|
||||
pub fn collect_cover_progress(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
_cover_root: &Path,
|
||||
_server_index_key: &str,
|
||||
cached_dirs_with_canonical: i64,
|
||||
) -> Result<LibraryCoverProgressDto, String> {
|
||||
let total = count_distinct_cover_ids(store, library_server_id)?;
|
||||
let done = cached_dirs_with_canonical.min(total);
|
||||
Ok(LibraryCoverProgressDto {
|
||||
total_distinct: total,
|
||||
pending: (total - done).max(0),
|
||||
done,
|
||||
})
|
||||
}
|
||||
|
||||
/// Accurate pending count — expensive; run off the UI thread only.
|
||||
#[allow(dead_code)]
|
||||
pub fn collect_cover_progress_accurate(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
) -> Result<LibraryCoverProgressDto, String> {
|
||||
let total = count_distinct_cover_ids(store, library_server_id)?;
|
||||
let pending = count_pending_canonical_covers(
|
||||
store,
|
||||
library_server_id,
|
||||
cover_root,
|
||||
server_index_key,
|
||||
)?;
|
||||
let done = (total - pending).max(0);
|
||||
Ok(LibraryCoverProgressDto {
|
||||
total_distinct: total,
|
||||
pending,
|
||||
done,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
fn seed_track(store: &LibraryStore, server_id: &str, track_id: &str, album_id: &str, cover: Option<&str>) {
|
||||
store
|
||||
.with_conn_mut("test_seed", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (
|
||||
server_id, id, title, album, album_id, duration_sec, deleted, synced_at, raw_json,
|
||||
cover_art_id
|
||||
) VALUES (?1, ?2, 't', 'al', ?3, 200, 0, 1, '{}', ?4)",
|
||||
rusqlite::params![server_id, track_id, album_id, cover],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_uses_track_album_id_when_cover_art_null() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "al-99", None);
|
||||
let batch = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
Path::new("/tmp/empty-cover-root"),
|
||||
"srv-host",
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(batch.cover_ids, vec!["al-99".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_skips_when_canonical_800_exists() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "al-partial", None);
|
||||
let root = std::env::temp_dir().join("psysonic-cover-backfill-test");
|
||||
let host = "srv-host";
|
||||
let id_dir = root.join(host).join("al-partial");
|
||||
std::fs::create_dir_all(&id_dir).unwrap();
|
||||
std::fs::write(id_dir.join("128.webp"), b"x").unwrap();
|
||||
|
||||
let batch = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
&root,
|
||||
host,
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(batch.cover_ids, vec!["al-partial".to_string()]);
|
||||
|
||||
std::fs::write(id_dir.join("800.webp"), b"canonical").unwrap();
|
||||
let batch2 = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
&root,
|
||||
host,
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(batch2.cover_ids.is_empty());
|
||||
|
||||
let _ = std::fs::remove_dir_all(root.join(host));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_distinct_includes_artist_ids() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "al-1", Some("cv-1"));
|
||||
store
|
||||
.with_conn_mut("test_artist", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (
|
||||
server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json
|
||||
) VALUES ('srv', 'tr2', 't', 'al', 'al-2', 'ar-1', 200, 0, 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let n = count_distinct_cover_ids(&store, "srv").unwrap();
|
||||
assert_eq!(n, 2); // cv-1, al-1 — artist ids excluded from backfill catalog
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub(crate) mod bulk_ingest;
|
||||
pub mod advanced_search;
|
||||
mod advanced_search_mood;
|
||||
pub mod analysis_backfill;
|
||||
pub mod cover_backfill;
|
||||
pub mod canonical;
|
||||
pub mod commands;
|
||||
pub mod cross_server;
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Library cover backfill — one background pass per wake (native, not webview timers).
|
||||
|
||||
use super::{state, CoverCacheEnsureArgs, CoverCacheState};
|
||||
use psysonic_library::cover_backfill::{
|
||||
clear_cover_fetch_failures, collect_cover_backfill_batch, collect_cover_progress,
|
||||
LibraryCoverBackfillBatchDto, LIBRARY_COVER_CANONICAL_TIER,
|
||||
};
|
||||
use psysonic_library::payload::LibrarySyncProgressPayload;
|
||||
use psysonic_library::repos::sync_state::SyncStateRepository;
|
||||
use psysonic_library::LibraryRuntime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Listener, Manager};
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
use super::{count_cached_cover_ids, dir_usage_for_server};
|
||||
|
||||
/// Concurrent library downloads (encode runs on blocking pool; no webview tier events).
|
||||
const LIBRARY_BACKFILL_PARALLEL: usize = 4;
|
||||
const BATCH_SIZE: u32 = 24;
|
||||
const PENDING_RESTART_THRESHOLD: i64 = 32;
|
||||
const SYNC_WAIT_MS: u64 = 5000;
|
||||
const PROGRESS_EVERY_BATCHES: u32 = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CoverBackfillSession {
|
||||
pub server_index_key: String,
|
||||
pub library_server_id: String,
|
||||
pub rest_base_url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub struct CoverBackfillWorker {
|
||||
pub enabled: AtomicBool,
|
||||
/// When true, the active pass yields so visible-route cover IPC is not starved.
|
||||
pub ui_priority_hold: AtomicBool,
|
||||
session: Mutex<Option<CoverBackfillSession>>,
|
||||
cursor: Mutex<String>,
|
||||
pass_running: AtomicBool,
|
||||
backfill_http: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverBackfillPulseDto {
|
||||
pub scheduled: u32,
|
||||
pub exhausted: bool,
|
||||
pub pending: i64,
|
||||
pub done: i64,
|
||||
pub total: i64,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverBackfillRunDto {
|
||||
pub started: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncIdlePayload {
|
||||
server_id: String,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
impl CoverBackfillWorker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
enabled: AtomicBool::new(false),
|
||||
ui_priority_hold: AtomicBool::new(false),
|
||||
session: Mutex::new(None),
|
||||
cursor: Mutex::new(String::new()),
|
||||
pass_running: AtomicBool::new(false),
|
||||
backfill_http: Arc::new(Semaphore::new(LIBRARY_BACKFILL_PARALLEL)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ui_priority_hold(&self, hold: bool) {
|
||||
self.ui_priority_hold.store(hold, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub async fn set_session(&self, enabled: bool, session: Option<CoverBackfillSession>) {
|
||||
self.enabled.store(enabled, Ordering::Relaxed);
|
||||
*self.session.lock().await = session;
|
||||
if !enabled {
|
||||
*self.cursor.lock().await = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reset_cursor(&self) {
|
||||
*self.cursor.lock().await = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_allows_cover_backfill(store: &psysonic_library::store::LibraryStore, server_id: &str) -> bool {
|
||||
let repo = SyncStateRepository::new(store);
|
||||
match repo.get_sync_phase(server_id, "") {
|
||||
Ok(Some(phase)) => phase != "initial_sync" && phase != "probing",
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_matches_server(session: &CoverBackfillSession, server_id: &str) -> bool {
|
||||
server_id == session.server_index_key || server_id == session.library_server_id
|
||||
}
|
||||
|
||||
/// Backfill runs only while this session is still the configured focus (active server).
|
||||
async fn session_still_focused(worker: &CoverBackfillWorker, expected: &CoverBackfillSession) -> bool {
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
worker
|
||||
.session
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.server_index_key == expected.server_index_key)
|
||||
}
|
||||
|
||||
async fn progress_snapshot(
|
||||
store: &psysonic_library::store::LibraryStore,
|
||||
root: &std::path::Path,
|
||||
library_server_id: &str,
|
||||
server_index_key: &str,
|
||||
) -> Result<(i64, i64, i64), String> {
|
||||
let cached = count_cached_cover_ids(root, server_index_key);
|
||||
let p = collect_cover_progress(store, library_server_id, root, server_index_key, cached)?;
|
||||
Ok((p.done, p.total_distinct, p.pending))
|
||||
}
|
||||
|
||||
async fn emit_library_progress(
|
||||
app: &AppHandle,
|
||||
session: &CoverBackfillSession,
|
||||
done: i64,
|
||||
total: i64,
|
||||
pending: i64,
|
||||
root: &std::path::Path,
|
||||
) {
|
||||
let (bytes, entry_count) = dir_usage_for_server(root, &session.server_index_key);
|
||||
let _ = app.emit(
|
||||
"cover:library-progress",
|
||||
serde_json::json!({
|
||||
"serverIndexKey": session.server_index_key,
|
||||
"done": done,
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"bytes": bytes,
|
||||
"entryCount": entry_count,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async fn ensure_one(
|
||||
worker: &CoverBackfillWorker,
|
||||
st: Arc<tokio::sync::Mutex<CoverCacheState>>,
|
||||
http_sem: Arc<Semaphore>,
|
||||
app: AppHandle,
|
||||
session: CoverBackfillSession,
|
||||
cover_art_id: String,
|
||||
) {
|
||||
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let args = CoverCacheEnsureArgs {
|
||||
server_index_key: session.server_index_key,
|
||||
cover_art_id,
|
||||
tier: LIBRARY_COVER_CANONICAL_TIER,
|
||||
rest_base_url: session.rest_base_url,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
library_bulk: true,
|
||||
};
|
||||
let _ = CoverCacheState::ensure_inner(&st, &app, &args, Some(http_sem)).await;
|
||||
}
|
||||
|
||||
async fn run_full_pass(app: AppHandle, worker: Arc<CoverBackfillWorker>) {
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let session = worker.session.lock().await.clone();
|
||||
let Some(session) = session else {
|
||||
return;
|
||||
};
|
||||
|
||||
let runtime = match app.try_state::<LibraryRuntime>() {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
|
||||
while !sync_allows_cover_backfill(&runtime.store, &session.library_server_id) {
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(SYNC_WAIT_MS)).await;
|
||||
}
|
||||
|
||||
let st = match state(&app) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let st_arc = st.clone();
|
||||
|
||||
worker.reset_cursor().await;
|
||||
let http_sem = worker.backfill_http.clone();
|
||||
let mut batch_count = 0u32;
|
||||
|
||||
loop {
|
||||
if !session_still_focused(&worker, &session).await {
|
||||
break;
|
||||
}
|
||||
|
||||
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let cursor = worker.cursor.lock().await.clone();
|
||||
let cursor_opt = if cursor.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(cursor)
|
||||
};
|
||||
let store = runtime.store.clone();
|
||||
let lib_id = session.library_server_id.clone();
|
||||
let index_key = session.server_index_key.clone();
|
||||
let root_for_batch = root.clone();
|
||||
|
||||
let batch: Option<LibraryCoverBackfillBatchDto> =
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
collect_cover_backfill_batch(
|
||||
&store,
|
||||
&lib_id,
|
||||
&root_for_batch,
|
||||
&index_key,
|
||||
cursor_opt.as_deref(),
|
||||
Some(BATCH_SIZE),
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(b)) => Some(b),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let Some(batch) = batch else {
|
||||
break;
|
||||
};
|
||||
|
||||
batch_count += 1;
|
||||
if !session_still_focused(&worker, &session).await {
|
||||
break;
|
||||
}
|
||||
let ids = batch.cover_ids.clone();
|
||||
let mut paused_for_ui_priority = false;
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for id in ids {
|
||||
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||
paused_for_ui_priority = true;
|
||||
break;
|
||||
}
|
||||
let st = st_arc.clone();
|
||||
let http_sem = http_sem.clone();
|
||||
let app = app.clone();
|
||||
let session = session.clone();
|
||||
let worker_arc = worker.clone();
|
||||
set.spawn(async move {
|
||||
ensure_one(worker_arc.as_ref(), st, http_sem, app, session, id).await;
|
||||
});
|
||||
}
|
||||
while set.join_next().await.is_some() {}
|
||||
if paused_for_ui_priority || worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if batch_count.is_multiple_of(PROGRESS_EVERY_BATCHES) {
|
||||
if let Ok((done, total, pending)) = progress_snapshot(
|
||||
&runtime.store,
|
||||
&root,
|
||||
&session.library_server_id,
|
||||
&session.server_index_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
emit_library_progress(&app, &session, done, total, pending, &root).await;
|
||||
}
|
||||
}
|
||||
|
||||
if batch.exhausted {
|
||||
worker.cursor.lock().await.clear();
|
||||
if let Ok((done, total, pending)) = progress_snapshot(
|
||||
&runtime.store,
|
||||
&root,
|
||||
&session.library_server_id,
|
||||
&session.server_index_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if pending > PENDING_RESTART_THRESHOLD {
|
||||
let root3 = root.clone();
|
||||
let index_key3 = session.server_index_key.clone();
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
clear_cover_fetch_failures(&root3, &index_key3)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
emit_library_progress(&app, &session, done, total, pending, &root).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(next) = batch.next_cursor {
|
||||
*worker.cursor.lock().await = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start one full-catalog pass on the Tokio runtime (survives inactive webview).
|
||||
pub async fn try_schedule_full_pass(app: &AppHandle) -> bool {
|
||||
let worker = match app.try_state::<Arc<CoverBackfillWorker>>() {
|
||||
Some(w) => w.inner().clone(),
|
||||
None => return false,
|
||||
};
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
if worker
|
||||
.pass_running
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_full_pass(app, worker.clone()).await;
|
||||
worker.pass_running.store(false, Ordering::SeqCst);
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn on_sync_idle(app: &AppHandle, payload: SyncIdlePayload) {
|
||||
if !payload.ok {
|
||||
return;
|
||||
}
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let worker = match app.try_state::<Arc<CoverBackfillWorker>>() {
|
||||
Some(w) => w.inner().clone(),
|
||||
None => return,
|
||||
};
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let session = worker.session.lock().await.clone();
|
||||
let Some(session) = session else {
|
||||
return;
|
||||
};
|
||||
if !session_matches_server(&session, &payload.server_id) {
|
||||
return;
|
||||
}
|
||||
let _ = try_schedule_full_pass(&app).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for library sync completion in native code (not throttled with the webview).
|
||||
pub fn setup_library_sync_idle_listener(app: &AppHandle) {
|
||||
let app_handle = app.clone();
|
||||
let _ = app.listen(LibrarySyncProgressPayload::IDLE_EVENT_NAME, move |event| {
|
||||
let Ok(payload) = serde_json::from_str::<SyncIdlePayload>(event.payload()) else {
|
||||
return;
|
||||
};
|
||||
on_sync_idle(&app_handle, payload);
|
||||
});
|
||||
}
|
||||
|
||||
/// Legacy single-step API (optional diagnostics).
|
||||
pub async fn pulse_backfill(app: &AppHandle, _worker: &Arc<CoverBackfillWorker>) -> CoverBackfillPulseDto {
|
||||
if try_schedule_full_pass(app).await {
|
||||
return CoverBackfillPulseDto {
|
||||
scheduled: 0,
|
||||
exhausted: false,
|
||||
pending: 0,
|
||||
done: 0,
|
||||
total: 0,
|
||||
status: "active".into(),
|
||||
};
|
||||
}
|
||||
CoverBackfillPulseDto {
|
||||
scheduled: 0,
|
||||
exhausted: true,
|
||||
pending: 0,
|
||||
done: 0,
|
||||
total: 0,
|
||||
status: "disabled".into(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DERIVE_TIERS: [u32; 4] = [128, 256, 512, 800];
|
||||
|
||||
/// `server_index_key` — host (+ optional path), same bucket as library `server_id`.
|
||||
pub fn cover_dir(root: &Path, server_index_key: &str, cover_art_id: &str) -> PathBuf {
|
||||
root.join(server_index_key).join(cover_art_id)
|
||||
}
|
||||
|
||||
pub fn tier_path(dir: &Path, tier: u32) -> PathBuf {
|
||||
dir.join(format!("{tier}.webp"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn meta_path(dir: &Path) -> PathBuf {
|
||||
dir.join("meta.json")
|
||||
}
|
||||
|
||||
pub fn tier_exists(dir: &Path, tier: u32) -> Option<PathBuf> {
|
||||
let p = tier_path(dir, tier);
|
||||
if p.is_file() { Some(p) } else { None }
|
||||
}
|
||||
|
||||
/// Write missing WebP tiers up to `max_tier` (used by library bulk backfill).
|
||||
pub fn write_derived_webp_tiers(
|
||||
dir: &Path,
|
||||
img: &image::DynamicImage,
|
||||
max_tier: u32,
|
||||
) -> Result<(), String> {
|
||||
use super::encode::write_webp_tier;
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
for &tier in DERIVE_TIERS.iter() {
|
||||
if tier > max_tier {
|
||||
continue;
|
||||
}
|
||||
if tier_exists(dir, tier).is_some() {
|
||||
continue;
|
||||
}
|
||||
write_webp_tier(img, tier, &tier_path(dir, tier))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use image::imageops::FilterType;
|
||||
use image::DynamicImage;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn resize_tier(img: &DynamicImage, tier: u32) -> DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let max_dim = w.max(h);
|
||||
if max_dim <= tier {
|
||||
return img.clone();
|
||||
}
|
||||
let scale = tier as f32 / max_dim as f32;
|
||||
let nw = (w as f32 * scale).round().max(1.0) as u32;
|
||||
let nh = (h as f32 * scale).round().max(1.0) as u32;
|
||||
img.resize(nw, nh, FilterType::Triangle)
|
||||
}
|
||||
|
||||
/// Lossy WebP quality (0–100). Larger tiers use lower Q — UI rarely shows 800px raw;
|
||||
/// dense grids cap at 512px (see `COVER_ART_DENSE_MAX_TIER`).
|
||||
pub fn webp_quality_for_tier(tier: u32) -> f32 {
|
||||
match tier {
|
||||
2000 => 82.0,
|
||||
800 => 70.0,
|
||||
512 => 73.0,
|
||||
256 => 76.0,
|
||||
128 => 78.0,
|
||||
_ => 74.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_webp(img: &DynamicImage, tier: u32) -> Result<Vec<u8>, String> {
|
||||
let rgba = img.to_rgba8();
|
||||
let enc = webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height());
|
||||
Ok(enc.encode(webp_quality_for_tier(tier)).to_vec())
|
||||
}
|
||||
|
||||
pub fn write_webp_tier(img: &DynamicImage, tier: u32, path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let resized = resize_tier(img, tier);
|
||||
let bytes = encode_webp(&resized, tier)?;
|
||||
std::fs::write(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::RgbaImage;
|
||||
|
||||
#[test]
|
||||
fn resize_tier_scales_down_large_canvas() {
|
||||
let img = DynamicImage::ImageRgba8(RgbaImage::new(800, 600));
|
||||
let out = resize_tier(&img, 128);
|
||||
assert!(out.width() <= 128);
|
||||
assert!(out.height() <= 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_quality_decreases_with_tier_size() {
|
||||
assert!(webp_quality_for_tier(800) < webp_quality_for_tier(512));
|
||||
assert!(webp_quality_for_tier(512) < webp_quality_for_tier(128));
|
||||
assert!(webp_quality_for_tier(800) < webp_quality_for_tier(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_encode_800_smaller_than_old_lossless_upper_bound() {
|
||||
let img = DynamicImage::ImageRgba8(RgbaImage::new(800, 800));
|
||||
let bytes = encode_webp(&img, 800).expect("webp");
|
||||
assert!(
|
||||
bytes.len() < 250_000,
|
||||
"expected lossy 800 webp well under legacy JPEG cap, got {} bytes",
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use reqwest::Client;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUBSONIC_CLIENT: &str = "Psysonic";
|
||||
|
||||
fn random_salt() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("{nanos:x}")
|
||||
}
|
||||
|
||||
pub fn build_cover_art_url(
|
||||
rest_base: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
cover_art_id: &str,
|
||||
size: u32,
|
||||
) -> String {
|
||||
let base = rest_base.trim_end_matches('/');
|
||||
let api_base = if base.ends_with("/rest") {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/rest")
|
||||
};
|
||||
let salt = random_salt();
|
||||
let token = format!("{:x}", md5::compute(format!("{password}{salt}")));
|
||||
let endpoint = format!("{api_base}/getCoverArt.view");
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("id", cover_art_id);
|
||||
serializer.append_pair("size", &size.to_string());
|
||||
serializer.append_pair("u", username);
|
||||
serializer.append_pair("t", &token);
|
||||
serializer.append_pair("s", &salt);
|
||||
serializer.append_pair("v", "1.16.1");
|
||||
serializer.append_pair("c", SUBSONIC_CLIENT);
|
||||
let query = serializer.finish();
|
||||
match Url::parse(&endpoint) {
|
||||
Ok(mut url) => {
|
||||
url.set_query(Some(&query));
|
||||
url.to_string()
|
||||
}
|
||||
Err(_) => format!("{endpoint}?{query}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_cover_bytes(client: &Client, url: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("cover HTTP {}", resp.status()));
|
||||
}
|
||||
resp.bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_cover_art_url;
|
||||
|
||||
#[test]
|
||||
fn cover_url_from_host_root() {
|
||||
let url = build_cover_art_url(
|
||||
"http://navidrome.local:4533",
|
||||
"u",
|
||||
"p",
|
||||
"al-1",
|
||||
800,
|
||||
);
|
||||
assert!(url.starts_with("http://navidrome.local:4533/rest/getCoverArt.view?"));
|
||||
assert!(url.contains("id=al-1"));
|
||||
assert!(url.contains("size=800"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_url_when_rest_suffix_already_present() {
|
||||
let url = build_cover_art_url(
|
||||
"http://navidrome.local:4533/rest",
|
||||
"u",
|
||||
"p",
|
||||
"al-1",
|
||||
128,
|
||||
);
|
||||
assert!(url.starts_with("http://navidrome.local:4533/rest/getCoverArt.view?"));
|
||||
assert!(!url.contains("/rest/rest/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_url_does_not_panic_on_malformed_base() {
|
||||
let url = build_cover_art_url("://bad-url", "u", "p", "al-1", 128);
|
||||
assert!(url.contains("/rest/getCoverArt.view?"));
|
||||
assert!(url.contains("id=al-1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
//! Cover art disk cache — WebP tiers, prefetch, revalidation (phase B).
|
||||
|
||||
mod backfill_worker;
|
||||
mod disk;
|
||||
mod encode;
|
||||
mod fetch;
|
||||
|
||||
use disk::{cover_dir, tier_exists, tier_path, DERIVE_TIERS};
|
||||
use encode::write_webp_tier;
|
||||
use fetch::{build_cover_art_url, fetch_cover_bytes};
|
||||
use image::{DynamicImage, ImageReader};
|
||||
use psysonic_library::cover_backfill::{
|
||||
clear_cover_fetch_failures, collect_cover_backfill_batch, collect_cover_progress,
|
||||
count_distinct_cover_ids, cover_fetch_recently_failed, LibraryCoverBackfillBatchDto,
|
||||
LibraryCoverProgressDto, COVER_FETCH_FAIL_MARKER,
|
||||
};
|
||||
use psysonic_library::LibraryRuntime;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverCacheEnsureResult {
|
||||
pub hit: bool,
|
||||
pub path: String,
|
||||
pub tier: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverCacheStatsDto {
|
||||
pub bytes: u64,
|
||||
pub count: u64,
|
||||
pub pressure: String,
|
||||
pub auto_download_enabled: bool,
|
||||
pub entry_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverCacheEnsureArgs {
|
||||
pub server_index_key: String,
|
||||
pub cover_art_id: String,
|
||||
pub tier: u32,
|
||||
pub rest_base_url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// Library backfill: all derived tiers, no `cover:tier-ready` floods to the webview.
|
||||
#[serde(default)]
|
||||
pub library_bulk: bool,
|
||||
}
|
||||
|
||||
/// Cap concurrent cover HTTP fetches (library backfill + UI share this pool).
|
||||
const COVER_HTTP_CONCURRENCY: usize = 16;
|
||||
|
||||
pub struct CoverCacheState {
|
||||
pub root: PathBuf,
|
||||
pub client: Client,
|
||||
pub max_bytes: u64,
|
||||
pub high_watermark_pct: u64,
|
||||
pub resume_watermark_pct: u64,
|
||||
pub http_sem: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl CoverCacheState {
|
||||
pub fn new(root: PathBuf) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(&root).map_err(|e| e.to_string())?;
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(25))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
root,
|
||||
client,
|
||||
max_bytes: 10 * 1024 * 1024 * 1024,
|
||||
high_watermark_pct: 90,
|
||||
resume_watermark_pct: 85,
|
||||
http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)),
|
||||
})
|
||||
}
|
||||
|
||||
fn pressure_from_bytes(&self, _bytes: u64) -> (String, bool) {
|
||||
("ok".into(), true)
|
||||
}
|
||||
|
||||
fn pressure(&self) -> (String, bool) {
|
||||
let (bytes, _) = dir_usage_at_root(&self.root);
|
||||
self.pressure_from_bytes(bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_inner(
|
||||
state: &Arc<Mutex<CoverCacheState>>,
|
||||
app: &AppHandle,
|
||||
args: &CoverCacheEnsureArgs,
|
||||
http_sem_override: Option<Arc<Semaphore>>,
|
||||
) -> Result<CoverCacheEnsureResult, String> {
|
||||
let this = state.lock().await;
|
||||
let dir = cover_dir(&this.root, &args.server_index_key, &args.cover_art_id);
|
||||
if let Some(path) = peek_tier_path(&dir, args.tier) {
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: true,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
tier: args.tier,
|
||||
});
|
||||
}
|
||||
|
||||
let (_, auto_dl) = this.pressure();
|
||||
if !auto_dl && args.tier != 2000 {
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
path: String::new(),
|
||||
tier: args.tier,
|
||||
});
|
||||
}
|
||||
|
||||
let client = this.client.clone();
|
||||
let root = this.root.clone();
|
||||
let http_sem = http_sem_override.unwrap_or_else(|| this.http_sem.clone());
|
||||
drop(this);
|
||||
|
||||
if cover_fetch_recently_failed(&dir) {
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
path: String::new(),
|
||||
tier: args.tier,
|
||||
});
|
||||
}
|
||||
|
||||
let img = match load_cover_source(&dir, &client, &http_sem, args).await {
|
||||
Ok(img) => img,
|
||||
Err(_) => {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = std::fs::write(dir.join(COVER_FETCH_FAIL_MARKER), b"1");
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
path: String::new(),
|
||||
tier: args.tier,
|
||||
});
|
||||
}
|
||||
};
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let requested = args.tier;
|
||||
let quiet = args.library_bulk;
|
||||
let tiers_now: Vec<u32> = if args.library_bulk {
|
||||
DERIVE_TIERS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| *t <= requested)
|
||||
.collect()
|
||||
} else if requested == 2000 {
|
||||
vec![2000]
|
||||
} else {
|
||||
DERIVE_TIERS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| *t <= requested)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut wrote_requested = false;
|
||||
if quiet {
|
||||
let dir_bg = dir.clone();
|
||||
let img_bg = img.clone();
|
||||
let max_tier = requested;
|
||||
let wrote = tauri::async_runtime::spawn_blocking(move || -> Result<bool, String> {
|
||||
disk::write_derived_webp_tiers(&dir_bg, &img_bg, max_tier)?;
|
||||
Ok(tier_exists(&dir_bg, max_tier).is_some())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
wrote_requested = wrote;
|
||||
} else {
|
||||
for tier in tiers_now {
|
||||
if tier_exists(&dir, tier).is_some() {
|
||||
if tier == requested {
|
||||
wrote_requested = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let path = tier_path(&dir, tier);
|
||||
write_webp_tier(&img, tier, &path)?;
|
||||
emit_tier_ready(app, args, tier, &path);
|
||||
if tier == requested {
|
||||
wrote_requested = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !wrote_requested && tier_exists(&dir, requested).is_some() {
|
||||
wrote_requested = true;
|
||||
}
|
||||
|
||||
let out_path = tier_path(&dir, requested);
|
||||
if wrote_requested || out_path.is_file() {
|
||||
if !quiet {
|
||||
spawn_derive_remaining_tiers(
|
||||
app.clone(),
|
||||
state.clone(),
|
||||
root,
|
||||
args.clone(),
|
||||
img,
|
||||
requested,
|
||||
);
|
||||
}
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: true,
|
||||
path: out_path.to_string_lossy().into_owned(),
|
||||
tier: requested,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
path: String::new(),
|
||||
tier: requested,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_tier_ready(app: &AppHandle, args: &CoverCacheEnsureArgs, tier: u32, path: &Path) {
|
||||
let Ok(meta) = std::fs::metadata(path) else {
|
||||
return;
|
||||
};
|
||||
if !meta.is_file() || meta.len() == 0 {
|
||||
return;
|
||||
}
|
||||
let _ = app.emit(
|
||||
"cover:tier-ready",
|
||||
serde_json::json!({
|
||||
"serverIndexKey": args.server_index_key,
|
||||
"coverArtId": args.cover_art_id,
|
||||
"tier": tier,
|
||||
"path": path.to_string_lossy(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn decode_image_bytes(bytes: &[u8]) -> Result<DynamicImage, String> {
|
||||
ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| e.to_string())?
|
||||
.decode()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn load_image_from_disk(dir: &Path) -> Option<DynamicImage> {
|
||||
for tier in [800u32, 512, 256, 128] {
|
||||
if let Some(path) = tier_exists(dir, tier) {
|
||||
if let Ok(img) = image::open(&path) {
|
||||
return Some(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn load_cover_source(
|
||||
dir: &Path,
|
||||
client: &Client,
|
||||
http_sem: &Semaphore,
|
||||
args: &CoverCacheEnsureArgs,
|
||||
) -> Result<DynamicImage, String> {
|
||||
if let Some(img) = load_image_from_disk(dir) {
|
||||
return Ok(img);
|
||||
}
|
||||
let _permit = http_sem
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let fetch_size = if args.tier >= 2000 {
|
||||
2000
|
||||
} else {
|
||||
800
|
||||
};
|
||||
let url = build_cover_art_url(
|
||||
&args.rest_base_url,
|
||||
&args.username,
|
||||
&args.password,
|
||||
&args.cover_art_id,
|
||||
fetch_size,
|
||||
);
|
||||
let bytes = fetch_cover_bytes(client, &url).await?;
|
||||
decode_image_bytes(&bytes)
|
||||
}
|
||||
|
||||
fn spawn_derive_remaining_tiers(
|
||||
app: AppHandle,
|
||||
state: Arc<Mutex<CoverCacheState>>,
|
||||
_root: PathBuf,
|
||||
args: CoverCacheEnsureArgs,
|
||||
img: DynamicImage,
|
||||
requested: u32,
|
||||
) {
|
||||
let tiers_bg: Vec<u32> = if requested == 2000 {
|
||||
vec![]
|
||||
} else {
|
||||
DERIVE_TIERS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| *t > requested && *t <= 800)
|
||||
.collect()
|
||||
};
|
||||
if tiers_bg.is_empty() {
|
||||
return;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let dir = {
|
||||
let guard = state.lock().await;
|
||||
cover_dir(&guard.root, &args.server_index_key, &args.cover_art_id)
|
||||
};
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
for tier in tiers_bg {
|
||||
if tier_exists(&dir, tier).is_some() {
|
||||
continue;
|
||||
}
|
||||
let path = tier_path(&dir, tier);
|
||||
if write_webp_tier(&img, tier, &path).is_ok() {
|
||||
emit_tier_ready(&app, &args, tier, &path);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
fn dir_has_any_cached_tier(dir: &Path) -> bool {
|
||||
if tier_exists(dir, 800).is_some() {
|
||||
return true;
|
||||
}
|
||||
for tier in DERIVE_TIERS {
|
||||
if tier != 800 && tier_exists(dir, tier).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
tier_exists(dir, 2000).is_some()
|
||||
}
|
||||
|
||||
fn count_cached_in_server_dir(server_dir: &Path) -> i64 {
|
||||
let Ok(entries) = std::fs::read_dir(server_dir) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter(|e| dir_has_any_cached_tier(&e.path()))
|
||||
.count() as i64
|
||||
}
|
||||
|
||||
/// Count cover ID dirs with any cached tier (UI progress — matches visible disk cache).
|
||||
pub(crate) fn count_cached_cover_ids(root: &Path, server_index_key: &str) -> i64 {
|
||||
let keyed = count_cached_in_server_dir(&root.join(server_index_key));
|
||||
if keyed > 0 {
|
||||
return keyed;
|
||||
}
|
||||
// Legacy profile-uuid bucket or host alias — don't show 0 when files exist elsewhere.
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.path().is_dir()
|
||||
&& e.file_name().to_string_lossy() != ".storage-layout"
|
||||
})
|
||||
.map(|e| count_cached_in_server_dir(&e.path()))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Disk usage for one server bucket only (cheaper than scanning all hosts).
|
||||
pub(crate) fn dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64, u64) {
|
||||
let mut bytes = 0u64;
|
||||
let mut count = 0u64;
|
||||
let server_dir = root.join(server_index_key);
|
||||
let Ok(ids) = std::fs::read_dir(&server_dir) else {
|
||||
return (0, 0);
|
||||
};
|
||||
for id_dir in ids.flatten() {
|
||||
if !id_dir.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
if dir_has_any_cached_tier(&id_dir.path()) {
|
||||
count += 1;
|
||||
}
|
||||
let Ok(files) = std::fs::read_dir(id_dir.path()) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
if let Ok(meta) = f.metadata() {
|
||||
bytes += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
(bytes, count)
|
||||
}
|
||||
|
||||
pub(crate) fn dir_usage_at_root(root: &Path) -> (u64, u64) {
|
||||
let mut bytes = 0u64;
|
||||
let mut count = 0u64;
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return (0, 0);
|
||||
};
|
||||
for server in entries.flatten() {
|
||||
if server.file_name().to_string_lossy() == ".storage-layout" {
|
||||
continue;
|
||||
}
|
||||
if !server.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(ids) = std::fs::read_dir(server.path()) else {
|
||||
continue;
|
||||
};
|
||||
for id_dir in ids.flatten() {
|
||||
if !id_dir.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
if dir_has_any_cached_tier(&id_dir.path()) {
|
||||
count += 1;
|
||||
}
|
||||
let Ok(files) = std::fs::read_dir(id_dir.path()) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
if let Ok(meta) = f.metadata() {
|
||||
bytes += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(bytes, count)
|
||||
}
|
||||
|
||||
fn state(app: &AppHandle) -> Result<Arc<Mutex<CoverCacheState>>, String> {
|
||||
app.try_state::<Arc<Mutex<CoverCacheState>>>()
|
||||
.map(|s| s.inner().clone())
|
||||
.ok_or_else(|| "cover cache not initialized".into())
|
||||
}
|
||||
|
||||
const COVER_CACHE_LAYOUT_STAMP: &str = "index-key-v1";
|
||||
|
||||
/// Drop legacy profile-uuid directories when switching to host index keys (no migration).
|
||||
fn reset_cover_cache_for_index_key_layout(root: &Path) -> Result<(), String> {
|
||||
let stamp = root.join(".storage-layout");
|
||||
if stamp.is_file() {
|
||||
if let Ok(s) = std::fs::read_to_string(&stamp) {
|
||||
if s.trim() == COVER_CACHE_LAYOUT_STAMP {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.exists() {
|
||||
for entry in std::fs::read_dir(root).map_err(|e| e.to_string())?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some(".storage-layout") {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(root).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&stamp, COVER_CACHE_LAYOUT_STAMP).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub use backfill_worker::{
|
||||
pulse_backfill, setup_library_sync_idle_listener, try_schedule_full_pass, CoverBackfillPulseDto,
|
||||
CoverBackfillRunDto, CoverBackfillSession, CoverBackfillWorker,
|
||||
};
|
||||
|
||||
pub fn init_cover_cache(app: &AppHandle) -> Result<(), String> {
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("cover-cache");
|
||||
reset_cover_cache_for_index_key_layout(&root)?;
|
||||
app.manage(Arc::new(Mutex::new(CoverCacheState::new(root)?)));
|
||||
app.manage(Arc::new(CoverBackfillWorker::new()));
|
||||
setup_library_sync_idle_listener(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_run_full_pass(app: AppHandle) -> Result<CoverBackfillRunDto, String> {
|
||||
Ok(CoverBackfillRunDto {
|
||||
started: try_schedule_full_pass(&app).await,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_pulse(app: AppHandle) -> Result<CoverBackfillPulseDto, String> {
|
||||
let worker = app
|
||||
.try_state::<Arc<CoverBackfillWorker>>()
|
||||
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
|
||||
Ok(pulse_backfill(&app, &worker).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_reset_cursor(app: AppHandle) -> Result<(), String> {
|
||||
let worker = app
|
||||
.try_state::<Arc<CoverBackfillWorker>>()
|
||||
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
|
||||
worker.reset_cursor().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pause library backfill while the user navigates / visible covers load (Rust pass yields).
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_set_ui_priority(
|
||||
app: AppHandle,
|
||||
hold: bool,
|
||||
) -> Result<(), String> {
|
||||
let worker = app
|
||||
.try_state::<Arc<CoverBackfillWorker>>()
|
||||
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
|
||||
worker.set_ui_priority_hold(hold);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_configure(
|
||||
app: AppHandle,
|
||||
enabled: bool,
|
||||
server_index_key: String,
|
||||
library_server_id: String,
|
||||
rest_base_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let worker = app
|
||||
.try_state::<Arc<CoverBackfillWorker>>()
|
||||
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
|
||||
let session = if enabled && !library_server_id.is_empty() && !server_index_key.is_empty() {
|
||||
Some(CoverBackfillSession {
|
||||
server_index_key,
|
||||
library_server_id,
|
||||
rest_base_url,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
worker
|
||||
.set_session(enabled && session.is_some(), session)
|
||||
.await;
|
||||
if enabled {
|
||||
let _ = try_schedule_full_pass(&app).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverCachePeekItem {
|
||||
pub server_index_key: String,
|
||||
pub cover_art_id: String,
|
||||
pub tier: u32,
|
||||
}
|
||||
|
||||
/// Best-effort disk hit without network (exact tier, then largest tier on disk ≤ wanted).
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_peek_batch(
|
||||
app: AppHandle,
|
||||
items: Vec<CoverCachePeekItem>,
|
||||
) -> Result<HashMap<String, String>, String> {
|
||||
let st = state(&app)?;
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let mut out = HashMap::new();
|
||||
for item in items {
|
||||
let dir = cover_dir(&root, &item.server_index_key, &item.cover_art_id);
|
||||
let path = peek_tier_path(&dir, item.tier);
|
||||
if let Some(p) = path {
|
||||
let key = format!(
|
||||
"{}:cover:{}:{}",
|
||||
item.server_index_key, item.cover_art_id, item.tier
|
||||
);
|
||||
out.insert(key, p.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn peek_fallback_tiers(want: u32) -> &'static [u32] {
|
||||
match want {
|
||||
512 => &[800, 256, 128],
|
||||
256 => &[800, 512, 128],
|
||||
128 => &[256, 512, 800],
|
||||
64 => &[128, 256, 512, 800],
|
||||
w if w > 512 && w < 800 => &[800, 512, 256, 128],
|
||||
w if w > 800 => &[512, 256, 128],
|
||||
_ => &[800, 512, 256, 128],
|
||||
}
|
||||
}
|
||||
|
||||
/// Disk-only: exact tier, then grid-friendly upscales (512 → 800 before 128).
|
||||
fn peek_tier_path(dir: &Path, want: u32) -> Option<PathBuf> {
|
||||
if let Some(p) = tier_exists(dir, want) {
|
||||
return Some(p);
|
||||
}
|
||||
for &tier in peek_fallback_tiers(want) {
|
||||
if let Some(p) = tier_exists(dir, tier) {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_ensure(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
cover_art_id: String,
|
||||
tier: u32,
|
||||
rest_base_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<CoverCacheEnsureResult, String> {
|
||||
let args = CoverCacheEnsureArgs {
|
||||
server_index_key,
|
||||
cover_art_id,
|
||||
tier,
|
||||
rest_base_url,
|
||||
username,
|
||||
password,
|
||||
library_bulk: false,
|
||||
};
|
||||
let st = state(&app)?;
|
||||
CoverCacheState::ensure_inner(&st, &app, &args, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_ensure_batch(
|
||||
app: AppHandle,
|
||||
items: Vec<CoverCacheEnsureArgs>,
|
||||
) -> Result<(), String> {
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let st = state(&app)?;
|
||||
for item in items {
|
||||
let st = st.clone();
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ = CoverCacheState::ensure_inner(&st, &app, &item, None).await;
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_stats(app: AppHandle) -> Result<CoverCacheStatsDto, String> {
|
||||
let st = state(&app)?;
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let (bytes, entry_count) = tauri::async_runtime::spawn_blocking(move || dir_usage_at_root(&root))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
let (pressure, auto_download_enabled) = guard.pressure_from_bytes(bytes);
|
||||
Ok(CoverCacheStatsDto {
|
||||
bytes,
|
||||
count: entry_count,
|
||||
pressure,
|
||||
auto_download_enabled,
|
||||
entry_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_evict_tick(_app: AppHandle) -> Result<u32, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_stats_server(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
) -> Result<CoverCacheStatsDto, String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
let (bytes, entry_count) = dir_usage_for_server(&guard.root, &server_index_key);
|
||||
let (pressure, auto_download_enabled) = guard.pressure_from_bytes(bytes);
|
||||
Ok(CoverCacheStatsDto {
|
||||
bytes,
|
||||
count: entry_count,
|
||||
pressure,
|
||||
auto_download_enabled,
|
||||
entry_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_clear_server(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
) -> Result<(), String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
let path = guard.root.join(&server_index_key);
|
||||
if path.is_dir() {
|
||||
std::fs::remove_dir_all(&path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
drop(guard);
|
||||
let _ = app.emit(
|
||||
"cover:cache-cleared",
|
||||
serde_json::json!({ "serverIndexKey": server_index_key }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_configure(
|
||||
app: AppHandle,
|
||||
max_mb: u64,
|
||||
high_watermark_pct: u64,
|
||||
resume_watermark_pct: u64,
|
||||
) -> Result<(), String> {
|
||||
let st = state(&app)?;
|
||||
let mut guard = st.lock().await;
|
||||
guard.max_bytes = max_mb.saturating_mul(1024 * 1024);
|
||||
guard.high_watermark_pct = high_watermark_pct.clamp(50, 99);
|
||||
guard.resume_watermark_pct = resume_watermark_pct.clamp(40, 95);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_clear(app: AppHandle) -> Result<(), String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
if guard.root.exists() {
|
||||
for entry in std::fs::read_dir(&guard.root).map_err(|e| e.to_string())?.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy() == ".storage-layout" {
|
||||
continue;
|
||||
}
|
||||
if entry.path().is_dir() {
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
} else {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
let _ = app.emit("cover:cache-cleared", serde_json::json!({}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_batch(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
library_server_id: String,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<LibraryCoverBackfillBatchDto, String> {
|
||||
let runtime = app
|
||||
.try_state::<LibraryRuntime>()
|
||||
.ok_or_else(|| "LibraryRuntime not initialized".to_string())?;
|
||||
let st = state(&app)?;
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let store = runtime.store.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
collect_cover_backfill_batch(
|
||||
&store,
|
||||
&library_server_id,
|
||||
&root,
|
||||
&server_index_key,
|
||||
cursor.as_deref(),
|
||||
limit,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_progress(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
library_server_id: String,
|
||||
) -> Result<LibraryCoverProgressDto, String> {
|
||||
let runtime = app
|
||||
.try_state::<LibraryRuntime>()
|
||||
.ok_or_else(|| "LibraryRuntime not initialized".to_string())?;
|
||||
let st = state(&app)?;
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let index_key = server_index_key.clone();
|
||||
let store = runtime.store.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let cached_dirs = count_cached_cover_ids(&root, &index_key);
|
||||
collect_cover_progress(
|
||||
&store,
|
||||
&library_server_id,
|
||||
&root,
|
||||
&index_key,
|
||||
cached_dirs,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_clear_fetch_failures(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
) -> Result<u32, String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
Ok(clear_cover_fetch_failures(&guard.root, &server_index_key))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_catalog_size(
|
||||
app: AppHandle,
|
||||
library_server_id: String,
|
||||
) -> Result<i64, String> {
|
||||
let runtime = app
|
||||
.try_state::<LibraryRuntime>()
|
||||
.ok_or_else(|| "LibraryRuntime not initialized".to_string())?;
|
||||
let store = runtime.store.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
count_distinct_cover_ids(&store, &library_server_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cover_revalidate_enqueue() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cover_revalidate_tick(_cycle_days: Option<u32>) -> Result<u32, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cover_revalidate_batch() -> Result<serde_json::Value, String> {
|
||||
Ok(serde_json::json!({
|
||||
"cursor": null,
|
||||
"processed": 0,
|
||||
"changed": 0
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::disk::{cover_dir, tier_path};
|
||||
|
||||
#[test]
|
||||
fn disk_layout_paths() {
|
||||
let root = std::path::Path::new("/tmp/cover-test");
|
||||
let dir = cover_dir(root, "srv", "al-1");
|
||||
assert_eq!(dir, root.join("srv").join("al-1"));
|
||||
assert_eq!(tier_path(&dir, 512), dir.join("512.webp"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
pub mod cli;
|
||||
mod cover_cache;
|
||||
mod lib_commands;
|
||||
|
||||
pub use psysonic_integration::discord;
|
||||
@@ -119,6 +120,9 @@ pub fn run() {
|
||||
app.manage(cache);
|
||||
}
|
||||
|
||||
cover_cache::init_cover_cache(app.handle())
|
||||
.map_err(|e| format!("cover cache init failed: {e}"))?;
|
||||
|
||||
// ── Library track store (psysonic-library, PR-5a + PR-5b) ─────
|
||||
// PR-5a brought up the read-only Tauri surface + LibraryRuntime.
|
||||
// PR-5b adds the mutating commands, sync session map, current-job
|
||||
@@ -726,6 +730,27 @@ pub fn run() {
|
||||
psysonic_library::commands::library_migrate_server_index_keys,
|
||||
psysonic_library::commands::library_delete_server_data,
|
||||
psysonic_library::commands::library_analysis_backfill_batch,
|
||||
cover_cache::cover_cache_peek_batch,
|
||||
cover_cache::cover_cache_ensure,
|
||||
cover_cache::cover_cache_ensure_batch,
|
||||
cover_cache::cover_cache_stats,
|
||||
cover_cache::cover_cache_evict_tick,
|
||||
cover_cache::cover_cache_configure,
|
||||
cover_cache::cover_cache_clear,
|
||||
cover_cache::cover_cache_clear_server,
|
||||
cover_cache::cover_cache_stats_server,
|
||||
cover_cache::library_cover_backfill_batch,
|
||||
cover_cache::library_cover_progress,
|
||||
cover_cache::library_cover_catalog_size,
|
||||
cover_cache::library_cover_clear_fetch_failures,
|
||||
cover_cache::library_cover_backfill_configure,
|
||||
cover_cache::library_cover_backfill_pulse,
|
||||
cover_cache::library_cover_backfill_reset_cursor,
|
||||
cover_cache::library_cover_backfill_set_ui_priority,
|
||||
cover_cache::library_cover_backfill_run_full_pass,
|
||||
cover_cache::cover_revalidate_enqueue,
|
||||
cover_cache::cover_revalidate_tick,
|
||||
cover_cache::cover_revalidate_batch,
|
||||
psysonic_syncfs::cache::offline::download_track_offline,
|
||||
psysonic_syncfs::cache::offline::cancel_offline_downloads,
|
||||
psysonic_syncfs::cache::offline::clear_offline_cancel,
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost tauri:; connect-src 'self' ipc: http://ipc.localhost tauri: https: http: ws: wss:; img-src 'self' asset: http://asset.localhost https: http: data: blob:; media-src 'self' asset: http://asset.localhost https: http: data: blob:;",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/**", "$APPDATA/cover-cache/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { makeTrack } from '../test/helpers/factories';
|
||||
import { resetAllStores } from '../test/helpers/storeReset';
|
||||
import { invokeMock, onInvoke } from '../test/mocks/tauri';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { coverCacheEnsure, coverCacheRestHost, librarySqlServerId } from './coverCache';
|
||||
|
||||
describe('librarySqlServerId', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
useAuthStore.setState({
|
||||
servers: [{ id: 'profile-uuid', name: 'Home', url: 'http://music.example:4533', username: 'u', password: 'p' }],
|
||||
activeServerId: 'profile-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps auth profile UUID to host index key for SQLite', () => {
|
||||
expect(librarySqlServerId('profile-uuid')).toBe('music.example:4533');
|
||||
});
|
||||
|
||||
it('passes through values that are already index keys', () => {
|
||||
expect(librarySqlServerId('music.example:4533')).toBe('music.example:4533');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverCacheRestHost', () => {
|
||||
it('strips /rest for Rust cover fetch', () => {
|
||||
expect(coverCacheRestHost('http://music.example:4533')).toBe('http://music.example:4533');
|
||||
expect(coverCacheRestHost('http://music.example:4533/')).toBe('http://music.example:4533');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverCacheEnsure', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
it('uses playback server credentials when scope kind is playback', async () => {
|
||||
const activeServerId = useAuthStore.getState().addServer({
|
||||
name: 'Active',
|
||||
url: 'http://active.example:4533',
|
||||
username: 'active-user',
|
||||
password: 'active-pass',
|
||||
});
|
||||
const playbackServerId = useAuthStore.getState().addServer({
|
||||
name: 'Playback',
|
||||
url: 'http://playback.example:5533',
|
||||
username: 'playback-user',
|
||||
password: 'playback-pass',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(activeServerId);
|
||||
|
||||
const track = makeTrack({ id: 'q1', coverArt: 'cover-1' });
|
||||
usePlayerStore.setState({
|
||||
queue: [track],
|
||||
queueIndex: 0,
|
||||
queueServerId: playbackServerId,
|
||||
currentTrack: track,
|
||||
});
|
||||
|
||||
onInvoke('cover_cache_ensure', () => ({ hit: false, path: '', tier: 256 }));
|
||||
|
||||
await coverCacheEnsure(coverArtRef('cover-1', { kind: 'playback' }), 256);
|
||||
|
||||
const call = invokeMock.mock.calls.find(c => c[0] === 'cover_cache_ensure');
|
||||
expect(call).toBeTruthy();
|
||||
const payload = call?.[1] as Record<string, unknown>;
|
||||
expect(payload.restBaseUrl).toBe('http://playback.example:5533');
|
||||
expect(payload.username).toBe('playback-user');
|
||||
expect(payload.password).toBe('playback-pass');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { coverIndexKeyFromRef } from '../cover/storageKeys';
|
||||
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { restBaseFromUrl } from './subsonicClient';
|
||||
import type { CoverArtRef, CoverArtTier } from '../cover/types';
|
||||
|
||||
/** Library SQLite `track.server_id` uses host index keys, not auth profile UUIDs. */
|
||||
export function librarySqlServerId(profileOrIndexServerId: string): string {
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === profileOrIndexServerId);
|
||||
if (server) return serverIndexKeyForProfile(server);
|
||||
return profileOrIndexServerId;
|
||||
}
|
||||
|
||||
/** Host root for Rust `build_cover_art_url` (`{host}/rest/getCoverArt.view`). */
|
||||
export function coverCacheRestHost(serverUrl: string): string {
|
||||
return restBaseFromUrl(serverUrl).replace(/\/rest$/i, '');
|
||||
}
|
||||
|
||||
export type CoverCacheEnsureResult = {
|
||||
hit: boolean;
|
||||
path: string;
|
||||
tier: CoverArtTier;
|
||||
};
|
||||
|
||||
export type CoverCacheStats = {
|
||||
bytes: number;
|
||||
count: number;
|
||||
pressure: 'ok' | 'pressure' | 'full';
|
||||
autoDownloadEnabled: boolean;
|
||||
entryCount: number;
|
||||
};
|
||||
|
||||
let coverAutoDownloadEnabled = true;
|
||||
|
||||
export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void {
|
||||
coverAutoDownloadEnabled = enabled;
|
||||
}
|
||||
|
||||
function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const scope = ref.serverScope;
|
||||
if (scope.kind === 'server') {
|
||||
return {
|
||||
serverIndexKey: coverIndexKeyFromRef(ref),
|
||||
coverArtId: ref.coverArtId,
|
||||
tier,
|
||||
restBaseUrl: coverCacheRestHost(scope.url),
|
||||
username: scope.username,
|
||||
password: scope.password,
|
||||
};
|
||||
}
|
||||
const server =
|
||||
scope.kind === 'playback'
|
||||
? (() => {
|
||||
const playbackServerId = getPlaybackServerId();
|
||||
if (playbackServerId) {
|
||||
const playbackServer = useAuthStore
|
||||
.getState()
|
||||
.servers.find(s => s.id === playbackServerId);
|
||||
if (playbackServer) return playbackServer;
|
||||
}
|
||||
return getActiveServer();
|
||||
})()
|
||||
: getActiveServer();
|
||||
const baseUrl = server?.url || getBaseUrl();
|
||||
return {
|
||||
serverIndexKey: coverIndexKeyFromRef(ref),
|
||||
coverArtId: ref.coverArtId,
|
||||
tier,
|
||||
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export type CoverCachePeekItem = {
|
||||
serverIndexKey: string;
|
||||
coverArtId: string;
|
||||
tier: CoverArtTier;
|
||||
};
|
||||
|
||||
/** Disk-only — no HTTP. Returns map storageKey → absolute .webp path. */
|
||||
export async function coverCachePeekBatch(
|
||||
items: CoverCachePeekItem[],
|
||||
): Promise<Record<string, string>> {
|
||||
if (items.length === 0) return {};
|
||||
const raw = await invoke<Record<string, string>>('cover_cache_peek_batch', { items });
|
||||
const out: Record<string, string> = {};
|
||||
for (const item of items) {
|
||||
const key = `${item.serverIndexKey}:cover:${item.coverArtId}:${item.tier}`;
|
||||
if (raw[key]) out[key] = raw[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function coverCacheEnsure(
|
||||
ref: CoverArtRef,
|
||||
tier: CoverArtTier,
|
||||
_priority?: string,
|
||||
): Promise<CoverCacheEnsureResult> {
|
||||
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', ensureArgsFromRef(ref, tier));
|
||||
}
|
||||
|
||||
export async function coverCacheEnsureBatch(
|
||||
refs: CoverArtRef[],
|
||||
tier: CoverArtTier,
|
||||
_priority?: string,
|
||||
): Promise<void> {
|
||||
if (refs.length === 0) return;
|
||||
const items = refs.map(ref => ensureArgsFromRef(ref, tier));
|
||||
await invoke('cover_cache_ensure_batch', { items });
|
||||
}
|
||||
|
||||
export async function coverCacheStats(): Promise<CoverCacheStats> {
|
||||
const stats = await invoke<CoverCacheStats>('cover_cache_stats', {});
|
||||
setCoverCacheAutoDownloadEnabled(stats.autoDownloadEnabled);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** Clears all servers (legacy). Prefer `coverCacheClearServer`. */
|
||||
export async function coverCacheClear(): Promise<void> {
|
||||
return invoke('cover_cache_clear', {});
|
||||
}
|
||||
|
||||
export async function coverCacheClearServer(serverIndexKey: string): Promise<void> {
|
||||
return invoke('cover_cache_clear_server', { serverIndexKey });
|
||||
}
|
||||
|
||||
export async function coverCacheStatsServer(
|
||||
serverIndexKey: string,
|
||||
): Promise<Pick<CoverCacheStats, 'bytes' | 'entryCount'>> {
|
||||
const stats = await invoke<CoverCacheStats>('cover_cache_stats_server', { serverIndexKey });
|
||||
return { bytes: stats.bytes, entryCount: stats.entryCount };
|
||||
}
|
||||
|
||||
export async function libraryCoverBackfillBatch(
|
||||
serverIndexKey: string,
|
||||
libraryServerId: string,
|
||||
cursor?: string | null,
|
||||
limit?: number,
|
||||
): Promise<{ coverIds: string[]; nextCursor: string | null; exhausted: boolean }> {
|
||||
const sqlServerId = librarySqlServerId(libraryServerId);
|
||||
const diskKey = serverIndexKey || sqlServerId;
|
||||
return invoke('library_cover_backfill_batch', {
|
||||
serverIndexKey: diskKey,
|
||||
libraryServerId: sqlServerId,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function libraryCoverProgress(
|
||||
serverIndexKey: string,
|
||||
libraryServerId: string,
|
||||
): Promise<{ totalDistinct: number; pending: number; done: number }> {
|
||||
const sqlServerId = librarySqlServerId(libraryServerId);
|
||||
const diskKey = serverIndexKey || sqlServerId;
|
||||
return invoke('library_cover_progress', {
|
||||
serverIndexKey: diskKey,
|
||||
libraryServerId: sqlServerId,
|
||||
});
|
||||
}
|
||||
|
||||
export type LibraryCoverBackfillConfigureArgs = {
|
||||
enabled: boolean;
|
||||
serverIndexKey: string;
|
||||
libraryServerId: string;
|
||||
restBaseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export async function libraryCoverBackfillConfigure(
|
||||
args: LibraryCoverBackfillConfigureArgs,
|
||||
): Promise<void> {
|
||||
return invoke('library_cover_backfill_configure', args);
|
||||
}
|
||||
|
||||
export type CoverBackfillPulseResult = {
|
||||
scheduled: number;
|
||||
exhausted: boolean;
|
||||
pending: number;
|
||||
done: number;
|
||||
total: number;
|
||||
status: 'idle' | 'active' | 'blocked_sync' | 'blocked_pressure' | 'disabled' | string;
|
||||
};
|
||||
|
||||
/** One backfill step (legacy); prefer `libraryCoverBackfillRunFullPass`. */
|
||||
export async function libraryCoverBackfillPulse(): Promise<CoverBackfillPulseResult> {
|
||||
return invoke<CoverBackfillPulseResult>('library_cover_backfill_pulse');
|
||||
}
|
||||
|
||||
/** Start one full-catalog pass on the native runtime (works when the window is inactive). */
|
||||
export async function libraryCoverBackfillRunFullPass(): Promise<{ started: boolean }> {
|
||||
return invoke<{ started: boolean }>('library_cover_backfill_run_full_pass');
|
||||
}
|
||||
|
||||
export async function libraryCoverBackfillResetCursor(): Promise<void> {
|
||||
return invoke('library_cover_backfill_reset_cursor');
|
||||
}
|
||||
|
||||
/** Yield native library backfill while the user navigates (visible covers first). */
|
||||
export async function libraryCoverBackfillSetUiPriority(hold: boolean): Promise<void> {
|
||||
return invoke('library_cover_backfill_set_ui_priority', { hold });
|
||||
}
|
||||
|
||||
export async function libraryCoverClearFetchFailures(serverIndexKey: string): Promise<number> {
|
||||
return invoke<number>('library_cover_clear_fetch_failures', { serverIndexKey });
|
||||
}
|
||||
|
||||
export async function libraryCoverCatalogSize(libraryServerId: string): Promise<number> {
|
||||
return invoke<number>('library_cover_catalog_size', {
|
||||
libraryServerId: librarySqlServerId(libraryServerId),
|
||||
});
|
||||
}
|
||||
|
||||
export function coverCacheMayBackgroundDownload(): boolean {
|
||||
return coverAutoDownloadEnabled;
|
||||
}
|
||||
@@ -128,10 +128,10 @@ describe('getClient', () => {
|
||||
});
|
||||
|
||||
describe('coverArtCacheKey', () => {
|
||||
it('uses serverId + entity id + size as a stable cache key', () => {
|
||||
const id = setUpServer();
|
||||
expect(coverArtCacheKey('cover-1')).toBe(`${id}:cover:cover-1:256`);
|
||||
expect(coverArtCacheKey('cover-1', 200)).toBe(`${id}:cover:cover-1:200`);
|
||||
it('uses host index key + entity id + tier as a stable cache key', () => {
|
||||
setUpServer();
|
||||
expect(coverArtCacheKey('cover-1')).toBe('music.example.com:cover:cover-1:256');
|
||||
expect(coverArtCacheKey('cover-1', 200)).toBe('music.example.com:cover:cover-1:200');
|
||||
});
|
||||
|
||||
it('falls back to "_" as the server-id segment when no server is active', () => {
|
||||
@@ -212,8 +212,9 @@ describe('buildCoverArtUrlForServer', () => {
|
||||
});
|
||||
|
||||
describe('coverArtCacheKeyForServer', () => {
|
||||
it('scopes cache keys by server id', () => {
|
||||
expect(coverArtCacheKeyForServer('srv-b', 'cover-1', 80)).toBe('srv-b:cover:cover-1:80');
|
||||
it('scopes cache keys by host index key when profile is known', () => {
|
||||
const profileId = setUpServer({ url: 'https://b.example' });
|
||||
expect(coverArtCacheKeyForServer(profileId, 'cover-1', 80)).toBe('b.example:cover:cover-1:80');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import md5 from 'md5';
|
||||
import { coverStorageKey } from '../cover/storageKeys';
|
||||
import type { CoverArtTier } from '../cover/types';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
|
||||
@@ -53,16 +55,29 @@ export function buildStreamUrl(id: string): string {
|
||||
return streamUrlFromProfile(server.url, server.username, server.password, id);
|
||||
}
|
||||
|
||||
/** Stable cache key for cover art — does not include ephemeral auth params. */
|
||||
/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */
|
||||
export function coverArtCacheKey(id: string, size = 256): string {
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
return coverArtCacheKeyForServer(server?.id ?? '_', id, size);
|
||||
return coverStorageKey({ kind: 'active' }, id, size as CoverArtTier);
|
||||
}
|
||||
|
||||
export function coverArtCacheKeyForServer(serverId: string, id: string, size = 256): string {
|
||||
return `${serverId}:cover:${id}:${size}`;
|
||||
/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */
|
||||
export function coverArtCacheKeyForServer(serverIdOrKey: string, id: string, size = 256): string {
|
||||
const server = findServerByIdOrIndexKey(serverIdOrKey);
|
||||
if (!server) return `${serverIdOrKey}:cover:${id}:${size}`;
|
||||
return coverStorageKey(
|
||||
{
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
},
|
||||
id,
|
||||
size as CoverArtTier,
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */
|
||||
export function buildCoverArtUrl(id: string, size = 256): string {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
@@ -71,7 +86,7 @@ export function buildCoverArtUrl(id: string, size = 256): string {
|
||||
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
/** Cover art for a specific saved server (e.g. share-search preview on a non-active server). */
|
||||
/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */
|
||||
export function buildCoverArtUrlForServer(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
|
||||
@@ -46,6 +46,8 @@ import { useQueueResizer } from '../hooks/useQueueResizer';
|
||||
import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelectionBlockers';
|
||||
import { useAppActivityTracking } from '../hooks/useAppActivityTracking';
|
||||
import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator';
|
||||
import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority';
|
||||
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
|
||||
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
|
||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
@@ -96,6 +98,8 @@ export function AppShell() {
|
||||
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
useCoverNavigationPriority();
|
||||
useNowPlayingPrewarm();
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
|
||||
@@ -18,6 +18,10 @@ import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigrat
|
||||
import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
|
||||
import { hydrateQueueFromIndex } from '../utils/library/queueRestore';
|
||||
import { useLibraryAnalysisBackfill } from '../hooks/useLibraryAnalysisBackfill';
|
||||
import { useCoverArtPrefetch } from '../cover/useCoverArtPrefetch';
|
||||
import { useLibraryCoverBackfill } from '../hooks/useLibraryCoverBackfill';
|
||||
import { useCoverRevalidateScheduler } from '../cover/useCoverRevalidateScheduler';
|
||||
import { runCoverIdbUpgradeMigration } from '../utils/migrations/coverIdbUpgradeMigration';
|
||||
import { useMigrationOrchestrator } from '../hooks/useMigrationOrchestrator';
|
||||
import { IS_WINDOWS } from '../utils/platform';
|
||||
import TauriEventBridge from './TauriEventBridge';
|
||||
@@ -61,6 +65,14 @@ export default function MainApp() {
|
||||
}, [activeServerId, serverIdsKey, masterEnabled, migrationReady]);
|
||||
|
||||
useLibraryAnalysisBackfill(migrationReady);
|
||||
useCoverArtPrefetch(migrationReady);
|
||||
useLibraryCoverBackfill(migrationReady);
|
||||
useCoverRevalidateScheduler(migrationReady);
|
||||
|
||||
useEffect(() => {
|
||||
if (!migrationReady) return;
|
||||
void runCoverIdbUpgradeMigration();
|
||||
}, [migrationReady]);
|
||||
|
||||
// Push playback state to mini window + handle control events.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useInAppKeybindings } from '../hooks/tauriBridge/useInAppKeybindings';
|
||||
import { useMediaAndWindowBridge } from '../hooks/tauriBridge/useMediaAndWindowBridge';
|
||||
import { usePlayerSnapshotPublisher } from '../hooks/tauriBridge/usePlayerSnapshotPublisher';
|
||||
import { useLibraryDevSyncLog } from '../hooks/tauriBridge/useLibraryDevSyncLog';
|
||||
import { useCoverArtBridge } from '../hooks/tauriBridge/useCoverArtBridge';
|
||||
|
||||
/**
|
||||
* Single mount point for everything that bridges Rust ↔ React in the main
|
||||
@@ -35,6 +36,7 @@ export function TauriEventBridge() {
|
||||
useMediaAndWindowBridge(navigate);
|
||||
usePlayerSnapshotPublisher();
|
||||
useLibraryDevSyncLog();
|
||||
useCoverArtBridge();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
@@ -9,7 +8,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import type { CoverPrefetchPriority } from '../cover/types';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { coverStorageKey } from '../cover/storageKeys';
|
||||
import { resolveCoverDisplayTier } from '../cover/tiers';
|
||||
import { acquireUrl } from '../utils/imageCache/urlPool';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { playAlbum } from '../utils/playback/playAlbum';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
@@ -24,7 +28,14 @@ interface AlbumCardProps {
|
||||
showRating?: boolean;
|
||||
selectedAlbums?: SubsonicAlbum[];
|
||||
disableArtwork?: boolean;
|
||||
/** Layout-native cover square width in CSS px (from parent grid). */
|
||||
displayCssPx?: number;
|
||||
/** @deprecated Use displayCssPx — kept for call-site transition only */
|
||||
artworkSize?: number;
|
||||
/** In-page scroll viewport (`VirtualCardGrid` `scrollRootId`) for cover IO priority. */
|
||||
observeScrollRootId?: string;
|
||||
/** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */
|
||||
ensurePriority?: CoverPrefetchPriority;
|
||||
}
|
||||
|
||||
function AlbumCard({
|
||||
@@ -35,7 +46,10 @@ function AlbumCard({
|
||||
showRating = false,
|
||||
selectedAlbums = [],
|
||||
disableArtwork = false,
|
||||
artworkSize = 300,
|
||||
displayCssPx = COVER_DENSE_GRID_MIN_CELL_CSS_PX,
|
||||
artworkSize: _artworkSize,
|
||||
observeScrollRootId,
|
||||
ensurePriority,
|
||||
}: AlbumCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -47,16 +61,12 @@ function AlbumCard({
|
||||
if (!meta || meta.trackIds.length === 0) return false;
|
||||
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||
});
|
||||
// buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
|
||||
const coverUrl = useMemo(
|
||||
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, artworkSize) : ''),
|
||||
[album.coverArt, artworkSize],
|
||||
);
|
||||
const coverCacheKey = useMemo(
|
||||
() => (album.coverArt ? coverArtCacheKey(album.coverArt, artworkSize) : ''),
|
||||
[album.coverArt, artworkSize],
|
||||
);
|
||||
const psyDrag = useDragDrop();
|
||||
const dragCoverKey = useMemo(() => {
|
||||
if (!album.coverArt) return '';
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
||||
return coverStorageKey({ kind: 'active' }, album.coverArt, tier);
|
||||
}, [album.coverArt, displayCssPx]);
|
||||
const isNewAlbum = isAlbumRecentlyAdded(album.created);
|
||||
const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]);
|
||||
|
||||
@@ -89,7 +99,8 @@ function AlbumCard({
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl: coverUrl || undefined }, me.clientX, me.clientY);
|
||||
const coverUrl = dragCoverKey ? acquireUrl(dragCoverKey) ?? undefined : undefined;
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
@@ -98,13 +109,16 @@ function AlbumCard({
|
||||
}}
|
||||
>
|
||||
<div className="album-card-cover">
|
||||
{!disableArtwork && coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
{!disableArtwork && album.coverArt ? (
|
||||
<CoverArtImage
|
||||
coverArtId={album.coverArt}
|
||||
displayCssPx={displayCssPx}
|
||||
surface="dense"
|
||||
alt={`${album.name} Cover`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
observeScrollRootId={observeScrollRootId}
|
||||
ensurePriority={ensurePriority}
|
||||
/>
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { buildCoverArtUrl } from '../api/subsonicStreamUrl';
|
||||
import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { useCoverLightboxSrc } from '../cover/lightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
@@ -68,8 +68,7 @@ interface AlbumHeaderProps {
|
||||
/** OpenSubsonic album credits (derived from album + songs). */
|
||||
headerArtistRefs: SubsonicOpenArtistRef[];
|
||||
songs: SubsonicSong[];
|
||||
coverUrl: string;
|
||||
coverKey: string;
|
||||
coverArtId?: string;
|
||||
resolvedCoverUrl: string | null;
|
||||
isStarred: boolean;
|
||||
downloadProgress: number | null;
|
||||
@@ -96,8 +95,7 @@ export default function AlbumHeader({
|
||||
info,
|
||||
headerArtistRefs,
|
||||
songs,
|
||||
coverUrl,
|
||||
coverKey,
|
||||
coverArtId,
|
||||
resolvedCoverUrl,
|
||||
isStarred,
|
||||
downloadProgress,
|
||||
@@ -122,7 +120,14 @@ export default function AlbumHeader({
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const coverRef = useMemo(
|
||||
() => (coverArtId ? coverArtRef(coverArtId) : null),
|
||||
[coverArtId],
|
||||
);
|
||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
|
||||
alt: `${info.name} Cover`,
|
||||
});
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
@@ -130,11 +135,6 @@ export default function AlbumHeader({
|
||||
const isNewAlbum = isAlbumRecentlyAdded(info.created);
|
||||
const showBioButton = !isVariousArtistsLabel(info.artist);
|
||||
|
||||
const lightboxCoverSrc = useMemo(
|
||||
() => (info.coverArt ? buildCoverArtUrl(info.coverArt, 2000) : ''),
|
||||
[info.coverArt],
|
||||
);
|
||||
|
||||
const handleShareAlbum = async () => {
|
||||
try {
|
||||
const ok = await copyEntityShareLink('album', info.id);
|
||||
@@ -148,13 +148,7 @@ export default function AlbumHeader({
|
||||
return (
|
||||
<>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
{lightboxOpen && info.coverArt && (
|
||||
<CoverLightbox
|
||||
src={lightboxCoverSrc}
|
||||
alt={`${info.name} Cover`}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{lightbox}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && enableCoverArtBackground && (
|
||||
@@ -173,14 +167,20 @@ export default function AlbumHeader({
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
{coverArtId ? (
|
||||
<button
|
||||
className="album-detail-cover-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
onClick={openLightbox}
|
||||
data-tooltip={t('albumDetail.enlargeCover')}
|
||||
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
|
||||
>
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
<CoverArtImage
|
||||
className="album-detail-cover"
|
||||
coverArtId={coverArtId}
|
||||
displayCssPx={400}
|
||||
surface="sparse"
|
||||
alt={`${info.name} Cover`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { coverArtIdFromArtist } from '../cover/ids';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -13,19 +14,16 @@ interface Props {
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
// buildCoverArtUrl generates a new crypto salt on every call — must be
|
||||
// memoized to prevent a new URL on every parent re-render causing refetch loops.
|
||||
const coverSrc = useMemo(() => buildCoverArtUrl(coverId, 300), [coverId]);
|
||||
const coverCacheKey = useMemo(() => coverArtCacheKey(coverId, 300), [coverId]);
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
|
||||
return (
|
||||
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverCacheKey}
|
||||
{artist.coverArt || artist.id ? (
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Music } from 'lucide-react';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
||||
import { coverImgSrc } from '../cover/imgSrc';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { primeAlbumCoversForDisplay } from '../cover/warmDiskPeek';
|
||||
import {
|
||||
readBecauseYouLikeCache,
|
||||
writeBecauseYouLikeCache,
|
||||
type BecauseYouLikeAnchor,
|
||||
} from '../store/becauseYouLikeCache';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { playAlbum } from '../utils/playback/playAlbum';
|
||||
@@ -39,11 +47,229 @@ const SIMILAR_FETCH = 25;
|
||||
const SIMILAR_PICK = 6;
|
||||
const SHOW_COUNT = 3;
|
||||
const PICKS_HISTORY_SIZE = 30;
|
||||
const COVER_SIZE = 300;
|
||||
/** `.because-card-cover-wrap` layout square (160×160). */
|
||||
const BECAUSE_CARD_COVER_CSS_PX = 160;
|
||||
const ROW_STAGGER_MS = 150;
|
||||
|
||||
interface Anchor {
|
||||
id: string;
|
||||
name: string;
|
||||
// ── Module-level reserve: next batch pre-fetched in background after each display ──
|
||||
type BecauseReserve = {
|
||||
serverId: string;
|
||||
// poolKey intentionally omitted — reserve is valid for any pool state on the
|
||||
// same server. Pool (top-played artists) changes slowly; showing a slightly-off
|
||||
// anchor once before the next fill corrects it is far better than showing a
|
||||
// skeleton because the pool hadn't loaded yet.
|
||||
anchor: BecauseYouLikeAnchor;
|
||||
recs: SubsonicAlbum[];
|
||||
/** Rotation state to commit to localStorage when this reserve is consumed. */
|
||||
nextAnchorHistory: string[];
|
||||
nextPicksHistory: string[];
|
||||
};
|
||||
let _becauseReserve: BecauseReserve | null = null;
|
||||
let _becauseReserveFilling = false;
|
||||
|
||||
/** Helper: read a JSON string[] from localStorage, returning [] on any failure. */
|
||||
function readJsonArray(key: string | null): string[] {
|
||||
if (!key) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a set of album picks for one anchor candidate. */
|
||||
async function resolvePicks(
|
||||
candidate: BecauseYouLikeAnchor,
|
||||
recentPicks: Set<string>,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH });
|
||||
const similar = (info.similarArtist ?? []).filter(s => s.id);
|
||||
if (similar.length === 0) return null;
|
||||
|
||||
const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK);
|
||||
const results = await Promise.all(sampled.map(s => getArtist(s.id).catch(() => null)));
|
||||
|
||||
const picks: SubsonicAlbum[] = [];
|
||||
for (const r of results) {
|
||||
if (!r || r.albums.length === 0) continue;
|
||||
const fresh = r.albums.filter(a => !recentPicks.has(a.id));
|
||||
const choice = fresh.length > 0 ? fresh : r.albums;
|
||||
const album = choice[Math.floor(Math.random() * choice.length)];
|
||||
picks.push(album);
|
||||
if (picks.length >= SHOW_COUNT) break;
|
||||
}
|
||||
return picks.length > 0 ? picks : null;
|
||||
}
|
||||
|
||||
type FetchBecauseResult = {
|
||||
anchor: BecauseYouLikeAnchor;
|
||||
recs: SubsonicAlbum[];
|
||||
nextAnchorHistory: string[];
|
||||
nextPicksHistory: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Core fetch: rotate anchor, call Last.fm / Subsonic, return result + updated
|
||||
* rotation snapshots. Does NOT touch React state or localStorage — callers do that.
|
||||
* Reads the CURRENT localStorage values so it always reflects the latest rotation.
|
||||
*/
|
||||
async function fetchBecauseYouLike(
|
||||
pool: BecauseYouLikeAnchor[],
|
||||
anchorHistKey: string | null,
|
||||
picksHistKey: string | null,
|
||||
): Promise<FetchBecauseResult | null> {
|
||||
const anchorHistory = readJsonArray(anchorHistKey);
|
||||
const picksHistory = readJsonArray(picksHistKey);
|
||||
|
||||
const cooldown = Math.min(ANCHOR_COOLDOWN, Math.max(0, Math.floor(pool.length / 2)));
|
||||
const recentAnchors = new Set(anchorHistory.slice(-cooldown));
|
||||
const eligibleRaw = pool.filter(a => !recentAnchors.has(a.id));
|
||||
const eligible = eligibleRaw.length > 0 ? eligibleRaw : pool.slice();
|
||||
const candidates = shuffleArray(eligible);
|
||||
const recentPicks = new Set(picksHistory);
|
||||
|
||||
const tries = Math.min(ANCHOR_MAX_TRIES, candidates.length);
|
||||
const tryList = candidates.slice(0, tries);
|
||||
|
||||
const buildResult = (candidate: BecauseYouLikeAnchor, picks: SubsonicAlbum[]): FetchBecauseResult => ({
|
||||
anchor: candidate,
|
||||
recs: picks,
|
||||
nextAnchorHistory: [...anchorHistory, candidate.id].slice(-ANCHOR_COOLDOWN),
|
||||
nextPicksHistory: [...picksHistory, ...picks.map(p => p.id)].slice(-PICKS_HISTORY_SIZE),
|
||||
});
|
||||
|
||||
/** First two shuffled anchors in parallel — cuts cold-start wait on slow Last.fm. */
|
||||
if (tryList.length >= 2) {
|
||||
const raced = await Promise.all(
|
||||
tryList.slice(0, 2).map(async candidate => {
|
||||
try {
|
||||
const picks = await resolvePicks(candidate, recentPicks);
|
||||
return picks ? { candidate, picks } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const hit = raced.find((r): r is { candidate: BecauseYouLikeAnchor; picks: SubsonicAlbum[] } => r != null);
|
||||
if (hit) return buildResult(hit.candidate, hit.picks);
|
||||
}
|
||||
|
||||
for (const candidate of tryList) {
|
||||
try {
|
||||
const picks = await resolvePicks(candidate, recentPicks);
|
||||
if (!picks) continue;
|
||||
return buildResult(candidate, picks);
|
||||
} catch {
|
||||
/* try next anchor */
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: fetch the next batch in the background so the next visit is
|
||||
* instant. localStorage rotation is NOT updated here — the snapshots are stored
|
||||
* in the reserve and applied only when the reserve is consumed.
|
||||
* Covers are NOT pre-warmed here (avoids bumpDiskSrcCache side-effects on the
|
||||
* currently-visible page); they are warmed via primeAlbumCoversForDisplay on consume.
|
||||
*/
|
||||
async function fillBecauseReserve(
|
||||
pool: BecauseYouLikeAnchor[],
|
||||
serverId: string,
|
||||
anchorHistKey: string | null,
|
||||
picksHistKey: string | null,
|
||||
): Promise<void> {
|
||||
if (_becauseReserveFilling) return;
|
||||
_becauseReserveFilling = true;
|
||||
try {
|
||||
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey);
|
||||
if (result) {
|
||||
_becauseReserve = { serverId, ...result };
|
||||
// Also refresh the session snapshot so a quick leave→return can pick up
|
||||
// newer cards even before the reserve is explicitly consumed.
|
||||
writeBecauseYouLikeCache({ serverId, anchor: result.anchor, recs: result.recs });
|
||||
}
|
||||
} catch {
|
||||
/* Network failure — next visit falls back to a fresh fetch. */
|
||||
} finally {
|
||||
_becauseReserveFilling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** One classic because-card shell, then extra grid slots fill in. */
|
||||
function useBecauseRowSlotCount(active: boolean, max = SHOW_COUNT): number {
|
||||
const [count, setCount] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setCount(1);
|
||||
return;
|
||||
}
|
||||
setCount(1);
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
for (let slot = 2; slot <= max; slot += 1) {
|
||||
timers.push(setTimeout(() => setCount(slot), ROW_STAGGER_MS * (slot - 1)));
|
||||
}
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [active, max]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Lead placeholder — same shell as a loaded because-card (cover + text block). */
|
||||
function BecauseCardSkeletonLead() {
|
||||
return (
|
||||
<div className="because-card because-card--skeleton because-card--skeleton-lead" aria-hidden="true">
|
||||
<div className="because-card-cover-wrap">
|
||||
<div className="because-card-cover because-card-cover-placeholder" />
|
||||
</div>
|
||||
<div className="because-card-text">
|
||||
<div className="because-card-top">
|
||||
<div className="because-card-skeleton-line because-card-skeleton-line--similar" />
|
||||
<div className="because-card-skeleton-line because-card-skeleton-line--title" />
|
||||
<div className="because-card-skeleton-line because-card-skeleton-line--artist" />
|
||||
<div className="because-card-skeleton-line because-card-skeleton-line--meta" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Extra grid slots — cover tile only, fills in beside the lead card. */
|
||||
function BecauseCardSkeletonSlot({ enter }: { enter?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`because-card because-card--skeleton because-card--skeleton-slot${
|
||||
enter ? ' because-card--slot-enter' : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="because-card-cover-wrap">
|
||||
<div className="because-card-cover because-card-cover-placeholder" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BecauseYouLikeSkeleton({ title, slotCount }: { title: string; slotCount: number }) {
|
||||
return (
|
||||
<section className="album-row-section because-you-like-rail">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="because-card-grid because-card-grid--stagger">
|
||||
{slotCount >= 1 ? <BecauseCardSkeletonLead /> : null}
|
||||
{slotCount >= 2 ? <BecauseCardSkeletonSlot enter /> : null}
|
||||
{slotCount >= 3 ? <BecauseCardSkeletonSlot enter /> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -57,9 +283,9 @@ interface Props {
|
||||
* Cycling sources (most-played, recently-played, starred) means the per-mount
|
||||
* rotation cursor visits a different listening *mode* each visit instead of
|
||||
* walking only down the top-played list. */
|
||||
function buildAnchorPool(sources: SubsonicAlbum[][], limit: number): Anchor[] {
|
||||
function buildAnchorPool(sources: SubsonicAlbum[][], limit: number): BecauseYouLikeAnchor[] {
|
||||
const seen = new Set<string>();
|
||||
const out: Anchor[] = [];
|
||||
const out: BecauseYouLikeAnchor[] = [];
|
||||
const maxLen = sources.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
for (let i = 0; i < maxLen && out.length < limit; i++) {
|
||||
for (const src of sources) {
|
||||
@@ -83,16 +309,9 @@ function anchorHistoryKey(serverId: string | null): string | null {
|
||||
function picksHistoryKey(serverId: string | null): string | null {
|
||||
return serverId ? `${PICKS_HISTORY_KEY_PREFIX}${serverId}` : null;
|
||||
}
|
||||
function readJsonArray(key: string | null): string[] {
|
||||
if (!key) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
function hasValidReserve(serverId: string | null): boolean {
|
||||
return _becauseReserve != null && _becauseReserve.serverId === (serverId ?? '');
|
||||
}
|
||||
|
||||
export default function BecauseYouLikeRail({
|
||||
@@ -107,18 +326,62 @@ export default function BecauseYouLikeRail({
|
||||
() => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL),
|
||||
[mostPlayed, recentlyPlayed, starred],
|
||||
);
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||||
const [recs, setRecs] = useState<SubsonicAlbum[]>([]);
|
||||
const poolKey = useMemo(
|
||||
() => pool.slice(0, 8).map(a => a.id).join('\u0001'),
|
||||
[pool],
|
||||
);
|
||||
// Initialise state in priority order: reserve (new batch) > session cache (stale-while-
|
||||
// revalidate) > skeleton. Both checks work without poolKey so they fire correctly on the
|
||||
// first render when pool is still [] (Home.tsx loads mostPlayed asynchronously).
|
||||
const [anchor, setAnchor] = useState<BecauseYouLikeAnchor | null>(() => {
|
||||
if (hasValidReserve(activeServerId)) return _becauseReserve!.anchor;
|
||||
return readBecauseYouLikeCache(activeServerId)?.anchor ?? null;
|
||||
});
|
||||
const [recs, setRecs] = useState<SubsonicAlbum[]>(() => {
|
||||
if (hasValidReserve(activeServerId)) return _becauseReserve!.recs;
|
||||
return readBecauseYouLikeCache(activeServerId)?.recs ?? [];
|
||||
});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(() => {
|
||||
if (hasValidReserve(activeServerId)) return false;
|
||||
const snap = readBecauseYouLikeCache(activeServerId);
|
||||
return !snap || snap.recs.length === 0;
|
||||
});
|
||||
const skeletonSlots = useBecauseRowSlotCount(refreshing, SHOW_COUNT);
|
||||
const contentReady = !refreshing && Boolean(anchor) && recs.length > 0;
|
||||
const contentSlots = contentReady ? recs.length : 1;
|
||||
|
||||
/** On every navigation / server / pool change: apply reserve immediately
|
||||
* (synchronous, before browser paint) or fall back to session cache (stale-
|
||||
* while-revalidate), only clearing to skeleton when nothing is available. */
|
||||
useLayoutEffect(() => {
|
||||
if (hasValidReserve(activeServerId)) {
|
||||
setAnchor(_becauseReserve!.anchor);
|
||||
setRecs(_becauseReserve!.recs);
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
const snap = readBecauseYouLikeCache(activeServerId);
|
||||
if (snap && snap.recs.length > 0) {
|
||||
setAnchor(snap.anchor);
|
||||
setRecs(snap.recs);
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setRefreshing(true);
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
}
|
||||
}
|
||||
}, [activeServerId, poolKey]);
|
||||
|
||||
// 696px ≙ exactly 2 BecauseCards side-by-side (2*340 + 16 gap). Below that
|
||||
// the hero-style cards stretch full-width and dwarf the rest of the page,
|
||||
// so we swap in a standard AlbumRow which is already perf-tuned for narrow
|
||||
// rails (artwork budget, viewport windowing, scroll-paging).
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
setNarrow(el.getBoundingClientRect().width < 696);
|
||||
const ro = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
setNarrow(entry.contentRect.width < 696);
|
||||
@@ -131,89 +394,133 @@ export default function BecauseYouLikeRail({
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (pool.length === 0) {
|
||||
// Pool is still being loaded (Home.tsx fetches data asynchronously). Do not
|
||||
// run the fetch/reserve logic yet — useLayoutEffect already shows reserve or
|
||||
// cache content. The effect will re-run once pool is populated.
|
||||
return;
|
||||
}
|
||||
if (!activeServerId) {
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorHistKey = anchorHistoryKey(activeServerId);
|
||||
const picksHistKey = picksHistoryKey(activeServerId);
|
||||
const anchorHistory = readJsonArray(anchorHistKey);
|
||||
const picksHistory = readJsonArray(picksHistKey);
|
||||
const snap = readBecauseYouLikeCache(activeServerId);
|
||||
|
||||
/** Cooldown caps at half the pool size so a small library doesn't soft-lock
|
||||
* itself out (a server with 4 anchor-eligible artists shouldn't be told
|
||||
* "the last 5 are forbidden"). */
|
||||
const cooldown = Math.min(ANCHOR_COOLDOWN, Math.max(0, Math.floor(pool.length / 2)));
|
||||
const recentAnchors = new Set(anchorHistory.slice(-cooldown));
|
||||
const eligibleRaw = pool.filter(a => !recentAnchors.has(a.id));
|
||||
const eligible = eligibleRaw.length > 0 ? eligibleRaw : pool.slice();
|
||||
const candidates = shuffleArray(eligible);
|
||||
const recentPicks = new Set(picksHistory);
|
||||
// Consume module-level reserve (keyed by serverId only — poolKey omitted so
|
||||
// the reserve is usable even before pool has loaded on first render).
|
||||
const reserved = hasValidReserve(activeServerId) ? _becauseReserve : null;
|
||||
_becauseReserve = null;
|
||||
|
||||
(async () => {
|
||||
const tries = Math.min(ANCHOR_MAX_TRIES, candidates.length);
|
||||
/** Random pick (with cooldown) replaces deterministic round-robin so the
|
||||
* same anchor doesn't surface every pool.length mounts. The retry loop
|
||||
* still walks forward through the shuffled `candidates` list when the
|
||||
* current pick is a dud (no Last.fm similar artists, or no library
|
||||
* matches). On success: append the chosen anchor + chosen album ids to
|
||||
* their respective ring buffers so future mounts see different stuff. */
|
||||
for (let i = 0; i < tries; i++) {
|
||||
if (reserved) {
|
||||
// ── Reserve path: instant display, no network ──────────────────────
|
||||
await primeAlbumCoversForDisplay(reserved.recs, BECAUSE_CARD_COVER_CSS_PX, {
|
||||
limit: SHOW_COUNT,
|
||||
disabled: disableArtwork,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const candidate = candidates[i];
|
||||
// Advance rotation in localStorage now that these picks are being shown.
|
||||
try {
|
||||
const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH });
|
||||
if (cancelled) return;
|
||||
const similar = (info.similarArtist ?? []).filter(s => s.id);
|
||||
if (similar.length === 0) continue;
|
||||
|
||||
const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK);
|
||||
const results = await Promise.all(
|
||||
sampled.map(s => getArtist(s.id).catch(() => null))
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
||||
const picks: SubsonicAlbum[] = [];
|
||||
for (const r of results) {
|
||||
if (!r || r.albums.length === 0) continue;
|
||||
/** Prefer an album not in the recently-shown buffer; fall back to
|
||||
* *any* album when the artist's whole catalogue is in the buffer
|
||||
* so the slot isn't lost. */
|
||||
const fresh = r.albums.filter(a => !recentPicks.has(a.id));
|
||||
const choice = fresh.length > 0 ? fresh : r.albums;
|
||||
const album = choice[Math.floor(Math.random() * choice.length)];
|
||||
picks.push(album);
|
||||
if (picks.length >= SHOW_COUNT) break;
|
||||
}
|
||||
if (picks.length === 0) continue;
|
||||
|
||||
const newAnchorHistory = [...anchorHistory, candidate.id].slice(-ANCHOR_COOLDOWN);
|
||||
const newPicksHistory = [...picksHistory, ...picks.map(p => p.id)].slice(-PICKS_HISTORY_SIZE);
|
||||
try {
|
||||
if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(newAnchorHistory));
|
||||
if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(newPicksHistory));
|
||||
} catch { /* ignore */ }
|
||||
setAnchor(candidate);
|
||||
setRecs(picks);
|
||||
return;
|
||||
} catch {
|
||||
/* network / server error — try next anchor */
|
||||
if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(reserved.nextAnchorHistory));
|
||||
if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(reserved.nextPicksHistory));
|
||||
} catch { /* ignore */ }
|
||||
setAnchor(reserved.anchor);
|
||||
setRecs(reserved.recs);
|
||||
if (activeServerId) {
|
||||
writeBecauseYouLikeCache({ serverId: activeServerId, anchor: reserved.anchor, recs: reserved.recs });
|
||||
}
|
||||
setRefreshing(false);
|
||||
// Pre-fetch the next batch so the next visit is also instant.
|
||||
void fillBecauseReserve(pool, activeServerId, anchorHistKey, picksHistKey);
|
||||
return;
|
||||
}
|
||||
if (!cancelled) {
|
||||
|
||||
// Keep visible cards stable on return visits: if we already have a valid
|
||||
// session snapshot, leave it on screen and only prefetch the next batch
|
||||
// for the next mount instead of swapping cards mid-visit.
|
||||
if (snap && snap.recs.length > 0) {
|
||||
setRefreshing(false);
|
||||
void fillBecauseReserve(pool, activeServerId, anchorHistKey, picksHistKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Full-fetch path (first visit or reserve miss) ──────────────────
|
||||
// Only clear to skeleton if nothing is currently displayed. When cached
|
||||
// content is visible, leave it in place and swap silently (stale-while-
|
||||
// revalidate) — better UX than flashing a skeleton for a network round-trip.
|
||||
if (!snap || snap.recs.length === 0) {
|
||||
setRefreshing(true);
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
}
|
||||
|
||||
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey);
|
||||
if (cancelled) return;
|
||||
|
||||
if (result) {
|
||||
await primeAlbumCoversForDisplay(result.recs, BECAUSE_CARD_COVER_CSS_PX, {
|
||||
limit: SHOW_COUNT,
|
||||
disabled: disableArtwork,
|
||||
});
|
||||
if (cancelled) return;
|
||||
try {
|
||||
if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(result.nextAnchorHistory));
|
||||
if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(result.nextPicksHistory));
|
||||
} catch { /* ignore */ }
|
||||
setAnchor(result.anchor);
|
||||
setRecs(result.recs);
|
||||
if (activeServerId) {
|
||||
writeBecauseYouLikeCache({ serverId: activeServerId, anchor: result.anchor, recs: result.recs });
|
||||
}
|
||||
setRefreshing(false);
|
||||
// Pre-fetch next batch so the next visit is instant.
|
||||
void fillBecauseReserve(pool, activeServerId, anchorHistKey, picksHistKey);
|
||||
} else {
|
||||
// Network failed — restore session cache if available.
|
||||
if (snap) {
|
||||
await primeAlbumCoversForDisplay(snap.recs, BECAUSE_CARD_COVER_CSS_PX, {
|
||||
limit: SHOW_COUNT,
|
||||
disabled: disableArtwork,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setAnchor(snap.anchor);
|
||||
setRecs(snap.recs);
|
||||
} else if (!cancelled) {
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
}
|
||||
if (!cancelled) setRefreshing(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [pool, activeServerId]);
|
||||
}, [pool, activeServerId, disableArtwork, poolKey]);
|
||||
|
||||
if (!anchor || recs.length === 0) {
|
||||
useEffect(() => {
|
||||
if (disableArtwork || recs.length === 0) return;
|
||||
const refs = recs.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : []));
|
||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
||||
}, [recs, disableArtwork]);
|
||||
|
||||
if (pool.length === 0) {
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
|
||||
if (refreshing || !anchor || recs.length === 0) {
|
||||
if (!refreshing && (!anchor || recs.length === 0)) {
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<BecauseYouLikeSkeleton title={t('home.becauseYouLike')} slotCount={skeletonSlots} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionTitle = t('home.becauseYouLikeFor', { artist: anchor.name });
|
||||
|
||||
return (
|
||||
@@ -227,13 +534,14 @@ export default function BecauseYouLikeRail({
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="because-card-grid">
|
||||
{recs.map(album => (
|
||||
<div className="because-card-grid because-card-grid--stagger">
|
||||
{recs.slice(0, contentSlots).map((album, index) => (
|
||||
<BecauseCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
anchor={anchor.name}
|
||||
disableArtwork={disableArtwork}
|
||||
enter={index > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -247,22 +555,19 @@ interface CardProps {
|
||||
album: SubsonicAlbum;
|
||||
anchor: string;
|
||||
disableArtwork: boolean;
|
||||
enter?: boolean;
|
||||
}
|
||||
|
||||
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }: CardProps) {
|
||||
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, enter }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const coverUrl = useMemo(
|
||||
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, COVER_SIZE) : ''),
|
||||
[album.coverArt],
|
||||
);
|
||||
const coverKey = useMemo(
|
||||
() => (album.coverArt ? coverArtCacheKey(album.coverArt, COVER_SIZE) : ''),
|
||||
[album.coverArt],
|
||||
);
|
||||
const bgResolved = useCachedUrl(coverUrl, coverKey);
|
||||
|
||||
const coverHandle = useCoverArt(album.coverArt, BECAUSE_CARD_COVER_CSS_PX, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
const imgSrc = coverImgSrc(coverHandle.src);
|
||||
const bgResolved = coverHandle.src;
|
||||
const handleOpen = () => navigate(`/album/${album.id}`);
|
||||
const handlePlay = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -282,7 +587,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }:
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="because-card"
|
||||
className={`because-card${enter ? ' because-card--slot-enter' : ''}`}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
|
||||
aria-label={`${album.name} – ${album.artist}`}
|
||||
@@ -295,14 +600,22 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }:
|
||||
/>
|
||||
)}
|
||||
<div className="because-card-cover-wrap">
|
||||
{!disableArtwork && coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverKey}
|
||||
alt={album.name}
|
||||
className="because-card-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{!disableArtwork && album.coverArt ? (
|
||||
imgSrc ? (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={album.name}
|
||||
className="because-card-cover"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
onError={coverHandle.onImgError}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="because-card-cover because-card-cover-placeholder because-card-cover-loading"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="because-card-cover because-card-cover-placeholder" aria-hidden="true">
|
||||
<Music size={42} strokeWidth={1.5} />
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
</div>
|
||||
{servers.map(srv => {
|
||||
const active = srv.id === activeServerId;
|
||||
const busy = switchingId !== null;
|
||||
const busy = switchingId === srv.id;
|
||||
const labelText = serverListDisplayLabel(srv, servers);
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -54,7 +54,7 @@ import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -64,6 +64,7 @@ beforeEach(() => {
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
@@ -107,9 +108,10 @@ describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
// Find the call whose cacheKey targets the 500 px cover (`...:cover:art-1:500`).
|
||||
const coverCall = calls.find(c => typeof c[1] === 'string' && c[1].includes(':500'));
|
||||
const coverCall = calls.find(c => c[2] === false);
|
||||
expect(coverCall).toBeDefined();
|
||||
expect(coverCall?.[2]).toBe(false);
|
||||
expect(typeof coverCall?.[1]).toBe('string');
|
||||
expect(String(coverCall?.[1])).toContain('art-1');
|
||||
});
|
||||
|
||||
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
|
||||
@@ -117,10 +119,9 @@ describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
// The 300 px art box uses the default (truthy) opt.
|
||||
const artBoxCall = calls.find(c => typeof c[1] === 'string' && c[1].includes(':300'));
|
||||
expect(artBoxCall).toBeDefined();
|
||||
expect(artBoxCall?.[2]).toBe(true);
|
||||
const defaultOptCalls = calls.filter(c => c[2] !== false);
|
||||
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(defaultOptCalls.some(c => String(c[1]).includes('art-1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+27
-21
@@ -1,11 +1,11 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playback/playAlbum';
|
||||
@@ -20,6 +20,10 @@ const INTERVAL_MS = 10000;
|
||||
const HERO_ALBUM_COUNT = 8;
|
||||
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
|
||||
const HERO_RANDOM_POOL = 32;
|
||||
/** Hero foreground cover (`.hero-cover` 220×220). */
|
||||
const HERO_FG_CSS_PX = 220;
|
||||
/** Hero blurred backdrop (full banner height). */
|
||||
const HERO_BG_CSS_PX = 360;
|
||||
|
||||
// Crossfading background — same layer pattern as FullscreenPlayer
|
||||
function HeroBg({ url }: { url: string }) {
|
||||
@@ -40,14 +44,16 @@ function HeroBg({ url }: { url: string }) {
|
||||
return (
|
||||
<>
|
||||
{layers.map(layer => (
|
||||
<div
|
||||
<img
|
||||
key={layer.id}
|
||||
className="hero-bg"
|
||||
style={{
|
||||
backgroundImage: `url(${layer.url})`,
|
||||
opacity: layer.visible ? 1 : 0,
|
||||
}}
|
||||
className="hero-bg-image"
|
||||
src={layer.url}
|
||||
style={{ opacity: layer.visible ? 1 : 0 }}
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
draggable={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -158,6 +164,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (heroInView || windowHidden) return;
|
||||
// Recovery guard: if a scroll/RAF event was missed while hero was outside
|
||||
@@ -252,18 +259,15 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
});
|
||||
}, [album?.id]);
|
||||
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized.
|
||||
const bgRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '', [album?.coverArt]);
|
||||
const bgCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '', [album?.coverArt]);
|
||||
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
|
||||
const bgHandle = useCoverArt(album?.coverArt, HERO_BG_CSS_PX, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
|
||||
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
|
||||
// transition (which would cause the background to flash empty before fading in).
|
||||
const stableBgUrl = useRef('');
|
||||
if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl;
|
||||
|
||||
const coverRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '', [album?.coverArt]);
|
||||
const coverCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '', [album?.coverArt]);
|
||||
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
|
||||
|
||||
if (!album) return <div className="hero-placeholder" />;
|
||||
|
||||
@@ -280,12 +284,14 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
<div className="hero-content animate-fade-in" key={album.id}>
|
||||
{coverRawUrl && !isMobile && (
|
||||
<CachedImage
|
||||
<div className="hero-content" key={album.id}>
|
||||
{album.coverArt && !isMobile && (
|
||||
<CoverArtImage
|
||||
coverArtId={album.coverArt}
|
||||
displayCssPx={HERO_FG_CSS_PX}
|
||||
surface="dense"
|
||||
ensurePriority="high"
|
||||
className="hero-cover"
|
||||
src={coverRawUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={`${album.name} Cover`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getSong } from '../api/subsonicLibrary';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -10,9 +9,11 @@ import {
|
||||
declineOrbitSuggestion,
|
||||
suggestionKey,
|
||||
} from '../utils/orbit';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
||||
|
||||
const HOST_APPROVAL_COVER_CSS_PX = 36;
|
||||
|
||||
/**
|
||||
* Host-only approval strip. Renders directly below the OrbitQueueHead
|
||||
* when `autoApprove === false` and at least one guest suggestion is
|
||||
@@ -85,9 +86,10 @@ export default function HostApprovalQueue() {
|
||||
return (
|
||||
<div key={key} className="host-approval__item">
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
<CoverArtImage
|
||||
coverArtId={song.coverArt}
|
||||
displayCssPx={HOST_APPROVAL_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="host-approval__cover"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '../api/library';
|
||||
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
@@ -28,7 +27,12 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
|
||||
import { coverArtIdFromArtist } from '../cover/ids';
|
||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
@@ -37,23 +41,28 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
|
||||
type LiveSearchSource = 'local' | 'network';
|
||||
|
||||
function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]);
|
||||
return <CachedImage className="search-result-thumb" src={src} cacheKey={cacheKey} alt="" />;
|
||||
return (
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
className="search-result-thumb"
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const src = useMemo(() => buildCoverArtUrl(coverId, 40), [coverId]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverId, 40), [coverId]);
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
useEffect(() => { setFailed(false); }, [coverId]);
|
||||
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
|
||||
return (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
className="search-result-thumb"
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||
@@ -413,6 +422,19 @@ export default function LiveSearch() {
|
||||
!!share.shareMatch ||
|
||||
(results && (results.artists.length || results.albums.length || results.songs.length));
|
||||
|
||||
useEffect(() => {
|
||||
if (!results || share.shareMatch) return () => {};
|
||||
const refs = [
|
||||
...results.artists.map(a => coverArtRef(coverArtIdFromArtist(a))),
|
||||
...results.albums.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])),
|
||||
...results.songs.flatMap(s => {
|
||||
const id = s.coverArt ?? s.albumId;
|
||||
return id ? [coverArtRef(id)] : [];
|
||||
}),
|
||||
];
|
||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
||||
}, [results, share.shareMatch]);
|
||||
|
||||
// Flat list of all navigable items for keyboard nav
|
||||
const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [
|
||||
{
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { coverArtIdFromArtist } from '../cover/ids';
|
||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
@@ -32,11 +35,12 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
|
||||
}
|
||||
|
||||
/** Mobile search row thumb — larger than desktop live search (32px). */
|
||||
const MOBILE_SEARCH_THUMB_CSS_PX = 80;
|
||||
|
||||
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const src = useMemo(() => buildCoverArtUrl(coverId, 80), [coverId]);
|
||||
const ck = useMemo(() => coverArtCacheKey(coverId, 80), [coverId]);
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
useEffect(() => { setFailed(false); }, [coverId]);
|
||||
if (failed) {
|
||||
return (
|
||||
@@ -46,10 +50,11 @@ function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id'
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
||||
surface="dense"
|
||||
className="mobile-search-thumb mobile-search-thumb--artist-round"
|
||||
src={src}
|
||||
cacheKey={ck}
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||
@@ -98,6 +103,16 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
doSearch(query);
|
||||
}, [query, doSearch, share.shareMatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!results) return () => {};
|
||||
const refs = [
|
||||
...results.artists.map(a => coverArtRef(coverArtIdFromArtist(a))),
|
||||
...results.albums.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])),
|
||||
...results.songs.flatMap(s => (s.coverArt ? [coverArtRef(s.coverArt)] : [])),
|
||||
];
|
||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
||||
}, [results]);
|
||||
|
||||
const commit = (q: string) => {
|
||||
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
||||
};
|
||||
@@ -272,10 +287,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
{results!.albums.map(a => (
|
||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
||||
{a.coverArt ? (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={a.coverArt}
|
||||
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
||||
surface="dense"
|
||||
className="mobile-search-thumb"
|
||||
src={buildCoverArtUrl(a.coverArt, 80)}
|
||||
cacheKey={coverArtCacheKey(a.coverArt, 80)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
@@ -299,10 +315,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
{results!.songs.map(s => (
|
||||
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
|
||||
{s.coverArt ? (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={s.coverArt}
|
||||
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
||||
surface="dense"
|
||||
className="mobile-search-thumb"
|
||||
src={buildCoverArtUrl(s.coverArt, 80)}
|
||||
cacheKey={coverArtCacheKey(s.coverArt, 80)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildCoverArtUrl } from '../api/subsonicStreamUrl';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { getNowPlaying } from '../api/subsonicScrobble';
|
||||
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
|
||||
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
|
||||
@@ -162,7 +162,13 @@ export default function NowPlayingDropdown() {
|
||||
>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||
{stream.coverArt ? (
|
||||
<img src={buildCoverArtUrl(stream.coverArt, 100)} alt="Cover" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<CoverArtImage
|
||||
coverArtId={stream.coverArt}
|
||||
displayCssPx={50}
|
||||
surface="sparse"
|
||||
alt="Cover"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<PlayCircle size={24} style={{ margin: '12px', color: 'var(--text-muted)' }} />
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getSong } from '../api/subsonicLibrary';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Radio, Clock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import OrbitQueueHead from './OrbitQueueHead';
|
||||
|
||||
const ORBIT_QUEUE_COVER_LG_CSS_PX = 54;
|
||||
const ORBIT_QUEUE_COVER_SM_CSS_PX = 48;
|
||||
|
||||
/**
|
||||
* Orbit — guest-side queue view.
|
||||
*
|
||||
@@ -76,9 +78,10 @@ export default function OrbitGuestQueue() {
|
||||
</div>
|
||||
<div className="orbit-guest-queue__current-body">
|
||||
{currentSong?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(currentSong.coverArt, 96)}
|
||||
cacheKey={coverArtCacheKey(currentSong.coverArt, 96)}
|
||||
<CoverArtImage
|
||||
coverArtId={currentSong.coverArt}
|
||||
displayCssPx={ORBIT_QUEUE_COVER_LG_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
|
||||
/>
|
||||
@@ -112,9 +115,10 @@ export default function OrbitGuestQueue() {
|
||||
return (
|
||||
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
<CoverArtImage
|
||||
coverArtId={song.coverArt}
|
||||
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover"
|
||||
/>
|
||||
@@ -150,9 +154,10 @@ export default function OrbitGuestQueue() {
|
||||
className="orbit-guest-queue__item"
|
||||
>
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
<CoverArtImage
|
||||
coverArtId={song.coverArt}
|
||||
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover"
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -48,6 +48,7 @@ beforeEach(() => {
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_resume', () => undefined);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { queueSongStar } from '../store/pendingStarSync';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||
import { coverArtIdFromRadio } from '../cover/ids';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -13,7 +13,6 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import StarRating from './StarRating';
|
||||
@@ -139,13 +138,7 @@ export default function PlayerBar() {
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Cover art: prefer radio station art, fall back to track art.
|
||||
// Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns.
|
||||
const radioCoverSrc = useMemo(
|
||||
() => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '',
|
||||
[currentRadio?.coverArt, currentRadio?.id]
|
||||
);
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
|
||||
const radioCoverArtId = currentRadio?.coverArt ? coverArtIdFromRadio(currentRadio.id) : undefined;
|
||||
// Preview takes visual priority over the queued track in the player-bar info
|
||||
// cell, but only when not in radio mode (radio has its own meta layout).
|
||||
const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null;
|
||||
@@ -156,14 +149,8 @@ export default function PlayerBar() {
|
||||
? currentTrack.artists
|
||||
: undefined;
|
||||
|
||||
const previewCover = useMemo(() => {
|
||||
if (!showPreviewMeta || !previewingTrack?.coverArt) return { src: '', cacheKey: '' };
|
||||
const id = previewingTrack.coverArt;
|
||||
return { src: buildCoverArtUrl(id, 128), cacheKey: coverArtCacheKey(id, 128) };
|
||||
}, [showPreviewMeta, previewingTrack?.coverArt]);
|
||||
const queueCover = usePlaybackCoverArt(showPreviewMeta ? undefined : displayCoverArt, 128);
|
||||
const coverSrc = showPreviewMeta ? previewCover.src : queueCover.src;
|
||||
const coverKey = showPreviewMeta ? previewCover.cacheKey : queueCover.cacheKey;
|
||||
usePlaybackCoverArt(showPreviewMeta ? undefined : displayCoverArt, 128);
|
||||
const coverArtId = showPreviewMeta ? previewingTrack?.coverArt : displayCoverArt;
|
||||
|
||||
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
@@ -208,10 +195,8 @@ export default function PlayerBar() {
|
||||
currentRadio={currentRadio}
|
||||
isRadio={isRadio}
|
||||
radioMeta={radioMeta}
|
||||
radioCoverSrc={radioCoverSrc}
|
||||
radioCoverKey={radioCoverKey}
|
||||
coverSrc={coverSrc}
|
||||
coverKey={coverKey}
|
||||
radioCoverArtId={radioCoverArtId}
|
||||
coverArtId={coverArtId}
|
||||
displayCoverArt={displayCoverArt}
|
||||
displayTitle={displayTitle}
|
||||
displayArtist={displayArtist}
|
||||
|
||||
@@ -42,7 +42,7 @@ import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -52,6 +52,7 @@ beforeEach(() => {
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
|
||||
@@ -8,7 +8,6 @@ import OrbitGuestQueue from './OrbitGuestQueue';
|
||||
import OrbitQueueHead from './OrbitQueueHead';
|
||||
import HostApprovalQueue from './HostApprovalQueue';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -77,11 +76,7 @@ function QueuePanelHostOrSolo() {
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const { src: currentCoverFetchUrl, cacheKey: currentCoverCacheKey } = usePlaybackCoverArt(
|
||||
currentTrack?.coverArt,
|
||||
128,
|
||||
);
|
||||
const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey);
|
||||
const { src: currentCoverSrc } = usePlaybackCoverArt(currentTrack?.coverArt, 128);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
+24
-17
@@ -1,12 +1,13 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, Star } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { enqueueAndPlay } from '../utils/playback/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
@@ -14,23 +15,28 @@ import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
interface SongCardProps {
|
||||
song: SubsonicSong;
|
||||
disableArtwork?: boolean;
|
||||
/** Layout-native cover square width in CSS px (rail cell). */
|
||||
displayCssPx?: number;
|
||||
/** @deprecated Use displayCssPx */
|
||||
artworkSize?: number;
|
||||
}
|
||||
|
||||
function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardProps) {
|
||||
function SongCard({
|
||||
song,
|
||||
disableArtwork = false,
|
||||
displayCssPx = COVER_DENSE_RAIL_CELL_CSS_PX,
|
||||
artworkSize,
|
||||
}: SongCardProps) {
|
||||
const layoutPx = artworkSize ?? displayCssPx;
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
// buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
|
||||
const coverUrl = useMemo(
|
||||
() => (song.coverArt ? buildCoverArtUrl(song.coverArt, artworkSize) : ''),
|
||||
[song.coverArt, artworkSize],
|
||||
);
|
||||
const coverCacheKey = useMemo(
|
||||
() => (song.coverArt ? coverArtCacheKey(song.coverArt, artworkSize) : ''),
|
||||
[song.coverArt, artworkSize],
|
||||
);
|
||||
const coverHandle = useCoverArt(song.coverArt, layoutPx, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'middle',
|
||||
});
|
||||
const coverUrl = coverHandle.src;
|
||||
const psyDrag = useDragDrop();
|
||||
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
|
||||
|
||||
@@ -86,10 +92,11 @@ function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardP
|
||||
}}
|
||||
>
|
||||
<div className="song-card-cover">
|
||||
{!disableArtwork && coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
{!disableArtwork && song.coverArt ? (
|
||||
<CoverArtImage
|
||||
coverArtId={song.coverArt}
|
||||
displayCssPx={layoutPx}
|
||||
surface="dense"
|
||||
alt={`${song.album} Cover`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { GRID_COVER_WARM_LIMIT } from '../cover/layoutSizes';
|
||||
import { useWarmGridCovers } from '../hooks/useWarmGridCovers';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
||||
@@ -22,6 +24,12 @@ export type VirtualCardGridProps<T> = {
|
||||
gridGap?: string;
|
||||
/** When set, row virtualization uses this scroll container instead of the main route viewport. */
|
||||
scrollRootId?: string;
|
||||
/** Pre-peek disk WebP for the first viewport of cards (one IPC batch before cells ensure). */
|
||||
warmGridCovers?: {
|
||||
pickCoverArtId: (item: T) => string | null | undefined;
|
||||
displayCssPx: number;
|
||||
limit?: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -40,7 +48,25 @@ export function VirtualCardGrid<T>({
|
||||
wrapStyle,
|
||||
gridGap = 'var(--space-4)',
|
||||
scrollRootId,
|
||||
warmGridCovers,
|
||||
}: VirtualCardGridProps<T>): React.JSX.Element {
|
||||
const warmLimit = warmGridCovers?.limit ?? GRID_COVER_WARM_LIMIT;
|
||||
const warmItems = useMemo(() => {
|
||||
if (!warmGridCovers) return [];
|
||||
return items
|
||||
.slice(0, warmLimit)
|
||||
.map(item => ({ coverArt: warmGridCovers.pickCoverArtId(item) ?? null }));
|
||||
}, [items, warmGridCovers, warmLimit]);
|
||||
const warmPeekKey = useMemo(
|
||||
() => warmItems.map(i => i.coverArt ?? '').join('\u0001'),
|
||||
[warmItems],
|
||||
);
|
||||
useWarmGridCovers(warmItems, warmGridCovers?.displayCssPx ?? 0, {
|
||||
enabled: Boolean(warmGridCovers && warmGridCovers.displayCssPx > 0),
|
||||
limit: warmLimit,
|
||||
warmKey: warmPeekKey,
|
||||
});
|
||||
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const { gridCols, rowHeightEst } = useCardGridMetrics(wrapRef, true, rowVariant, layoutSignal);
|
||||
const cols = Math.max(1, gridCols);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -10,8 +10,9 @@ import { useOfflineStore } from '../../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../../store/offlineJobStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import CachedImage from '../CachedImage';
|
||||
import CoverLightbox from '../CoverLightbox';
|
||||
import { ArtistHeroCover } from '../../cover/artistHero';
|
||||
import { coverArtRef } from '../../cover/ref';
|
||||
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import StarRating from '../StarRating';
|
||||
|
||||
@@ -35,14 +36,9 @@ interface Props {
|
||||
openedLink: string | null;
|
||||
openLink: (url: string, key: string) => void;
|
||||
coverId: string;
|
||||
artistCover300Src: string;
|
||||
artistCover300Key: string;
|
||||
artistCover2000Src: string;
|
||||
coverRevision: number;
|
||||
headerCoverFailed: boolean;
|
||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
lightboxOpen: boolean;
|
||||
setLightboxOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
@@ -50,9 +46,7 @@ export default function ArtistDetailHero({
|
||||
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, artistCover300Src, artistCover300Key, artistCover2000Src,
|
||||
coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
lightboxOpen, setLightboxOpen,
|
||||
coverId, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -64,6 +58,12 @@ export default function ArtistDetailHero({
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
|
||||
const coverRef = useMemo(
|
||||
() => (coverId ? coverArtRef(coverId) : null),
|
||||
[coverId],
|
||||
);
|
||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: artist.name });
|
||||
|
||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||
|
||||
return (
|
||||
@@ -76,27 +76,24 @@ export default function ArtistDetailHero({
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{lightboxOpen && (
|
||||
<CoverLightbox
|
||||
src={artistCover2000Src}
|
||||
alt={artist.name}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{lightbox}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{coverId ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
onClick={openLightbox}
|
||||
aria-label={`${artist.name} Bild vergrößern`}
|
||||
>
|
||||
{!headerCoverFailed ? (
|
||||
<CachedImage
|
||||
<ArtistHeroCover
|
||||
key={coverRevision}
|
||||
src={artistCover300Src}
|
||||
cacheKey={artistCover300Key}
|
||||
artistId={id ?? artist.id}
|
||||
artistInfo={info}
|
||||
coverFallback={coverRef}
|
||||
displayCssPx={300}
|
||||
surface="sparse"
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setHeaderCoverFailed(true)}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import CachedImage from '../CachedImage';
|
||||
import React from 'react';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_ARTIST_LIST_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
export default function ArtistSuggestionTrackCover({ coverArt, album }: { coverArt: string; album: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 64), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 64), [coverArt]);
|
||||
return (
|
||||
<CachedImage
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
||||
surface="dense"
|
||||
alt={album}
|
||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import React from 'react';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { coverArtIdFromArtist } from '../../cover/ids';
|
||||
import {
|
||||
COVER_DENSE_ARTIST_LIST_CSS_PX,
|
||||
COVER_DENSE_GRID_MIN_CELL_CSS_PX,
|
||||
} from '../../cover/layoutSizes';
|
||||
import { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||
import { nameColor, nameInitial } from '../../utils/componentHelpers/artistsHelpers';
|
||||
|
||||
@@ -17,20 +21,14 @@ interface AvatarProps {
|
||||
*/
|
||||
export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
||||
const color = nameColor(artist.name);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const { coverSrc, coverKey } = useMemo(
|
||||
() => ({
|
||||
coverSrc: coverId ? buildCoverArtUrl(coverId, 300) : '',
|
||||
coverKey: coverId ? coverArtCacheKey(coverId, 300) : '',
|
||||
}),
|
||||
[coverId],
|
||||
);
|
||||
if (showImages && coverId) {
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
if (showImages && (artist.coverArt || artist.id)) {
|
||||
return (
|
||||
<div className="artist-card-avatar">
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
/>
|
||||
@@ -46,25 +44,18 @@ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
||||
|
||||
/**
|
||||
* Row-sized artist avatar for the list view. Same fallback rules as the
|
||||
* card variant, but smaller cover-art size (64px vs 300px) so list rows
|
||||
* don't pull oversized images from the server.
|
||||
* card variant, but smaller layout px so list rows don't pull oversized images.
|
||||
*/
|
||||
export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
|
||||
const color = nameColor(artist.name);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const { coverSrc, coverKey } = useMemo(
|
||||
() => ({
|
||||
coverSrc: coverId ? buildCoverArtUrl(coverId, 64) : '',
|
||||
coverKey: coverId ? coverArtCacheKey(coverId, 64) : '',
|
||||
}),
|
||||
[coverId],
|
||||
);
|
||||
if (showImages && coverId) {
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
if (showImages && (artist.coverArt || artist.id)) {
|
||||
return (
|
||||
<div className="artist-avatar">
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { coverArtIdFromRadio } from '../../cover/ids';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
interface RadioStationRowProps {
|
||||
title: string;
|
||||
@@ -76,9 +77,10 @@ function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }:
|
||||
<div className={`album-card${isActive ? ' radio-card-active' : ''}`}>
|
||||
<div className="album-card-cover">
|
||||
{s.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
|
||||
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArtIdFromRadio(s.id)}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={s.name}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
export interface TopFavoriteArtist {
|
||||
id: string;
|
||||
@@ -83,8 +83,6 @@ interface TopFavoriteArtistCardProps {
|
||||
|
||||
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
|
||||
const coverId = artist.coverArtId;
|
||||
const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
|
||||
const coverCacheKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -94,9 +92,10 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverCacheKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -2,10 +2,11 @@ import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cast, Globe, Heart, Square, Trash2, X } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { coverArtIdFromRadio } from '../../cover/ids';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
interface RadioCardProps {
|
||||
s: InternetRadioStation;
|
||||
@@ -85,9 +86,10 @@ export default function RadioCard({
|
||||
{/* Cover */}
|
||||
<div className="album-card-cover">
|
||||
{s.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
|
||||
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArtIdFromRadio(s.id)}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={s.name}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, { useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Camera, Cast, Loader2, X } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { coverArtIdFromRadio } from '../../cover/ids';
|
||||
|
||||
interface RadioEditModalProps {
|
||||
station: InternetRadioStation | null; // null = create new
|
||||
@@ -91,9 +91,10 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
|
||||
{coverPreview ? (
|
||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : !coverRemoved && station?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(`ra-${station.id}`, 256)}
|
||||
cacheKey={coverArtCacheKey(`ra-${station.id}`, 256)}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArtIdFromRadio(station.id)}
|
||||
displayCssPx={140}
|
||||
surface="sparse"
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Disc3, ExternalLink, Music } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
interface DiscographyCardProps {
|
||||
artistId?: string;
|
||||
@@ -44,16 +44,22 @@ const DiscographyCard = memo(function DiscographyCard({ artistId, albums, curren
|
||||
<div className="np-dash-disc-grid">
|
||||
{visible.map(a => {
|
||||
const isActive = a.id === currentAlbumId;
|
||||
const fetchUrl = a.coverArt ? buildCoverArtUrl(a.coverArt, 200) : '';
|
||||
const key = a.coverArt ? coverArtCacheKey(a.coverArt, 200) : '';
|
||||
return (
|
||||
<div key={a.id}
|
||||
className={`np-dash-disc-tile${isActive ? ' active' : ''}`}
|
||||
onClick={() => onNavigate(`/album/${a.id}`)}
|
||||
data-tooltip={`${a.name}${a.year ? ` · ${a.year}` : ''}`}>
|
||||
<div className="np-dash-disc-cover">
|
||||
{fetchUrl && key
|
||||
? <CachedImage src={fetchUrl} cacheKey={key} alt={a.name} className="np-dash-disc-img" />
|
||||
{a.coverArt
|
||||
? (
|
||||
<CoverArtImage
|
||||
coverArtId={a.coverArt}
|
||||
displayCssPx={COVER_DENSE_RAIL_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={a.name}
|
||||
className="np-dash-disc-img"
|
||||
/>
|
||||
)
|
||||
: <div className="np-dash-disc-fallback"><Music size={18} /></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { InternetRadioStation, SubsonicAlbum, SubsonicOpenArtistRef } from
|
||||
import type { PlayerState, Track } from '../../store/playerStoreTypes';
|
||||
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
|
||||
import type { PreviewingTrack } from '../../store/previewStore';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import MarqueeText from '../MarqueeText';
|
||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||
@@ -22,10 +22,8 @@ interface Props {
|
||||
currentRadio: InternetRadioStation | null;
|
||||
isRadio: boolean;
|
||||
radioMeta: RadioMetadata;
|
||||
radioCoverSrc: string;
|
||||
radioCoverKey: string;
|
||||
coverSrc: string;
|
||||
coverKey: string;
|
||||
radioCoverArtId?: string;
|
||||
coverArtId?: string;
|
||||
displayCoverArt: string | undefined;
|
||||
displayTitle: string;
|
||||
displayArtist: string;
|
||||
@@ -46,8 +44,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function PlayerTrackInfo({
|
||||
currentTrack, currentRadio, isRadio, radioMeta, radioCoverSrc, radioCoverKey,
|
||||
coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
|
||||
currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
|
||||
coverArtId, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
|
||||
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
||||
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
|
||||
userRatingOverrides, toggleFullscreen,
|
||||
@@ -66,26 +64,29 @@ export function PlayerTrackInfo({
|
||||
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
|
||||
>
|
||||
{isRadio ? (
|
||||
currentRadio?.coverArt ? (
|
||||
<CachedImage
|
||||
radioCoverArtId ? (
|
||||
<CoverArtImage
|
||||
className="player-album-art"
|
||||
src={radioCoverSrc}
|
||||
cacheKey={radioCoverKey}
|
||||
alt={currentRadio.name}
|
||||
coverArtId={radioCoverArtId}
|
||||
displayCssPx={128}
|
||||
surface="sparse"
|
||||
alt={currentRadio?.name ?? ''}
|
||||
/>
|
||||
) : (
|
||||
<div className="player-album-art-placeholder">
|
||||
<Cast size={20} />
|
||||
</div>
|
||||
)
|
||||
) : displayCoverArt ? (
|
||||
<CachedImage
|
||||
) : coverArtId ? (
|
||||
<CoverArtImage
|
||||
className="player-album-art"
|
||||
src={coverSrc}
|
||||
cacheKey={coverKey}
|
||||
coverArtId={coverArtId}
|
||||
displayCssPx={128}
|
||||
surface="sparse"
|
||||
serverScope={showPreviewMeta ? { kind: 'active' } : { kind: 'playback' }}
|
||||
alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`}
|
||||
/>
|
||||
) : (
|
||||
) : (
|
||||
<div className="player-album-art-placeholder">
|
||||
<Music size={22} />
|
||||
</div>
|
||||
|
||||
@@ -2,21 +2,21 @@ import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Camera, Loader2, X } from 'lucide-react';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import type { CoverArtId } from '../../cover/types';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
||||
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
||||
|
||||
interface EditModalProps {
|
||||
playlist: SubsonicPlaylist;
|
||||
customCoverId: string | null;
|
||||
customCoverFetchUrl: string | null;
|
||||
customCoverCacheKey: string | null;
|
||||
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
|
||||
coverQuadIds: (CoverArtId | null)[];
|
||||
onClose: () => void;
|
||||
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function PlaylistEditModal({
|
||||
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
|
||||
coverQuadUrls, onClose, onSave,
|
||||
playlist, customCoverId, coverQuadIds, onClose, onSave,
|
||||
}: EditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(playlist.name);
|
||||
@@ -78,18 +78,19 @@ export default function PlaylistEditModal({
|
||||
>
|
||||
{coverPreview ? (
|
||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
) : !coverRemoved && customCoverId ? (
|
||||
<CoverArtImage
|
||||
coverArtId={customCoverId}
|
||||
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
{coverQuadIds.map((coverId, i) =>
|
||||
coverId
|
||||
? <PlaylistSmartCoverCell key={i} coverId={coverId} />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,16 +12,17 @@ import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/p
|
||||
import {
|
||||
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
|
||||
} from '../../utils/componentHelpers/playlistDetailHelpers';
|
||||
import CachedImage from '../CachedImage';
|
||||
import type { CoverArtId } from '../../cover/types';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
||||
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
||||
|
||||
interface Props {
|
||||
playlist: SubsonicPlaylist;
|
||||
songs: SubsonicSong[];
|
||||
id: string | undefined;
|
||||
customCoverId: string | null;
|
||||
customCoverFetchUrl: string | null;
|
||||
customCoverCacheKey: string | null;
|
||||
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
|
||||
coverQuadIds: (CoverArtId | null)[];
|
||||
resolvedBgUrl: string | null;
|
||||
saving: boolean;
|
||||
searchOpen: boolean;
|
||||
@@ -48,7 +49,7 @@ interface Props {
|
||||
|
||||
export default function PlaylistHero({
|
||||
playlist, songs, id,
|
||||
customCoverId, customCoverFetchUrl, customCoverCacheKey, coverQuadUrls,
|
||||
customCoverId, coverQuadIds,
|
||||
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
||||
isCached, isDownloading, offlineProgress, activeServerId,
|
||||
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
||||
@@ -85,19 +86,20 @@ export default function PlaylistHero({
|
||||
className="playlist-hero-cover"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
>
|
||||
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
{customCoverId ? (
|
||||
<CoverArtImage
|
||||
coverArtId={customCoverId}
|
||||
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="playlist-cover-grid"
|
||||
style={{ objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid">
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
{coverQuadIds.map((coverId, i) =>
|
||||
coverId
|
||||
? <PlaylistSmartCoverCell key={i} coverId={coverId} />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ListPlus, X } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||
|
||||
function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]);
|
||||
return <CachedImage src={src} cacheKey={cacheKey} alt="" className="playlist-search-thumb" />;
|
||||
return (
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="playlist-search-thumb"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import CachedImage from '../CachedImage';
|
||||
import React from 'react';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
|
||||
/** 2×2 collage cell — half of clamp(120px, 15vw, 200px) playlist hero grid. */
|
||||
const PLAYLIST_QUAD_CELL_CSS_PX = 100;
|
||||
/** Full playlist hero / card cover square. */
|
||||
const PLAYLIST_MAIN_COVER_CSS_PX = 200;
|
||||
|
||||
export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverId, 200), [coverId]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverId, 200), [coverId]);
|
||||
return (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={PLAYLIST_QUAD_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
className="playlist-cover-cell"
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]);
|
||||
return (
|
||||
<CachedImage
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt={alt}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
|
||||
@@ -201,7 +201,7 @@ export function QueueCurrentTrack({
|
||||
})()}
|
||||
<div className="queue-current-track-body">
|
||||
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
|
||||
{currentTrack.coverArt ? (
|
||||
{currentTrack.coverArt && currentCoverSrc ? (
|
||||
<img src={currentCoverSrc} alt="" loading="eager" />
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
|
||||
@@ -7,15 +7,11 @@ import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview';
|
||||
import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch';
|
||||
import CachedImage from '../CachedImage';
|
||||
import OverlayScrollArea from '../OverlayScrollArea';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import {
|
||||
buildCoverArtUrl,
|
||||
buildCoverArtUrlForServer,
|
||||
coverArtCacheKey,
|
||||
coverArtCacheKeyForServer,
|
||||
} from '../../api/subsonicStreamUrl';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
||||
import type { CoverServerScope } from '../../cover/types';
|
||||
|
||||
type ShareQueuePreviewModalProps = {
|
||||
open: boolean;
|
||||
@@ -30,6 +26,19 @@ type ShareQueuePreviewModalProps = {
|
||||
confirmBusyLabel?: string;
|
||||
};
|
||||
|
||||
function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
|
||||
if (coverServer) {
|
||||
return {
|
||||
kind: 'server',
|
||||
serverId: coverServer.id,
|
||||
url: coverServer.url,
|
||||
username: coverServer.username,
|
||||
password: coverServer.password,
|
||||
};
|
||||
}
|
||||
return { kind: 'active' };
|
||||
}
|
||||
|
||||
function QueuePreviewTrackRow({
|
||||
song,
|
||||
coverServer,
|
||||
@@ -37,18 +46,19 @@ function QueuePreviewTrackRow({
|
||||
song: SubsonicSong;
|
||||
coverServer?: ServerProfile | null;
|
||||
}) {
|
||||
const coverId = song.coverArt ?? '';
|
||||
const src = coverServer
|
||||
? buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId || song.id, 48)
|
||||
: buildCoverArtUrl(coverId || song.id, 48);
|
||||
const cacheKey = coverServer
|
||||
? coverArtCacheKeyForServer(coverServer.id, coverId || song.id, 48)
|
||||
: coverArtCacheKey(coverId || song.id, 48);
|
||||
const coverId = song.coverArt || song.id;
|
||||
|
||||
return (
|
||||
<li className="share-queue-preview-track">
|
||||
{coverId ? (
|
||||
<CachedImage className="share-queue-preview-track__thumb" src={src} cacheKey={cacheKey} alt="" />
|
||||
{song.coverArt ? (
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
serverScope={shareCoverServerScope(coverServer)}
|
||||
className="share-queue-preview-track__thumb"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="share-queue-preview-track__icon">
|
||||
<Music size={16} />
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Disc3, Eye, Link2, ListPlus, Music, Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
buildCoverArtUrl,
|
||||
buildCoverArtUrlForServer,
|
||||
coverArtCacheKey,
|
||||
coverArtCacheKeyForServer,
|
||||
} from '../../api/subsonicStreamUrl';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchPayload';
|
||||
import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch';
|
||||
import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage';
|
||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
||||
import { coverArtIdFromArtist } from '../../cover/ids';
|
||||
import type { CoverServerScope } from '../../cover/types';
|
||||
import { useShareQueuePreview } from '../../hooks/useShareQueuePreview';
|
||||
import ShareQueuePreviewModal from './ShareQueuePreviewModal';
|
||||
|
||||
@@ -34,51 +32,56 @@ type ShareSearchResultsProps = {
|
||||
onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void;
|
||||
} & ShareSearchPreviewState;
|
||||
|
||||
function useShareCoverArt(coverId: string, size: number, coverServer: ServerProfile | null | undefined) {
|
||||
return useMemo(() => {
|
||||
if (coverServer) {
|
||||
return {
|
||||
src: buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId, size),
|
||||
cacheKey: coverArtCacheKeyForServer(coverServer.id, coverId, size),
|
||||
};
|
||||
}
|
||||
function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
|
||||
if (coverServer) {
|
||||
return {
|
||||
src: buildCoverArtUrl(coverId, size),
|
||||
cacheKey: coverArtCacheKey(coverId, size),
|
||||
kind: 'server',
|
||||
serverId: coverServer.id,
|
||||
url: coverServer.url,
|
||||
username: coverServer.username,
|
||||
password: coverServer.password,
|
||||
};
|
||||
}, [coverServer, coverId, size]);
|
||||
}
|
||||
return { kind: 'active' };
|
||||
}
|
||||
|
||||
function ShareAlbumThumb({
|
||||
coverArt,
|
||||
size,
|
||||
displayCssPx,
|
||||
coverServer,
|
||||
}: {
|
||||
coverArt: string;
|
||||
size: number;
|
||||
displayCssPx: number;
|
||||
coverServer?: ServerProfile | null;
|
||||
}) {
|
||||
const { src, cacheKey } = useShareCoverArt(coverArt, size, coverServer);
|
||||
const cls = size >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
|
||||
return <CachedImage className={cls} src={src} cacheKey={cacheKey} alt="" />;
|
||||
const cls = displayCssPx >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
|
||||
return (
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={displayCssPx}
|
||||
surface="dense"
|
||||
serverScope={shareCoverServerScope(coverServer)}
|
||||
className={cls}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareArtistThumb({
|
||||
artist,
|
||||
size,
|
||||
displayCssPx,
|
||||
coverServer,
|
||||
}: {
|
||||
artist: Pick<SubsonicArtist, 'id' | 'coverArt'>;
|
||||
size: number;
|
||||
displayCssPx: number;
|
||||
coverServer?: ServerProfile | null;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const { src, cacheKey } = useShareCoverArt(coverId, size, coverServer);
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
useEffect(() => { setFailed(false); }, [coverId]);
|
||||
|
||||
if (failed) {
|
||||
if (size >= 64) {
|
||||
if (displayCssPx >= 64) {
|
||||
return (
|
||||
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
||||
<Users size={20} />
|
||||
@@ -93,14 +96,16 @@ function ShareArtistThumb({
|
||||
}
|
||||
|
||||
const cls =
|
||||
size >= 64
|
||||
displayCssPx >= 64
|
||||
? 'mobile-search-thumb mobile-search-thumb--artist-round'
|
||||
: 'search-result-thumb';
|
||||
return (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={displayCssPx}
|
||||
surface="dense"
|
||||
serverScope={shareCoverServerScope(coverServer)}
|
||||
className={cls}
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||
@@ -158,7 +163,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
|
||||
const { t } = useTranslation();
|
||||
const desktop = variant === 'desktop';
|
||||
const thumbSize = desktop ? 40 : 80;
|
||||
const thumbDisplayCssPx = desktop ? COVER_DENSE_SEARCH_CSS_PX : 80;
|
||||
const sectionCls = desktop ? 'search-section' : 'mobile-search-section';
|
||||
const labelCls = desktop ? 'search-section-label' : 'mobile-search-section-label';
|
||||
const mutedCls = desktop ? 'search-result-item search-result-item--muted' : 'mobile-search-item mobile-search-item--muted';
|
||||
@@ -225,7 +230,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
role={desktop ? 'option' : undefined}
|
||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||
>
|
||||
<ShareArtistThumb artist={shareArtist} size={thumbSize} coverServer={shareCoverServer} />
|
||||
<ShareArtistThumb artist={shareArtist} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||
<div className={infoWrap}>
|
||||
<div className={nameCls}>{shareArtist.name}</div>
|
||||
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('search.artists') : '')}</div>}
|
||||
@@ -267,7 +272,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
role={desktop ? 'option' : undefined}
|
||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||
>
|
||||
<ShareArtistThumb artist={shareComposer} size={thumbSize} coverServer={shareCoverServer} />
|
||||
<ShareArtistThumb artist={shareComposer} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||
<div className={infoWrap}>
|
||||
<div className={nameCls}>{shareComposer.name}</div>
|
||||
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('sidebar.composers') : '')}</div>}
|
||||
@@ -314,7 +319,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||
>
|
||||
{shareAlbum.coverArt ? (
|
||||
<ShareAlbumThumb coverArt={shareAlbum.coverArt} size={thumbSize} coverServer={shareCoverServer} />
|
||||
<ShareAlbumThumb coverArt={shareAlbum.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||
) : (
|
||||
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
|
||||
)}
|
||||
@@ -365,7 +370,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||
>
|
||||
{shareTrackSong.coverArt ? (
|
||||
<ShareAlbumThumb coverArt={shareTrackSong.coverArt} size={thumbSize} coverServer={shareCoverServer} />
|
||||
<ShareAlbumThumb coverArt={shareTrackSong.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||
) : (
|
||||
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
|
||||
)}
|
||||
|
||||
@@ -78,10 +78,6 @@ export default function AnalyticsStrategySection() {
|
||||
return Array.from(known).filter(id => !activeServerIds.has(id));
|
||||
}, [strategyByServer, advancedParallelismByServer, activeServerIds]);
|
||||
|
||||
const anyAggressive = useMemo(() => {
|
||||
return servers.some(server => getStrategyForServer(server.id) === 'advanced');
|
||||
}, [servers, getStrategyForServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (servers.length === 0) return;
|
||||
let cancelled = false;
|
||||
@@ -257,10 +253,6 @@ export default function AnalyticsStrategySection() {
|
||||
icon={<BarChart3 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.analyticsStrategyDesc')}
|
||||
</p>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
|
||||
<thead>
|
||||
@@ -398,24 +390,9 @@ export default function AnalyticsStrategySection() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.85rem',
|
||||
padding: '0.65rem 0.75rem',
|
||||
borderRadius: 8,
|
||||
background: 'var(--surface-elevated, rgba(255,255,255,0.03))',
|
||||
border: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: '0.45rem', color: 'var(--text-secondary)' }}>
|
||||
{t('settings.analyticsStrategyPriorityTitle')}
|
||||
</div>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.1rem', fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
|
||||
<li>{t('settings.analyticsStrategyPriorityHigh')}</li>
|
||||
<li>{t('settings.analyticsStrategyPriorityMiddle')}</li>
|
||||
<li>{t('settings.analyticsStrategyPriorityLow')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: '0.9rem', lineHeight: 1.5 }}>
|
||||
{t('settings.analyticsStrategyDesc')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -445,18 +422,16 @@ export default function AnalyticsStrategySection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{anyAggressive && (
|
||||
<div
|
||||
className="settings-hint settings-hint-info"
|
||||
role="note"
|
||||
style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
|
||||
>
|
||||
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--color-warning, #f59e0b)' }} />
|
||||
<span style={{ fontSize: 12, lineHeight: 1.5 }}>
|
||||
{t('settings.analyticsStrategyAdvancedWarning')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="settings-hint settings-hint-info"
|
||||
role="note"
|
||||
style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
|
||||
>
|
||||
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--color-warning, #f59e0b)' }} />
|
||||
<span style={{ fontSize: 12, lineHeight: 1.5 }}>
|
||||
{t('settings.analyticsStrategyAdvancedWarning')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clearTarget &&
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { Image, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
coverCacheClearServer,
|
||||
coverCacheStatsServer,
|
||||
libraryCoverCatalogSize,
|
||||
libraryCoverProgress,
|
||||
} from '../../api/coverCache';
|
||||
import { clearDiskSrcCacheForServer } from '../../cover/diskSrcCache';
|
||||
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
|
||||
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import { formatBytes } from '../../utils/format/formatBytes';
|
||||
import { wakeLibraryCoverBackfill } from '../../utils/library/coverBackfillWake';
|
||||
import {
|
||||
COVER_CACHE_STRATEGIES,
|
||||
type CoverCacheStrategy,
|
||||
} from '../../utils/library/coverStrategy';
|
||||
|
||||
type ClearTarget = {
|
||||
serverId: string;
|
||||
indexKey: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ServerRowState = {
|
||||
bytes: number;
|
||||
entryCount: number;
|
||||
done: number;
|
||||
total: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export default function CoverCacheStrategySection() {
|
||||
const { t } = useTranslation();
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const { strategyByServer, setServerStrategy, getStrategyForServer } = useCoverStrategyStore();
|
||||
const [rowState, setRowState] = useState<Record<string, ServerRowState>>({});
|
||||
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
|
||||
const [clearingKey, setClearingKey] = useState<string | null>(null);
|
||||
|
||||
const activeIndexKeys = useMemo(
|
||||
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
|
||||
[servers],
|
||||
);
|
||||
const removedServerKeys = useMemo(() => {
|
||||
const known = new Set(Object.keys(strategyByServer));
|
||||
return Array.from(known).filter(key => !activeIndexKeys.has(key));
|
||||
}, [strategyByServer, activeIndexKeys]);
|
||||
|
||||
const refreshRow = useCallback(async (serverId: string, indexKey: string) => {
|
||||
const [stats, progress, catalog] = await Promise.all([
|
||||
coverCacheStatsServer(indexKey).catch(() => ({ bytes: 0, entryCount: 0 })),
|
||||
libraryCoverProgress(indexKey, serverId).catch(() => ({ done: 0, totalDistinct: 0, pending: 0 })),
|
||||
libraryCoverCatalogSize(serverId).catch(() => 0),
|
||||
]);
|
||||
const total = Math.max(progress.totalDistinct, catalog);
|
||||
setRowState(prev => ({
|
||||
...prev,
|
||||
[indexKey]: {
|
||||
bytes: stats.bytes,
|
||||
entryCount: stats.entryCount,
|
||||
done: progress.done,
|
||||
total,
|
||||
pending: Math.max(progress.pending, total > 0 ? total - progress.done : 0),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
void Promise.all(
|
||||
servers.map(server => refreshRow(server.id, serverIndexKeyForProfile(server))),
|
||||
);
|
||||
}, [servers, refreshRow]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
const id = window.setInterval(refreshAll, 15_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refreshAll]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubs: Array<() => void> = [];
|
||||
void (async () => {
|
||||
unsubs.push(await listen<{
|
||||
serverIndexKey?: string;
|
||||
done?: number;
|
||||
total?: number;
|
||||
pending?: number;
|
||||
bytes?: number;
|
||||
entryCount?: number;
|
||||
}>('cover:library-progress', e => {
|
||||
const key = e.payload.serverIndexKey;
|
||||
if (!key) return;
|
||||
setRowState(prev => {
|
||||
const cur = prev[key];
|
||||
if (!cur) return prev;
|
||||
const done = typeof e.payload.done === 'number' ? e.payload.done : cur.done;
|
||||
const total = typeof e.payload.total === 'number' ? e.payload.total : cur.total;
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...cur,
|
||||
done,
|
||||
total,
|
||||
pending: e.payload.pending ?? Math.max(0, total - done),
|
||||
bytes: typeof e.payload.bytes === 'number' ? e.payload.bytes : cur.bytes,
|
||||
entryCount:
|
||||
typeof e.payload.entryCount === 'number' ? e.payload.entryCount : cur.entryCount,
|
||||
},
|
||||
};
|
||||
});
|
||||
}));
|
||||
unsubs.push(await listen<{ serverIndexKey?: string }>('cover:cache-cleared', e => {
|
||||
const key = e.payload.serverIndexKey;
|
||||
if (key) {
|
||||
setRowState(prev => ({
|
||||
...prev,
|
||||
[key]: { bytes: 0, entryCount: 0, done: 0, total: prev[key]?.total ?? 0, pending: prev[key]?.total ?? 0 },
|
||||
}));
|
||||
} else {
|
||||
refreshAll();
|
||||
}
|
||||
}));
|
||||
unsubs.push(await listen('cover:tier-ready', () => {
|
||||
refreshAll();
|
||||
}));
|
||||
})();
|
||||
return () => {
|
||||
for (const u of unsubs) u();
|
||||
};
|
||||
}, [refreshAll]);
|
||||
|
||||
const strategyLabel = (s: CoverCacheStrategy) => {
|
||||
switch (s) {
|
||||
case 'lazy':
|
||||
return t('settings.coverCacheStrategyLazy');
|
||||
case 'aggressive':
|
||||
return t('settings.coverCacheStrategyAggressive');
|
||||
}
|
||||
};
|
||||
|
||||
const progressLabel = (row: ServerRowState | undefined, strategy: CoverCacheStrategy) => {
|
||||
if (!row || strategy !== 'aggressive' || row.total <= 0) {
|
||||
return row ? t('settings.coverCacheStrategyDiskUsage', { size: formatBytes(row.bytes) }) : '—';
|
||||
}
|
||||
const percent = Math.max(0, Math.min(100, Math.round((row.done / row.total) * 100)));
|
||||
return t('settings.coverCacheStrategyProgressValue', {
|
||||
percent,
|
||||
done: row.done.toLocaleString(),
|
||||
total: row.total.toLocaleString(),
|
||||
size: formatBytes(row.bytes),
|
||||
});
|
||||
};
|
||||
|
||||
const handleStrategyChange = (serverId: string, strategy: CoverCacheStrategy) => {
|
||||
setServerStrategy(serverId, strategy);
|
||||
if (serverId === activeServerId) {
|
||||
wakeLibraryCoverBackfill();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCoverCache = async () => {
|
||||
if (!clearTarget) return;
|
||||
setClearingKey(clearTarget.indexKey);
|
||||
try {
|
||||
await coverCacheClearServer(clearTarget.indexKey);
|
||||
clearDiskSrcCacheForServer(clearTarget.indexKey);
|
||||
await refreshRow(clearTarget.serverId, clearTarget.indexKey);
|
||||
showToast(t('settings.coverCacheStrategyClearSuccess'), 4000, 'success');
|
||||
} catch {
|
||||
showToast(t('settings.coverCacheStrategyClearError'), 5000, 'error');
|
||||
} finally {
|
||||
setClearingKey(null);
|
||||
setClearTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
|
||||
<div className="settings-card">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 520 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyServerLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyProgressLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyActionsLabel')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map(server => {
|
||||
const strategy = getStrategyForServer(server.id);
|
||||
const key = serverIndexKeyForProfile(server);
|
||||
const row = rowState[key];
|
||||
const label = serverListDisplayLabel(server, servers);
|
||||
return (
|
||||
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{COVER_CACHE_STRATEGIES.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => handleStrategyChange(server.id, s)}
|
||||
>
|
||||
{strategyLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, lineHeight: 1.4 }}>
|
||||
{strategy === 'aggressive'
|
||||
? t('settings.coverCacheStrategyAggressiveDesc')
|
||||
: t('settings.coverCacheStrategyLazyDesc')}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{progressLabel(row, strategy)}
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() =>
|
||||
setClearTarget({ serverId: server.id, indexKey: key, label })}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{removedServerKeys.map(key => (
|
||||
<tr key={`removed-${key}`} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{key}
|
||||
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
<td style={{ padding: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() =>
|
||||
setClearTarget({ serverId: key, indexKey: key, label: key })}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: '0.9rem', lineHeight: 1.5 }}>
|
||||
{t('settings.coverCacheStrategyDesc')}
|
||||
</p>
|
||||
|
||||
{clearTarget && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '10px 14px',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.coverCacheStrategyClearTitle')}</div>
|
||||
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>
|
||||
{t('settings.coverCacheStrategyClearDesc', { server: clearTarget.label })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||
disabled={clearingKey !== null}
|
||||
onClick={() => void handleClearCoverCache()}
|
||||
>
|
||||
{t('settings.coverCacheStrategyClearConfirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
disabled={clearingKey !== null}
|
||||
onClick={() => setClearTarget(null)}
|
||||
>
|
||||
{t('settings.coverCacheStrategyClearCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import StarRating from '../StarRating';
|
||||
import AnalyticsStrategySection from './AnalyticsStrategySection';
|
||||
|
||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||
|
||||
export function LibraryTab() {
|
||||
|
||||
@@ -6,11 +6,10 @@ import { Download, FolderOpen, Trash2, X } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useHotCacheStore } from '../../store/hotCacheStore';
|
||||
import { useOfflineStore } from '../../store/offlineStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
|
||||
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import CoverCacheStrategySection from './CoverCacheStrategySection';
|
||||
|
||||
export function StorageTab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -75,26 +74,6 @@ export function StorageTab() {
|
||||
setClearing(false);
|
||||
}, [clearAllOffline, serverId, auth.offlineDownloadDir]);
|
||||
|
||||
const handleClearWaveformCache = useCallback(async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
const deleted = await invoke<number>('analysis_delete_all_waveforms');
|
||||
usePlayerStore.setState({
|
||||
waveformBins: null,
|
||||
});
|
||||
showToast(
|
||||
t('settings.waveformCacheCleared', { count: deleted }),
|
||||
3500,
|
||||
'success',
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(t('settings.waveformCacheClearFailed'), 4500, 'error');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const pickOfflineDir = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
|
||||
if (selected && typeof selected === 'string') {
|
||||
@@ -190,6 +169,7 @@ export function StorageTab() {
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
|
||||
</div>
|
||||
|
||||
{showClearConfirm ? (
|
||||
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
||||
@@ -212,19 +192,11 @@ export function StorageTab() {
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={handleClearWaveformCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.waveformCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<CoverCacheStrategySection />
|
||||
|
||||
{/* Buffering */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.nextTrackBufferingTitle')}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' },
|
||||
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
|
||||
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
||||
{ tab: 'storage', titleKey: 'settings.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server' },
|
||||
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
|
||||
{ tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering preload hot cache streaming' },
|
||||
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
|
||||
|
||||
@@ -130,6 +130,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Track enrichment: oximedia BPM/mood analysis, mood-group Advanced Search, queue display, unified playback analysis dispatch (PR #863)',
|
||||
'Server index-key rebuild follow-up: startup-safe migration orchestration, per-server analysis strategy controls, playback/cache scope hardening, and backup/restore for library databases with blocking progress UX (PR #864)',
|
||||
'Live Search: server-scoped local FTS, multi-server hit fix, and local vs search3 race merge (PR #868)',
|
||||
'Cover art pipeline: tier ladder, WebP disk cache, dense-grid prefetch, Settings cover cache budget (PR #869)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user