mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870)
* chore(cover): scaffold cover module and rust cover_cache stub Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands in cover_cache/mod.rs (no-op returns until phase B). * feat(cover): add unified cover module and tier resolver (phase A) Wave 1A: tiers, storage keys, resolveJs with cold/sibling races, useCoverArt, CoverArtImage, layoutSizes, playback scope helpers, coverSiblings tier ladder, deprecated shims on subsonicStreamUrl. * feat(cover): rust disk cache and tier-ready events (phase B) Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch, cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests. * feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C) Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB cover key clear, prefetch registry drain, MainApp wiring. * feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D) Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense, coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid. * feat(cover): migrate sparse surfaces and integrations (phase E sparse) Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover, MPRIS/Discord/export integrations, playback chrome and detail heroes. * feat(cover): revalidation scheduler and disk pressure gate (phase E+) Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure, useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks. * docs: CHANGELOG and credits for cover art pipeline PR #869 * fix(cover): stop webview getCoverArt storm on dense grids (429) Dense surfaces no longer put rotating getCoverArt URLs in img src; load disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead of invalidating IDB. Throttle background prefetch and cap Home registry. * fix(cover): omit empty img src until cover URL is ready React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB resolves; queue current track shows placeholder when src is still empty. * fix(cover): disk cache by host index key, parallel ensure, asset protocol Bind cover storage to serverIndexKey (library host), rename cover IPC/events, fix REST base URL and Tauri flat args, enable protocol-asset for disk paths, add prioritized ensure queue, and wipe legacy profile-UUID cache once. Limit Vite dep scan to index.html so research/target HTML is ignored. * fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage Encode lossy WebP (~82), write only missing tiers, library cover backfill, and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP asset protocol; no IDB fallback when server is up. Session Home feed cache with warm peek on return; BecauseYouLike deduped cover hook and high prefetch. * feat(cover): per-server cache strategy and native library backfill Move cover disk cache settings to Offline & cache with Lazy/Aggressive per server, per-server clear, and no size cap. Run full-catalog backfill on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes without flooding the webview). Drop global prefetch limits from auth store and waveform clear from the offline storage block. * fix(build): CSP connect-src for Subsonic API; quieter prod nix build Prod webview blocked axios ping after cover CSP (missing connect-src). Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo. * fix(cover): complete WebP ladder in library bulk backfill Aggressive backfill now writes all derived tiers (128–800), skips IDs only when the full ladder exists (not 800 alone), avoids fetch-failed markers on bulk HTTP errors, and stops the pass when the active server changes. * fix(cover,home): navigation-priority backfill and Because You Like UX Pause library cover backfill while navigating; split peek/ensure traffic so grids and rails win over bulk work. Disk src lookup, grid warm hooks, and non-blocking mainstage prime for faster visible covers. Because You Like: session snapshot, staggered horizontal skeleton row, text hidden until cover is ready, and layout aligned with loaded cards. * feat(random-albums,library): local-first album fetch + cover art pipeline Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM() LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер исчезает практически мгновенно; сеть используется только как фолбэк. - advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок - browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums - RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve прогревает следующий батч в фоне после каждого Refresh Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue, diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers, useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие компоненты/хуки). * fix(random-albums): prevent double-load on Zustand rehydration useEffect([selectedGenres, load]) fired twice on every visit: first with default store values, then again ~50 ms later when Zustand rehydrated mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage. Previously this was invisible because the first network fetch took ~1.5 s, so loadingRef.current was still true on the second fire. With the new local-first SQLite path the first load completes in ~50 ms, leaving the guard cleared before rehydration triggers a second random batch. Fix: ref-pattern — keep loadRef.current fresh on every render, effect depends only on selectedGenres. Manual Refresh and genre-filter changes still call the latest closure correctly. * fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает сетевые запросы к рейтингам артистов). - fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при consume резерва через primeAlbumCoversForDisplay - reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums (аналогично non-reserve пути; при уже прогретом кэше — мгновенно) * feat(because-you-like): reserve-first pattern — instant display on return visits Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку мгновенно, вместо spinner → сетевые запросы → контент. Архитектура: - resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из замыкания useEffect); читают текущий localStorage, возвращают { anchor, recs, nextAnchorHistory, nextPicksHistory } - fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу после отображения результата, кладёт следующий батч в _becauseReserve. Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не нужен); они прогреваются через primeAlbumCoversForDisplay при consume. - useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton (контент появляется без мигания) - useEffect: reserve-first path — consume → primeCovers → setState → fill; full-fetch path сохранён как fallback при первом визите или промахе Поведение: - Визит 1: full fetch (как раньше) → показ → fillReserve R1 - Визит 2+: consume R1 → мгновенный показ → fillReserve R2 - При сетевом сбое: restore из session cache (как раньше) * fix(because-you-like): initialise state from reserve — no skeleton flash on remount При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и показывал skeleton на один тик до того как useEffect отработает. Теперь useState() использует lazy initializers, которые читают _becauseReserve прямо в первом рендере: если reserve валиден — state сразу refreshing=false, anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache (из предыдущего показа) и появляются без дополнительных запросов. useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton только если reserve отсутствует (для случая navigation без ремаунта). * fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival Lazy initializers не могли применить reserve при первом рендере, потому что mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[] на первом рендере, poolKey не совпадает с reserve. useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает): когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false. При отсутствии reserve — сбрасывает в skeleton как прежде. * fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect, поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным poolKey → mismatch → lazy initializers запускали skeleton. Теперь двухуровневый fallback без зависимости от poolKey: 1. reserve (serverId + poolKey совпадают) → мгновенный новый батч 2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент доступен сразу с mount, обновляется тихо в фоне 3. skeleton → только при полном отсутствии данных (первый визит) Применяется одинаково в lazy useState initializers, useLayoutEffect и full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент). * fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey='' → mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал mostPlayed. Исправления: - BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms - hasValidReserve: проверяет только serverId - fillBecauseReserve: убран poolKey из сигнатуры и хранилища - useEffect: guard pool.length === 0 → возврат без fetch/consume; effect перезапустится когда pool заполнится (реальные deps изменятся) → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate) > skeleton (только первый визит) * fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId, потом mix-rating значения. Это вызывало двойной запуск эффекта: - Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра - Второй запуск (после rehydration): cache miss или повторный fetch с реальными mix-настройками → ~500ms → новый набор Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор первые ~500ms при каждом возврате на Mainstage. Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth() читается внутри эффекта через getState() — всегда актуальные значения без пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch, но не вызывают двойной запуск при rehydration. * feat(home): local-first discover songs via SQLite ORDER BY RANDOM() Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков) в browseTextSearch.ts — использует libraryAdvancedSearch с sort random, field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку. В Home.tsx: discoverSongs теперь сначала пробует локальный индекс, и только при недоступности (индекс не готов, ошибка) падает обратно на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки берутся из SSD вместо сети. * fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits Причина: Home.tsx размонтируется при навигации. При возврате первый рендер всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]). Решение: getInitialHomeFeed() читает homeFeedCache синхронно через useAuthStore.getState() (не hook) в lazy useState initializers. К моменту повторного визита store уже rehydrated — все state получают кэшированные данные до первого рендера. Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot в useEffect когда state уже заполнен — иначе новые ссылки на массивы вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными. * fix(mainstage): keep refresh without return flicker Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation. * fix(mainstage): stabilize because rail and hero background framing Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount. * fix(now-playing): prewarm track data and prevent stale carry-over Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track. * fix(cover,test): refresh playback scope and default tauri cover mocks Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI. * fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs. * test(cover): hoist mocked useCoverArt and clean EOF whitespace Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line. * fix(cover): align playback ensure auth and harden backfill retry flow Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch. * fix(ci): resolve clippy lint and update frontend node runtime Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20. * chore(settings): simplify cover and analytics strategy copy Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||
import { queueSongStar, queueSongRating } from '../store/pendingStarSync';
|
||||
import { getArtistInfo } from '../api/subsonicArtists';
|
||||
@@ -22,13 +22,14 @@ import AlbumCard from '../components/AlbumCard';
|
||||
import AlbumHeader from '../components/AlbumHeader';
|
||||
import AlbumTrackList from '../components/AlbumTrackList';
|
||||
import { AlbumDetailToolbar } from '../components/albumDetail/AlbumDetailToolbar';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
import { sanitizeFilename } from '../utils/componentHelpers/albumDetailHelpers';
|
||||
import { deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
|
||||
export default function AlbumDetail() {
|
||||
@@ -251,13 +252,8 @@ const handleShuffleAll = () => {
|
||||
userRatingOverrides,
|
||||
});
|
||||
|
||||
// Hooks must be called unconditionally — derive from nullable album state.
|
||||
// useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
|
||||
// memoization every re-render (e.g. currentTrack change) produces a new fetchUrl,
|
||||
// which cancels and restarts the useCachedUrl effect → background never resolves.
|
||||
const coverUrl = useMemo(() => album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||||
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
const albumCover = useCoverArt(album?.album.coverArt, 400, { surface: 'sparse' });
|
||||
const resolvedCoverUrl = albumCover.src || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPlPicker) return;
|
||||
@@ -285,8 +281,7 @@ const handleShuffleAll = () => {
|
||||
info={info}
|
||||
headerArtistRefs={headerArtistRefs}
|
||||
songs={songs}
|
||||
coverUrl={coverUrl}
|
||||
coverKey={coverKey}
|
||||
coverArtId={info.coverArt}
|
||||
resolvedCoverUrl={resolvedCoverUrl}
|
||||
isStarred={isStarred}
|
||||
downloadProgress={null}
|
||||
@@ -362,6 +357,7 @@ const handleShuffleAll = () => {
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={relatedAlbums.length}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+58
-19
@@ -4,14 +4,19 @@ import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { albumGridWarmCovers, coverDisplayCssPxForAlbumGrid } from '../cover/layoutSizes';
|
||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { clampLibraryGridMaxColumns } from '../store/authStoreHelpers';
|
||||
import { computeCardGridColumnCount } from '../utils/cardGridLayout';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import YearFilterButton from '../components/YearFilterButton';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
import SortDropdown from '../components/SortDropdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -68,6 +73,10 @@ export default function Albums() {
|
||||
const [compFilter, setCompFilter] = useState<CompFilter>('all');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const gridMeasureRef = useRef<HTMLDivElement>(null);
|
||||
const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns));
|
||||
const [albumCellDisplayCssPx, setAlbumCellDisplayCssPx] = useState(140);
|
||||
const [albumGridCols, setAlbumGridCols] = useState(4);
|
||||
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
const bindAlbumsScrollBody = useCallback((el: HTMLDivElement | null) => {
|
||||
@@ -170,6 +179,29 @@ export default function Albums() {
|
||||
const toNum = parseInt(yearTo, 10);
|
||||
const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = gridMeasureRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const w = el.clientWidth;
|
||||
const cols = computeCardGridColumnCount(w, maxGridCols);
|
||||
setAlbumGridCols(cols);
|
||||
setAlbumCellDisplayCssPx(coverDisplayCssPxForAlbumGrid(w, maxGridCols));
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [maxGridCols, visibleAlbums.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewportBudget = Math.max(albumGridCols * 3, albumGridCols);
|
||||
const refs = visibleAlbums
|
||||
.slice(0, viewportBudget)
|
||||
.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : []));
|
||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
||||
}, [visibleAlbums, albumGridCols]);
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
sort,
|
||||
genreFiltered,
|
||||
@@ -385,23 +417,30 @@ export default function Albums() {
|
||||
) : (
|
||||
<>
|
||||
{!perfFlags.disableMainstageGridCards && (
|
||||
<VirtualCardGrid
|
||||
items={visibleAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={visibleAlbums.length}
|
||||
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div ref={gridMeasureRef}>
|
||||
<VirtualCardGrid
|
||||
items={visibleAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={visibleAlbums.length}
|
||||
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers(
|
||||
albumCellDisplayCssPx,
|
||||
Math.min(visibleAlbums.length, Math.max(albumGridCols * 6, 48)),
|
||||
)}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
displayCssPx={albumCellDisplayCssPx}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!genreFiltered && (
|
||||
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { uploadArtistImage } from '../api/subsonicPlaylists';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
@@ -7,8 +7,6 @@ import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Play, Square, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronRight, ChevronUp, Share2, AudioLines } from 'lucide-react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
@@ -40,6 +38,7 @@ import ArtistDetailTopTracks from '../components/artistDetail/ArtistDetailTopTra
|
||||
import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailSimilarArtists';
|
||||
import ArtistCard from '../components/nowPlaying/ArtistCard';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
|
||||
|
||||
@@ -57,12 +56,11 @@ export default function ArtistDetail() {
|
||||
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const { similarArtists, similarLoading } = useArtistSimilarArtists(artist, info, artistInfoLoading);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [similarCollapsed, setSimilarCollapsed] = useState(true);
|
||||
const isMobile = useIsMobile();
|
||||
const [coverRevision, setCoverRevision] = useState(0);
|
||||
/** True after header CachedImage onError — avoid `display:none` on the img (breaks recovery). */
|
||||
/** True after header cover onError — avoid `display:none` on the img (breaks recovery). */
|
||||
const [headerCoverFailed, setHeaderCoverFailed] = useState(false);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -156,22 +154,7 @@ export default function ArtistDetail() {
|
||||
|
||||
// Cover URLs — must run every render (before early returns) or hook order breaks.
|
||||
const coverId = artist ? (artist.coverArt || artist.id) : '';
|
||||
const artistCover300Src = useMemo(
|
||||
() => (coverId ? buildCoverArtUrl(coverId, 300) : ''),
|
||||
[coverId],
|
||||
);
|
||||
const artistCover300Key = useMemo(
|
||||
() => (coverId ? coverArtCacheKey(coverId, 300) : ''),
|
||||
[coverId],
|
||||
);
|
||||
const artistCover2000Src = useMemo(
|
||||
() => (coverId ? buildCoverArtUrl(coverId, 2000) : ''),
|
||||
[coverId],
|
||||
);
|
||||
const artistCover80FallbackSrc = useMemo(
|
||||
() => (coverId ? buildCoverArtUrl(coverId, 80) : ''),
|
||||
[coverId],
|
||||
);
|
||||
const artistCoverFallback = useCoverArt(coverId || undefined, 80, { surface: 'sparse' });
|
||||
|
||||
const groupedAlbums = useMemo(() => {
|
||||
if (albums.length === 0) return [];
|
||||
@@ -282,14 +265,9 @@ export default function ArtistDetail() {
|
||||
openedLink={openedLink}
|
||||
openLink={openLink}
|
||||
coverId={coverId}
|
||||
artistCover300Src={artistCover300Src}
|
||||
artistCover300Key={artistCover300Key}
|
||||
artistCover2000Src={artistCover2000Src}
|
||||
coverRevision={coverRevision}
|
||||
headerCoverFailed={headerCoverFailed}
|
||||
setHeaderCoverFailed={setHeaderCoverFailed}
|
||||
lightboxOpen={lightboxOpen}
|
||||
setLightboxOpen={setLightboxOpen}
|
||||
/>
|
||||
|
||||
{/* User-reorderable sections — order + visibility configured in Settings.
|
||||
@@ -305,7 +283,7 @@ export default function ArtistDetail() {
|
||||
artistInfo={info}
|
||||
hideArtistName
|
||||
hideSimilar
|
||||
coverFallback={coverId ? { src: artistCover80FallbackSrc, cacheKey: coverArtCacheKey(coverId, 80) } : undefined}
|
||||
coverFallback={coverId ? { src: artistCoverFallback.src, cacheKey: artistCoverFallback.cacheKey } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -347,6 +325,7 @@ export default function ArtistDetail() {
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
/>
|
||||
) : groupedAlbums.map(([label, group]) => (
|
||||
@@ -362,6 +341,7 @@ export default function ArtistDetail() {
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={group.length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
/>
|
||||
</div>
|
||||
@@ -392,6 +372,7 @@ export default function ArtistDetail() {
|
||||
layoutSignal={featuredAlbums.length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
wrapStyle={{ animation: 'fadeIn 0.3s ease' }}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { star, unstar } from '../api/subsonicStarRating';
|
||||
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
@@ -6,8 +5,9 @@ import { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ndListAlbumsByArtistRole } from '../api/navidromeBrowse';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArtistHeroCover } from '../cover/artistHero';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { useCoverLightboxSrc } from '../cover/lightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Heart, Feather, Share2 } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -17,6 +17,7 @@ import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
|
||||
export default function ComposerDetail() {
|
||||
@@ -30,7 +31,6 @@ export default function ComposerDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [headerCoverFailed, setHeaderCoverFailed] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
|
||||
@@ -85,9 +85,13 @@ export default function ComposerDetail() {
|
||||
}, [id]);
|
||||
|
||||
const coverId = artist?.coverArt || artist?.id || '';
|
||||
const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
|
||||
const coverKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
|
||||
const coverLargeSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 2000) : '', [coverId]);
|
||||
const coverFallbackRef = useMemo(
|
||||
() => (coverId ? coverArtRef(coverId) : null),
|
||||
[coverId],
|
||||
);
|
||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverFallbackRef, {
|
||||
alt: artist?.name ?? t('composerDetail.unknownComposer'),
|
||||
});
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!artist) return;
|
||||
@@ -146,16 +150,10 @@ export default function ComposerDetail() {
|
||||
const wikiUrl = artist?.name
|
||||
? `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`
|
||||
: '';
|
||||
// Header image source can be either Last.fm (artist-info path) or the Subsonic
|
||||
// cover-art endpoint. Cache key must mirror the actual URL or we'd alias both
|
||||
// entries under a single Subsonic key, polluting the cache between servers.
|
||||
// The Last.fm key is derived from the route id (same id namespace as the
|
||||
// SubsonicArtist record) so it stays stable even when getArtist failed and
|
||||
// we still render a Last.fm avatar from the bio fetch alone.
|
||||
const headerImageSrc = info?.largeImageUrl || coverSrc;
|
||||
const headerImageCacheKey = info?.largeImageUrl
|
||||
? `lastfm:artist:${id}:large`
|
||||
: coverKey;
|
||||
|
||||
const hasHeroImage = Boolean(
|
||||
info?.largeImageUrl || info?.mediumImageUrl || coverId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
@@ -167,25 +165,22 @@ export default function ComposerDetail() {
|
||||
<ArrowLeft size={16} /> <span>{t('composerDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{lightboxOpen && headerImageSrc && (
|
||||
<CoverLightbox
|
||||
src={info?.largeImageUrl || coverLargeSrc}
|
||||
alt={displayName}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{lightbox}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{headerImageSrc && !headerCoverFailed ? (
|
||||
{hasHeroImage && !headerCoverFailed && id ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
onClick={openLightbox}
|
||||
aria-label={displayName}
|
||||
>
|
||||
<CachedImage
|
||||
src={headerImageSrc}
|
||||
cacheKey={headerImageCacheKey}
|
||||
<ArtistHeroCover
|
||||
artistId={id}
|
||||
artistInfo={info}
|
||||
coverFallback={coverFallbackRef}
|
||||
displayCssPx={300}
|
||||
surface="sparse"
|
||||
alt={displayName}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setHeaderCoverFailed(true)}
|
||||
@@ -275,6 +270,7 @@ export default function ComposerDetail() {
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ArrowLeft, Disc3 } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -81,6 +82,7 @@ export default function GenreDetail() {
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={album => <AlbumCard album={album} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
+152
-41
@@ -1,6 +1,7 @@
|
||||
import { getArtists } from '../api/subsonicArtists';
|
||||
import { getAlbumList, getRandomSongs } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonicTypes';
|
||||
import { runLocalRandomSongs } from '../utils/library/browseTextSearch';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Hero from '../components/Hero';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
@@ -17,6 +18,16 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
import { coverArtIdFromArtist } from '../cover/ids';
|
||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '../cover/warmDiskPeek';
|
||||
import { readBecauseYouLikeCache } from '../store/becauseYouLikeCache';
|
||||
import {
|
||||
readHomeFeedCache,
|
||||
writeHomeFeedCache,
|
||||
type HomeFeedSnapshot,
|
||||
} from '../store/homeFeedCache';
|
||||
|
||||
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
|
||||
const HOME_RANDOM_FETCH = 100;
|
||||
@@ -29,9 +40,23 @@ const HOME_ARTWORK_WINDOWING = true;
|
||||
// At least one viewport width of cards on first paint (low values left half the row as placeholders).
|
||||
const HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET = 14;
|
||||
const HOME_SONG_RAIL_INITIAL_ARTWORK_BUDGET = 16;
|
||||
const HOME_BECAUSE_CARD_COVER_CSS_PX = 160;
|
||||
// Keep artwork enabled across Home rows in normal mode.
|
||||
const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
|
||||
|
||||
/**
|
||||
* Read the in-memory homeFeedCache synchronously at component mount time.
|
||||
* Uses Zustand getState() (not a hook) so it can be called from useState lazy
|
||||
* initializers — by the time the user navigates back to Home the store is
|
||||
* fully rehydrated and activeServerId is set, so on every return visit the
|
||||
* first render already has data, eliminating the empty-state flash.
|
||||
*/
|
||||
function getInitialHomeFeed(): HomeFeedSnapshot | null {
|
||||
const { activeServerId, musicLibraryFilterVersion } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
return readHomeFeedCache(activeServerId, musicLibraryFilterVersion);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const homeAlbumRowsDisabled = perfFlags.disableMainstageRails || perfFlags.disableHomeAlbumRows;
|
||||
@@ -40,55 +65,144 @@ export default function Home() {
|
||||
const homeSections = useHomeStore(s => s.sections);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
|
||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||
// Mix-rating deps intentionally NOT subscribed here — they change during Zustand
|
||||
// rehydration and would trigger a second useEffect fire right after the first,
|
||||
// showing the cached home feed briefly and then replacing it (~500 ms later)
|
||||
// when the re-fetch with the rehydrated values completes. getMixMinRatingsConfigFromAuth
|
||||
// reads the current store state directly inside the effect so the correct
|
||||
// values are always used without re-triggering the effect on rehydration.
|
||||
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
|
||||
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
|
||||
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>([]);
|
||||
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [starred, setStarred] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.starred ?? []);
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.recent ?? []);
|
||||
const [random, setRandom] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.random ?? []);
|
||||
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.heroAlbums ?? []);
|
||||
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.mostPlayed ?? []);
|
||||
const [recentlyPlayed, setRecentlyPlayed] = useState<SubsonicAlbum[]>(() => getInitialHomeFeed()?.recentlyPlayed ?? []);
|
||||
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>(() => getInitialHomeFeed()?.randomArtists ?? []);
|
||||
const [discoverSongs, setDiscoverSongs] = useState<SubsonicSong[]>(() => getInitialHomeFeed()?.discoverSongs ?? []);
|
||||
// Pre-populated from cache → no loading spinner on return visits.
|
||||
const [loading, setLoading] = useState(() => getInitialHomeFeed() == null);
|
||||
// Track whether state was pre-populated from cache at mount so useEffect can
|
||||
// skip re-applying the same snapshot (avoids creating new array references
|
||||
// that would cause child components to re-render with unchanged data).
|
||||
const [wasPrePopulated] = useState(() => getInitialHomeFeed() != null);
|
||||
|
||||
const applyFeedSnapshot = (snap: HomeFeedSnapshot) => {
|
||||
setStarred(snap.starred);
|
||||
setRecent(snap.recent);
|
||||
setRandom(snap.random);
|
||||
setHeroAlbums(snap.heroAlbums);
|
||||
setMostPlayed(snap.mostPlayed);
|
||||
setRecentlyPlayed(snap.recentlyPlayed);
|
||||
setRandomArtists(snap.randomArtists);
|
||||
setDiscoverSongs(snap.discoverSongs);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
bumpPerfCounter('homeCommits');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const heroRefs = heroAlbums.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : []));
|
||||
const recentRefs = recent.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : []));
|
||||
const restAlbumRefs = [...random, ...mostPlayed, ...recentlyPlayed, ...starred].flatMap(a =>
|
||||
a.coverArt ? [coverArtRef(a.coverArt)] : [],
|
||||
);
|
||||
const artistRefs = randomArtists.map(a => coverArtRef(coverArtIdFromArtist(a)));
|
||||
const songRefs = discoverSongs.flatMap(s => (s.coverArt ? [coverArtRef(s.coverArt)] : []));
|
||||
const unregHero = coverPrefetchRegister(heroRefs, { surface: 'dense', priority: 'high' });
|
||||
const unregRecent = coverPrefetchRegister(recentRefs, { surface: 'dense', priority: 'high' });
|
||||
const cappedRest = [...restAlbumRefs, ...artistRefs, ...songRefs].slice(0, 24);
|
||||
const unregRest = coverPrefetchRegister(cappedRest, { surface: 'dense', priority: 'low' });
|
||||
return () => {
|
||||
unregHero();
|
||||
unregRecent();
|
||||
unregRest();
|
||||
};
|
||||
}, [heroAlbums, recent, random, mostPlayed, recentlyPlayed, starred, randomArtists, discoverSongs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeServerId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchFreshHomeFeed = async (): Promise<HomeFeedSnapshot | null> => {
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMix =
|
||||
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
|
||||
const [s, n, rRaw, f, rp, artists, songs] = await Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', randomSize).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
isVisible('discoverSongs')
|
||||
? (runLocalRandomSongs(activeServerId, HOME_DISCOVER_SONGS_SIZE)
|
||||
.then(local => local ?? getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[]))
|
||||
.catch(() => [] as SubsonicSong[]))
|
||||
: Promise.resolve<SubsonicSong[]>([]),
|
||||
]);
|
||||
const r = dedupeById(await filterAlbumsByMixRatings(rRaw, mixCfg));
|
||||
return {
|
||||
serverId: activeServerId,
|
||||
filterVersion: musicLibraryFilterVersion,
|
||||
savedAt: Date.now(),
|
||||
starred: dedupeById(s),
|
||||
recent: dedupeById(n),
|
||||
heroAlbums: r.slice(0, HOME_HERO_COUNT),
|
||||
random: r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE),
|
||||
mostPlayed: dedupeById(f),
|
||||
recentlyPlayed: dedupeById(rp),
|
||||
discoverSongs: dedupeById(songs),
|
||||
randomArtists: dedupeById(shuffleArray(artists)).slice(0, 16),
|
||||
};
|
||||
};
|
||||
|
||||
const cached = readHomeFeedCache(activeServerId, musicLibraryFilterVersion);
|
||||
if (cached) {
|
||||
// When lazy initializers already pre-populated state from this same
|
||||
// snapshot, re-applying it would only create new array references and
|
||||
// trigger unnecessary child re-renders with identical data.
|
||||
if (!wasPrePopulated) applyFeedSnapshot(cached);
|
||||
setLoading(false);
|
||||
void warmHomeMainstageCovers(cached);
|
||||
const becauseSnap = readBecauseYouLikeCache(activeServerId);
|
||||
void primeAlbumCoversForDisplay(becauseSnap?.recs ?? [], HOME_BECAUSE_CARD_COVER_CSS_PX, {
|
||||
limit: 6,
|
||||
});
|
||||
// Keep the current visit visually stable, but prepare fresh data so the
|
||||
// next re-enter opens with a newer snapshot immediately.
|
||||
void (async () => {
|
||||
try {
|
||||
const fresh = await fetchFreshHomeFeed();
|
||||
if (!fresh || cancelled) return;
|
||||
writeHomeFeedCache(fresh);
|
||||
void warmHomeMainstageCovers(fresh);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMix =
|
||||
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||
const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
|
||||
const [s, n, rRaw, f, rp, artists, songs] = await Promise.all([
|
||||
getAlbumList('starred', 12).catch(() => []),
|
||||
getAlbumList('newest', 12).catch(() => []),
|
||||
getAlbumList('random', randomSize).catch(() => []),
|
||||
getAlbumList('frequent', 12).catch(() => []),
|
||||
getAlbumList('recent', 12).catch(() => []),
|
||||
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
|
||||
isVisible('discoverSongs')
|
||||
? getRandomSongs(HOME_DISCOVER_SONGS_SIZE).catch(() => [] as SubsonicSong[])
|
||||
: Promise.resolve<SubsonicSong[]>([]),
|
||||
]);
|
||||
const snap = await fetchFreshHomeFeed();
|
||||
if (!snap) return;
|
||||
if (cancelled) return;
|
||||
const r = dedupeById(await filterAlbumsByMixRatings(rRaw, mixCfg));
|
||||
setStarred(dedupeById(s));
|
||||
setRecent(dedupeById(n));
|
||||
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
|
||||
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
|
||||
setMostPlayed(dedupeById(f));
|
||||
setRecentlyPlayed(dedupeById(rp));
|
||||
setDiscoverSongs(dedupeById(songs));
|
||||
setRandomArtists(dedupeById(shuffleArray(artists)).slice(0, 16));
|
||||
writeHomeFeedCache(snap);
|
||||
applyFeedSnapshot(snap);
|
||||
if (!cancelled) setLoading(false);
|
||||
void warmHomeMainstageCovers(snap);
|
||||
const becauseSnap = readBecauseYouLikeCache(activeServerId);
|
||||
void primeAlbumCoversForDisplay(becauseSnap?.recs ?? [], HOME_BECAUSE_CARD_COVER_CSS_PX, {
|
||||
limit: 6,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
@@ -100,10 +214,7 @@ export default function Home() {
|
||||
activeServerId,
|
||||
musicLibraryFilterVersion,
|
||||
homeSections,
|
||||
mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum,
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||
@@ -195,7 +306,7 @@ export default function Home() {
|
||||
starred.length === 0;
|
||||
return (
|
||||
<div className={`animate-fade-in${homeLiteArtworkFx ? ' home-lite-artwork' : ''}${homeFlatArtworkClip ? ' home-flat-artwork-clip' : ''}`}>
|
||||
{!perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
|
||||
{!loading && !perfFlags.disableMainstageHero && isVisible('hero') && <Hero albums={heroAlbums} />}
|
||||
|
||||
<div className="content-body" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
|
||||
{loading ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import AlbumCard from '../components/AlbumCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
|
||||
export default function LabelAlbums() {
|
||||
@@ -62,6 +63,7 @@ export default function LabelAlbums() {
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { showToast } from '../utils/ui/toast';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { CheckSquare2, Download, HardDriveDownload, ListPlus } from 'lucide-react';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
@@ -306,6 +307,7 @@ export default function LosslessAlbums() {
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
scrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
@@ -7,7 +6,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound, Play, ListPlus } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { playAlbum } from '../utils/playback/playAlbum';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -53,10 +52,19 @@ function formatPlays(n: number, t: ReturnType<typeof import('react-i18next').use
|
||||
return t('mostPlayed.plays', { n: n.toLocaleString() }) as string;
|
||||
}
|
||||
|
||||
/** Most-played list row cover layout px. */
|
||||
const MOST_PLAYED_COVER_CSS_PX = 80;
|
||||
|
||||
function MpCover80({ coverArt, alt, className }: { coverArt: string; alt: string; className: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 80), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 80), [coverArt]);
|
||||
return <CachedImage src={src} cacheKey={cacheKey} alt={alt} className={className} />;
|
||||
return (
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={MOST_PLAYED_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt={alt}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MostPlayed() {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { useRangeSelection } from '../hooks/useRangeSelection';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
import { NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
@@ -227,6 +228,7 @@ export default function NewReleases() {
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { coverArtIdFromRadio } from '../cover/ids';
|
||||
import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
@@ -11,8 +12,6 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useCachedUrl } from '../components/CachedImage';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
|
||||
import OverlayScrollArea from '../components/OverlayScrollArea';
|
||||
@@ -96,12 +95,12 @@ export default function NowPlaying() {
|
||||
showLyrics();
|
||||
}, [isQueueVisible, toggleQueue, showLyrics]);
|
||||
|
||||
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(currentTrack?.coverArt, 800);
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
const trackCover = usePlaybackCoverArt(currentTrack?.coverArt, 800);
|
||||
const resolvedCover = trackCover.src;
|
||||
|
||||
const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : '';
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 800) : '';
|
||||
const resolvedRadioCover = useCachedUrl(radioCoverFetchUrl, radioCoverKey);
|
||||
const radioCoverArtId = currentRadio?.coverArt ? coverArtIdFromRadio(currentRadio.id) : undefined;
|
||||
const radioCover = useCoverArt(radioCoverArtId, 800, { surface: 'sparse' });
|
||||
const resolvedRadioCover = radioCover.src;
|
||||
|
||||
const contributorRows = useMemo(
|
||||
() => buildContributorRows(songMeta, artistName),
|
||||
|
||||
@@ -4,15 +4,18 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useOfflineStore, type OfflineAlbumMeta } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import {
|
||||
buildOfflineTracksForAlbum,
|
||||
ensureServerForOfflineAlbum,
|
||||
offlineAlbumCoverArt,
|
||||
offlineAlbumCoverScope,
|
||||
offlineTrackCount,
|
||||
} from '../utils/offline/offlineLibraryHelpers';
|
||||
|
||||
const OFFLINE_CARD_COVER_CSS_PX = 300;
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
|
||||
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
|
||||
@@ -73,14 +76,21 @@ export default function OfflineLibrary() {
|
||||
};
|
||||
|
||||
const renderCard = (album: OfflineAlbumMeta) => {
|
||||
const { src: coverUrl, cacheKey } = offlineAlbumCoverArt(album, 300);
|
||||
const coverScope = offlineAlbumCoverScope(album);
|
||||
const trackCount = offlineTrackCount(album, offlineTracks);
|
||||
const serverLabel = serverNames[album.serverId];
|
||||
return (
|
||||
<div className="album-card card offline-library-card">
|
||||
<div className="album-card-cover">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
|
||||
{coverScope && album.coverArt ? (
|
||||
<CoverArtImage
|
||||
coverArtId={album.coverArt}
|
||||
displayCssPx={OFFLINE_CARD_COVER_CSS_PX}
|
||||
serverScope={coverScope}
|
||||
surface="dense"
|
||||
alt={`${album.name} Cover`}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
<HardDriveDownload size={32} />
|
||||
@@ -149,6 +159,7 @@ export default function OfflineLibrary() {
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={groups[artistName].length}
|
||||
warmGridCovers={albumGridWarmCovers(OFFLINE_CARD_COVER_CSS_PX)}
|
||||
renderItem={renderCard}
|
||||
/>
|
||||
</div>
|
||||
@@ -202,6 +213,7 @@ export default function OfflineLibrary() {
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={filtered.length}
|
||||
warmGridCovers={albumGridWarmCovers(OFFLINE_CARD_COVER_CSS_PX)}
|
||||
renderItem={renderCard}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -164,8 +164,7 @@ export default function PlaylistDetail() {
|
||||
useBulkPlPickerOutsideClick(showBulkPlPicker, setShowBulkPlPicker);
|
||||
|
||||
// ── 2×2 cover quad (first 4 unique album covers) ─────────────
|
||||
const { coverQuadUrls, customCoverFetchUrl, customCoverCacheKey, resolvedBgUrl } =
|
||||
usePlaylistCovers(songs, customCoverId);
|
||||
const { coverQuadIds, resolvedBgUrl } = usePlaylistCovers(songs, customCoverId);
|
||||
|
||||
// Song search
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
@@ -282,9 +281,7 @@ export default function PlaylistDetail() {
|
||||
songs={songs}
|
||||
id={id}
|
||||
customCoverId={customCoverId}
|
||||
customCoverFetchUrl={customCoverFetchUrl}
|
||||
customCoverCacheKey={customCoverCacheKey}
|
||||
coverQuadUrls={coverQuadUrls}
|
||||
coverQuadIds={coverQuadIds}
|
||||
resolvedBgUrl={resolvedBgUrl}
|
||||
saving={saving}
|
||||
searchOpen={searchOpen}
|
||||
@@ -399,9 +396,7 @@ export default function PlaylistDetail() {
|
||||
<PlaylistEditModal
|
||||
playlist={playlist}
|
||||
customCoverId={customCoverId}
|
||||
customCoverFetchUrl={customCoverFetchUrl ?? null}
|
||||
customCoverCacheKey={customCoverCacheKey ?? null}
|
||||
coverQuadUrls={coverQuadUrls}
|
||||
coverQuadIds={coverQuadIds}
|
||||
onClose={() => setEditingMeta(false)}
|
||||
onSave={handleSaveMeta}
|
||||
/>
|
||||
|
||||
+110
-9
@@ -10,7 +10,9 @@ import AlbumCard from '../components/AlbumCard';
|
||||
import GenreFilterBar from '../components/GenreFilterBar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mix/mixRatingFilter';
|
||||
import { runLocalRandomAlbums, runLocalAlbumsByGenres } from '../utils/library/browseTextSearch';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
@@ -19,6 +21,10 @@ import { showToast } from '../utils/ui/toast';
|
||||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||
import { useRangeSelection } from '../hooks/useRangeSelection';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers, COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import {
|
||||
primeAlbumCoversForDisplay,
|
||||
} from '../cover/warmDiskPeek';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
|
||||
const ALBUM_COUNT = 30;
|
||||
@@ -38,6 +44,85 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
return filtered.slice(0, ALBUM_COUNT);
|
||||
}
|
||||
|
||||
/** Shared fetch logic — used by both `load` and the background reserve fill. */
|
||||
async function doFetchRandomAlbums(genres: string[]): Promise<SubsonicAlbum[]> {
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMixActive = mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
|
||||
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
||||
|
||||
if (genres.length === 0 && indexEnabled && serverId) {
|
||||
// Local path: SQLite ORDER BY RANDOM() LIMIT N — no network, effectively instant.
|
||||
const local = await runLocalRandomAlbums(serverId, randomSize);
|
||||
if (local && local.length > 0) {
|
||||
return (await filterAlbumsByMixRatings(local, mixCfg)).slice(0, ALBUM_COUNT);
|
||||
}
|
||||
}
|
||||
|
||||
if (genres.length > 0 && indexEnabled && serverId) {
|
||||
// Genre path: local index union + JS shuffle (avoids per-genre network requests).
|
||||
const allLocal = await runLocalAlbumsByGenres(serverId, genres, 'alphabeticalByName', GENRE_UNION_PREFILTER_CAP);
|
||||
if (allLocal && allLocal.length > 0) {
|
||||
const pool = shuffleArray(dedupeById(allLocal)).slice(0, GENRE_UNION_PREFILTER_CAP);
|
||||
return (await filterAlbumsByMixRatings(pool, mixCfg)).slice(0, ALBUM_COUNT);
|
||||
}
|
||||
}
|
||||
|
||||
// Network fallback when local index is unavailable or returned nothing.
|
||||
return genres.length > 0
|
||||
? fetchByGenres(genres)
|
||||
: (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
|
||||
}
|
||||
|
||||
// ── Module-level reserve: next batch pre-fetched after each Refresh ──────────
|
||||
type AlbumReserve = { filterId: string; albums: SubsonicAlbum[] };
|
||||
let _nextReserve: AlbumReserve | null = null;
|
||||
let _reserveFilling = false;
|
||||
|
||||
function makeFilterId(
|
||||
libraryVersion: number,
|
||||
mixEnabled: boolean,
|
||||
minAlbum: number,
|
||||
minArtist: number,
|
||||
genres: string[],
|
||||
): string {
|
||||
return `${libraryVersion}:${mixEnabled}:${minAlbum}:${minArtist}:${genres.join('\x01')}`;
|
||||
}
|
||||
|
||||
/** Consume the pre-fetched reserve if the filter matches, otherwise discard it. */
|
||||
function takeReserve(filterId: string): SubsonicAlbum[] | null {
|
||||
if (_nextReserve?.filterId === filterId) {
|
||||
const albums = _nextReserve.albums;
|
||||
_nextReserve = null;
|
||||
return albums;
|
||||
}
|
||||
_nextReserve = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: fetch the next batch in the background so it's ready for
|
||||
* the next Refresh. Covers are NOT pre-warmed here — doing so would call
|
||||
* bumpDiskSrcCache() for every reserve cover, which re-renders all useCoverArt
|
||||
* subscribers on the current page and causes a visible flash ~1.5 s after load.
|
||||
* Covers are warmed lazily via primeAlbumCoversForDisplay when the reserve is
|
||||
* actually consumed.
|
||||
*/
|
||||
async function fillReserve(filterId: string, genres: string[]): Promise<void> {
|
||||
if (_reserveFilling) return;
|
||||
_reserveFilling = true;
|
||||
try {
|
||||
const albums = await doFetchRandomAlbums(genres);
|
||||
_nextReserve = { filterId, albums };
|
||||
} catch {
|
||||
// Network or cache failure — next Refresh falls back to a fresh fetch.
|
||||
} finally {
|
||||
_reserveFilling = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RandomAlbums() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
@@ -106,14 +191,21 @@ export default function RandomAlbums() {
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMixActive =
|
||||
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
||||
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
|
||||
const data = genres.length > 0
|
||||
? await fetchByGenres(genres)
|
||||
: (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
|
||||
setAlbums(data);
|
||||
const filterId = makeFilterId(
|
||||
musicLibraryFilterVersion, mixMinRatingFilterEnabled,
|
||||
mixMinRatingAlbum, mixMinRatingArtist, genres,
|
||||
);
|
||||
const reserved = takeReserve(filterId);
|
||||
if (reserved) {
|
||||
await primeAlbumCoversForDisplay(reserved, COVER_DENSE_GRID_MIN_CELL_CSS_PX);
|
||||
setAlbums(reserved);
|
||||
} else {
|
||||
const data = await doFetchRandomAlbums(genres);
|
||||
await primeAlbumCoversForDisplay(data, COVER_DENSE_GRID_MIN_CELL_CSS_PX);
|
||||
setAlbums(data);
|
||||
}
|
||||
// Pre-fetch + disk-warm the next batch so the next Refresh is instant.
|
||||
void fillReserve(filterId, genres);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
@@ -127,7 +219,14 @@ export default function RandomAlbums() {
|
||||
mixMinRatingArtist,
|
||||
]);
|
||||
|
||||
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
|
||||
// Keep a ref so the effect closure is always fresh without re-triggering the
|
||||
// effect on every `load` reference change. The effect must NOT list `load` as a
|
||||
// dep — Zustand rehydration changes deps (e.g. mixMinRatingFilterEnabled) and
|
||||
// recreates `load`, which would otherwise double-fire on every page visit and
|
||||
// show a different random batch ~1.5 s after the first one.
|
||||
const loadRef = useRef(load);
|
||||
loadRef.current = load;
|
||||
useEffect(() => { loadRef.current(selectedGenres); }, [selectedGenres]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
@@ -191,6 +290,7 @@ export default function RandomAlbums() {
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={albums.length}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
@@ -198,6 +298,7 @@ export default function RandomAlbums() {
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
ensurePriority="high"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
+6
-15
@@ -1,4 +1,4 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { getRandomSongs } from '../api/subsonicLibrary';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
@@ -8,7 +8,6 @@ import { Play, ListPlus, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import SongRail from '../components/SongRail';
|
||||
import VirtualSongList from '../components/VirtualSongList';
|
||||
import { playSongNow } from '../utils/playback/playSong';
|
||||
@@ -89,15 +88,6 @@ export default function Tracks() {
|
||||
reloadRated();
|
||||
}, [activeServerId, rerollHero, rerollRandom, reloadRated]);
|
||||
|
||||
const heroCoverUrl = useMemo(
|
||||
() => (hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : ''),
|
||||
[hero?.coverArt],
|
||||
);
|
||||
const heroCoverKey = useMemo(
|
||||
() => (hero?.coverArt ? coverArtCacheKey(hero.coverArt, 600) : ''),
|
||||
[hero?.coverArt],
|
||||
);
|
||||
|
||||
// Hide the hero song from the random rail if the server happens to return it in
|
||||
// both fetches (Navidrome's getRandomSongs sometimes overlaps within a short window).
|
||||
const railSongs = useMemo(
|
||||
@@ -119,10 +109,11 @@ export default function Tracks() {
|
||||
{!perfFlags.disableMainstageHero && hero && (
|
||||
<section className="tracks-hero">
|
||||
<div className="tracks-hero-cover">
|
||||
{heroCoverUrl ? (
|
||||
<CachedImage
|
||||
src={heroCoverUrl}
|
||||
cacheKey={heroCoverKey}
|
||||
{hero.coverArt ? (
|
||||
<CoverArtImage
|
||||
coverArtId={hero.coverArt}
|
||||
displayCssPx={600}
|
||||
surface="sparse"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user