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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(mainstage): keep refresh without return flicker

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
This commit is contained in:
cucadmuh
2026-05-26 19:35:08 +03:00
committed by GitHub
parent 91e7195e0f
commit 418b25914a
160 changed files with 7551 additions and 914 deletions
+6 -6
View File
@@ -38,9 +38,9 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
node-version: '20' node-version: 'lts/*'
cache: 'npm' cache: 'npm'
- run: npm ci - run: npm ci
- name: vitest - name: vitest
@@ -51,9 +51,9 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
node-version: '20' node-version: 'lts/*'
cache: 'npm' cache: 'npm'
- run: npm ci - run: npm ci
- name: tsc - name: tsc
@@ -69,9 +69,9 @@ jobs:
continue-on-error: true continue-on-error: true
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
node-version: '20' node-version: 'lts/*'
cache: 'npm' cache: 'npm'
- name: install jq - name: install jq
run: sudo apt-get update && sudo apt-get install -y jq run: sudo apt-get update && sudo apt-get install -y jq
+1
View File
@@ -63,3 +63,4 @@ result-*
dev.sh dev.sh
shell.nix shell.nix
prod.sh prod.sh
tsconfig.tsbuildinfo
+10
View File
@@ -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 ## Changed
### Linux — session GDK, WebKitGTK mitigations, and Wayland text ### Linux — session GDK, WebKitGTK mitigations, and Wayland text
+1 -1
View File
@@ -138,7 +138,7 @@ stdenv.mkDerivation (finalAttrs: {
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
export HOME=$(mktemp -d) export HOME=$(mktemp -d)
(cd src-tauri && cargo tauri build --no-bundle -v) (cd src-tauri && cargo tauri build --no-bundle)
runHook postBuild runHook postBuild
''; '';
+63
View File
@@ -2216,6 +2216,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -2456,9 +2462,22 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png 0.18.1", "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]] [[package]]
@@ -2805,6 +2824,16 @@ dependencies = [
"vcpkg", "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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.3.8" version = "0.3.8"
@@ -4014,6 +4043,7 @@ dependencies = [
"ebur128", "ebur128",
"futures-util", "futures-util",
"id3", "id3",
"image",
"libc", "libc",
"lofty", "lofty",
"md5", "md5",
@@ -4049,6 +4079,7 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webkit2gtk-nvidia-quirk", "webkit2gtk-nvidia-quirk",
"webp",
"windows 0.62.2", "windows 0.62.2",
"zbus 5.15.0", "zbus 5.15.0",
"zip 0.6.6", "zip 0.6.6",
@@ -4173,6 +4204,12 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.3" version = "0.39.3"
@@ -5672,6 +5709,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range",
"image", "image",
"jni 0.21.1", "jni 0.21.1",
"libc", "libc",
@@ -6907,6 +6945,16 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webpki-root-certs" name = "webpki-root-certs"
version = "1.0.7" version = "1.0.7"
@@ -8083,6 +8131,21 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "zvariant" name = "zvariant"
version = "3.15.2" version = "3.15.2"
+3 -1
View File
@@ -42,7 +42,7 @@ psysonic-audio = { path = "crates/psysonic-audio" }
psysonic-library = { path = "crates/psysonic-library" } psysonic-library = { path = "crates/psysonic-library" }
psysonic-syncfs = { path = "crates/psysonic-syncfs" } psysonic-syncfs = { path = "crates/psysonic-syncfs" }
psysonic-integration = { path = "crates/psysonic-integration" } 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-single-instance = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
@@ -74,6 +74,8 @@ rusqlite = { version = "0.39", features = ["bundled"] }
ebur128 = "0.1" ebur128 = "0.1"
dasp_sample = "0.11.0" dasp_sample = "0.11.0"
zip = "0.6.6" zip = "0.6.6"
image = { version = "0.25", default-features = false, features = ["jpeg", "webp"] }
webp = "0.3"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
@@ -1094,6 +1094,9 @@ fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> {
("year", EntityKind::Album) => Some("a.year"), ("year", EntityKind::Album) => Some("a.year"),
("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"), ("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"),
("name", EntityKind::Artist) => Some("ar.name 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, _ => 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; pub mod advanced_search;
mod advanced_search_mood; mod advanced_search_mood;
pub mod analysis_backfill; pub mod analysis_backfill;
pub mod cover_backfill;
pub mod canonical; pub mod canonical;
pub mod commands; pub mod commands;
pub mod cross_server; 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(),
}
}
+42
View File
@@ -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(())
}
+75
View File
@@ -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 (0100). 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()
);
}
}
+98
View File
@@ -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"));
}
}
+885
View File
@@ -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"));
}
}
+25
View File
@@ -2,6 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
pub mod cli; pub mod cli;
mod cover_cache;
mod lib_commands; mod lib_commands;
pub use psysonic_integration::discord; pub use psysonic_integration::discord;
@@ -119,6 +120,9 @@ pub fn run() {
app.manage(cache); 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) ───── // ── Library track store (psysonic-library, PR-5a + PR-5b) ─────
// PR-5a brought up the read-only Tauri surface + LibraryRuntime. // PR-5a brought up the read-only Tauri surface + LibraryRuntime.
// PR-5b adds the mutating commands, sync session map, current-job // 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_migrate_server_index_keys,
psysonic_library::commands::library_delete_server_data, psysonic_library::commands::library_delete_server_data,
psysonic_library::commands::library_analysis_backfill_batch, 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::download_track_offline,
psysonic_syncfs::cache::offline::cancel_offline_downloads, psysonic_syncfs::cache::offline::cancel_offline_downloads,
psysonic_syncfs::cache::offline::clear_offline_cancel, psysonic_syncfs::cache::offline::clear_offline_cancel,
+5 -1
View File
@@ -27,7 +27,11 @@
} }
], ],
"security": { "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": { "plugins": {
+74
View File
@@ -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');
});
});
+221
View File
@@ -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;
}
+7 -6
View File
@@ -128,10 +128,10 @@ describe('getClient', () => {
}); });
describe('coverArtCacheKey', () => { describe('coverArtCacheKey', () => {
it('uses serverId + entity id + size as a stable cache key', () => { it('uses host index key + entity id + tier as a stable cache key', () => {
const id = setUpServer(); setUpServer();
expect(coverArtCacheKey('cover-1')).toBe(`${id}:cover:cover-1:256`); expect(coverArtCacheKey('cover-1')).toBe('music.example.com:cover:cover-1:256');
expect(coverArtCacheKey('cover-1', 200)).toBe(`${id}:cover:cover-1:200`); 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', () => { it('falls back to "_" as the server-id segment when no server is active', () => {
@@ -212,8 +212,9 @@ describe('buildCoverArtUrlForServer', () => {
}); });
describe('coverArtCacheKeyForServer', () => { describe('coverArtCacheKeyForServer', () => {
it('scopes cache keys by server id', () => { it('scopes cache keys by host index key when profile is known', () => {
expect(coverArtCacheKeyForServer('srv-b', 'cover-1', 80)).toBe('srv-b:cover:cover-1:80'); const profileId = setUpServer({ url: 'https://b.example' });
expect(coverArtCacheKeyForServer(profileId, 'cover-1', 80)).toBe('b.example:cover:cover-1:80');
}); });
}); });
+21 -6
View File
@@ -1,4 +1,6 @@
import md5 from 'md5'; import md5 from 'md5';
import { coverStorageKey } from '../cover/storageKeys';
import type { CoverArtTier } from '../cover/types';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient'; 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); 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 { export function coverArtCacheKey(id: string, size = 256): string {
const server = useAuthStore.getState().getActiveServer(); return coverStorageKey({ kind: 'active' }, id, size as CoverArtTier);
return coverArtCacheKeyForServer(server?.id ?? '_', id, size);
} }
export function coverArtCacheKeyForServer(serverId: string, id: string, size = 256): string { /** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */
return `${serverId}:cover:${id}:${size}`; 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 { export function buildCoverArtUrl(id: string, size = 256): string {
const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer(); const server = getActiveServer();
@@ -71,7 +86,7 @@ export function buildCoverArtUrl(id: string, size = 256): string {
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`; 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( export function buildCoverArtUrlForServer(
serverUrl: string, serverUrl: string,
username: string, username: string,
+4
View File
@@ -46,6 +46,8 @@ import { useQueueResizer } from '../hooks/useQueueResizer';
import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelectionBlockers'; import { useGlobalDndAndSelectionBlockers } from '../hooks/useGlobalDndAndSelectionBlockers';
import { useAppActivityTracking } from '../hooks/useAppActivityTracking'; import { useAppActivityTracking } from '../hooks/useAppActivityTracking';
import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator'; import { useMainScrollingIndicator } from '../hooks/useMainScrollingIndicator';
import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority';
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav'; import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam'; import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
import { IS_LINUX } from '../utils/platform'; import { IS_LINUX } from '../utils/platform';
@@ -96,6 +98,8 @@ export function AppShell() {
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus(); const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
useCoverNavigationPriority();
useNowPlayingPrewarm();
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums); const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
+12
View File
@@ -18,6 +18,10 @@ import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigrat
import { bootstrapAllIndexedServers } from '../utils/library/librarySession'; import { bootstrapAllIndexedServers } from '../utils/library/librarySession';
import { hydrateQueueFromIndex } from '../utils/library/queueRestore'; import { hydrateQueueFromIndex } from '../utils/library/queueRestore';
import { useLibraryAnalysisBackfill } from '../hooks/useLibraryAnalysisBackfill'; 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 { useMigrationOrchestrator } from '../hooks/useMigrationOrchestrator';
import { IS_WINDOWS } from '../utils/platform'; import { IS_WINDOWS } from '../utils/platform';
import TauriEventBridge from './TauriEventBridge'; import TauriEventBridge from './TauriEventBridge';
@@ -61,6 +65,14 @@ export default function MainApp() {
}, [activeServerId, serverIdsKey, masterEnabled, migrationReady]); }, [activeServerId, serverIdsKey, masterEnabled, migrationReady]);
useLibraryAnalysisBackfill(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. // Push playback state to mini window + handle control events.
useEffect(() => { useEffect(() => {
+2
View File
@@ -8,6 +8,7 @@ import { useInAppKeybindings } from '../hooks/tauriBridge/useInAppKeybindings';
import { useMediaAndWindowBridge } from '../hooks/tauriBridge/useMediaAndWindowBridge'; import { useMediaAndWindowBridge } from '../hooks/tauriBridge/useMediaAndWindowBridge';
import { usePlayerSnapshotPublisher } from '../hooks/tauriBridge/usePlayerSnapshotPublisher'; import { usePlayerSnapshotPublisher } from '../hooks/tauriBridge/usePlayerSnapshotPublisher';
import { useLibraryDevSyncLog } from '../hooks/tauriBridge/useLibraryDevSyncLog'; import { useLibraryDevSyncLog } from '../hooks/tauriBridge/useLibraryDevSyncLog';
import { useCoverArtBridge } from '../hooks/tauriBridge/useCoverArtBridge';
/** /**
* Single mount point for everything that bridges Rust React in the main * Single mount point for everything that bridges Rust React in the main
@@ -35,6 +36,7 @@ export function TauriEventBridge() {
useMediaAndWindowBridge(navigate); useMediaAndWindowBridge(navigate);
usePlayerSnapshotPublisher(); usePlayerSnapshotPublisher();
useLibraryDevSyncLog(); useLibraryDevSyncLog();
useCoverArtBridge();
return null; return null;
} }
+31 -17
View File
@@ -1,4 +1,3 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getAlbum } from '../api/subsonicLibrary'; import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes'; import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; import { songToTrack } from '../utils/playback/songToTrack';
@@ -9,7 +8,12 @@ import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore'; 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 { OpenArtistRefInline } from './OpenArtistRefInline';
import { playAlbum } from '../utils/playback/playAlbum'; import { playAlbum } from '../utils/playback/playAlbum';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
@@ -24,7 +28,14 @@ interface AlbumCardProps {
showRating?: boolean; showRating?: boolean;
selectedAlbums?: SubsonicAlbum[]; selectedAlbums?: SubsonicAlbum[];
disableArtwork?: boolean; 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; 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({ function AlbumCard({
@@ -35,7 +46,10 @@ function AlbumCard({
showRating = false, showRating = false,
selectedAlbums = [], selectedAlbums = [],
disableArtwork = false, disableArtwork = false,
artworkSize = 300, displayCssPx = COVER_DENSE_GRID_MIN_CELL_CSS_PX,
artworkSize: _artworkSize,
observeScrollRootId,
ensurePriority,
}: AlbumCardProps) { }: AlbumCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -47,16 +61,12 @@ function AlbumCard({
if (!meta || meta.trackIds.length === 0) return false; if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]); 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 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 isNewAlbum = isAlbumRecentlyAdded(album.created);
const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]); 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) { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp); 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); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
@@ -98,13 +109,16 @@ function AlbumCard({
}} }}
> >
<div className="album-card-cover"> <div className="album-card-cover">
{!disableArtwork && coverUrl ? ( {!disableArtwork && album.coverArt ? (
<CachedImage <CoverArtImage
src={coverUrl} coverArtId={album.coverArt}
cacheKey={coverCacheKey} displayCssPx={displayCssPx}
surface="dense"
alt={`${album.name} Cover`} alt={`${album.name} Cover`}
loading="eager" loading="eager"
decoding="async" decoding="async"
observeScrollRootId={observeScrollRootId}
ensurePriority={ensurePriority}
/> />
) : ( ) : (
<div className="album-card-cover-placeholder"> <div className="album-card-cover-placeholder">
+23 -23
View File
@@ -1,11 +1,11 @@
import { buildCoverArtUrl } from '../api/subsonicStreamUrl';
import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes'; import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react'; import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
import CachedImage from './CachedImage'; import { CoverArtImage } from '../cover/CoverArtImage';
import CoverLightbox from './CoverLightbox'; import { coverArtRef } from '../cover/ref';
import { useCoverLightboxSrc } from '../cover/lightbox';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
@@ -68,8 +68,7 @@ interface AlbumHeaderProps {
/** OpenSubsonic album credits (derived from album + songs). */ /** OpenSubsonic album credits (derived from album + songs). */
headerArtistRefs: SubsonicOpenArtistRef[]; headerArtistRefs: SubsonicOpenArtistRef[];
songs: SubsonicSong[]; songs: SubsonicSong[];
coverUrl: string; coverArtId?: string;
coverKey: string;
resolvedCoverUrl: string | null; resolvedCoverUrl: string | null;
isStarred: boolean; isStarred: boolean;
downloadProgress: number | null; downloadProgress: number | null;
@@ -96,8 +95,7 @@ export default function AlbumHeader({
info, info,
headerArtistRefs, headerArtistRefs,
songs, songs,
coverUrl, coverArtId,
coverKey,
resolvedCoverUrl, resolvedCoverUrl,
isStarred, isStarred,
downloadProgress, downloadProgress,
@@ -122,7 +120,14 @@ export default function AlbumHeader({
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); 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 totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 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 isNewAlbum = isAlbumRecentlyAdded(info.created);
const showBioButton = !isVariousArtistsLabel(info.artist); const showBioButton = !isVariousArtistsLabel(info.artist);
const lightboxCoverSrc = useMemo(
() => (info.coverArt ? buildCoverArtUrl(info.coverArt, 2000) : ''),
[info.coverArt],
);
const handleShareAlbum = async () => { const handleShareAlbum = async () => {
try { try {
const ok = await copyEntityShareLink('album', info.id); const ok = await copyEntityShareLink('album', info.id);
@@ -148,13 +148,7 @@ export default function AlbumHeader({
return ( return (
<> <>
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />} {bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
{lightboxOpen && info.coverArt && ( {lightbox}
<CoverLightbox
src={lightboxCoverSrc}
alt={`${info.name} Cover`}
onClose={() => setLightboxOpen(false)}
/>
)}
<div className="album-detail-header"> <div className="album-detail-header">
{resolvedCoverUrl && enableCoverArtBackground && ( {resolvedCoverUrl && enableCoverArtBackground && (
@@ -173,14 +167,20 @@ export default function AlbumHeader({
<ChevronLeft size={16} /> {t('albumDetail.back')} <ChevronLeft size={16} /> {t('albumDetail.back')}
</button> </button>
<div className="album-detail-hero"> <div className="album-detail-hero">
{coverUrl ? ( {coverArtId ? (
<button <button
className="album-detail-cover-btn" className="album-detail-cover-btn"
onClick={() => setLightboxOpen(true)} onClick={openLightbox}
data-tooltip={t('albumDetail.enlargeCover')} data-tooltip={t('albumDetail.enlargeCover')}
aria-label={`${info.name} ${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> </button>
) : ( ) : (
<div className="album-detail-cover album-cover-placeholder"></div> <div className="album-detail-cover album-cover-placeholder"></div>
+10 -12
View File
@@ -1,10 +1,11 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import type { SubsonicArtist } from '../api/subsonicTypes'; import type { SubsonicArtist } from '../api/subsonicTypes';
import React, { useMemo } from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next'; 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 { interface Props {
artist: SubsonicArtist; artist: SubsonicArtist;
@@ -13,19 +14,16 @@ interface Props {
export default function ArtistCardLocal({ artist }: Props) { export default function ArtistCardLocal({ artist }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const coverId = artist.coverArt || artist.id; const coverId = coverArtIdFromArtist(artist);
// 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]);
return ( return (
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}> <div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
<div className="artist-card-avatar"> <div className="artist-card-avatar">
{coverId ? ( {artist.coverArt || artist.id ? (
<CachedImage <CoverArtImage
src={coverSrc} coverArtId={coverId}
cacheKey={coverCacheKey} displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name} alt={artist.name}
loading="lazy" loading="lazy"
onError={(e) => { onError={(e) => {
+416 -103
View File
@@ -1,14 +1,22 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getArtist, getArtistInfo } from '../api/subsonicArtists'; import { getArtist, getArtistInfo } from '../api/subsonicArtists';
import { getAlbum } from '../api/subsonicLibrary'; import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes'; import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray'; 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 { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Music } from 'lucide-react'; 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 { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { playAlbum } from '../utils/playback/playAlbum'; import { playAlbum } from '../utils/playback/playAlbum';
@@ -39,11 +47,229 @@ const SIMILAR_FETCH = 25;
const SIMILAR_PICK = 6; const SIMILAR_PICK = 6;
const SHOW_COUNT = 3; const SHOW_COUNT = 3;
const PICKS_HISTORY_SIZE = 30; 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 { // ── Module-level reserve: next batch pre-fetched in background after each display ──
id: string; type BecauseReserve = {
name: string; 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 { interface Props {
@@ -57,9 +283,9 @@ interface Props {
* Cycling sources (most-played, recently-played, starred) means the per-mount * Cycling sources (most-played, recently-played, starred) means the per-mount
* rotation cursor visits a different listening *mode* each visit instead of * rotation cursor visits a different listening *mode* each visit instead of
* walking only down the top-played list. */ * 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 seen = new Set<string>();
const out: Anchor[] = []; const out: BecauseYouLikeAnchor[] = [];
const maxLen = sources.reduce((m, s) => Math.max(m, s.length), 0); const maxLen = sources.reduce((m, s) => Math.max(m, s.length), 0);
for (let i = 0; i < maxLen && out.length < limit; i++) { for (let i = 0; i < maxLen && out.length < limit; i++) {
for (const src of sources) { for (const src of sources) {
@@ -83,16 +309,9 @@ function anchorHistoryKey(serverId: string | null): string | null {
function picksHistoryKey(serverId: string | null): string | null { function picksHistoryKey(serverId: string | null): string | null {
return serverId ? `${PICKS_HISTORY_KEY_PREFIX}${serverId}` : null; return serverId ? `${PICKS_HISTORY_KEY_PREFIX}${serverId}` : null;
} }
function readJsonArray(key: string | null): string[] {
if (!key) return []; function hasValidReserve(serverId: string | null): boolean {
try { return _becauseReserve != null && _becauseReserve.serverId === (serverId ?? '');
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 [];
}
} }
export default function BecauseYouLikeRail({ export default function BecauseYouLikeRail({
@@ -107,18 +326,62 @@ export default function BecauseYouLikeRail({
() => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL), () => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL),
[mostPlayed, recentlyPlayed, starred], [mostPlayed, recentlyPlayed, starred],
); );
const [anchor, setAnchor] = useState<Anchor | null>(null); const poolKey = useMemo(
const [recs, setRecs] = useState<SubsonicAlbum[]>([]); () => 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 containerRef = useRef<HTMLDivElement>(null);
const [narrow, setNarrow] = useState(false); 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 // 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, // 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 // so we swap in a standard AlbumRow which is already perf-tuned for narrow
// rails (artwork budget, viewport windowing, scroll-paging). // rails (artwork budget, viewport windowing, scroll-paging).
useEffect(() => { useLayoutEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
setNarrow(el.getBoundingClientRect().width < 696);
const ro = new ResizeObserver(entries => { const ro = new ResizeObserver(entries => {
for (const entry of entries) { for (const entry of entries) {
setNarrow(entry.contentRect.width < 696); setNarrow(entry.contentRect.width < 696);
@@ -131,89 +394,133 @@ export default function BecauseYouLikeRail({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (pool.length === 0) { 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); setAnchor(null);
setRecs([]); setRecs([]);
setRefreshing(false);
return; return;
} }
const anchorHistKey = anchorHistoryKey(activeServerId); const anchorHistKey = anchorHistoryKey(activeServerId);
const picksHistKey = picksHistoryKey(activeServerId); const picksHistKey = picksHistoryKey(activeServerId);
const anchorHistory = readJsonArray(anchorHistKey); const snap = readBecauseYouLikeCache(activeServerId);
const picksHistory = readJsonArray(picksHistKey);
/** Cooldown caps at half the pool size so a small library doesn't soft-lock // Consume module-level reserve (keyed by serverId only — poolKey omitted so
* itself out (a server with 4 anchor-eligible artists shouldn't be told // the reserve is usable even before pool has loaded on first render).
* "the last 5 are forbidden"). */ const reserved = hasValidReserve(activeServerId) ? _becauseReserve : null;
const cooldown = Math.min(ANCHOR_COOLDOWN, Math.max(0, Math.floor(pool.length / 2))); _becauseReserve = null;
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);
(async () => { (async () => {
const tries = Math.min(ANCHOR_MAX_TRIES, candidates.length); if (reserved) {
/** Random pick (with cooldown) replaces deterministic round-robin so the // ── Reserve path: instant display, no network ──────────────────────
* same anchor doesn't surface every pool.length mounts. The retry loop await primeAlbumCoversForDisplay(reserved.recs, BECAUSE_CARD_COVER_CSS_PX, {
* still walks forward through the shuffled `candidates` list when the limit: SHOW_COUNT,
* current pick is a dud (no Last.fm similar artists, or no library disabled: disableArtwork,
* 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 (cancelled) return; if (cancelled) return;
const candidate = candidates[i]; // Advance rotation in localStorage now that these picks are being shown.
try { try {
const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH }); if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(reserved.nextAnchorHistory));
if (cancelled) return; if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(reserved.nextPicksHistory));
const similar = (info.similarArtist ?? []).filter(s => s.id); } catch { /* ignore */ }
if (similar.length === 0) continue; setAnchor(reserved.anchor);
setRecs(reserved.recs);
const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK); if (activeServerId) {
const results = await Promise.all( writeBecauseYouLikeCache({ serverId: activeServerId, anchor: reserved.anchor, recs: reserved.recs });
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 */
} }
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); setAnchor(null);
setRecs([]); 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; }; 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} />; 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 }); const sectionTitle = t('home.becauseYouLikeFor', { artist: anchor.name });
return ( return (
@@ -227,13 +534,14 @@ export default function BecauseYouLikeRail({
{sectionTitle} {sectionTitle}
</h2> </h2>
</div> </div>
<div className="because-card-grid"> <div className="because-card-grid because-card-grid--stagger">
{recs.map(album => ( {recs.slice(0, contentSlots).map((album, index) => (
<BecauseCard <BecauseCard
key={album.id} key={album.id}
album={album} album={album}
anchor={anchor.name} anchor={anchor.name}
disableArtwork={disableArtwork} disableArtwork={disableArtwork}
enter={index > 0}
/> />
))} ))}
</div> </div>
@@ -247,22 +555,19 @@ interface CardProps {
album: SubsonicAlbum; album: SubsonicAlbum;
anchor: string; anchor: string;
disableArtwork: boolean; 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 { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
const coverUrl = useMemo( const coverHandle = useCoverArt(album.coverArt, BECAUSE_CARD_COVER_CSS_PX, {
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, COVER_SIZE) : ''), surface: 'dense',
[album.coverArt], ensurePriority: 'high',
); });
const coverKey = useMemo( const imgSrc = coverImgSrc(coverHandle.src);
() => (album.coverArt ? coverArtCacheKey(album.coverArt, COVER_SIZE) : ''), const bgResolved = coverHandle.src;
[album.coverArt],
);
const bgResolved = useCachedUrl(coverUrl, coverKey);
const handleOpen = () => navigate(`/album/${album.id}`); const handleOpen = () => navigate(`/album/${album.id}`);
const handlePlay = (e: React.MouseEvent) => { const handlePlay = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -282,7 +587,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }:
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
className="because-card" className={`because-card${enter ? ' because-card--slot-enter' : ''}`}
onClick={handleOpen} onClick={handleOpen}
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }} onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
aria-label={`${album.name} ${album.artist}`} aria-label={`${album.name} ${album.artist}`}
@@ -295,14 +600,22 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }:
/> />
)} )}
<div className="because-card-cover-wrap"> <div className="because-card-cover-wrap">
{!disableArtwork && coverUrl ? ( {!disableArtwork && album.coverArt ? (
<CachedImage imgSrc ? (
src={coverUrl} <img
cacheKey={coverKey} src={imgSrc}
alt={album.name} alt={album.name}
className="because-card-cover" className="because-card-cover"
loading="lazy" 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"> <div className="because-card-cover because-card-cover-placeholder" aria-hidden="true">
<Music size={42} strokeWidth={1.5} /> <Music size={42} strokeWidth={1.5} />
+1 -1
View File
@@ -160,7 +160,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
</div> </div>
{servers.map(srv => { {servers.map(srv => {
const active = srv.id === activeServerId; const active = srv.id === activeServerId;
const busy = switchingId !== null; const busy = switchingId === srv.id;
const labelText = serverListDisplayLabel(srv, servers); const labelText = serverListDisplayLabel(srv, servers);
return ( return (
<button <button
+8 -7
View File
@@ -54,7 +54,7 @@ import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset'; import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack } from '@/test/helpers/factories'; 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'; import { fireEvent } from '@testing-library/react';
beforeEach(() => { beforeEach(() => {
@@ -64,6 +64,7 @@ beforeEach(() => {
}); });
useAuthStore.getState().setActiveServer(id); useAuthStore.getState().setActiveServer(id);
vi.mocked(useCachedUrl).mockClear(); vi.mocked(useCachedUrl).mockClear();
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined); onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined); onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined); onInvoke('audio_stop', () => undefined);
@@ -107,9 +108,10 @@ describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
const calls = vi.mocked(useCachedUrl).mock.calls; const calls = vi.mocked(useCachedUrl).mock.calls;
// Find the call whose cacheKey targets the 500 px cover (`...:cover:art-1:500`). // 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).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', () => { 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={() => {}} />); renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls; const calls = vi.mocked(useCachedUrl).mock.calls;
// The 300 px art box uses the default (truthy) opt. const defaultOptCalls = calls.filter(c => c[2] !== false);
const artBoxCall = calls.find(c => typeof c[1] === 'string' && c[1].includes(':300')); expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
expect(artBoxCall).toBeDefined(); expect(defaultOptCalls.some(c => String(c[1]).includes('art-1'))).toBe(true);
expect(artBoxCall?.[2]).toBe(true);
}); });
}); });
+27 -21
View File
@@ -1,11 +1,11 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary'; import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes'; import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react'; 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 { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playback/playAlbum'; import { playAlbum } from '../utils/playback/playAlbum';
@@ -20,6 +20,10 @@ const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8; const HERO_ALBUM_COUNT = 8;
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */ /** Larger pool when mix rating filter is on so we can still fill the hero strip. */
const HERO_RANDOM_POOL = 32; 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 // Crossfading background — same layer pattern as FullscreenPlayer
function HeroBg({ url }: { url: string }) { function HeroBg({ url }: { url: string }) {
@@ -40,14 +44,16 @@ function HeroBg({ url }: { url: string }) {
return ( return (
<> <>
{layers.map(layer => ( {layers.map(layer => (
<div <img
key={layer.id} key={layer.id}
className="hero-bg" className="hero-bg-image"
style={{ src={layer.url}
backgroundImage: `url(${layer.url})`, style={{ opacity: layer.visible ? 1 : 0 }}
opacity: layer.visible ? 1 : 0,
}}
aria-hidden="true" aria-hidden="true"
alt=""
loading="eager"
decoding="sync"
draggable={false}
/> />
))} ))}
</> </>
@@ -158,6 +164,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
if (heroInView || windowHidden) return; if (heroInView || windowHidden) return;
// Recovery guard: if a scroll/RAF event was missed while hero was outside // 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]); }, [album?.id]);
// buildCoverArtUrl generates a new salt on every call — must be memoized. const bgHandle = useCoverArt(album?.coverArt, HERO_BG_CSS_PX, {
const bgRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '', [album?.coverArt]); surface: 'dense',
const bgCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '', [album?.coverArt]); ensurePriority: 'high',
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey); });
// Keep the last known good URL so HeroBg never receives '' during a cache-miss // 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). // transition (which would cause the background to flash empty before fading in).
const stableBgUrl = useRef(''); const stableBgUrl = useRef('');
if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl; if (bgHandle.src) stableBgUrl.current = bgHandle.src;
const coverRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '', [album?.coverArt]);
const coverCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '', [album?.coverArt]);
if (!album) return <div className="hero-placeholder" />; 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" />} {enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */} {/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content animate-fade-in" key={album.id}> <div className="hero-content" key={album.id}>
{coverRawUrl && !isMobile && ( {album.coverArt && !isMobile && (
<CachedImage <CoverArtImage
coverArtId={album.coverArt}
displayCssPx={HERO_FG_CSS_PX}
surface="dense"
ensurePriority="high"
className="hero-cover" className="hero-cover"
src={coverRawUrl}
cacheKey={coverCacheKey}
alt={`${album.name} Cover`} alt={`${album.name} Cover`}
/> />
)} )}
+7 -5
View File
@@ -1,4 +1,3 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getSong } from '../api/subsonicLibrary'; import { getSong } from '../api/subsonicLibrary';
import type { SubsonicSong } from '../api/subsonicTypes'; import type { SubsonicSong } from '../api/subsonicTypes';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
@@ -10,9 +9,11 @@ import {
declineOrbitSuggestion, declineOrbitSuggestion,
suggestionKey, suggestionKey,
} from '../utils/orbit'; } from '../utils/orbit';
import CachedImage from './CachedImage'; import { CoverArtImage } from '../cover/CoverArtImage';
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit'; import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
const HOST_APPROVAL_COVER_CSS_PX = 36;
/** /**
* Host-only approval strip. Renders directly below the OrbitQueueHead * Host-only approval strip. Renders directly below the OrbitQueueHead
* when `autoApprove === false` and at least one guest suggestion is * when `autoApprove === false` and at least one guest suggestion is
@@ -85,9 +86,10 @@ export default function HostApprovalQueue() {
return ( return (
<div key={key} className="host-approval__item"> <div key={key} className="host-approval__item">
{song?.coverArt ? ( {song?.coverArt ? (
<CachedImage <CoverArtImage
src={buildCoverArtUrl(song.coverArt, 48)} coverArtId={song.coverArt}
cacheKey={coverArtCacheKey(song.coverArt, 48)} displayCssPx={HOST_APPROVAL_COVER_CSS_PX}
surface="dense"
alt="" alt=""
className="host-approval__cover" className="host-approval__cover"
/> />
+33 -11
View File
@@ -1,4 +1,3 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '../api/library'; import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '../api/library';
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes'; import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; import { songToTrack } from '../utils/playback/songToTrack';
@@ -28,7 +27,12 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useTranslation } from 'react-i18next'; 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 { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch'; import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults'; import ShareSearchResults from './search/ShareSearchResults';
@@ -37,23 +41,28 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network'; type LiveSearchSource = 'local' | 'network';
function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) { function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); return (
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]); <CoverArtImage
return <CachedImage className="search-result-thumb" src={src} cacheKey={cacheKey} alt="" />; coverArtId={coverArt}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
className="search-result-thumb"
alt=""
/>
);
} }
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) { function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const coverId = artist.coverArt || artist.id; const coverId = coverArtIdFromArtist(artist);
const src = useMemo(() => buildCoverArtUrl(coverId, 40), [coverId]);
const cacheKey = useMemo(() => coverArtCacheKey(coverId, 40), [coverId]);
useEffect(() => { setFailed(false); }, [coverId]); useEffect(() => { setFailed(false); }, [coverId]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>; if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
return ( return (
<CachedImage <CoverArtImage
coverArtId={coverId}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
className="search-result-thumb" className="search-result-thumb"
src={src}
cacheKey={cacheKey}
alt="" alt=""
loading="eager" loading="eager"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM} fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
@@ -413,6 +422,19 @@ export default function LiveSearch() {
!!share.shareMatch || !!share.shareMatch ||
(results && (results.artists.length || results.albums.length || results.songs.length)); (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 // Flat list of all navigable items for keyboard nav
const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [ const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [
{ {
+32 -15
View File
@@ -1,15 +1,18 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { search } from '../api/subsonicSearch'; import { search } from '../api/subsonicSearch';
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes'; import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; 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 { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react'; import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next'; 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 { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch'; import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults'; 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); }; 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'> }) { function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const coverId = artist.coverArt || artist.id; const coverId = coverArtIdFromArtist(artist);
const src = useMemo(() => buildCoverArtUrl(coverId, 80), [coverId]);
const ck = useMemo(() => coverArtCacheKey(coverId, 80), [coverId]);
useEffect(() => { setFailed(false); }, [coverId]); useEffect(() => { setFailed(false); }, [coverId]);
if (failed) { if (failed) {
return ( return (
@@ -46,10 +50,11 @@ function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id'
); );
} }
return ( return (
<CachedImage <CoverArtImage
coverArtId={coverId}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb mobile-search-thumb--artist-round" className="mobile-search-thumb mobile-search-thumb--artist-round"
src={src}
cacheKey={ck}
alt="" alt=""
loading="eager" loading="eager"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM} fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
@@ -98,6 +103,16 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
doSearch(query); doSearch(query);
}, [query, doSearch, share.shareMatch]); }, [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) => { const commit = (q: string) => {
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev)); if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
}; };
@@ -272,10 +287,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
{results!.albums.map(a => ( {results!.albums.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}> <button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
{a.coverArt ? ( {a.coverArt ? (
<CachedImage <CoverArtImage
coverArtId={a.coverArt}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb" className="mobile-search-thumb"
src={buildCoverArtUrl(a.coverArt, 80)}
cacheKey={coverArtCacheKey(a.coverArt, 80)}
alt="" alt=""
/> />
) : ( ) : (
@@ -299,10 +315,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
{results!.songs.map(s => ( {results!.songs.map(s => (
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}> <button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
{s.coverArt ? ( {s.coverArt ? (
<CachedImage <CoverArtImage
coverArtId={s.coverArt}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb" className="mobile-search-thumb"
src={buildCoverArtUrl(s.coverArt, 80)}
cacheKey={coverArtCacheKey(s.coverArt, 80)}
alt="" alt=""
/> />
) : ( ) : (
+8 -2
View File
@@ -1,4 +1,4 @@
import { buildCoverArtUrl } from '../api/subsonicStreamUrl'; import { CoverArtImage } from '../cover/CoverArtImage';
import { getNowPlaying } from '../api/subsonicScrobble'; import { getNowPlaying } from '../api/subsonicScrobble';
import type { SubsonicNowPlaying } from '../api/subsonicTypes'; import type { SubsonicNowPlaying } from '../api/subsonicTypes';
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react'; 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)' }}> <div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
{stream.coverArt ? ( {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)' }} /> <PlayCircle size={24} style={{ margin: '12px', color: 'var(--text-muted)' }} />
)} )}
+16 -11
View File
@@ -1,13 +1,15 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getSong } from '../api/subsonicLibrary'; import { getSong } from '../api/subsonicLibrary';
import type { SubsonicSong } from '../api/subsonicTypes'; import type { SubsonicSong } from '../api/subsonicTypes';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Radio, Clock } from 'lucide-react'; import { Radio, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import CachedImage from './CachedImage'; import { CoverArtImage } from '../cover/CoverArtImage';
import OrbitQueueHead from './OrbitQueueHead'; 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. * Orbit guest-side queue view.
* *
@@ -76,9 +78,10 @@ export default function OrbitGuestQueue() {
</div> </div>
<div className="orbit-guest-queue__current-body"> <div className="orbit-guest-queue__current-body">
{currentSong?.coverArt ? ( {currentSong?.coverArt ? (
<CachedImage <CoverArtImage
src={buildCoverArtUrl(currentSong.coverArt, 96)} coverArtId={currentSong.coverArt}
cacheKey={coverArtCacheKey(currentSong.coverArt, 96)} displayCssPx={ORBIT_QUEUE_COVER_LG_CSS_PX}
surface="dense"
alt="" alt=""
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg" className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
/> />
@@ -112,9 +115,10 @@ export default function OrbitGuestQueue() {
return ( return (
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending"> <div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
{song?.coverArt ? ( {song?.coverArt ? (
<CachedImage <CoverArtImage
src={buildCoverArtUrl(song.coverArt, 48)} coverArtId={song.coverArt}
cacheKey={coverArtCacheKey(song.coverArt, 48)} displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
surface="dense"
alt="" alt=""
className="orbit-guest-queue__cover" className="orbit-guest-queue__cover"
/> />
@@ -150,9 +154,10 @@ export default function OrbitGuestQueue() {
className="orbit-guest-queue__item" className="orbit-guest-queue__item"
> >
{song?.coverArt ? ( {song?.coverArt ? (
<CachedImage <CoverArtImage
src={buildCoverArtUrl(song.coverArt, 48)} coverArtId={song.coverArt}
cacheKey={coverArtCacheKey(song.coverArt, 48)} displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
surface="dense"
alt="" alt=""
className="orbit-guest-queue__cover" className="orbit-guest-queue__cover"
/> />
+2 -1
View File
@@ -38,7 +38,7 @@ import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset'; import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack } from '@/test/helpers/factories'; 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'; import { fireEvent } from '@testing-library/react';
beforeEach(() => { beforeEach(() => {
@@ -48,6 +48,7 @@ beforeEach(() => {
name: 'T', url: 'https://x.test', username: 'u', password: 'p', name: 'T', url: 'https://x.test', username: 'u', password: 'p',
}); });
useAuthStore.getState().setActiveServer(id); useAuthStore.getState().setActiveServer(id);
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined); onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined); onInvoke('audio_pause', () => undefined);
onInvoke('audio_resume', () => undefined); onInvoke('audio_resume', () => undefined);
+6 -21
View File
@@ -1,6 +1,6 @@
import { queueSongStar } from '../store/pendingStarSync'; import { queueSongStar } from '../store/pendingStarSync';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { coverArtIdFromRadio } from '../cover/ids';
import type { SubsonicAlbum } from '../api/subsonicTypes'; import type { SubsonicAlbum } from '../api/subsonicTypes';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
@@ -13,7 +13,6 @@ import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek'; import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer'; import Equalizer from './Equalizer';
import StarRating from './StarRating'; import StarRating from './StarRating';
@@ -139,13 +138,7 @@ export default function PlayerBar() {
const duration = currentTrack?.duration ?? 0; const duration = currentTrack?.duration ?? 0;
// Cover art: prefer radio station art, fall back to track art. const radioCoverArtId = currentRadio?.coverArt ? coverArtIdFromRadio(currentRadio.id) : undefined;
// 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) : '';
// Preview takes visual priority over the queued track in the player-bar info // 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). // cell, but only when not in radio mode (radio has its own meta layout).
const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null; const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null;
@@ -156,14 +149,8 @@ export default function PlayerBar() {
? currentTrack.artists ? currentTrack.artists
: undefined; : undefined;
const previewCover = useMemo(() => { usePlaybackCoverArt(showPreviewMeta ? undefined : displayCoverArt, 128);
if (!showPreviewMeta || !previewingTrack?.coverArt) return { src: '', cacheKey: '' }; const coverArtId = showPreviewMeta ? previewingTrack?.coverArt : displayCoverArt;
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;
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVolume(parseFloat(e.target.value)); setVolume(parseFloat(e.target.value));
@@ -208,10 +195,8 @@ export default function PlayerBar() {
currentRadio={currentRadio} currentRadio={currentRadio}
isRadio={isRadio} isRadio={isRadio}
radioMeta={radioMeta} radioMeta={radioMeta}
radioCoverSrc={radioCoverSrc} radioCoverArtId={radioCoverArtId}
radioCoverKey={radioCoverKey} coverArtId={coverArtId}
coverSrc={coverSrc}
coverKey={coverKey}
displayCoverArt={displayCoverArt} displayCoverArt={displayCoverArt}
displayTitle={displayTitle} displayTitle={displayTitle}
displayArtist={displayArtist} displayArtist={displayArtist}
+2 -1
View File
@@ -42,7 +42,7 @@ import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset'; import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories'; 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 { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -52,6 +52,7 @@ beforeEach(() => {
name: 'T', url: 'https://x.test', username: 'u', password: 'p', name: 'T', url: 'https://x.test', username: 'u', password: 'p',
}); });
useAuthStore.getState().setActiveServer(id); useAuthStore.getState().setActiveServer(id);
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined); onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined); onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined); onInvoke('audio_stop', () => undefined);
+1 -6
View File
@@ -8,7 +8,6 @@ import OrbitGuestQueue from './OrbitGuestQueue';
import OrbitQueueHead from './OrbitQueueHead'; import OrbitQueueHead from './OrbitQueueHead';
import HostApprovalQueue from './HostApprovalQueue'; import HostApprovalQueue from './HostApprovalQueue';
import { usePlaylistStore } from '../store/playlistStore'; import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -77,11 +76,7 @@ function QueuePanelHostOrSolo() {
const queueIndex = usePlayerStore(s => s.queueIndex); const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const { src: currentCoverFetchUrl, cacheKey: currentCoverCacheKey } = usePlaybackCoverArt( const { src: currentCoverSrc } = usePlaybackCoverArt(currentTrack?.coverArt, 128);
currentTrack?.coverArt,
128,
);
const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const playTrack = usePlayerStore(s => s.playTrack); const playTrack = usePlayerStore(s => s.playTrack);
const clearQueue = usePlayerStore(s => s.clearQueue); const clearQueue = usePlayerStore(s => s.clearQueue);
+24 -17
View File
@@ -1,12 +1,13 @@
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import type { SubsonicSong } from '../api/subsonicTypes'; import type { SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo, useMemo } from 'react'; import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, Star } from 'lucide-react'; import { Play, ListPlus, Star } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore'; 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 { enqueueAndPlay } from '../utils/playback/playSong';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
@@ -14,23 +15,28 @@ import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
interface SongCardProps { interface SongCardProps {
song: SubsonicSong; song: SubsonicSong;
disableArtwork?: boolean; disableArtwork?: boolean;
/** Layout-native cover square width in CSS px (rail cell). */
displayCssPx?: number;
/** @deprecated Use displayCssPx */
artworkSize?: number; 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 { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
// buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders. const coverHandle = useCoverArt(song.coverArt, layoutPx, {
const coverUrl = useMemo( surface: 'dense',
() => (song.coverArt ? buildCoverArtUrl(song.coverArt, artworkSize) : ''), ensurePriority: 'middle',
[song.coverArt, artworkSize], });
); const coverUrl = coverHandle.src;
const coverCacheKey = useMemo(
() => (song.coverArt ? coverArtCacheKey(song.coverArt, artworkSize) : ''),
[song.coverArt, artworkSize],
);
const psyDrag = useDragDrop(); const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
@@ -86,10 +92,11 @@ function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardP
}} }}
> >
<div className="song-card-cover"> <div className="song-card-cover">
{!disableArtwork && coverUrl ? ( {!disableArtwork && song.coverArt ? (
<CachedImage <CoverArtImage
src={coverUrl} coverArtId={song.coverArt}
cacheKey={coverCacheKey} displayCssPx={layoutPx}
surface="dense"
alt={`${song.album} Cover`} alt={`${song.album} Cover`}
loading="eager" loading="eager"
decoding="async" decoding="async"
+27 -1
View File
@@ -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 { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight'; import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
@@ -22,6 +24,12 @@ export type VirtualCardGridProps<T> = {
gridGap?: string; gridGap?: string;
/** When set, row virtualization uses this scroll container instead of the main route viewport. */ /** When set, row virtualization uses this scroll container instead of the main route viewport. */
scrollRootId?: string; 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, wrapStyle,
gridGap = 'var(--space-4)', gridGap = 'var(--space-4)',
scrollRootId, scrollRootId,
warmGridCovers,
}: VirtualCardGridProps<T>): React.JSX.Element { }: 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 wrapRef = useRef<HTMLDivElement>(null);
const { gridCols, rowHeightEst } = useCardGridMetrics(wrapRef, true, rowVariant, layoutSignal); const { gridCols, rowHeightEst } = useCardGridMetrics(wrapRef, true, rowVariant, layoutSignal);
const cols = Math.max(1, gridCols); 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 { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
@@ -10,8 +10,9 @@ import { useOfflineStore } from '../../store/offlineStore';
import { useOfflineJobStore } from '../../store/offlineJobStore'; import { useOfflineJobStore } from '../../store/offlineJobStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import CachedImage from '../CachedImage'; import { ArtistHeroCover } from '../../cover/artistHero';
import CoverLightbox from '../CoverLightbox'; import { coverArtRef } from '../../cover/ref';
import { useCoverLightboxSrc } from '../../cover/lightbox';
import LastfmIcon from '../LastfmIcon'; import LastfmIcon from '../LastfmIcon';
import StarRating from '../StarRating'; import StarRating from '../StarRating';
@@ -35,14 +36,9 @@ interface Props {
openedLink: string | null; openedLink: string | null;
openLink: (url: string, key: string) => void; openLink: (url: string, key: string) => void;
coverId: string; coverId: string;
artistCover300Src: string;
artistCover300Key: string;
artistCover2000Src: string;
coverRevision: number; coverRevision: number;
headerCoverFailed: boolean; headerCoverFailed: boolean;
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>; setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
lightboxOpen: boolean;
setLightboxOpen: React.Dispatch<React.SetStateAction<boolean>>;
} }
export default function ArtistDetailHero({ export default function ArtistDetailHero({
@@ -50,9 +46,7 @@ export default function ArtistDetailHero({
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist, toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
handleImageUpload, playAllLoading, radioLoading, uploading, handleImageUpload, playAllLoading, radioLoading, uploading,
openedLink, openLink, openedLink, openLink,
coverId, artistCover300Src, artistCover300Key, artistCover2000Src, coverId, coverRevision, headerCoverFailed, setHeaderCoverFailed,
coverRevision, headerCoverFailed, setHeaderCoverFailed,
lightboxOpen, setLightboxOpen,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -64,6 +58,12 @@ export default function ArtistDetailHero({
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown'; 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)}`; const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
return ( return (
@@ -76,27 +76,24 @@ export default function ArtistDetailHero({
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span> <ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
</button> </button>
{lightboxOpen && ( {lightbox}
<CoverLightbox
src={artistCover2000Src}
alt={artist.name}
onClose={() => setLightboxOpen(false)}
/>
)}
<div className="artist-detail-header"> <div className="artist-detail-header">
<div className="artist-detail-avatar" style={{ position: 'relative' }}> <div className="artist-detail-avatar" style={{ position: 'relative' }}>
{coverId ? ( {coverId ? (
<button <button
className="artist-detail-avatar-btn" className="artist-detail-avatar-btn"
onClick={() => setLightboxOpen(true)} onClick={openLightbox}
aria-label={`${artist.name} Bild vergrößern`} aria-label={`${artist.name} Bild vergrößern`}
> >
{!headerCoverFailed ? ( {!headerCoverFailed ? (
<CachedImage <ArtistHeroCover
key={coverRevision} key={coverRevision}
src={artistCover300Src} artistId={id ?? artist.id}
cacheKey={artistCover300Key} artistInfo={info}
coverFallback={coverRef}
displayCssPx={300}
surface="sparse"
alt={artist.name} alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setHeaderCoverFailed(true)} onError={() => setHeaderCoverFailed(true)}
@@ -1,14 +1,13 @@
import React, { useMemo } from 'react'; import React from 'react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl'; import { CoverArtImage } from '../../cover/CoverArtImage';
import CachedImage from '../CachedImage'; import { COVER_DENSE_ARTIST_LIST_CSS_PX } from '../../cover/layoutSizes';
export default function ArtistSuggestionTrackCover({ coverArt, album }: { coverArt: string; album: string }) { 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 ( return (
<CachedImage <CoverArtImage
src={src} coverArtId={coverArt}
cacheKey={cacheKey} displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
surface="dense"
alt={album} alt={album}
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }} style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
+20 -29
View File
@@ -1,7 +1,11 @@
import React, { useMemo } from 'react'; import React from 'react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
import type { SubsonicArtist } from '../../api/subsonicTypes'; 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 { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
import { nameColor, nameInitial } from '../../utils/componentHelpers/artistsHelpers'; import { nameColor, nameInitial } from '../../utils/componentHelpers/artistsHelpers';
@@ -17,20 +21,14 @@ interface AvatarProps {
*/ */
export function ArtistCardAvatar({ artist, showImages }: AvatarProps) { export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
const color = nameColor(artist.name); const color = nameColor(artist.name);
const coverId = artist.coverArt || artist.id; const coverId = coverArtIdFromArtist(artist);
const { coverSrc, coverKey } = useMemo( if (showImages && (artist.coverArt || artist.id)) {
() => ({
coverSrc: coverId ? buildCoverArtUrl(coverId, 300) : '',
coverKey: coverId ? coverArtCacheKey(coverId, 300) : '',
}),
[coverId],
);
if (showImages && coverId) {
return ( return (
<div className="artist-card-avatar"> <div className="artist-card-avatar">
<CachedImage <CoverArtImage
src={coverSrc} coverArtId={coverId}
cacheKey={coverKey} displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name} alt={artist.name}
observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID} 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 * 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 * card variant, but smaller layout px so list rows don't pull oversized images.
* don't pull oversized images from the server.
*/ */
export function ArtistRowAvatar({ artist, showImages }: AvatarProps) { export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
const color = nameColor(artist.name); const color = nameColor(artist.name);
const coverId = artist.coverArt || artist.id; const coverId = coverArtIdFromArtist(artist);
const { coverSrc, coverKey } = useMemo( if (showImages && (artist.coverArt || artist.id)) {
() => ({
coverSrc: coverId ? buildCoverArtUrl(coverId, 64) : '',
coverKey: coverId ? coverArtCacheKey(coverId, 64) : '',
}),
[coverId],
);
if (showImages && coverId) {
return ( return (
<div className="artist-avatar"> <div className="artist-avatar">
<CachedImage <CoverArtImage
src={coverSrc} coverArtId={coverId}
cacheKey={coverKey} displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
surface="dense"
alt={artist.name} alt={artist.name}
observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID} observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
+7 -5
View File
@@ -1,9 +1,10 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react'; import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
import type { InternetRadioStation } from '../../api/subsonicTypes'; 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 { interface RadioStationRowProps {
title: string; 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${isActive ? ' radio-card-active' : ''}`}>
<div className="album-card-cover"> <div className="album-card-cover">
{s.coverArt ? ( {s.coverArt ? (
<CachedImage <CoverArtImage
src={buildCoverArtUrl(`ra-${s.id}`, 256)} coverArtId={coverArtIdFromRadio(s.id)}
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)} displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={s.name} alt={s.name}
className="album-card-cover-img" 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 { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, Users } from 'lucide-react'; import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl'; import { CoverArtImage } from '../../cover/CoverArtImage';
import CachedImage from '../CachedImage'; import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
export interface TopFavoriteArtist { export interface TopFavoriteArtist {
id: string; id: string;
@@ -83,8 +83,6 @@ interface TopFavoriteArtistCardProps {
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) { function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
const coverId = artist.coverArtId; const coverId = artist.coverArtId;
const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
const coverCacheKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
return ( return (
<div <div
@@ -94,9 +92,10 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
> >
<div className="artist-card-avatar"> <div className="artist-card-avatar">
{coverId ? ( {coverId ? (
<CachedImage <CoverArtImage
src={coverSrc} coverArtId={coverId}
cacheKey={coverCacheKey} displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name} alt={artist.name}
loading="lazy" loading="lazy"
onError={(e) => { onError={(e) => {
+7 -5
View File
@@ -2,10 +2,11 @@ import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Cast, Globe, Heart, Square, Trash2, X } from 'lucide-react'; import { Cast, Globe, Heart, Square, Trash2, X } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell'; import { open } from '@tauri-apps/plugin-shell';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
import type { InternetRadioStation } from '../../api/subsonicTypes'; import type { InternetRadioStation } from '../../api/subsonicTypes';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext'; 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 { interface RadioCardProps {
s: InternetRadioStation; s: InternetRadioStation;
@@ -85,9 +86,10 @@ export default function RadioCard({
{/* Cover */} {/* Cover */}
<div className="album-card-cover"> <div className="album-card-cover">
{s.coverArt ? ( {s.coverArt ? (
<CachedImage <CoverArtImage
src={buildCoverArtUrl(`ra-${s.id}`, 256)} coverArtId={coverArtIdFromRadio(s.id)}
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)} displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={s.name} alt={s.name}
className="album-card-cover-img" className="album-card-cover-img"
/> />
@@ -2,9 +2,9 @@ import React, { useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Camera, Cast, Loader2, X } from 'lucide-react'; import { Camera, Cast, Loader2, X } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
import type { InternetRadioStation } from '../../api/subsonicTypes'; import type { InternetRadioStation } from '../../api/subsonicTypes';
import CachedImage from '../CachedImage'; import { CoverArtImage } from '../../cover/CoverArtImage';
import { coverArtIdFromRadio } from '../../cover/ids';
interface RadioEditModalProps { interface RadioEditModalProps {
station: InternetRadioStation | null; // null = create new station: InternetRadioStation | null; // null = create new
@@ -91,9 +91,10 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
{coverPreview ? ( {coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} /> <img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && station?.coverArt ? ( ) : !coverRemoved && station?.coverArt ? (
<CachedImage <CoverArtImage
src={buildCoverArtUrl(`ra-${station.id}`, 256)} coverArtId={coverArtIdFromRadio(station.id)}
cacheKey={coverArtCacheKey(`ra-${station.id}`, 256)} displayCssPx={140}
surface="sparse"
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/> />
+12 -6
View File
@@ -1,9 +1,9 @@
import React, { memo, useEffect, useState } from 'react'; import React, { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Disc3, ExternalLink, Music } from 'lucide-react'; import { Disc3, ExternalLink, Music } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
import type { SubsonicAlbum } from '../../api/subsonicTypes'; 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 { interface DiscographyCardProps {
artistId?: string; artistId?: string;
@@ -44,16 +44,22 @@ const DiscographyCard = memo(function DiscographyCard({ artistId, albums, curren
<div className="np-dash-disc-grid"> <div className="np-dash-disc-grid">
{visible.map(a => { {visible.map(a => {
const isActive = a.id === currentAlbumId; const isActive = a.id === currentAlbumId;
const fetchUrl = a.coverArt ? buildCoverArtUrl(a.coverArt, 200) : '';
const key = a.coverArt ? coverArtCacheKey(a.coverArt, 200) : '';
return ( return (
<div key={a.id} <div key={a.id}
className={`np-dash-disc-tile${isActive ? ' active' : ''}`} className={`np-dash-disc-tile${isActive ? ' active' : ''}`}
onClick={() => onNavigate(`/album/${a.id}`)} onClick={() => onNavigate(`/album/${a.id}`)}
data-tooltip={`${a.name}${a.year ? ` · ${a.year}` : ''}`}> data-tooltip={`${a.name}${a.year ? ` · ${a.year}` : ''}`}>
<div className="np-dash-disc-cover"> <div className="np-dash-disc-cover">
{fetchUrl && key {a.coverArt
? <CachedImage src={fetchUrl} cacheKey={key} alt={a.name} className="np-dash-disc-img" /> ? (
<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 className="np-dash-disc-fallback"><Music size={18} /></div>}
</div> </div>
</div> </div>
+18 -17
View File
@@ -5,7 +5,7 @@ import type { InternetRadioStation, SubsonicAlbum, SubsonicOpenArtistRef } from
import type { PlayerState, Track } from '../../store/playerStoreTypes'; import type { PlayerState, Track } from '../../store/playerStoreTypes';
import type { RadioMetadata } from '../../hooks/useRadioMetadata'; import type { RadioMetadata } from '../../hooks/useRadioMetadata';
import type { PreviewingTrack } from '../../store/previewStore'; import type { PreviewingTrack } from '../../store/previewStore';
import CachedImage from '../CachedImage'; import { CoverArtImage } from '../../cover/CoverArtImage';
import LastfmIcon from '../LastfmIcon'; import LastfmIcon from '../LastfmIcon';
import MarqueeText from '../MarqueeText'; import MarqueeText from '../MarqueeText';
import { OpenArtistRefInline } from '../OpenArtistRefInline'; import { OpenArtistRefInline } from '../OpenArtistRefInline';
@@ -22,10 +22,8 @@ interface Props {
currentRadio: InternetRadioStation | null; currentRadio: InternetRadioStation | null;
isRadio: boolean; isRadio: boolean;
radioMeta: RadioMetadata; radioMeta: RadioMetadata;
radioCoverSrc: string; radioCoverArtId?: string;
radioCoverKey: string; coverArtId?: string;
coverSrc: string;
coverKey: string;
displayCoverArt: string | undefined; displayCoverArt: string | undefined;
displayTitle: string; displayTitle: string;
displayArtist: string; displayArtist: string;
@@ -46,8 +44,8 @@ interface Props {
} }
export function PlayerTrackInfo({ export function PlayerTrackInfo({
currentTrack, currentRadio, isRadio, radioMeta, radioCoverSrc, radioCoverKey, currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist, displayArtistRefs, coverArtId, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
showPreviewMeta, previewingTrack, isStarred, toggleStar, showPreviewMeta, previewingTrack, isStarred, toggleStar,
lastfmSessionKey, lastfmLoved, toggleLastfmLove, lastfmSessionKey, lastfmLoved, toggleLastfmLove,
userRatingOverrides, toggleFullscreen, userRatingOverrides, toggleFullscreen,
@@ -66,26 +64,29 @@ export function PlayerTrackInfo({
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined} data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
> >
{isRadio ? ( {isRadio ? (
currentRadio?.coverArt ? ( radioCoverArtId ? (
<CachedImage <CoverArtImage
className="player-album-art" className="player-album-art"
src={radioCoverSrc} coverArtId={radioCoverArtId}
cacheKey={radioCoverKey} displayCssPx={128}
alt={currentRadio.name} surface="sparse"
alt={currentRadio?.name ?? ''}
/> />
) : ( ) : (
<div className="player-album-art-placeholder"> <div className="player-album-art-placeholder">
<Cast size={20} /> <Cast size={20} />
</div> </div>
) )
) : displayCoverArt ? ( ) : coverArtId ? (
<CachedImage <CoverArtImage
className="player-album-art" className="player-album-art"
src={coverSrc} coverArtId={coverArtId}
cacheKey={coverKey} displayCssPx={128}
surface="sparse"
serverScope={showPreviewMeta ? { kind: 'active' } : { kind: 'playback' }}
alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`} alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`}
/> />
) : ( ) : (
<div className="player-album-art-placeholder"> <div className="player-album-art-placeholder">
<Music size={22} /> <Music size={22} />
</div> </div>
+14 -13
View File
@@ -2,21 +2,21 @@ import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Camera, Loader2, X } from 'lucide-react'; import { Camera, Loader2, X } from 'lucide-react';
import type { SubsonicPlaylist } from '../../api/subsonicTypes'; 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 { interface EditModalProps {
playlist: SubsonicPlaylist; playlist: SubsonicPlaylist;
customCoverId: string | null; customCoverId: string | null;
customCoverFetchUrl: string | null; coverQuadIds: (CoverArtId | null)[];
customCoverCacheKey: string | null;
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
onClose: () => void; onClose: () => void;
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>; onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
} }
export default function PlaylistEditModal({ export default function PlaylistEditModal({
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey, playlist, customCoverId, coverQuadIds, onClose, onSave,
coverQuadUrls, onClose, onSave,
}: EditModalProps) { }: EditModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(playlist.name); const [name, setName] = useState(playlist.name);
@@ -78,18 +78,19 @@ export default function PlaylistEditModal({
> >
{coverPreview ? ( {coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} /> <img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? ( ) : !coverRemoved && customCoverId ? (
<CachedImage <CoverArtImage
src={customCoverFetchUrl} coverArtId={customCoverId}
cacheKey={customCoverCacheKey} displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
alt="" alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/> />
) : ( ) : (
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}> <div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
{coverQuadUrls.map((entry, i) => {coverQuadIds.map((coverId, i) =>
entry coverId
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" /> ? <PlaylistSmartCoverCell key={i} coverId={coverId} />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" /> : <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)} )}
</div> </div>
+14 -12
View File
@@ -12,16 +12,17 @@ import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/p
import { import {
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel, displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
} from '../../utils/componentHelpers/playlistDetailHelpers'; } 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 { interface Props {
playlist: SubsonicPlaylist; playlist: SubsonicPlaylist;
songs: SubsonicSong[]; songs: SubsonicSong[];
id: string | undefined; id: string | undefined;
customCoverId: string | null; customCoverId: string | null;
customCoverFetchUrl: string | null; coverQuadIds: (CoverArtId | null)[];
customCoverCacheKey: string | null;
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
resolvedBgUrl: string | null; resolvedBgUrl: string | null;
saving: boolean; saving: boolean;
searchOpen: boolean; searchOpen: boolean;
@@ -48,7 +49,7 @@ interface Props {
export default function PlaylistHero({ export default function PlaylistHero({
playlist, songs, id, playlist, songs, id,
customCoverId, customCoverFetchUrl, customCoverCacheKey, coverQuadUrls, customCoverId, coverQuadIds,
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip, resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
isCached, isDownloading, offlineProgress, activeServerId, isCached, isDownloading, offlineProgress, activeServerId,
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults, setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
@@ -85,19 +86,20 @@ export default function PlaylistHero({
className="playlist-hero-cover" className="playlist-hero-cover"
onClick={() => setEditingMeta(true)} onClick={() => setEditingMeta(true)}
> >
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? ( {customCoverId ? (
<CachedImage <CoverArtImage
src={customCoverFetchUrl} coverArtId={customCoverId}
cacheKey={customCoverCacheKey} displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
alt="" alt=""
className="playlist-cover-grid" className="playlist-cover-grid"
style={{ objectFit: 'cover', display: 'block' }} style={{ objectFit: 'cover', display: 'block' }}
/> />
) : ( ) : (
<div className="playlist-cover-grid"> <div className="playlist-cover-grid">
{coverQuadUrls.map((entry, i) => {coverQuadIds.map((coverId, i) =>
entry coverId
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" /> ? <PlaylistSmartCoverCell key={i} coverId={coverId} />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" /> : <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)} )}
</div> </div>
@@ -1,18 +1,24 @@
import React, { useMemo } from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Check, ListPlus, X } from 'lucide-react'; import { Check, ListPlus, X } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
import type { SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { songToTrack } from '../../utils/playback/songToTrack'; import { songToTrack } from '../../utils/playback/songToTrack';
import { formatTrackTime } from '../../utils/format/formatDuration'; 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'; import { AddToPlaylistSubmenu } from '../ContextMenu';
function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) { function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) {
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); return (
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]); <CoverArtImage
return <CachedImage src={src} cacheKey={cacheKey} alt="" className="playlist-search-thumb" />; coverArtId={coverArt}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
alt=""
className="playlist-search-thumb"
/>
);
} }
interface Props { interface Props {
@@ -1,27 +1,29 @@
import React, { useMemo } from 'react'; import React from 'react';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl'; import { CoverArtImage } from '../../cover/CoverArtImage';
import CachedImage from '../CachedImage';
/** 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 }) { export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
const src = useMemo(() => buildCoverArtUrl(coverId, 200), [coverId]);
const cacheKey = useMemo(() => coverArtCacheKey(coverId, 200), [coverId]);
return ( return (
<CachedImage <CoverArtImage
coverArtId={coverId}
displayCssPx={PLAYLIST_QUAD_CELL_CSS_PX}
surface="dense"
className="playlist-cover-cell" className="playlist-cover-cell"
src={src}
cacheKey={cacheKey}
alt="" alt=""
/> />
); );
} }
export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) { export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]);
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]);
return ( return (
<CachedImage <CoverArtImage
src={src} coverArtId={coverArt}
cacheKey={cacheKey} displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
alt={alt} alt={alt}
className="album-card-cover-img" className="album-card-cover-img"
/> />
@@ -201,7 +201,7 @@ export function QueueCurrentTrack({
})()} })()}
<div className="queue-current-track-body"> <div className="queue-current-track-body">
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}> <div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
{currentTrack.coverArt ? ( {currentTrack.coverArt && currentCoverSrc ? (
<img src={currentCoverSrc} alt="" loading="eager" /> <img src={currentCoverSrc} alt="" loading="eager" />
) : ( ) : (
<div className="fallback"><Music size={32} /></div> <div className="fallback"><Music size={32} /></div>
@@ -7,15 +7,11 @@ import type { ServerProfile } from '../../store/authStoreTypes';
import { formatTrackTime } from '../../utils/format/formatDuration'; import { formatTrackTime } from '../../utils/format/formatDuration';
import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview'; import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview';
import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch'; import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch';
import CachedImage from '../CachedImage';
import OverlayScrollArea from '../OverlayScrollArea'; import OverlayScrollArea from '../OverlayScrollArea';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { import { CoverArtImage } from '../../cover/CoverArtImage';
buildCoverArtUrl, import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
buildCoverArtUrlForServer, import type { CoverServerScope } from '../../cover/types';
coverArtCacheKey,
coverArtCacheKeyForServer,
} from '../../api/subsonicStreamUrl';
type ShareQueuePreviewModalProps = { type ShareQueuePreviewModalProps = {
open: boolean; open: boolean;
@@ -30,6 +26,19 @@ type ShareQueuePreviewModalProps = {
confirmBusyLabel?: string; 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({ function QueuePreviewTrackRow({
song, song,
coverServer, coverServer,
@@ -37,18 +46,19 @@ function QueuePreviewTrackRow({
song: SubsonicSong; song: SubsonicSong;
coverServer?: ServerProfile | null; coverServer?: ServerProfile | null;
}) { }) {
const coverId = song.coverArt ?? ''; const coverId = song.coverArt || song.id;
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);
return ( return (
<li className="share-queue-preview-track"> <li className="share-queue-preview-track">
{coverId ? ( {song.coverArt ? (
<CachedImage className="share-queue-preview-track__thumb" src={src} cacheKey={cacheKey} alt="" /> <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"> <div className="share-queue-preview-track__icon">
<Music size={16} /> <Music size={16} />
+43 -38
View File
@@ -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 { Disc3, Eye, Link2, ListPlus, Music, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import {
buildCoverArtUrl,
buildCoverArtUrlForServer,
coverArtCacheKey,
coverArtCacheKeyForServer,
} from '../../api/subsonicStreamUrl';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import type { ServerProfile } from '../../store/authStoreTypes'; import type { ServerProfile } from '../../store/authStoreTypes';
import { songToTrack } from '../../utils/playback/songToTrack'; import { songToTrack } from '../../utils/playback/songToTrack';
import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchPayload'; import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchPayload';
import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch'; import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch';
import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview'; 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 { useShareQueuePreview } from '../../hooks/useShareQueuePreview';
import ShareQueuePreviewModal from './ShareQueuePreviewModal'; import ShareQueuePreviewModal from './ShareQueuePreviewModal';
@@ -34,51 +32,56 @@ type ShareSearchResultsProps = {
onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void; onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void;
} & ShareSearchPreviewState; } & ShareSearchPreviewState;
function useShareCoverArt(coverId: string, size: number, coverServer: ServerProfile | null | undefined) { function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
return useMemo(() => { if (coverServer) {
if (coverServer) {
return {
src: buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId, size),
cacheKey: coverArtCacheKeyForServer(coverServer.id, coverId, size),
};
}
return { return {
src: buildCoverArtUrl(coverId, size), kind: 'server',
cacheKey: coverArtCacheKey(coverId, size), serverId: coverServer.id,
url: coverServer.url,
username: coverServer.username,
password: coverServer.password,
}; };
}, [coverServer, coverId, size]); }
return { kind: 'active' };
} }
function ShareAlbumThumb({ function ShareAlbumThumb({
coverArt, coverArt,
size, displayCssPx,
coverServer, coverServer,
}: { }: {
coverArt: string; coverArt: string;
size: number; displayCssPx: number;
coverServer?: ServerProfile | null; coverServer?: ServerProfile | null;
}) { }) {
const { src, cacheKey } = useShareCoverArt(coverArt, size, coverServer); const cls = displayCssPx >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
const cls = size >= 64 ? 'mobile-search-thumb' : 'search-result-thumb'; return (
return <CachedImage className={cls} src={src} cacheKey={cacheKey} alt="" />; <CoverArtImage
coverArtId={coverArt}
displayCssPx={displayCssPx}
surface="dense"
serverScope={shareCoverServerScope(coverServer)}
className={cls}
alt=""
/>
);
} }
function ShareArtistThumb({ function ShareArtistThumb({
artist, artist,
size, displayCssPx,
coverServer, coverServer,
}: { }: {
artist: Pick<SubsonicArtist, 'id' | 'coverArt'>; artist: Pick<SubsonicArtist, 'id' | 'coverArt'>;
size: number; displayCssPx: number;
coverServer?: ServerProfile | null; coverServer?: ServerProfile | null;
}) { }) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const coverId = artist.coverArt || artist.id; const coverId = coverArtIdFromArtist(artist);
const { src, cacheKey } = useShareCoverArt(coverId, size, coverServer);
useEffect(() => { setFailed(false); }, [coverId]); useEffect(() => { setFailed(false); }, [coverId]);
if (failed) { if (failed) {
if (size >= 64) { if (displayCssPx >= 64) {
return ( return (
<div className="mobile-search-avatar mobile-search-avatar--circle"> <div className="mobile-search-avatar mobile-search-avatar--circle">
<Users size={20} /> <Users size={20} />
@@ -93,14 +96,16 @@ function ShareArtistThumb({
} }
const cls = const cls =
size >= 64 displayCssPx >= 64
? 'mobile-search-thumb mobile-search-thumb--artist-round' ? 'mobile-search-thumb mobile-search-thumb--artist-round'
: 'search-result-thumb'; : 'search-result-thumb';
return ( return (
<CachedImage <CoverArtImage
coverArtId={coverId}
displayCssPx={displayCssPx}
surface="dense"
serverScope={shareCoverServerScope(coverServer)}
className={cls} className={cls}
src={src}
cacheKey={cacheKey}
alt="" alt=""
loading="eager" loading="eager"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM} fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
@@ -158,7 +163,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const desktop = variant === 'desktop'; 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 sectionCls = desktop ? 'search-section' : 'mobile-search-section';
const labelCls = desktop ? 'search-section-label' : 'mobile-search-section-label'; 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'; 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} role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : 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={infoWrap}>
<div className={nameCls}>{shareArtist.name}</div> <div className={nameCls}>{shareArtist.name}</div>
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('search.artists') : '')}</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} role={desktop ? 'option' : undefined}
aria-selected={desktop ? activeIndex === 0 : 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={infoWrap}>
<div className={nameCls}>{shareComposer.name}</div> <div className={nameCls}>{shareComposer.name}</div>
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('sidebar.composers') : '')}</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} aria-selected={desktop ? activeIndex === 0 : undefined}
> >
{shareAlbum.coverArt ? ( {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> <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} aria-selected={desktop ? activeIndex === 0 : undefined}
> >
{shareTrackSong.coverArt ? ( {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> <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)); return Array.from(known).filter(id => !activeServerIds.has(id));
}, [strategyByServer, advancedParallelismByServer, activeServerIds]); }, [strategyByServer, advancedParallelismByServer, activeServerIds]);
const anyAggressive = useMemo(() => {
return servers.some(server => getStrategyForServer(server.id) === 'advanced');
}, [servers, getStrategyForServer]);
useEffect(() => { useEffect(() => {
if (servers.length === 0) return; if (servers.length === 0) return;
let cancelled = false; let cancelled = false;
@@ -257,10 +253,6 @@ export default function AnalyticsStrategySection() {
icon={<BarChart3 size={16} />} icon={<BarChart3 size={16} />}
> >
<div className="settings-card"> <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' }}> <div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}> <table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
<thead> <thead>
@@ -398,24 +390,9 @@ export default function AnalyticsStrategySection() {
</table> </table>
</div> </div>
<div <p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: '0.9rem', lineHeight: 1.5 }}>
style={{ {t('settings.analyticsStrategyDesc')}
marginTop: '0.85rem', </p>
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>
<div <div
style={{ style={{
@@ -445,18 +422,16 @@ export default function AnalyticsStrategySection() {
</div> </div>
</div> </div>
{anyAggressive && ( <div
<div className="settings-hint settings-hint-info"
className="settings-hint settings-hint-info" role="note"
role="note" style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
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)' }} />
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--color-warning, #f59e0b)' }} /> <span style={{ fontSize: 12, lineHeight: 1.5 }}>
<span style={{ fontSize: 12, lineHeight: 1.5 }}> {t('settings.analyticsStrategyAdvancedWarning')}
{t('settings.analyticsStrategyAdvancedWarning')} </span>
</span> </div>
</div>
)}
</div> </div>
{clearTarget && {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>
);
}
-1
View File
@@ -6,7 +6,6 @@ import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import StarRating from '../StarRating'; import StarRating from '../StarRating';
import AnalyticsStrategySection from './AnalyticsStrategySection'; 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']; 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() { export function LibraryTab() {
+4 -32
View File
@@ -6,11 +6,10 @@ import { Download, FolderOpen, Trash2, X } from 'lucide-react';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useHotCacheStore } from '../../store/hotCacheStore'; import { useHotCacheStore } from '../../store/hotCacheStore';
import { useOfflineStore } from '../../store/offlineStore'; import { useOfflineStore } from '../../store/offlineStore';
import { usePlayerStore } from '../../store/playerStore';
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache'; import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes'; import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
import { showToast } from '../../utils/ui/toast';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import CoverCacheStrategySection from './CoverCacheStrategySection';
export function StorageTab() { export function StorageTab() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -75,26 +74,6 @@ export function StorageTab() {
setClearing(false); setClearing(false);
}, [clearAllOffline, serverId, auth.offlineDownloadDir]); }, [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 pickOfflineDir = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') }); const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
if (selected && typeof selected === 'string') { if (selected && typeof selected === 'string') {
@@ -190,6 +169,7 @@ export function StorageTab() {
/> />
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span> <span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
</div> </div>
{showClearConfirm ? ( {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={{ 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> <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')} <Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button> </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> </div>
</SettingsSubSection> </SettingsSubSection>
<CoverCacheStrategySection />
{/* Buffering */} {/* Buffering */}
<SettingsSubSection <SettingsSubSection
title={t('settings.nextTrackBufferingTitle')} title={t('settings.nextTrackBufferingTitle')}
+1
View File
@@ -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.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.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' }, { 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.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.nextTrackBufferingTitle', keywords: 'next track buffering preload hot cache streaming' },
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' }, { tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
+1
View File
@@ -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)', '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)', '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)', '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)',
], ],
}, },
{ {
+112
View File
@@ -0,0 +1,112 @@
import type { ImgHTMLAttributes } from 'react';
import { useEffect, useRef, useState } from 'react';
import { DEFAULT_CACHED_IMAGE_PREPARE_MARGIN } from '../components/CachedImage';
import { resolveIntersectionScrollRoot } from '../utils/ui/resolveIntersectionScrollRoot';
import { coverEnsureBump } from './ensureQueue';
import { coverPrefetchBumpPriority } from './prefetchRegistry';
import { coverArtRef } from './ref';
import { coverStorageKey } from './storageKeys';
import { resolveCoverDisplayTier } from './tiers';
import { coverImgSrc } from './imgSrc';
import { useCoverArt } from './useCoverArt';
import type { CoverArtId, CoverPrefetchPriority, CoverServerScope, CoverSurfaceKind } from './types';
export type CoverArtImageProps = {
coverArtId: CoverArtId | null | undefined;
displayCssPx: number;
serverScope?: CoverServerScope;
surface?: CoverSurfaceKind;
fullRes?: boolean;
className?: string;
alt?: string;
fetchQueueBias?: number;
observeRootMargin?: string;
observeScrollRootId?: string;
/** Initial ensure tier — use `high` for hero / above-the-fold cells. */
ensurePriority?: CoverPrefetchPriority;
} & Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'>;
export function CoverArtImage({
coverArtId,
displayCssPx,
serverScope,
surface,
fullRes,
className,
alt,
fetchQueueBias: _fetchQueueBias,
observeRootMargin = DEFAULT_CACHED_IMAGE_PREPARE_MARGIN,
observeScrollRootId,
ensurePriority: ensurePriorityProp,
onError: restOnError,
...rest
}: CoverArtImageProps) {
const scope = serverScope ?? { kind: 'active' };
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
ensurePriorityProp ?? 'middle',
);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
if (ensurePriorityProp) setEnsurePriority(ensurePriorityProp);
}, [ensurePriorityProp]);
useEffect(() => {
const el = imgRef.current;
if (!el || !coverArtId) return;
const root =
(observeScrollRootId
? (document.getElementById(observeScrollRootId) as Element | null)
: null) ?? resolveIntersectionScrollRoot(el);
const ref = coverArtRef(coverArtId, scope);
const tier = resolveCoverDisplayTier(displayCssPx, { surface, fullRes });
const storageKey = coverStorageKey(scope, coverArtId, tier);
const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
setEnsurePriority('high');
coverPrefetchBumpPriority(ref, 'high');
coverEnsureBump(storageKey, 'high');
}
}
},
{
root: root ?? undefined,
rootMargin: observeRootMargin,
threshold: [0, 0.05, 0.15],
},
);
observer.observe(el);
return () => observer.disconnect();
}, [coverArtId, scope, displayCssPx, surface, fullRes, observeRootMargin, observeScrollRootId]);
const { src, provisional, onImgError } = useCoverArt(coverArtId, displayCssPx, {
serverScope: scope,
surface,
fullRes,
ensurePriority,
alt,
});
const imgSrc = coverImgSrc(src);
return (
<img
ref={imgRef}
src={imgSrc}
className={className}
alt={alt ?? ''}
data-cover-provisional={provisional ? 'true' : undefined}
data-observe-root-margin={observeRootMargin}
data-observe-scroll-root={observeScrollRootId}
{...rest}
onError={e => {
onImgError?.();
restOnError?.(e);
}}
/>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useMemo, useState, type CSSProperties, type ImgHTMLAttributes } from 'react';
import type { SubsonicArtistInfo } from '../api/subsonicTypes';
import { isRealArtistImage } from '../utils/componentHelpers/nowPlayingHelpers';
import { CoverArtImage } from './CoverArtImage';
import type { CoverArtRef, CoverSurfaceKind } from './types';
export type ArtistHeroCoverProps = {
artistId: string;
artistInfo: SubsonicArtistInfo | null;
coverFallback?: CoverArtRef | null;
displayCssPx: number;
surface?: CoverSurfaceKind;
className?: string;
alt?: string;
style?: CSSProperties;
onError?: () => void;
} & Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'style' | 'onError'>;
/** Artist hero — external Last.fm/Subsonic URLs with getCoverArt fallback (v1). */
export function ArtistHeroCover({
artistId,
artistInfo,
coverFallback,
displayCssPx,
surface = 'sparse',
className,
alt,
style,
onError,
...rest
}: ArtistHeroCoverProps) {
const [externalUrl, setExternalUrl] = useState('');
const [externalFailed, setExternalFailed] = useState(false);
const candidateUrl = useMemo(() => {
const rawLarge = artistInfo?.largeImageUrl;
const rawMed = artistInfo?.mediumImageUrl;
if (isRealArtistImage(rawLarge)) return rawLarge!;
if (isRealArtistImage(rawMed)) return rawMed!;
return '';
}, [artistInfo?.largeImageUrl, artistInfo?.mediumImageUrl]);
useEffect(() => {
setExternalFailed(false);
setExternalUrl('');
if (!candidateUrl) return;
let cancelled = false;
const probe = new Image();
probe.onload = () => { if (!cancelled) setExternalUrl(candidateUrl); };
probe.onerror = () => { if (!cancelled) setExternalFailed(true); };
probe.src = candidateUrl;
return () => {
cancelled = true;
probe.onload = probe.onerror = null;
};
}, [candidateUrl, artistId]);
if (externalUrl && !externalFailed) {
return (
<img
src={externalUrl}
className={className}
alt={alt ?? ''}
style={style}
onError={() => {
setExternalFailed(true);
onError?.();
}}
{...rest}
/>
);
}
if (coverFallback?.coverArtId) {
return (
<CoverArtImage
coverArtId={coverFallback.coverArtId}
serverScope={coverFallback.serverScope}
displayCssPx={displayCssPx}
surface={surface}
className={className}
alt={alt}
style={style}
onError={onError}
{...rest}
/>
);
}
return null;
}
+87
View File
@@ -0,0 +1,87 @@
import {
libraryCoverBackfillConfigure,
libraryCoverBackfillSetUiPriority,
} from '../api/coverCache';
import { coverEnsureCancelPending } from './ensureQueue';
import { coverPeekCancelPending } from './peekQueue';
import { coverPrefetchClearRegistry } from './prefetchRegistry';
function cancelVisibleCoverWork(): void {
coverEnsureCancelPending();
coverPeekCancelPending();
coverPrefetchClearRegistry();
}
let navigationHoldDepth = 0;
let serverSwitchHold = false;
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
let serverSwitchEndTimer: ReturnType<typeof setTimeout> | null = null;
const NAVIGATION_QUIET_MS = 400;
const SERVER_SWITCH_QUIET_MS = 700;
function syncBackfillUiPriority(): void {
const hold = navigationHoldDepth > 0 || serverSwitchHold;
void libraryCoverBackfillSetUiPriority(hold);
}
function pauseLibraryBackfillSession(): void {
void libraryCoverBackfillConfigure({
enabled: false,
serverIndexKey: '',
libraryServerId: '',
restBaseUrl: '',
username: '',
password: '',
});
}
/** Route change — drop queued peek/ensure/prefetch so the new page is not behind the old one. */
export function coverTrafficBeginNavigation(): void {
navigationHoldDepth += 1;
cancelVisibleCoverWork();
syncBackfillUiPriority();
}
export function coverTrafficEndNavigation(): void {
navigationHoldDepth = Math.max(0, navigationHoldDepth - 1);
scheduleNavigationResume();
}
/** Active server change — stop all cover IPC so ping + menu stay responsive. */
export function coverTrafficBeginServerSwitch(): void {
serverSwitchHold = true;
if (serverSwitchEndTimer) {
clearTimeout(serverSwitchEndTimer);
serverSwitchEndTimer = null;
}
cancelVisibleCoverWork();
pauseLibraryBackfillSession();
syncBackfillUiPriority();
}
export function coverTrafficEndServerSwitch(): void {
if (serverSwitchEndTimer) clearTimeout(serverSwitchEndTimer);
serverSwitchEndTimer = setTimeout(() => {
serverSwitchHold = false;
serverSwitchEndTimer = null;
syncBackfillUiPriority();
}, SERVER_SWITCH_QUIET_MS);
}
export function coverTrafficBackgroundPaused(): boolean {
return navigationHoldDepth > 0 || serverSwitchHold;
}
/** Hard stop for ensure/peek pumps (includes visible `high` grid jobs). */
export function coverTrafficServerSwitchPaused(): boolean {
return serverSwitchHold;
}
function scheduleNavigationResume(): void {
if (resumeTimer) clearTimeout(resumeTimer);
resumeTimer = setTimeout(() => {
resumeTimer = null;
syncBackfillUiPriority();
}, NAVIGATION_QUIET_MS);
}
+37
View File
@@ -0,0 +1,37 @@
/** Listeners for Rust `cover:tier-ready` — keyed by IDB storage key. */
type DiskReadyListener = (path: string) => void;
const listeners = new Map<string, Set<DiskReadyListener>>();
export function subscribeCoverDiskReady(storageKey: string, onReady: DiskReadyListener): () => void {
let set = listeners.get(storageKey);
if (!set) {
set = new Set();
listeners.set(storageKey, set);
}
set.add(onReady);
return () => {
const s = listeners.get(storageKey);
if (!s) return;
s.delete(onReady);
if (s.size === 0) listeners.delete(storageKey);
};
}
export function hasCoverDiskReadyListeners(storageKey: string): boolean {
const set = listeners.get(storageKey);
return !!set && set.size > 0;
}
export function notifyCoverDiskReady(storageKey: string, path: string): void {
const set = listeners.get(storageKey);
if (!set) return;
for (const fn of [...set]) {
try {
fn(path);
} catch {
/* ignore */
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import { convertFileSrc, isTauri } from '@tauri-apps/api/core';
/** Stable asset URLs for disk `.webp` tiers — survives route unmount. */
const diskSrcByStorageKey = new Map<string, string>();
let cacheGeneration = 0;
const cacheListeners = new Set<() => void>();
function bumpDiskSrcCache(): void {
cacheGeneration += 1;
for (const fn of cacheListeners) {
try {
fn();
} catch {
/* ignore */
}
}
}
/** Re-render `useCoverArt` when warm/peek seeds this map (no wait for ensure queue). */
export function subscribeDiskSrcCache(onStoreChange: () => void): () => void {
cacheListeners.add(onStoreChange);
return () => cacheListeners.delete(onStoreChange);
}
export function getDiskSrcCacheGeneration(): number {
return cacheGeneration;
}
/** True when `convertFileSrc` failed and returned the filesystem path unchanged. */
function isRawFsPath(url: string, fsPath: string): boolean {
return url === fsPath || (url.startsWith('/') && fsPath.startsWith('/'));
}
/**
* Turn a Rust disk path into a webview-loadable URL.
* Returns empty when not in Tauri or path is outside asset scope (never put raw paths in `<img src>`).
*/
export function coverDiskUrl(fsPath: string): string {
if (!fsPath || !isTauri()) return '';
const src = convertFileSrc(fsPath);
if (isRawFsPath(src, fsPath)) {
if (import.meta.env.DEV) {
console.warn('[cover] convertFileSrc out of asset scope — check tauri.conf assetProtocol', fsPath);
}
return '';
}
return src;
}
export function rememberDiskSrc(storageKey: string, fsPath: string): string {
if (!storageKey || !fsPath) return '';
const src = coverDiskUrl(fsPath);
if (!src) return '';
const prev = diskSrcByStorageKey.get(storageKey);
if (prev === src) return src;
diskSrcByStorageKey.set(storageKey, src);
bumpDiskSrcCache();
return src;
}
export function getDiskSrc(storageKey: string): string {
return diskSrcByStorageKey.get(storageKey) ?? '';
}
export function forgetDiskSrc(storageKey: string): void {
if (diskSrcByStorageKey.delete(storageKey)) bumpDiskSrcCache();
}
export function forgetDiskSrcPrefix(serverIndexKey: string, coverArtId: string): void {
const prefix = `${serverIndexKey}:cover:${coverArtId}:`;
let changed = false;
for (const key of diskSrcByStorageKey.keys()) {
if (key.startsWith(prefix)) {
diskSrcByStorageKey.delete(key);
changed = true;
}
}
if (changed) bumpDiskSrcCache();
}
export function clearAllDiskSrcCache(): void {
if (diskSrcByStorageKey.size === 0) return;
diskSrcByStorageKey.clear();
bumpDiskSrcCache();
}
export function clearDiskSrcCacheForServer(serverIndexKey: string): void {
const prefix = `${serverIndexKey}:cover:`;
let changed = false;
for (const key of [...diskSrcByStorageKey.keys()]) {
if (key.startsWith(prefix)) {
diskSrcByStorageKey.delete(key);
changed = true;
}
}
if (changed) bumpDiskSrcCache();
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('./diskSrcCache', () => ({
rememberDiskSrc: vi.fn(() => 'asset://cover.webp'),
getDiskSrc: vi.fn(() => ''),
}));
vi.mock('./diskHandoff', () => ({
hasCoverDiskReadyListeners: vi.fn(() => true),
notifyCoverDiskReady: vi.fn(),
}));
import { rememberDiskSrc } from './diskSrcCache';
import { notifyCoverDiskReady } from './diskHandoff';
import { gridDiskSrcLookupOrder, rememberGridDiskSrc } from './diskSrcLookup';
describe('gridDiskSrcLookupOrder', () => {
it('prefers 800 right after 512 when 512 is wanted', () => {
expect(gridDiskSrcLookupOrder(512)).toEqual([512, 800, 256, 128]);
});
it('prefers 800 for 256 display tier', () => {
expect(gridDiskSrcLookupOrder(256)[1]).toBe(800);
});
});
describe('rememberGridDiskSrc', () => {
beforeEach(() => {
vi.mocked(rememberDiskSrc).mockClear();
vi.mocked(notifyCoverDiskReady).mockClear();
vi.mocked(rememberDiskSrc).mockReturnValue('asset://x');
});
it('seeds 512 and 800 keys from one on-disk path (800.webp fallback)', () => {
const hit = rememberGridDiskSrc({ kind: 'active' }, 'al-1', 512, '/data/800.webp');
expect(hit).toBe(true);
expect(vi.mocked(rememberDiskSrc).mock.calls.length).toBeGreaterThanOrEqual(2);
expect(vi.mocked(notifyCoverDiskReady)).toHaveBeenCalledTimes(1);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
import { hasCoverDiskReadyListeners, notifyCoverDiskReady } from './diskHandoff';
import { coverStorageKey } from './storageKeys';
import type { CoverArtId, CoverArtTier, CoverServerScope } from './types';
/** Dense grids: prefer a larger on-disk tier (800) before tiny thumbs when the ideal tier is missing. */
export function gridDiskSrcLookupOrder(want: CoverArtTier): CoverArtTier[] {
const out: CoverArtTier[] = [want];
if (want >= 256 && want < 800) out.push(800);
const ladder: CoverArtTier[] = [128, 256, 512, 800];
for (let i = ladder.length - 1; i >= 0; i -= 1) {
const t = ladder[i]!;
if (t !== want && t < want && !out.includes(t)) out.push(t);
}
if (want < 800 && !out.includes(800)) out.push(800);
return out;
}
/** Synchronous hit from `diskSrcCache` — any tier already warmed/peeked for this cover. */
export function getDiskSrcForGrid(
scope: CoverServerScope,
coverArtId: CoverArtId,
wantTier: CoverArtTier,
): string {
for (const tier of gridDiskSrcLookupOrder(wantTier)) {
const src = getDiskSrc(coverStorageKey(scope, coverArtId, tier));
if (src) return src;
}
return '';
}
/** Seed lookup-order tier keys (512 + 800 fallback path, etc.) — no subscriber wakeups. */
export function seedGridDiskSrcCache(
scope: CoverServerScope,
coverArtId: CoverArtId,
wantTier: CoverArtTier,
fsPath: string,
): boolean {
if (!fsPath) return false;
let hit = false;
for (const tier of gridDiskSrcLookupOrder(wantTier)) {
if (rememberDiskSrc(coverStorageKey(scope, coverArtId, tier), fsPath)) hit = true;
}
return hit;
}
/**
* After peek/ensure: seed cache and wake mounted cells once (avoids 4× notify / re-render storms).
*/
export function rememberGridDiskSrc(
scope: CoverServerScope,
coverArtId: CoverArtId,
wantTier: CoverArtTier,
fsPath: string,
): boolean {
const hit = seedGridDiskSrcCache(scope, coverArtId, wantTier, fsPath);
if (!hit) return false;
const wantKey = coverStorageKey(scope, coverArtId, wantTier);
if (hasCoverDiskReadyListeners(wantKey)) {
notifyCoverDiskReady(wantKey, fsPath);
}
return true;
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
const { ensureImpl } = vi.hoisted(() => ({
ensureImpl: vi.fn(
async (_ref: { coverArtId: string }, _tier: number, _priority: string) => {
await new Promise(r => setTimeout(r, 2));
return { hit: true, path: `/tmp/${_ref.coverArtId}.webp`, tier: 128 };
},
),
}));
vi.mock('../api/coverCache', () => ({
coverCacheEnsure: ensureImpl,
libraryCoverBackfillConfigure: vi.fn(async () => {}),
libraryCoverBackfillSetUiPriority: vi.fn(async () => {}),
}));
import { coverArtRef } from './ref';
import { coverTrafficBeginServerSwitch, coverTrafficEndServerSwitch } from './coverTraffic';
import {
__test_queuedCoverIds,
__test_resetCoverEnsureQueue,
coverEnsureBump,
coverEnsureQueued,
coverEnsureRelease,
} from './ensureQueue';
describe('coverEnsureQueued', () => {
beforeEach(() => {
__test_resetCoverEnsureQueue();
ensureImpl.mockClear();
});
it('dedupes concurrent ensures for the same storage key', async () => {
const ref = coverArtRef('al-1');
const [a, b] = await Promise.all([
coverEnsureQueued('s:cover:al-1:128', ref, 128, 'high'),
coverEnsureQueued('s:cover:al-1:128', ref, 128, 'low'),
]);
expect(a.path).toBe('/tmp/al-1.webp');
expect(b.path).toBe('/tmp/al-1.webp');
expect(ensureImpl).toHaveBeenCalledTimes(1);
});
it('bumps a queued job ahead of older high-priority work', () => {
coverTrafficBeginServerSwitch();
const refA = coverArtRef('al-a');
const refB = coverArtRef('al-b');
const refC = coverArtRef('al-c');
void coverEnsureQueued('s:cover:al-a:128', refA, 128, 'high');
void coverEnsureQueued('s:cover:al-b:128', refB, 128, 'high');
void coverEnsureQueued('s:cover:al-c:128', refC, 128, 'high');
coverEnsureBump('s:cover:al-c:128', 'high');
expect(__test_queuedCoverIds()[0]).toBe('al-c');
coverTrafficEndServerSwitch();
});
it('release drops a pending job so a remount can re-queue', async () => {
coverTrafficBeginServerSwitch();
const ref = coverArtRef('al-drop');
const pending = coverEnsureQueued('s:cover:al-drop:128', ref, 128, 'middle');
coverEnsureRelease('s:cover:al-drop:128');
const result = await pending;
expect(result.path).toBe('');
expect(ensureImpl).not.toHaveBeenCalled();
coverTrafficEndServerSwitch();
await new Promise(r => setTimeout(r, 800));
await coverEnsureQueued('s:cover:al-drop:128', ref, 128, 'high');
expect(ensureImpl).toHaveBeenCalledTimes(1);
});
});
+215
View File
@@ -0,0 +1,215 @@
import { coverCacheEnsure } from '../api/coverCache';
import { getDiskSrc } from './diskSrcCache';
import { getDiskSrcForGrid } from './diskSrcLookup';
import { coverIndexKeyFromRef } from './storageKeys';
import type { CoverArtRef, CoverArtTier, CoverPrefetchPriority } from './types';
type EnsureJob = {
storageKey: string;
ref: CoverArtRef;
tier: CoverArtTier;
priority: CoverPrefetchPriority;
/** Larger = closer to viewport / more recently bumped — dequeued first within the same priority band. */
orderKey: number;
resolve: (r: { hit: boolean; path: string }) => void;
};
import {
coverTrafficBackgroundPaused,
coverTrafficServerSwitchPaused,
} from './coverTraffic';
/** Parallel Rust cover ensures (visible UI; library backfill is native-only). */
export const COVER_ENSURE_MAX_INFLIGHT = 10;
const MAX_INFLIGHT = COVER_ENSURE_MAX_INFLIGHT;
/** Drop stale scroll-ahead work so the queue cannot grow without bound. */
const MAX_QUEUE = 96;
let inflight = 0;
let queue: EnsureJob[] = [];
let nextOrderKey = 0;
const inflightStorageKeys = new Set<string>();
function priorityRank(p: CoverPrefetchPriority): number {
return p === 'high' ? 0 : p === 'middle' ? 1 : 2;
}
function sortQueue(): void {
queue.sort((a, b) => {
const pr = priorityRank(a.priority) - priorityRank(b.priority);
if (pr !== 0) return pr;
return b.orderKey - a.orderKey;
});
}
function trimQueue(): void {
while (queue.length > MAX_QUEUE) {
let worstIdx = 0;
for (let i = 1; i < queue.length; i += 1) {
const a = queue[worstIdx]!;
const b = queue[i]!;
const rankA = priorityRank(a.priority);
const rankB = priorityRank(b.priority);
if (rankB > rankA || (rankB === rankA && b.orderKey < a.orderKey)) {
worstIdx = i;
}
}
const [job] = queue.splice(worstIdx, 1);
job.resolve({ hit: false, path: '' });
ensureInflight.delete(job.storageKey);
}
}
function coverInflightKey(ref: CoverArtRef): string {
return `${coverIndexKeyFromRef(ref)}:${ref.coverArtId}`;
}
/** Serialize ensures per cover ID so we do not re-download for every tier. */
const coverDownloadTail = new Map<string, Promise<unknown>>();
function ensureForCover(
ref: CoverArtRef,
tier: CoverArtTier,
priority: CoverPrefetchPriority,
) {
const key = coverInflightKey(ref);
const tail = coverDownloadTail.get(key) ?? Promise.resolve();
const run = tail.then(() => coverCacheEnsure(ref, tier, priority));
coverDownloadTail.set(key, run.catch(() => {}));
return run;
}
function findQueuedJob(storageKey: string): EnsureJob | undefined {
return queue.find(j => j.storageKey === storageKey);
}
function bumpJob(job: EnsureJob, priority?: CoverPrefetchPriority): void {
if (priority && priorityRank(priority) < priorityRank(job.priority)) {
job.priority = priority;
}
job.orderKey = ++nextOrderKey;
sortQueue();
}
function pump(): void {
if (coverTrafficServerSwitchPaused()) return;
while (inflight < MAX_INFLIGHT && queue.length > 0) {
const next = queue[0]!;
if (coverTrafficBackgroundPaused() && next.priority !== 'high') {
break;
}
const job = queue.shift()!;
inflight += 1;
inflightStorageKeys.add(job.storageKey);
void ensureForCover(job.ref, job.tier, job.priority)
.then(r => job.resolve({ hit: r.hit, path: r.path }))
.catch(() => job.resolve({ hit: false, path: '' }))
.finally(() => {
inflight -= 1;
inflightStorageKeys.delete(job.storageKey);
pump();
});
}
}
const ensureInflight = new Map<string, Promise<{ hit: boolean; path: string }>>();
/** Move a queued job ahead of older scroll-ahead work (viewport / prefetch bump). */
export function coverEnsureBump(
storageKey: string,
priority: CoverPrefetchPriority = 'high',
): void {
const job = findQueuedJob(storageKey);
if (!job) return;
bumpJob(job, priority);
pump();
}
/** Drop queued ensures (route/server change) — in-flight jobs finish on their own. */
export function coverEnsureCancelPending(): void {
const dropped = queue;
queue = [];
for (const job of dropped) {
job.resolve({ hit: false, path: '' });
ensureInflight.delete(job.storageKey);
}
}
/** Cell unmounted or deferred — drop pending work so the viewport can jump the queue. */
export function coverEnsureRelease(storageKey: string): void {
const idx = queue.findIndex(j => j.storageKey === storageKey);
if (idx >= 0) {
const [job] = queue.splice(idx, 1);
job.resolve({ hit: false, path: '' });
ensureInflight.delete(storageKey);
} else if (!inflightStorageKeys.has(storageKey)) {
ensureInflight.delete(storageKey);
}
}
/** Queued + active ensure jobs (for library backfill watermark). */
export function coverEnsureQueueBacklog(): number {
return queue.length + inflight;
}
/** @internal Vitest-only — module singleton queue. */
export function __test_resetCoverEnsureQueue(): void {
queue = [];
inflight = 0;
nextOrderKey = 0;
inflightStorageKeys.clear();
ensureInflight.clear();
coverDownloadTail.clear();
}
/** @internal Vitest-only — queued cover art IDs front-to-back. */
export function __test_queuedCoverIds(): string[] {
return queue.map(j => j.ref.coverArtId);
}
function ensureMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean {
if (getDiskSrc(storageKey)) return true;
return Boolean(getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier));
}
/** Rust disk ensure — parallel slots; one download chain per cover art ID. */
export function coverEnsureQueued(
storageKey: string,
ref: CoverArtRef,
tier: CoverArtTier,
priority: CoverPrefetchPriority,
): Promise<{ hit: boolean; path: string }> {
if (ensureMemoryHit(storageKey, ref, tier)) {
return Promise.resolve({ hit: true, path: '' });
}
const existing = ensureInflight.get(storageKey);
if (existing) {
const queued = findQueuedJob(storageKey);
if (queued) bumpJob(queued, priority);
return existing;
}
const p = new Promise<{ hit: boolean; path: string }>(resolve => {
const orderKey = ++nextOrderKey;
const prev = findQueuedJob(storageKey);
if (prev) {
bumpJob(prev, priority);
const chain = prev.resolve;
prev.resolve = r => {
chain(r);
resolve(r);
};
trimQueue();
pump();
return;
}
queue.push({ storageKey, ref, tier, priority, orderKey, resolve });
sortQueue();
trimQueue();
pump();
}).finally(() => ensureInflight.delete(storageKey));
ensureInflight.set(storageKey, p);
return p;
}
+38
View File
@@ -0,0 +1,38 @@
import {
buildCoverArtUrl,
buildCoverArtUrlForServer,
} from '../api/subsonicStreamUrl';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import type { CoverArtRef, CoverArtTier } from './types';
/** Builds ephemeral getCoverArt URL — NOT a cache key */
export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): string {
const { coverArtId, serverScope } = ref;
if (serverScope.kind === 'server') {
return buildCoverArtUrlForServer(
serverScope.url,
serverScope.username,
serverScope.password,
coverArtId,
tier,
);
}
if (serverScope.kind === 'playback') {
const playbackSid = getPlaybackServerId();
const activeSid = useAuthStore.getState().activeServerId;
if (playbackSid && activeSid && playbackSid !== activeSid) {
const server = useAuthStore.getState().servers.find(s => s.id === playbackSid);
if (server) {
return buildCoverArtUrlForServer(
server.url,
server.username,
server.password,
coverArtId,
tier,
);
}
}
}
return buildCoverArtUrl(coverArtId, tier);
}
+17
View File
@@ -0,0 +1,17 @@
import type { CoverArtId } from './types';
export function coverArtIdFromAlbum(album: { coverArt?: string }): CoverArtId | null {
return album.coverArt ?? null;
}
export function coverArtIdFromSong(song: { coverArt?: string; id?: string }): CoverArtId | null {
return song.coverArt ?? null;
}
export function coverArtIdFromArtist(artist: { coverArt?: string; id: string }): CoverArtId {
return artist.coverArt ?? artist.id;
}
export function coverArtIdFromRadio(stationId: string): CoverArtId {
return `ra-${stationId}`;
}
+4
View File
@@ -0,0 +1,4 @@
/** Omit `src` until URL is ready — React 19 rejects `src=""` on `<img>`. */
export function coverImgSrc(url: string): string | undefined {
return url.length > 0 ? url : undefined;
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Unified cover art pipeline see workdocs tasks/2026-05-cover-art-pipeline/contracts.md
*/
export * from './types';
export * from './tiers';
export * from './ids';
export * from './storageKeys';
export * from './reachability';
export * from './layoutSizes';
export * from './ref';
export { useCoverArt } from './useCoverArt';
export { CoverArtImage } from './CoverArtImage';
export {
clearAllDiskSrcCache,
forgetDiskSrc,
forgetDiskSrcPrefix,
getDiskSrc,
rememberDiskSrc,
} from './diskSrcCache';
export { usePlaybackCoverArt } from './usePlaybackCoverArt';
export { ensureCoverTierJs } from './resolveJs';
export { ensureCoverTierDiskSrc, ensureCoverTierDiskBlob } from './resolveDisk';
export { buildCoverArtFetchUrl } from './fetchUrl';
export {
coverStorageKey,
coverIndexKeyFromScope,
coverIndexKeyFromRef,
} from './storageKeys';
+23
View File
@@ -0,0 +1,23 @@
import { coverCacheEnsure } from '../../api/coverCache';
import { buildCoverArtFetchUrl } from '../fetchUrl';
import type { CoverArtRef } from '../types';
function fileUrlFromDiskPath(path: string): string {
if (!path) return '';
if (path.startsWith('file://')) return path;
return `file://${path}`;
}
/** Discord large image — disk 800 path or HTTPS fetch URL. */
export async function coverArtUrlForDiscord(ref: CoverArtRef): Promise<string | null> {
try {
const result = await coverCacheEnsure(ref, 800);
if (result.hit && result.path) {
return fileUrlFromDiskPath(result.path);
}
} catch {
/* fall through */
}
const url = buildCoverArtFetchUrl(ref, 800);
return url || null;
}
+13
View File
@@ -0,0 +1,13 @@
import { ensureCoverTierJs } from '../resolveJs';
import { resolveCoverDisplayTier } from '../tiers';
import type { CoverArtRef } from '../types';
/** Canvas/export helper — resolves tier from CSS px then returns a Blob. */
export async function loadCoverBlobForExport(
ref: CoverArtRef,
displayCssPx: number,
signal?: AbortSignal,
): Promise<Blob | null> {
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' });
return ensureCoverTierJs(ref, tier, signal);
}
+22
View File
@@ -0,0 +1,22 @@
import { coverCacheEnsure } from '../../api/coverCache';
import { buildCoverArtFetchUrl } from '../fetchUrl';
import type { CoverArtRef } from '../types';
function fileUrlFromDiskPath(path: string): string {
if (!path) return '';
if (path.startsWith('file://')) return path;
return `file://${path}`;
}
/** MPRIS cover — prefer on-disk 800.webp, else ephemeral HTTPS 800 URL. */
export async function coverArtUrlForMpris(ref: CoverArtRef): Promise<string> {
try {
const result = await coverCacheEnsure(ref, 800);
if (result.hit && result.path) {
return fileUrlFromDiskPath(result.path);
}
} catch {
/* fall through to fetch URL */
}
return buildCoverArtFetchUrl(ref, 800);
}
+3
View File
@@ -0,0 +1,3 @@
import type { CoverArtId } from './types';
export async function invalidateCoverArt(_serverId: string, _coverArtId: CoverArtId): Promise<void> {}
+28
View File
@@ -0,0 +1,28 @@
import { computeCardGridColumnCount, computeCellWidthPx } from '../utils/cardGridLayout';
export const COVER_DENSE_SEARCH_CSS_PX = 40;
export const COVER_DENSE_ARTIST_LIST_CSS_PX = 64;
export const COVER_DENSE_RAIL_CELL_CSS_PX = 180;
export const COVER_DENSE_GRID_MIN_CELL_CSS_PX = 140;
export function coverDisplayCssPxForAlbumGrid(containerWidthPx: number, maxColumns: number): number {
const cols = computeCardGridColumnCount(containerWidthPx, maxColumns);
return Math.round(computeCellWidthPx(containerWidthPx, cols));
}
export const GRID_COVER_WARM_LIMIT = 120;
/** Bounded album grids (Random Albums, paginated slice, …) — prime HTTP ensures after peek. */
export const GRID_COVER_PRIME_ALL_MAX = 48;
/** Props for `VirtualCardGrid` `warmGridCovers` on album-style pages. */
export function albumGridWarmCovers<T extends { coverArt?: string | null }>(
displayCssPx: number = COVER_DENSE_GRID_MIN_CELL_CSS_PX,
limit: number = GRID_COVER_WARM_LIMIT,
) {
return {
pickCoverArtId: (item: T) => item.coverArt,
displayCssPx,
limit,
};
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import CoverLightbox from '../components/CoverLightbox';
import { buildCoverArtFetchUrl } from './fetchUrl';
import { coverImgSrc } from './imgSrc';
import { ensureCoverTierDiskSrc } from './resolveDisk';
import type { CoverArtRef } from './types';
export function useCoverLightboxSrc(
ref: CoverArtRef | null,
opts?: { alt?: string },
): { open: () => void; lightbox: ReactNode; src: string; loading: boolean } {
const [open, setOpen] = useState(false);
const [src, setSrc] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !ref) return;
let cancelled = false;
setLoading(true);
void (async () => {
const diskSrc = await ensureCoverTierDiskSrc(ref, 2000);
if (cancelled) return;
if (diskSrc) {
setSrc(diskSrc);
} else {
setSrc(buildCoverArtFetchUrl(ref, 2000));
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [open, ref?.coverArtId, ref?.serverScope]);
useEffect(() => {
if (open) return;
setSrc('');
setLoading(false);
}, [open]);
const handleClose = useCallback(() => setOpen(false), []);
const handleOpen = useCallback(() => setOpen(true), []);
const lightbox = open && coverImgSrc(src) && !loading ? (
<CoverLightbox src={src} alt={opts?.alt ?? ''} onClose={handleClose} />
) : null;
return { open: handleOpen, lightbox, src, loading };
}
+4
View File
@@ -0,0 +1,4 @@
/** Revalidation manifest types — populated in wave 4 */
export type CoverRevalidateSchedulerState = {
lastRunDate: string;
};
+114
View File
@@ -0,0 +1,114 @@
import { coverCachePeekBatch } from '../api/coverCache';
import { getDiskSrc } from './diskSrcCache';
import { getDiskSrcForGrid } from './diskSrcLookup';
import { coverTrafficServerSwitchPaused } from './coverTraffic';
import { rememberGridDiskSrc } from './diskSrcLookup';
import { coverIndexKeyFromRef } from './storageKeys';
import type { CoverArtRef, CoverArtTier } from './types';
function peekMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean {
if (getDiskSrc(storageKey)) return true;
return Boolean(getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier));
}
type PeekJob = {
storageKey: string;
ref: CoverArtRef;
tier: CoverArtTier;
resolve: (hit: boolean) => void;
};
let flushScheduled = false;
const pending = new Map<string, PeekJob>();
const inflight = new Map<string, Promise<boolean>>();
function scheduleFlush(): void {
if (flushScheduled) return;
flushScheduled = true;
queueMicrotask(() => {
flushScheduled = false;
void flush();
});
}
async function flush(): Promise<void> {
if (coverTrafficServerSwitchPaused()) {
coverPeekCancelPending();
return;
}
const jobs = [...pending.values()];
pending.clear();
if (jobs.length === 0) return;
const needDisk: PeekJob[] = [];
for (const job of jobs) {
if (peekMemoryHit(job.storageKey, job.ref, job.tier)) {
job.resolve(true);
inflight.delete(job.storageKey);
} else {
needDisk.push(job);
}
}
if (needDisk.length === 0) return;
const hits = await coverCachePeekBatch(
needDisk.map(job => ({
serverIndexKey: coverIndexKeyFromRef(job.ref),
coverArtId: job.ref.coverArtId,
tier: job.tier,
})),
);
for (const job of needDisk) {
const path = hits[job.storageKey];
const hit = Boolean(
path
&& rememberGridDiskSrc(job.ref.serverScope, job.ref.coverArtId, job.tier, path),
);
job.resolve(hit);
inflight.delete(job.storageKey);
}
}
/** Disk-only peek batched per microtask — seeds `diskSrcCache` without `cover_cache_ensure`. */
export function coverPeekQueued(
storageKey: string,
ref: CoverArtRef,
tier: CoverArtTier,
): Promise<boolean> {
if (peekMemoryHit(storageKey, ref, tier)) {
return Promise.resolve(true);
}
const running = inflight.get(storageKey);
if (running) return running;
const p = new Promise<boolean>(resolve => {
const prev = pending.get(storageKey);
if (prev) {
const chain = prev.resolve;
prev.resolve = hit => {
chain(hit);
resolve(hit);
};
return;
}
pending.set(storageKey, { storageKey, ref, tier, resolve });
scheduleFlush();
}).finally(() => {
if (inflight.get(storageKey) === p) inflight.delete(storageKey);
});
inflight.set(storageKey, p);
return p;
}
/** Drop batched peeks (server switch) — callers get `false`. */
export function coverPeekCancelPending(): void {
const jobs = [...pending.values()];
pending.clear();
for (const job of jobs) {
job.resolve(false);
inflight.delete(job.storageKey);
}
}
+77
View File
@@ -0,0 +1,77 @@
import { coverCacheMayBackgroundDownload as ipcMayDownload } from '../api/coverCache';
import { coverIndexKeyFromRef } from './storageKeys';
import { coverServerReachable } from './reachability';
import type { CoverArtRef, CoverArtTier, CoverPrefetchPriority, CoverSurfaceKind } from './types';
const MAX_REGISTRY = 120;
const registry = new Map<string, { ref: CoverArtRef; priority: CoverPrefetchPriority }>();
function registryKey(ref: CoverArtRef): string {
return `${coverIndexKeyFromRef(ref)}:${ref.coverArtId}`;
}
export function coverPrefetchRegister(
refs: CoverArtRef[],
opts: {
surface: CoverSurfaceKind;
priority: CoverPrefetchPriority;
deriveTiers?: CoverArtTier[];
},
): () => void {
if (opts.surface !== 'dense') return () => {};
if (!coverCacheMayBackgroundDownload()) return () => {};
const keys: string[] = [];
for (const ref of refs) {
if (!ref.coverArtId || !coverServerReachable(ref.serverScope)) continue;
const key = registryKey(ref);
if (registry.size >= MAX_REGISTRY && !registry.has(key)) {
const drop = [...registry.entries()].find(([, v]) => v.priority === 'low');
if (drop) registry.delete(drop[0]);
}
registry.set(key, { ref, priority: opts.priority });
keys.push(key);
}
return () => {
for (const key of keys) registry.delete(key);
};
}
export function coverCacheMayBackgroundDownload(): boolean {
return ipcMayDownload();
}
/** Drop all page-registered prefetch targets (route change). */
export function coverPrefetchClearRegistry(): void {
registry.clear();
}
/** Drain registered IDs for background ensure (viewport / page batches). */
export function coverPrefetchDrainBatch(limit: number): CoverArtRef[] {
const sorted = [...registry.entries()].sort((a, b) => {
const rank = (p: CoverPrefetchPriority) =>
p === 'high' ? 0 : p === 'middle' ? 1 : 2;
return rank(a[1].priority) - rank(b[1].priority);
});
return sorted.slice(0, limit).map(([, v]) => v.ref);
}
/** Raise priority when a cover enters the viewport (e.g. horizontal album row). */
export function coverPrefetchBumpPriority(
ref: CoverArtRef,
priority: CoverPrefetchPriority,
): void {
if (!ref.coverArtId || !coverServerReachable(ref.serverScope)) return;
const key = registryKey(ref);
const existing = registry.get(key);
if (!existing) {
registry.set(key, { ref, priority });
return;
}
const rank = (p: CoverPrefetchPriority) =>
p === 'high' ? 0 : p === 'middle' ? 1 : 2;
if (rank(priority) < rank(existing.priority)) {
registry.set(key, { ref, priority });
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { CoverCacheStrategy } from '../utils/library/coverStrategy';
import {
coverStrategyAllowsLibraryBackfill,
coverStrategyAllowsRoutePrefetch,
} from '../utils/library/coverStrategy';
/** @deprecated Use `coverStrategyAllowsRoutePrefetch` */
export function coverPrefetchStrategyAllowsRoutePrefetch(
strategy: CoverCacheStrategy,
): boolean {
return coverStrategyAllowsRoutePrefetch(strategy);
}
/** @deprecated Use `coverStrategyAllowsLibraryBackfill` */
export function coverPrefetchStrategyAllowsLibraryBackfill(
strategy: CoverCacheStrategy,
): boolean {
return coverStrategyAllowsLibraryBackfill(strategy);
}
+13
View File
@@ -0,0 +1,13 @@
import { useAuthStore } from '../store/authStore';
import type { CoverServerScope } from './types';
/** Per-server reachability — active/playback use navigator + configured server */
export function coverServerReachable(scope: CoverServerScope): boolean {
if (scope.kind === 'server') {
return !!scope.url && !!scope.username;
}
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
const active = useAuthStore.getState().getActiveServer();
const baseUrl = useAuthStore.getState().getBaseUrl();
return !!(active && baseUrl);
}
+28
View File
@@ -0,0 +1,28 @@
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import type { CoverArtId, CoverArtRef, CoverServerScope } from './types';
export function coverArtRef(
coverArtId: CoverArtId,
serverScope: CoverServerScope = { kind: 'active' },
): CoverArtRef {
return { coverArtId, serverScope };
}
export function resolvePlaybackCoverScope(): CoverServerScope {
const playbackSid = getPlaybackServerId();
const activeSid = useAuthStore.getState().activeServerId;
if (playbackSid && activeSid && playbackSid !== activeSid) {
const server = useAuthStore.getState().servers.find(s => s.id === playbackSid);
if (server) {
return {
kind: 'server',
serverId: server.id,
url: server.url,
username: server.username,
password: server.password,
};
}
}
return { kind: 'playback' };
}
+57
View File
@@ -0,0 +1,57 @@
import { isTauri } from '@tauri-apps/api/core';
import { coverCacheEnsure } from '../api/coverCache';
import { invalidateCacheKey } from '../utils/imageCache';
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
import { coverStorageKey } from './storageKeys';
import type { CoverArtRef, CoverArtTier } from './types';
/**
* Full-res / lightbox Rust WebP on disk (`cover-cache/…/2000.webp`), not IndexedDB.
*/
export async function ensureCoverTierDiskSrc(
ref: CoverArtRef,
tier: CoverArtTier,
): Promise<string> {
if (!ref.coverArtId || !isTauri()) return '';
const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
const cached = getDiskSrc(storageKey);
if (cached) return cached;
const result = await coverCacheEnsure(ref, tier, 'high');
if (!result.hit || !result.path) return '';
const src = rememberDiskSrc(storageKey, result.path);
if (src) {
void invalidateCacheKey(storageKey);
}
return src;
}
/** Blob consumers (export) — read back from disk asset URL after ensure. */
export async function ensureCoverTierDiskBlob(
ref: CoverArtRef,
tier: CoverArtTier,
signal?: AbortSignal,
): Promise<Blob | null> {
const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
const existing = getDiskSrc(storageKey);
if (existing) {
try {
const resp = await fetch(existing, { signal });
if (resp.ok) return resp.blob();
} catch {
/* fall through to ensure */
}
}
const src = await ensureCoverTierDiskSrc(ref, tier);
if (!src) return null;
try {
const resp = await fetch(src, { signal });
if (!resp.ok) return null;
return resp.blob();
} catch {
return null;
}
}
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureCoverTierJs } from './resolveJs';
import type { CoverArtRef } from './types';
vi.mock('../utils/imageCache', () => ({
getCachedBlob: vi.fn(async () => new Blob(['x'], { type: 'image/jpeg' })),
}));
vi.mock('../utils/imageCache/coverSiblings', () => ({
parseCoverCacheKey: (key: string) => {
const m = key.match(/^(.+):cover:(.+):(\d+)$/);
if (!m) return null;
return { stem: `${m[1]}:cover:${m[2]}`, size: Number(m[3]) };
},
probeSiblingCoverBlobInMemory: () => null,
probeSiblingCoverBlobFromIDB: async () => null,
scheduleSiblingVersusNetworkRace: vi.fn(),
}));
const { blobMap } = vi.hoisted(() => ({ blobMap: new Map<string, Blob>() }));
vi.mock('../utils/imageCache/blobCache', () => ({
blobCache: blobMap,
rememberBlob: (key: string, blob: Blob) => {
blobMap.set(key, blob);
},
}));
vi.mock('../utils/imageCache/idbStore', () => ({
putBlob: vi.fn(),
}));
vi.mock('./reachability', () => ({
coverServerReachable: () => true,
}));
vi.mock('./fetchUrl', () => ({
buildCoverArtFetchUrl: () => 'https://example.test/cover',
}));
const ref: CoverArtRef = {
coverArtId: 'al-1',
serverScope: { kind: 'active' },
};
describe('ensureCoverTierJs', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns null when cover id missing', async () => {
expect(await ensureCoverTierJs({ ...ref, coverArtId: '' }, 128)).toBeNull();
});
it('fetches via getCachedBlob on cold path', async () => {
const { getCachedBlob } = await import('../utils/imageCache');
vi.mocked(getCachedBlob).mockImplementation(async (_url, key) => {
const b = new Blob(['x'], { type: 'image/jpeg' });
blobMap.set(key, b);
return b;
});
const blob = await ensureCoverTierJs(ref, 128);
expect(blob).toBeTruthy();
expect(getCachedBlob).toHaveBeenCalled();
});
});
+141
View File
@@ -0,0 +1,141 @@
import { isTauri } from '@tauri-apps/api/core';
import { getCachedBlob } from '../utils/imageCache';
import { ensureCoverTierDiskBlob } from './resolveDisk';
import {
parseCoverCacheKey,
probeSiblingCoverBlobFromIDB,
probeSiblingCoverBlobInMemory,
scheduleSiblingVersusNetworkRace,
} from '../utils/imageCache/coverSiblings';
import { blobCache } from '../utils/imageCache/blobCache';
import { downscaleCoverBlob } from '../utils/cover/coverBlobDownscale';
import { rememberBlob } from '../utils/imageCache/blobCache';
import { putBlob } from '../utils/imageCache/idbStore';
import { buildCoverArtFetchUrl } from './fetchUrl';
import { coverServerReachable } from './reachability';
import { coverStorageKey } from './storageKeys';
import type { CoverArtRef, CoverArtTier } from './types';
const CANONICAL_TIER = 800 as CoverArtTier;
async function commitNormalizedTier(
cacheKey: string,
blob: Blob,
tier: CoverArtTier,
signal?: AbortSignal,
): Promise<Blob> {
const normalized = await downscaleCoverBlob(blob, tier, signal);
const out = normalized && normalized.size < blob.size * 0.92 ? normalized : blob;
putBlob(cacheKey, out);
rememberBlob(cacheKey, out);
return out;
}
/**
* Cold resolve: race canonical 800 chain vs direct tier fetch (§5.3).
*/
async function scheduleColdCoverRace(
ref: CoverArtRef,
tier: CoverArtTier,
signal: AbortSignal | undefined,
getPriority?: () => number,
): Promise<Blob | null> {
const targetKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
const canonicalKey = coverStorageKey(ref.serverScope, ref.coverArtId, CANONICAL_TIER);
const chainCtl = new AbortController();
const directCtl = new AbortController();
let winner = false;
const kill = () => {
chainCtl.abort();
directCtl.abort();
};
signal?.addEventListener('abort', kill, { once: true });
const tryWin = async (blob: Blob | null, key: string, normalizeTo: CoverArtTier) => {
if (!blob || winner || signal?.aborted) return;
winner = true;
kill();
const committed = await commitNormalizedTier(key, blob, normalizeTo, signal);
if (key !== targetKey && normalizeTo === tier) {
await commitNormalizedTier(targetKey, committed, tier, signal);
}
if (key === canonicalKey || normalizeTo === CANONICAL_TIER) {
rememberBlob(canonicalKey, committed);
}
return committed;
};
const chainBranch = (async () => {
const url = buildCoverArtFetchUrl(ref, CANONICAL_TIER);
const blob = await getCachedBlob(url, canonicalKey, signal, getPriority);
if (!blob || winner || signal?.aborted) return;
await tryWin(blob, canonicalKey, CANONICAL_TIER);
if (tier !== CANONICAL_TIER && !winner) {
const derived = await downscaleCoverBlob(blob, tier, signal);
if (derived) await tryWin(derived, targetKey, tier);
}
})();
const directBranch = (async () => {
const url = buildCoverArtFetchUrl(ref, tier);
const blob = await getCachedBlob(url, targetKey, signal, getPriority);
if (blob) await tryWin(blob, targetKey, tier);
})();
await Promise.allSettled([chainBranch, directBranch]);
return blobCache.get(targetKey) ?? null;
}
/**
* Ensure tier blob exists; run §5 races from implementation-spec.
*/
export async function ensureCoverTierJs(
ref: CoverArtRef,
tier: CoverArtTier,
signal?: AbortSignal,
getPriority?: () => number,
): Promise<Blob | null> {
if (!ref.coverArtId || signal?.aborted) return null;
const cacheKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
const mem = blobCache.get(cacheKey);
if (mem) return mem;
const fetchUrl = buildCoverArtFetchUrl(ref, tier);
const parsed = parseCoverCacheKey(cacheKey);
if (parsed) {
const provisional =
probeSiblingCoverBlobInMemory(parsed.stem, parsed.size) ??
(await probeSiblingCoverBlobFromIDB(parsed.stem, parsed.size));
if (provisional && !signal?.aborted) {
rememberBlob(cacheKey, provisional);
if (coverServerReachable(ref.serverScope)) {
scheduleSiblingVersusNetworkRace(fetchUrl, cacheKey, provisional, signal, getPriority);
}
return provisional;
}
}
if (!coverServerReachable(ref.serverScope)) {
return null;
}
if (tier === 2000) {
if (isTauri()) {
return ensureCoverTierDiskBlob(ref, tier, signal);
}
return getCachedBlob(buildCoverArtFetchUrl(ref, 2000), cacheKey, signal, getPriority);
}
const canonicalKey = coverStorageKey(ref.serverScope, ref.coverArtId, CANONICAL_TIER);
const hasChain =
blobCache.has(canonicalKey) ||
(parsed && (await probeSiblingCoverBlobFromIDB(parsed.stem, parsed.size)) !== null);
if (!hasChain && tier !== CANONICAL_TIER) {
return scheduleColdCoverRace(ref, tier, signal, getPriority);
}
return getCachedBlob(fetchUrl, cacheKey, signal, getPriority);
}
+7
View File
@@ -0,0 +1,7 @@
import type { CoverArtId, CoverRevalidateReason } from './types';
export async function coverRevalidateEnqueueIpc(
_serverId: string,
_coverArtId: CoverArtId,
_reason: CoverRevalidateReason,
): Promise<void> {}
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { coverIndexKeyFromScope, coverStorageKey } from './storageKeys';
const mockState = {
activeServerId: 'profile-uuid-1',
servers: [
{
id: 'profile-uuid-1',
url: 'http://music.local:4533',
username: 'u',
password: 'p',
name: 'Home',
},
{
id: 'profile-uuid-2',
url: 'https://nav.example.com/navidrome',
username: 'u2',
password: 'p2',
name: 'Remote',
},
],
getActiveServer: () => mockState.servers.find(s => s.id === mockState.activeServerId),
};
vi.mock('../store/authStore', () => ({
useAuthStore: { getState: () => mockState },
}));
vi.mock('../utils/playback/playbackServer', () => ({
getPlaybackServerId: () => null,
}));
describe('coverStorageKey', () => {
beforeEach(() => {
mockState.activeServerId = 'profile-uuid-1';
});
it('uses host index key for active scope (not profile uuid)', () => {
expect(coverStorageKey({ kind: 'active' }, 'al-42', 128)).toBe(
'music.local:4533:cover:al-42:128',
);
});
it('uses host index key from explicit server url', () => {
expect(
coverStorageKey(
{
kind: 'server',
serverId: 'profile-uuid-2',
url: 'https://nav.example.com/navidrome',
username: 'u',
password: 'p',
},
'ar-1',
512,
),
).toBe('nav.example.com/navidrome:cover:ar-1:512');
});
it('coverIndexKeyFromScope matches library-style keys', () => {
expect(coverIndexKeyFromScope({ kind: 'active' })).toBe('music.local:4533');
});
});
+45
View File
@@ -0,0 +1,45 @@
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import {
serverIndexKeyForProfile,
serverIndexKeyFromUrl,
} from '../utils/server/serverIndexKey';
import type { CoverArtId, CoverArtRef, CoverArtTier, CoverServerScope } from './types';
/**
* Stable server bucket for cover disk + IDB same host index key as library SQLite (`server_id` column).
* Not the auth profile UUID; URL aliases (LAN vs public) will map to one key later.
*/
export function coverIndexKeyFromScope(scope: CoverServerScope): string {
if (scope.kind === 'server') {
return serverIndexKeyFromUrl(scope.url) || scope.serverId;
}
if (scope.kind === 'playback') {
const playbackSid = getPlaybackServerId();
const activeSid = useAuthStore.getState().activeServerId;
const sid = playbackSid || activeSid;
const server = sid
? useAuthStore.getState().servers.find(s => s.id === sid)
: undefined;
if (server) return serverIndexKeyForProfile(server);
return '_';
}
const server = useAuthStore.getState().getActiveServer();
if (server) return serverIndexKeyForProfile(server);
return '_';
}
export function coverIndexKeyFromRef(ref: CoverArtRef): string {
return coverIndexKeyFromScope(ref.serverScope);
}
/** @deprecated Use `coverIndexKeyFromScope` */
export const serverIdFromScope = coverIndexKeyFromScope;
export function coverStorageKey(
serverScope: CoverServerScope,
coverArtId: CoverArtId,
tier: CoverArtTier,
): string {
return `${coverIndexKeyFromScope(serverScope)}:cover:${coverArtId}:${tier}`;
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { resolveCoverDisplayTier } from './tiers';
describe('resolveCoverDisplayTier', () => {
it('caps dense grids at 512', () => {
expect(resolveCoverDisplayTier(300, { dpr: 2, surface: 'dense' })).toBe(512);
expect(resolveCoverDisplayTier(300, { dpr: 1, surface: 'dense' })).toBe(512);
});
it('allows 800 on sparse surfaces', () => {
expect(resolveCoverDisplayTier(300, { dpr: 2, surface: 'sparse' })).toBe(800);
});
it('returns 2000 for full-res', () => {
expect(resolveCoverDisplayTier(40, { fullRes: true })).toBe(2000);
});
it('picks smallest tier >= needed px', () => {
expect(resolveCoverDisplayTier(40, { dpr: 2, surface: 'dense' })).toBe(128);
expect(resolveCoverDisplayTier(64, { dpr: 2, surface: 'dense' })).toBe(128);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { COVER_ART_DENSE_MAX_TIER, COVER_ART_TIERS, type CoverArtTier, type CoverSurfaceKind } from './types';
export { COVER_ART_TIERS, COVER_ART_DENSE_MAX_TIER };
export function resolveCoverDisplayTier(
displayCssPx: number,
opts?: { dpr?: number; fullRes?: boolean; surface?: CoverSurfaceKind },
): CoverArtTier {
if (opts?.fullRes) return 2000;
const dpr = opts?.dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
const neededPx = Math.ceil(displayCssPx * dpr);
let tier = COVER_ART_TIERS.find(t => t !== 2000 && t >= neededPx) ?? 800;
if (opts?.surface === 'dense' && tier > COVER_ART_DENSE_MAX_TIER) {
tier = COVER_ART_DENSE_MAX_TIER;
}
return tier;
}
+39
View File
@@ -0,0 +1,39 @@
/** Subsonic / Navidrome cover art ID passed to getCoverArt.view */
export type CoverArtId = string;
/** Fixed storage / server-request tiers */
export const COVER_ART_TIERS = [64, 128, 256, 512, 800, 2000] as const;
/** Max tier for dense grids — decode perf */
export const COVER_ART_DENSE_MAX_TIER = 512 as const;
export type CoverArtTier = (typeof COVER_ART_TIERS)[number];
export type CoverServerScope =
| { kind: 'active' }
| { kind: 'playback' }
| { kind: 'server'; serverId: string; url: string; username: string; password: string };
export type CoverSurfaceKind = 'dense' | 'sparse';
export type CoverPrefetchPriority = 'high' | 'middle' | 'low';
export type CoverArtRef = {
coverArtId: CoverArtId;
serverScope: CoverServerScope;
};
export type CoverArtHandle = {
src: string;
storageKey: string;
/** Alias for {@link storageKey} — migration shim for legacy `cacheKey` consumers */
cacheKey: string;
tier: CoverArtTier;
provisional: boolean;
/** Retry disk ensure after a broken/stale `src` (e.g. post cache clear). */
onImgError?: () => void;
};
export type CoverFullResIntent = { kind: 'tier2000' };
export type CoverRevalidateReason = 'library_delta' | 'scheduled' | 'upload' | 'manual';
+151
View File
@@ -0,0 +1,151 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react';
import { coverEnsureQueued, coverEnsureRelease } from './ensureQueue';
import { coverPeekQueued } from './peekQueue';
import { getDiskSrcForGrid, seedGridDiskSrcCache } from './diskSrcLookup';
import {
forgetDiskSrc,
getDiskSrc,
getDiskSrcCacheGeneration,
rememberDiskSrc,
subscribeDiskSrcCache,
} from './diskSrcCache';
import { subscribeCoverDiskReady } from './diskHandoff';
import { coverArtRef } from './ref';
import { coverServerReachable } from './reachability';
import { coverStorageKey } from './storageKeys';
import { resolveCoverDisplayTier } from './tiers';
import type {
CoverArtHandle,
CoverArtId,
CoverPrefetchPriority,
CoverServerScope,
CoverSurfaceKind,
} from './types';
/**
* Disk cache in Rust (WebP tiers) no webview `getCoverArt` fetch when server is reachable.
*/
export function useCoverArt(
coverArtId: CoverArtId | null | undefined,
displayCssPx: number,
opts?: {
serverScope?: CoverServerScope;
surface?: CoverSurfaceKind;
fullRes?: boolean;
fetchQueueBias?: number;
observeRootMargin?: string;
alt?: string;
/** Download / ensure ordering — visible cells should pass `high`. */
ensurePriority?: CoverPrefetchPriority;
},
): CoverArtHandle {
const serverScope = opts?.serverScope ?? { kind: 'active' };
const surface = opts?.surface ?? 'sparse';
const reachable = coverServerReachable(serverScope);
const tier = useMemo(
() =>
coverArtId
? resolveCoverDisplayTier(displayCssPx, {
surface,
fullRes: opts?.fullRes,
})
: 128,
[coverArtId, displayCssPx, surface, opts?.fullRes],
);
const ref = useMemo(
() => (coverArtId ? coverArtRef(coverArtId, serverScope) : null),
[coverArtId, serverScope],
);
const storageKey = useMemo(
() => (ref ? coverStorageKey(ref.serverScope, ref.coverArtId, tier) : ''),
[ref, tier],
);
const ensurePriority: CoverPrefetchPriority = opts?.ensurePriority ?? 'middle';
/** Dense grids: peek on mount; HTTP ensure only when IO marks the cell `high`. */
const deferEnsureUntilVisible = surface === 'dense' && ensurePriority !== 'high';
const readCachedSrc = useCallback(() => {
if (!ref) return '';
if (surface === 'dense') {
return getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier);
}
return getDiskSrc(storageKey);
}, [ref, storageKey, surface, tier]);
useSyncExternalStore(subscribeDiskSrcCache, getDiskSrcCacheGeneration);
const cachedSrc = readCachedSrc();
const applyDiskPath = useCallback((path: string) => {
if (!ref || !storageKey) return;
if (!path) {
forgetDiskSrc(storageKey);
return;
}
if (surface === 'dense') {
seedGridDiskSrcCache(ref.serverScope, ref.coverArtId, tier, path);
} else {
rememberDiskSrc(storageKey, path);
}
}, [ref, storageKey, tier, surface, readCachedSrc]);
useEffect(() => {
if (!ref || !storageKey) return;
if (readCachedSrc()) return;
let cancelled = false;
void (async () => {
const peekHit = await coverPeekQueued(storageKey, ref, tier);
if (cancelled) return;
if (peekHit || readCachedSrc()) return;
if (reachable && !deferEnsureUntilVisible) {
const result = await coverEnsureQueued(storageKey, ref, tier, ensurePriority);
if (cancelled) return;
if (result.hit && result.path) {
applyDiskPath(result.path);
}
}
})();
const unsubDisk = subscribeCoverDiskReady(storageKey, path => {
if (!cancelled && path) applyDiskPath(path);
});
return () => {
cancelled = true;
unsubDisk();
coverEnsureRelease(storageKey);
};
}, [
ref,
storageKey,
tier,
reachable,
ensurePriority,
deferEnsureUntilVisible,
applyDiskPath,
readCachedSrc,
]);
const src = cachedSrc;
const provisional = Boolean(ref && storageKey && !src);
const onImgError = useCallback(() => {
forgetDiskSrc(storageKey);
if (ref && reachable) {
void coverEnsureQueued(storageKey, ref, tier, 'high').then(result => {
if (result.hit && result.path) applyDiskPath(result.path);
});
}
}, [storageKey, ref, tier, reachable, applyDiskPath]);
return { src, storageKey, cacheKey: storageKey, tier, provisional, onImgError };
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { coverCacheStats } from '../api/coverCache';
import { coverStrategyAllowsRoutePrefetch } from '../utils/library/coverStrategy';
import { useCoverStrategyStore } from '../store/coverStrategyStore';
import { useAuthStore } from '../store/authStore';
import { coverPrefetchDrainBatch } from './prefetchRegistry';
import { coverTrafficBackgroundPaused } from './coverTraffic';
import { coverEnsureQueued } from './ensureQueue';
import { coverStorageKey } from './storageKeys';
import { resolveCoverDisplayTier } from './tiers';
import type { CoverArtTier } from './types';
const STEADY_POLL_MS = 1500;
const BATCH_LIMIT = 12;
/** Match dense card thumbs (~160 CSS px) — prefetch 128 wasted a full re-ensure for 512. */
const DENSE_PREFETCH_TIER = resolveCoverDisplayTier(160, { surface: 'dense' }) as CoverArtTier;
/**
* Background cover warm-up low rate; Rust HTTP only (never competes with webview grid fetches).
*/
export function useCoverArtPrefetch(enabled = true): void {
const activeServerId = useAuthStore(s => s.activeServerId);
const strategy = useCoverStrategyStore(s => s.getStrategyForServer(activeServerId));
useEffect(() => {
if (!enabled || !activeServerId || !coverStrategyAllowsRoutePrefetch(strategy)) return;
let cancelled = false;
void (async () => {
while (!cancelled) {
if (coverTrafficBackgroundPaused()) {
await new Promise(r => setTimeout(r, STEADY_POLL_MS));
continue;
}
const stats = await coverCacheStats().catch(() => null);
if (stats && !stats.autoDownloadEnabled) {
await new Promise(r => setTimeout(r, STEADY_POLL_MS * 2));
continue;
}
const batch = coverPrefetchDrainBatch(BATCH_LIMIT);
if (batch.length > 0) {
await Promise.all(
batch.map(ref => {
const key = coverStorageKey(ref.serverScope, ref.coverArtId, DENSE_PREFETCH_TIER);
return coverEnsureQueued(key, ref, DENSE_PREFETCH_TIER, 'low');
}),
);
}
await new Promise(r => setTimeout(r, STEADY_POLL_MS));
}
})();
return () => {
cancelled = true;
};
}, [enabled, activeServerId, strategy]);
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
const TICK_MS = 60_000;
/** Scheduled cover revalidate slices (Rust); pauses while streaming. */
export function useCoverRevalidateScheduler(enabled = true): void {
const cycleDays = useAuthStore(s => s.coverRevalidateCycleDays);
const isPlaying = usePlayerStore(s => s.isPlaying);
useEffect(() => {
if (!enabled || isPlaying) return;
let cancelled = false;
const tick = () => {
if (cancelled || usePlayerStore.getState().isPlaying) return;
void invoke<number>('cover_revalidate_tick', { cycleDays }).catch(() => {});
};
tick();
const id = window.setInterval(tick, TICK_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [enabled, cycleDays, isPlaying]);
}

Some files were not shown because too many files have changed in this diff Show More