mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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,3 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
@@ -9,7 +8,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import type { CoverPrefetchPriority } from '../cover/types';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { coverStorageKey } from '../cover/storageKeys';
|
||||
import { resolveCoverDisplayTier } from '../cover/tiers';
|
||||
import { acquireUrl } from '../utils/imageCache/urlPool';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { playAlbum } from '../utils/playback/playAlbum';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
@@ -24,7 +28,14 @@ interface AlbumCardProps {
|
||||
showRating?: boolean;
|
||||
selectedAlbums?: SubsonicAlbum[];
|
||||
disableArtwork?: boolean;
|
||||
/** Layout-native cover square width in CSS px (from parent grid). */
|
||||
displayCssPx?: number;
|
||||
/** @deprecated Use displayCssPx — kept for call-site transition only */
|
||||
artworkSize?: number;
|
||||
/** In-page scroll viewport (`VirtualCardGrid` `scrollRootId`) for cover IO priority. */
|
||||
observeScrollRootId?: string;
|
||||
/** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */
|
||||
ensurePriority?: CoverPrefetchPriority;
|
||||
}
|
||||
|
||||
function AlbumCard({
|
||||
@@ -35,7 +46,10 @@ function AlbumCard({
|
||||
showRating = false,
|
||||
selectedAlbums = [],
|
||||
disableArtwork = false,
|
||||
artworkSize = 300,
|
||||
displayCssPx = COVER_DENSE_GRID_MIN_CELL_CSS_PX,
|
||||
artworkSize: _artworkSize,
|
||||
observeScrollRootId,
|
||||
ensurePriority,
|
||||
}: AlbumCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -47,16 +61,12 @@ function AlbumCard({
|
||||
if (!meta || meta.trackIds.length === 0) return false;
|
||||
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||
});
|
||||
// buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
|
||||
const coverUrl = useMemo(
|
||||
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, artworkSize) : ''),
|
||||
[album.coverArt, artworkSize],
|
||||
);
|
||||
const coverCacheKey = useMemo(
|
||||
() => (album.coverArt ? coverArtCacheKey(album.coverArt, artworkSize) : ''),
|
||||
[album.coverArt, artworkSize],
|
||||
);
|
||||
const psyDrag = useDragDrop();
|
||||
const dragCoverKey = useMemo(() => {
|
||||
if (!album.coverArt) return '';
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
||||
return coverStorageKey({ kind: 'active' }, album.coverArt, tier);
|
||||
}, [album.coverArt, displayCssPx]);
|
||||
const isNewAlbum = isAlbumRecentlyAdded(album.created);
|
||||
const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]);
|
||||
|
||||
@@ -89,7 +99,8 @@ function AlbumCard({
|
||||
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl: coverUrl || undefined }, me.clientX, me.clientY);
|
||||
const coverUrl = dragCoverKey ? acquireUrl(dragCoverKey) ?? undefined : undefined;
|
||||
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl }, me.clientX, me.clientY);
|
||||
}
|
||||
};
|
||||
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||
@@ -98,13 +109,16 @@ function AlbumCard({
|
||||
}}
|
||||
>
|
||||
<div className="album-card-cover">
|
||||
{!disableArtwork && coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
{!disableArtwork && album.coverArt ? (
|
||||
<CoverArtImage
|
||||
coverArtId={album.coverArt}
|
||||
displayCssPx={displayCssPx}
|
||||
surface="dense"
|
||||
alt={`${album.name} Cover`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
observeScrollRootId={observeScrollRootId}
|
||||
ensurePriority={ensurePriority}
|
||||
/>
|
||||
) : (
|
||||
<div className="album-card-cover-placeholder">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { buildCoverArtUrl } from '../api/subsonicStreamUrl';
|
||||
import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { useCoverLightboxSrc } from '../cover/lightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
@@ -68,8 +68,7 @@ interface AlbumHeaderProps {
|
||||
/** OpenSubsonic album credits (derived from album + songs). */
|
||||
headerArtistRefs: SubsonicOpenArtistRef[];
|
||||
songs: SubsonicSong[];
|
||||
coverUrl: string;
|
||||
coverKey: string;
|
||||
coverArtId?: string;
|
||||
resolvedCoverUrl: string | null;
|
||||
isStarred: boolean;
|
||||
downloadProgress: number | null;
|
||||
@@ -96,8 +95,7 @@ export default function AlbumHeader({
|
||||
info,
|
||||
headerArtistRefs,
|
||||
songs,
|
||||
coverUrl,
|
||||
coverKey,
|
||||
coverArtId,
|
||||
resolvedCoverUrl,
|
||||
isStarred,
|
||||
downloadProgress,
|
||||
@@ -122,7 +120,14 @@ export default function AlbumHeader({
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const coverRef = useMemo(
|
||||
() => (coverArtId ? coverArtRef(coverArtId) : null),
|
||||
[coverArtId],
|
||||
);
|
||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
|
||||
alt: `${info.name} Cover`,
|
||||
});
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
@@ -130,11 +135,6 @@ export default function AlbumHeader({
|
||||
const isNewAlbum = isAlbumRecentlyAdded(info.created);
|
||||
const showBioButton = !isVariousArtistsLabel(info.artist);
|
||||
|
||||
const lightboxCoverSrc = useMemo(
|
||||
() => (info.coverArt ? buildCoverArtUrl(info.coverArt, 2000) : ''),
|
||||
[info.coverArt],
|
||||
);
|
||||
|
||||
const handleShareAlbum = async () => {
|
||||
try {
|
||||
const ok = await copyEntityShareLink('album', info.id);
|
||||
@@ -148,13 +148,7 @@ export default function AlbumHeader({
|
||||
return (
|
||||
<>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
{lightboxOpen && info.coverArt && (
|
||||
<CoverLightbox
|
||||
src={lightboxCoverSrc}
|
||||
alt={`${info.name} Cover`}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{lightbox}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && enableCoverArtBackground && (
|
||||
@@ -173,14 +167,20 @@ export default function AlbumHeader({
|
||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
{coverArtId ? (
|
||||
<button
|
||||
className="album-detail-cover-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
onClick={openLightbox}
|
||||
data-tooltip={t('albumDetail.enlargeCover')}
|
||||
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
|
||||
>
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
<CoverArtImage
|
||||
className="album-detail-cover"
|
||||
coverArtId={coverArtId}
|
||||
displayCssPx={400}
|
||||
surface="sparse"
|
||||
alt={`${info.name} Cover`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { coverArtIdFromArtist } from '../cover/ids';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -13,19 +14,16 @@ interface Props {
|
||||
export default function ArtistCardLocal({ artist }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
// buildCoverArtUrl generates a new crypto salt on every call — must be
|
||||
// memoized to prevent a new URL on every parent re-render causing refetch loops.
|
||||
const coverSrc = useMemo(() => buildCoverArtUrl(coverId, 300), [coverId]);
|
||||
const coverCacheKey = useMemo(() => coverArtCacheKey(coverId, 300), [coverId]);
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
|
||||
return (
|
||||
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverCacheKey}
|
||||
{artist.coverArt || artist.id ? (
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Music } from 'lucide-react';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
||||
import { coverImgSrc } from '../cover/imgSrc';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { primeAlbumCoversForDisplay } from '../cover/warmDiskPeek';
|
||||
import {
|
||||
readBecauseYouLikeCache,
|
||||
writeBecauseYouLikeCache,
|
||||
type BecauseYouLikeAnchor,
|
||||
} from '../store/becauseYouLikeCache';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { playAlbum } from '../utils/playback/playAlbum';
|
||||
@@ -39,11 +47,229 @@ const SIMILAR_FETCH = 25;
|
||||
const SIMILAR_PICK = 6;
|
||||
const SHOW_COUNT = 3;
|
||||
const PICKS_HISTORY_SIZE = 30;
|
||||
const COVER_SIZE = 300;
|
||||
/** `.because-card-cover-wrap` layout square (160×160). */
|
||||
const BECAUSE_CARD_COVER_CSS_PX = 160;
|
||||
const ROW_STAGGER_MS = 150;
|
||||
|
||||
interface Anchor {
|
||||
id: string;
|
||||
name: string;
|
||||
// ── Module-level reserve: next batch pre-fetched in background after each display ──
|
||||
type BecauseReserve = {
|
||||
serverId: string;
|
||||
// poolKey intentionally omitted — reserve is valid for any pool state on the
|
||||
// same server. Pool (top-played artists) changes slowly; showing a slightly-off
|
||||
// anchor once before the next fill corrects it is far better than showing a
|
||||
// skeleton because the pool hadn't loaded yet.
|
||||
anchor: BecauseYouLikeAnchor;
|
||||
recs: SubsonicAlbum[];
|
||||
/** Rotation state to commit to localStorage when this reserve is consumed. */
|
||||
nextAnchorHistory: string[];
|
||||
nextPicksHistory: string[];
|
||||
};
|
||||
let _becauseReserve: BecauseReserve | null = null;
|
||||
let _becauseReserveFilling = false;
|
||||
|
||||
/** Helper: read a JSON string[] from localStorage, returning [] on any failure. */
|
||||
function readJsonArray(key: string | null): string[] {
|
||||
if (!key) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a set of album picks for one anchor candidate. */
|
||||
async function resolvePicks(
|
||||
candidate: BecauseYouLikeAnchor,
|
||||
recentPicks: Set<string>,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH });
|
||||
const similar = (info.similarArtist ?? []).filter(s => s.id);
|
||||
if (similar.length === 0) return null;
|
||||
|
||||
const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK);
|
||||
const results = await Promise.all(sampled.map(s => getArtist(s.id).catch(() => null)));
|
||||
|
||||
const picks: SubsonicAlbum[] = [];
|
||||
for (const r of results) {
|
||||
if (!r || r.albums.length === 0) continue;
|
||||
const fresh = r.albums.filter(a => !recentPicks.has(a.id));
|
||||
const choice = fresh.length > 0 ? fresh : r.albums;
|
||||
const album = choice[Math.floor(Math.random() * choice.length)];
|
||||
picks.push(album);
|
||||
if (picks.length >= SHOW_COUNT) break;
|
||||
}
|
||||
return picks.length > 0 ? picks : null;
|
||||
}
|
||||
|
||||
type FetchBecauseResult = {
|
||||
anchor: BecauseYouLikeAnchor;
|
||||
recs: SubsonicAlbum[];
|
||||
nextAnchorHistory: string[];
|
||||
nextPicksHistory: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Core fetch: rotate anchor, call Last.fm / Subsonic, return result + updated
|
||||
* rotation snapshots. Does NOT touch React state or localStorage — callers do that.
|
||||
* Reads the CURRENT localStorage values so it always reflects the latest rotation.
|
||||
*/
|
||||
async function fetchBecauseYouLike(
|
||||
pool: BecauseYouLikeAnchor[],
|
||||
anchorHistKey: string | null,
|
||||
picksHistKey: string | null,
|
||||
): Promise<FetchBecauseResult | null> {
|
||||
const anchorHistory = readJsonArray(anchorHistKey);
|
||||
const picksHistory = readJsonArray(picksHistKey);
|
||||
|
||||
const cooldown = Math.min(ANCHOR_COOLDOWN, Math.max(0, Math.floor(pool.length / 2)));
|
||||
const recentAnchors = new Set(anchorHistory.slice(-cooldown));
|
||||
const eligibleRaw = pool.filter(a => !recentAnchors.has(a.id));
|
||||
const eligible = eligibleRaw.length > 0 ? eligibleRaw : pool.slice();
|
||||
const candidates = shuffleArray(eligible);
|
||||
const recentPicks = new Set(picksHistory);
|
||||
|
||||
const tries = Math.min(ANCHOR_MAX_TRIES, candidates.length);
|
||||
const tryList = candidates.slice(0, tries);
|
||||
|
||||
const buildResult = (candidate: BecauseYouLikeAnchor, picks: SubsonicAlbum[]): FetchBecauseResult => ({
|
||||
anchor: candidate,
|
||||
recs: picks,
|
||||
nextAnchorHistory: [...anchorHistory, candidate.id].slice(-ANCHOR_COOLDOWN),
|
||||
nextPicksHistory: [...picksHistory, ...picks.map(p => p.id)].slice(-PICKS_HISTORY_SIZE),
|
||||
});
|
||||
|
||||
/** First two shuffled anchors in parallel — cuts cold-start wait on slow Last.fm. */
|
||||
if (tryList.length >= 2) {
|
||||
const raced = await Promise.all(
|
||||
tryList.slice(0, 2).map(async candidate => {
|
||||
try {
|
||||
const picks = await resolvePicks(candidate, recentPicks);
|
||||
return picks ? { candidate, picks } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const hit = raced.find((r): r is { candidate: BecauseYouLikeAnchor; picks: SubsonicAlbum[] } => r != null);
|
||||
if (hit) return buildResult(hit.candidate, hit.picks);
|
||||
}
|
||||
|
||||
for (const candidate of tryList) {
|
||||
try {
|
||||
const picks = await resolvePicks(candidate, recentPicks);
|
||||
if (!picks) continue;
|
||||
return buildResult(candidate, picks);
|
||||
} catch {
|
||||
/* try next anchor */
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: fetch the next batch in the background so the next visit is
|
||||
* instant. localStorage rotation is NOT updated here — the snapshots are stored
|
||||
* in the reserve and applied only when the reserve is consumed.
|
||||
* Covers are NOT pre-warmed here (avoids bumpDiskSrcCache side-effects on the
|
||||
* currently-visible page); they are warmed via primeAlbumCoversForDisplay on consume.
|
||||
*/
|
||||
async function fillBecauseReserve(
|
||||
pool: BecauseYouLikeAnchor[],
|
||||
serverId: string,
|
||||
anchorHistKey: string | null,
|
||||
picksHistKey: string | null,
|
||||
): Promise<void> {
|
||||
if (_becauseReserveFilling) return;
|
||||
_becauseReserveFilling = true;
|
||||
try {
|
||||
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey);
|
||||
if (result) {
|
||||
_becauseReserve = { serverId, ...result };
|
||||
// Also refresh the session snapshot so a quick leave→return can pick up
|
||||
// newer cards even before the reserve is explicitly consumed.
|
||||
writeBecauseYouLikeCache({ serverId, anchor: result.anchor, recs: result.recs });
|
||||
}
|
||||
} catch {
|
||||
/* Network failure — next visit falls back to a fresh fetch. */
|
||||
} finally {
|
||||
_becauseReserveFilling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** One classic because-card shell, then extra grid slots fill in. */
|
||||
function useBecauseRowSlotCount(active: boolean, max = SHOW_COUNT): number {
|
||||
const [count, setCount] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setCount(1);
|
||||
return;
|
||||
}
|
||||
setCount(1);
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
for (let slot = 2; slot <= max; slot += 1) {
|
||||
timers.push(setTimeout(() => setCount(slot), ROW_STAGGER_MS * (slot - 1)));
|
||||
}
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [active, max]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Lead placeholder — same shell as a loaded because-card (cover + text block). */
|
||||
function BecauseCardSkeletonLead() {
|
||||
return (
|
||||
<div className="because-card because-card--skeleton because-card--skeleton-lead" aria-hidden="true">
|
||||
<div className="because-card-cover-wrap">
|
||||
<div className="because-card-cover because-card-cover-placeholder" />
|
||||
</div>
|
||||
<div className="because-card-text">
|
||||
<div className="because-card-top">
|
||||
<div className="because-card-skeleton-line because-card-skeleton-line--similar" />
|
||||
<div className="because-card-skeleton-line because-card-skeleton-line--title" />
|
||||
<div className="because-card-skeleton-line because-card-skeleton-line--artist" />
|
||||
<div className="because-card-skeleton-line because-card-skeleton-line--meta" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Extra grid slots — cover tile only, fills in beside the lead card. */
|
||||
function BecauseCardSkeletonSlot({ enter }: { enter?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`because-card because-card--skeleton because-card--skeleton-slot${
|
||||
enter ? ' because-card--slot-enter' : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="because-card-cover-wrap">
|
||||
<div className="because-card-cover because-card-cover-placeholder" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BecauseYouLikeSkeleton({ title, slotCount }: { title: string; slotCount: number }) {
|
||||
return (
|
||||
<section className="album-row-section because-you-like-rail">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="because-card-grid because-card-grid--stagger">
|
||||
{slotCount >= 1 ? <BecauseCardSkeletonLead /> : null}
|
||||
{slotCount >= 2 ? <BecauseCardSkeletonSlot enter /> : null}
|
||||
{slotCount >= 3 ? <BecauseCardSkeletonSlot enter /> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -57,9 +283,9 @@ interface Props {
|
||||
* Cycling sources (most-played, recently-played, starred) means the per-mount
|
||||
* rotation cursor visits a different listening *mode* each visit instead of
|
||||
* walking only down the top-played list. */
|
||||
function buildAnchorPool(sources: SubsonicAlbum[][], limit: number): Anchor[] {
|
||||
function buildAnchorPool(sources: SubsonicAlbum[][], limit: number): BecauseYouLikeAnchor[] {
|
||||
const seen = new Set<string>();
|
||||
const out: Anchor[] = [];
|
||||
const out: BecauseYouLikeAnchor[] = [];
|
||||
const maxLen = sources.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
for (let i = 0; i < maxLen && out.length < limit; i++) {
|
||||
for (const src of sources) {
|
||||
@@ -83,16 +309,9 @@ function anchorHistoryKey(serverId: string | null): string | null {
|
||||
function picksHistoryKey(serverId: string | null): string | null {
|
||||
return serverId ? `${PICKS_HISTORY_KEY_PREFIX}${serverId}` : null;
|
||||
}
|
||||
function readJsonArray(key: string | null): string[] {
|
||||
if (!key) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
function hasValidReserve(serverId: string | null): boolean {
|
||||
return _becauseReserve != null && _becauseReserve.serverId === (serverId ?? '');
|
||||
}
|
||||
|
||||
export default function BecauseYouLikeRail({
|
||||
@@ -107,18 +326,62 @@ export default function BecauseYouLikeRail({
|
||||
() => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL),
|
||||
[mostPlayed, recentlyPlayed, starred],
|
||||
);
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||||
const [recs, setRecs] = useState<SubsonicAlbum[]>([]);
|
||||
const poolKey = useMemo(
|
||||
() => pool.slice(0, 8).map(a => a.id).join('\u0001'),
|
||||
[pool],
|
||||
);
|
||||
// Initialise state in priority order: reserve (new batch) > session cache (stale-while-
|
||||
// revalidate) > skeleton. Both checks work without poolKey so they fire correctly on the
|
||||
// first render when pool is still [] (Home.tsx loads mostPlayed asynchronously).
|
||||
const [anchor, setAnchor] = useState<BecauseYouLikeAnchor | null>(() => {
|
||||
if (hasValidReserve(activeServerId)) return _becauseReserve!.anchor;
|
||||
return readBecauseYouLikeCache(activeServerId)?.anchor ?? null;
|
||||
});
|
||||
const [recs, setRecs] = useState<SubsonicAlbum[]>(() => {
|
||||
if (hasValidReserve(activeServerId)) return _becauseReserve!.recs;
|
||||
return readBecauseYouLikeCache(activeServerId)?.recs ?? [];
|
||||
});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(() => {
|
||||
if (hasValidReserve(activeServerId)) return false;
|
||||
const snap = readBecauseYouLikeCache(activeServerId);
|
||||
return !snap || snap.recs.length === 0;
|
||||
});
|
||||
const skeletonSlots = useBecauseRowSlotCount(refreshing, SHOW_COUNT);
|
||||
const contentReady = !refreshing && Boolean(anchor) && recs.length > 0;
|
||||
const contentSlots = contentReady ? recs.length : 1;
|
||||
|
||||
/** On every navigation / server / pool change: apply reserve immediately
|
||||
* (synchronous, before browser paint) or fall back to session cache (stale-
|
||||
* while-revalidate), only clearing to skeleton when nothing is available. */
|
||||
useLayoutEffect(() => {
|
||||
if (hasValidReserve(activeServerId)) {
|
||||
setAnchor(_becauseReserve!.anchor);
|
||||
setRecs(_becauseReserve!.recs);
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
const snap = readBecauseYouLikeCache(activeServerId);
|
||||
if (snap && snap.recs.length > 0) {
|
||||
setAnchor(snap.anchor);
|
||||
setRecs(snap.recs);
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setRefreshing(true);
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
}
|
||||
}
|
||||
}, [activeServerId, poolKey]);
|
||||
|
||||
// 696px ≙ exactly 2 BecauseCards side-by-side (2*340 + 16 gap). Below that
|
||||
// the hero-style cards stretch full-width and dwarf the rest of the page,
|
||||
// so we swap in a standard AlbumRow which is already perf-tuned for narrow
|
||||
// rails (artwork budget, viewport windowing, scroll-paging).
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
setNarrow(el.getBoundingClientRect().width < 696);
|
||||
const ro = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
setNarrow(entry.contentRect.width < 696);
|
||||
@@ -131,89 +394,133 @@ export default function BecauseYouLikeRail({
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (pool.length === 0) {
|
||||
// Pool is still being loaded (Home.tsx fetches data asynchronously). Do not
|
||||
// run the fetch/reserve logic yet — useLayoutEffect already shows reserve or
|
||||
// cache content. The effect will re-run once pool is populated.
|
||||
return;
|
||||
}
|
||||
if (!activeServerId) {
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorHistKey = anchorHistoryKey(activeServerId);
|
||||
const picksHistKey = picksHistoryKey(activeServerId);
|
||||
const anchorHistory = readJsonArray(anchorHistKey);
|
||||
const picksHistory = readJsonArray(picksHistKey);
|
||||
const snap = readBecauseYouLikeCache(activeServerId);
|
||||
|
||||
/** Cooldown caps at half the pool size so a small library doesn't soft-lock
|
||||
* itself out (a server with 4 anchor-eligible artists shouldn't be told
|
||||
* "the last 5 are forbidden"). */
|
||||
const cooldown = Math.min(ANCHOR_COOLDOWN, Math.max(0, Math.floor(pool.length / 2)));
|
||||
const recentAnchors = new Set(anchorHistory.slice(-cooldown));
|
||||
const eligibleRaw = pool.filter(a => !recentAnchors.has(a.id));
|
||||
const eligible = eligibleRaw.length > 0 ? eligibleRaw : pool.slice();
|
||||
const candidates = shuffleArray(eligible);
|
||||
const recentPicks = new Set(picksHistory);
|
||||
// Consume module-level reserve (keyed by serverId only — poolKey omitted so
|
||||
// the reserve is usable even before pool has loaded on first render).
|
||||
const reserved = hasValidReserve(activeServerId) ? _becauseReserve : null;
|
||||
_becauseReserve = null;
|
||||
|
||||
(async () => {
|
||||
const tries = Math.min(ANCHOR_MAX_TRIES, candidates.length);
|
||||
/** Random pick (with cooldown) replaces deterministic round-robin so the
|
||||
* same anchor doesn't surface every pool.length mounts. The retry loop
|
||||
* still walks forward through the shuffled `candidates` list when the
|
||||
* current pick is a dud (no Last.fm similar artists, or no library
|
||||
* matches). On success: append the chosen anchor + chosen album ids to
|
||||
* their respective ring buffers so future mounts see different stuff. */
|
||||
for (let i = 0; i < tries; i++) {
|
||||
if (reserved) {
|
||||
// ── Reserve path: instant display, no network ──────────────────────
|
||||
await primeAlbumCoversForDisplay(reserved.recs, BECAUSE_CARD_COVER_CSS_PX, {
|
||||
limit: SHOW_COUNT,
|
||||
disabled: disableArtwork,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const candidate = candidates[i];
|
||||
// Advance rotation in localStorage now that these picks are being shown.
|
||||
try {
|
||||
const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH });
|
||||
if (cancelled) return;
|
||||
const similar = (info.similarArtist ?? []).filter(s => s.id);
|
||||
if (similar.length === 0) continue;
|
||||
|
||||
const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK);
|
||||
const results = await Promise.all(
|
||||
sampled.map(s => getArtist(s.id).catch(() => null))
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
||||
const picks: SubsonicAlbum[] = [];
|
||||
for (const r of results) {
|
||||
if (!r || r.albums.length === 0) continue;
|
||||
/** Prefer an album not in the recently-shown buffer; fall back to
|
||||
* *any* album when the artist's whole catalogue is in the buffer
|
||||
* so the slot isn't lost. */
|
||||
const fresh = r.albums.filter(a => !recentPicks.has(a.id));
|
||||
const choice = fresh.length > 0 ? fresh : r.albums;
|
||||
const album = choice[Math.floor(Math.random() * choice.length)];
|
||||
picks.push(album);
|
||||
if (picks.length >= SHOW_COUNT) break;
|
||||
}
|
||||
if (picks.length === 0) continue;
|
||||
|
||||
const newAnchorHistory = [...anchorHistory, candidate.id].slice(-ANCHOR_COOLDOWN);
|
||||
const newPicksHistory = [...picksHistory, ...picks.map(p => p.id)].slice(-PICKS_HISTORY_SIZE);
|
||||
try {
|
||||
if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(newAnchorHistory));
|
||||
if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(newPicksHistory));
|
||||
} catch { /* ignore */ }
|
||||
setAnchor(candidate);
|
||||
setRecs(picks);
|
||||
return;
|
||||
} catch {
|
||||
/* network / server error — try next anchor */
|
||||
if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(reserved.nextAnchorHistory));
|
||||
if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(reserved.nextPicksHistory));
|
||||
} catch { /* ignore */ }
|
||||
setAnchor(reserved.anchor);
|
||||
setRecs(reserved.recs);
|
||||
if (activeServerId) {
|
||||
writeBecauseYouLikeCache({ serverId: activeServerId, anchor: reserved.anchor, recs: reserved.recs });
|
||||
}
|
||||
setRefreshing(false);
|
||||
// Pre-fetch the next batch so the next visit is also instant.
|
||||
void fillBecauseReserve(pool, activeServerId, anchorHistKey, picksHistKey);
|
||||
return;
|
||||
}
|
||||
if (!cancelled) {
|
||||
|
||||
// Keep visible cards stable on return visits: if we already have a valid
|
||||
// session snapshot, leave it on screen and only prefetch the next batch
|
||||
// for the next mount instead of swapping cards mid-visit.
|
||||
if (snap && snap.recs.length > 0) {
|
||||
setRefreshing(false);
|
||||
void fillBecauseReserve(pool, activeServerId, anchorHistKey, picksHistKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Full-fetch path (first visit or reserve miss) ──────────────────
|
||||
// Only clear to skeleton if nothing is currently displayed. When cached
|
||||
// content is visible, leave it in place and swap silently (stale-while-
|
||||
// revalidate) — better UX than flashing a skeleton for a network round-trip.
|
||||
if (!snap || snap.recs.length === 0) {
|
||||
setRefreshing(true);
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
}
|
||||
|
||||
const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey);
|
||||
if (cancelled) return;
|
||||
|
||||
if (result) {
|
||||
await primeAlbumCoversForDisplay(result.recs, BECAUSE_CARD_COVER_CSS_PX, {
|
||||
limit: SHOW_COUNT,
|
||||
disabled: disableArtwork,
|
||||
});
|
||||
if (cancelled) return;
|
||||
try {
|
||||
if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(result.nextAnchorHistory));
|
||||
if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(result.nextPicksHistory));
|
||||
} catch { /* ignore */ }
|
||||
setAnchor(result.anchor);
|
||||
setRecs(result.recs);
|
||||
if (activeServerId) {
|
||||
writeBecauseYouLikeCache({ serverId: activeServerId, anchor: result.anchor, recs: result.recs });
|
||||
}
|
||||
setRefreshing(false);
|
||||
// Pre-fetch next batch so the next visit is instant.
|
||||
void fillBecauseReserve(pool, activeServerId, anchorHistKey, picksHistKey);
|
||||
} else {
|
||||
// Network failed — restore session cache if available.
|
||||
if (snap) {
|
||||
await primeAlbumCoversForDisplay(snap.recs, BECAUSE_CARD_COVER_CSS_PX, {
|
||||
limit: SHOW_COUNT,
|
||||
disabled: disableArtwork,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setAnchor(snap.anchor);
|
||||
setRecs(snap.recs);
|
||||
} else if (!cancelled) {
|
||||
setAnchor(null);
|
||||
setRecs([]);
|
||||
}
|
||||
if (!cancelled) setRefreshing(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [pool, activeServerId]);
|
||||
}, [pool, activeServerId, disableArtwork, poolKey]);
|
||||
|
||||
if (!anchor || recs.length === 0) {
|
||||
useEffect(() => {
|
||||
if (disableArtwork || recs.length === 0) return;
|
||||
const refs = recs.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : []));
|
||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
||||
}, [recs, disableArtwork]);
|
||||
|
||||
if (pool.length === 0) {
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
|
||||
if (refreshing || !anchor || recs.length === 0) {
|
||||
if (!refreshing && (!anchor || recs.length === 0)) {
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<BecauseYouLikeSkeleton title={t('home.becauseYouLike')} slotCount={skeletonSlots} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionTitle = t('home.becauseYouLikeFor', { artist: anchor.name });
|
||||
|
||||
return (
|
||||
@@ -227,13 +534,14 @@ export default function BecauseYouLikeRail({
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="because-card-grid">
|
||||
{recs.map(album => (
|
||||
<div className="because-card-grid because-card-grid--stagger">
|
||||
{recs.slice(0, contentSlots).map((album, index) => (
|
||||
<BecauseCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
anchor={anchor.name}
|
||||
disableArtwork={disableArtwork}
|
||||
enter={index > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -247,22 +555,19 @@ interface CardProps {
|
||||
album: SubsonicAlbum;
|
||||
anchor: string;
|
||||
disableArtwork: boolean;
|
||||
enter?: boolean;
|
||||
}
|
||||
|
||||
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }: CardProps) {
|
||||
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, enter }: CardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const coverUrl = useMemo(
|
||||
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, COVER_SIZE) : ''),
|
||||
[album.coverArt],
|
||||
);
|
||||
const coverKey = useMemo(
|
||||
() => (album.coverArt ? coverArtCacheKey(album.coverArt, COVER_SIZE) : ''),
|
||||
[album.coverArt],
|
||||
);
|
||||
const bgResolved = useCachedUrl(coverUrl, coverKey);
|
||||
|
||||
const coverHandle = useCoverArt(album.coverArt, BECAUSE_CARD_COVER_CSS_PX, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
const imgSrc = coverImgSrc(coverHandle.src);
|
||||
const bgResolved = coverHandle.src;
|
||||
const handleOpen = () => navigate(`/album/${album.id}`);
|
||||
const handlePlay = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -282,7 +587,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }:
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="because-card"
|
||||
className={`because-card${enter ? ' because-card--slot-enter' : ''}`}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
|
||||
aria-label={`${album.name} – ${album.artist}`}
|
||||
@@ -295,14 +600,22 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }:
|
||||
/>
|
||||
)}
|
||||
<div className="because-card-cover-wrap">
|
||||
{!disableArtwork && coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverKey}
|
||||
alt={album.name}
|
||||
className="because-card-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{!disableArtwork && album.coverArt ? (
|
||||
imgSrc ? (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={album.name}
|
||||
className="because-card-cover"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
onError={coverHandle.onImgError}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="because-card-cover because-card-cover-placeholder because-card-cover-loading"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="because-card-cover because-card-cover-placeholder" aria-hidden="true">
|
||||
<Music size={42} strokeWidth={1.5} />
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
|
||||
</div>
|
||||
{servers.map(srv => {
|
||||
const active = srv.id === activeServerId;
|
||||
const busy = switchingId !== null;
|
||||
const busy = switchingId === srv.id;
|
||||
const labelText = serverListDisplayLabel(srv, servers);
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -54,7 +54,7 @@ import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -64,6 +64,7 @@ beforeEach(() => {
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
vi.mocked(useCachedUrl).mockClear();
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
@@ -107,9 +108,10 @@ describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
// Find the call whose cacheKey targets the 500 px cover (`...:cover:art-1:500`).
|
||||
const coverCall = calls.find(c => typeof c[1] === 'string' && c[1].includes(':500'));
|
||||
const coverCall = calls.find(c => c[2] === false);
|
||||
expect(coverCall).toBeDefined();
|
||||
expect(coverCall?.[2]).toBe(false);
|
||||
expect(typeof coverCall?.[1]).toBe('string');
|
||||
expect(String(coverCall?.[1])).toContain('art-1');
|
||||
});
|
||||
|
||||
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
|
||||
@@ -117,10 +119,9 @@ describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
|
||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||
|
||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||
// The 300 px art box uses the default (truthy) opt.
|
||||
const artBoxCall = calls.find(c => typeof c[1] === 'string' && c[1].includes(':300'));
|
||||
expect(artBoxCall).toBeDefined();
|
||||
expect(artBoxCall?.[2]).toBe(true);
|
||||
const defaultOptCalls = calls.filter(c => c[2] !== false);
|
||||
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(defaultOptCalls.some(c => String(c[1]).includes('art-1'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+27
-21
@@ -1,11 +1,11 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { playAlbum } from '../utils/playback/playAlbum';
|
||||
@@ -20,6 +20,10 @@ const INTERVAL_MS = 10000;
|
||||
const HERO_ALBUM_COUNT = 8;
|
||||
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
|
||||
const HERO_RANDOM_POOL = 32;
|
||||
/** Hero foreground cover (`.hero-cover` 220×220). */
|
||||
const HERO_FG_CSS_PX = 220;
|
||||
/** Hero blurred backdrop (full banner height). */
|
||||
const HERO_BG_CSS_PX = 360;
|
||||
|
||||
// Crossfading background — same layer pattern as FullscreenPlayer
|
||||
function HeroBg({ url }: { url: string }) {
|
||||
@@ -40,14 +44,16 @@ function HeroBg({ url }: { url: string }) {
|
||||
return (
|
||||
<>
|
||||
{layers.map(layer => (
|
||||
<div
|
||||
<img
|
||||
key={layer.id}
|
||||
className="hero-bg"
|
||||
style={{
|
||||
backgroundImage: `url(${layer.url})`,
|
||||
opacity: layer.visible ? 1 : 0,
|
||||
}}
|
||||
className="hero-bg-image"
|
||||
src={layer.url}
|
||||
style={{ opacity: layer.visible ? 1 : 0 }}
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
draggable={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -158,6 +164,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (heroInView || windowHidden) return;
|
||||
// Recovery guard: if a scroll/RAF event was missed while hero was outside
|
||||
@@ -252,18 +259,15 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
});
|
||||
}, [album?.id]);
|
||||
|
||||
// buildCoverArtUrl generates a new salt on every call — must be memoized.
|
||||
const bgRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '', [album?.coverArt]);
|
||||
const bgCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '', [album?.coverArt]);
|
||||
const resolvedBgUrl = useCachedUrl(bgRawUrl, bgCacheKey);
|
||||
const bgHandle = useCoverArt(album?.coverArt, HERO_BG_CSS_PX, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'high',
|
||||
});
|
||||
|
||||
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
|
||||
// transition (which would cause the background to flash empty before fading in).
|
||||
const stableBgUrl = useRef('');
|
||||
if (resolvedBgUrl) stableBgUrl.current = resolvedBgUrl;
|
||||
|
||||
const coverRawUrl = useMemo(() => album?.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '', [album?.coverArt]);
|
||||
const coverCacheKey = useMemo(() => album?.coverArt ? coverArtCacheKey(album.coverArt, 300) : '', [album?.coverArt]);
|
||||
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
|
||||
|
||||
if (!album) return <div className="hero-placeholder" />;
|
||||
|
||||
@@ -280,12 +284,14 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
|
||||
|
||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||
<div className="hero-content animate-fade-in" key={album.id}>
|
||||
{coverRawUrl && !isMobile && (
|
||||
<CachedImage
|
||||
<div className="hero-content" key={album.id}>
|
||||
{album.coverArt && !isMobile && (
|
||||
<CoverArtImage
|
||||
coverArtId={album.coverArt}
|
||||
displayCssPx={HERO_FG_CSS_PX}
|
||||
surface="dense"
|
||||
ensurePriority="high"
|
||||
className="hero-cover"
|
||||
src={coverRawUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
alt={`${album.name} Cover`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getSong } from '../api/subsonicLibrary';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -10,9 +9,11 @@ import {
|
||||
declineOrbitSuggestion,
|
||||
suggestionKey,
|
||||
} from '../utils/orbit';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
||||
|
||||
const HOST_APPROVAL_COVER_CSS_PX = 36;
|
||||
|
||||
/**
|
||||
* Host-only approval strip. Renders directly below the OrbitQueueHead
|
||||
* when `autoApprove === false` and at least one guest suggestion is
|
||||
@@ -85,9 +86,10 @@ export default function HostApprovalQueue() {
|
||||
return (
|
||||
<div key={key} className="host-approval__item">
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
<CoverArtImage
|
||||
coverArtId={song.coverArt}
|
||||
displayCssPx={HOST_APPROVAL_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="host-approval__cover"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { subscribeLibrarySyncIdle, subscribeLibrarySyncProgress } from '../api/library';
|
||||
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
@@ -28,7 +27,12 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
|
||||
import { coverArtIdFromArtist } from '../cover/ids';
|
||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
@@ -37,23 +41,28 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
|
||||
type LiveSearchSource = 'local' | 'network';
|
||||
|
||||
function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]);
|
||||
return <CachedImage className="search-result-thumb" src={src} cacheKey={cacheKey} alt="" />;
|
||||
return (
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
className="search-result-thumb"
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const src = useMemo(() => buildCoverArtUrl(coverId, 40), [coverId]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverId, 40), [coverId]);
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
useEffect(() => { setFailed(false); }, [coverId]);
|
||||
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
|
||||
return (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
className="search-result-thumb"
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||
@@ -413,6 +422,19 @@ export default function LiveSearch() {
|
||||
!!share.shareMatch ||
|
||||
(results && (results.artists.length || results.albums.length || results.songs.length));
|
||||
|
||||
useEffect(() => {
|
||||
if (!results || share.shareMatch) return () => {};
|
||||
const refs = [
|
||||
...results.artists.map(a => coverArtRef(coverArtIdFromArtist(a))),
|
||||
...results.albums.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])),
|
||||
...results.songs.flatMap(s => {
|
||||
const id = s.coverArt ?? s.albumId;
|
||||
return id ? [coverArtRef(id)] : [];
|
||||
}),
|
||||
];
|
||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
||||
}, [results, share.shareMatch]);
|
||||
|
||||
// Flat list of all navigable items for keyboard nav
|
||||
const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [
|
||||
{
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { coverArtIdFromArtist } from '../cover/ids';
|
||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
||||
import { coverArtRef } from '../cover/ref';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
@@ -32,11 +35,12 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
|
||||
}
|
||||
|
||||
/** Mobile search row thumb — larger than desktop live search (32px). */
|
||||
const MOBILE_SEARCH_THUMB_CSS_PX = 80;
|
||||
|
||||
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const src = useMemo(() => buildCoverArtUrl(coverId, 80), [coverId]);
|
||||
const ck = useMemo(() => coverArtCacheKey(coverId, 80), [coverId]);
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
useEffect(() => { setFailed(false); }, [coverId]);
|
||||
if (failed) {
|
||||
return (
|
||||
@@ -46,10 +50,11 @@ function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id'
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
||||
surface="dense"
|
||||
className="mobile-search-thumb mobile-search-thumb--artist-round"
|
||||
src={src}
|
||||
cacheKey={ck}
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||
@@ -98,6 +103,16 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
doSearch(query);
|
||||
}, [query, doSearch, share.shareMatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!results) return () => {};
|
||||
const refs = [
|
||||
...results.artists.map(a => coverArtRef(coverArtIdFromArtist(a))),
|
||||
...results.albums.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])),
|
||||
...results.songs.flatMap(s => (s.coverArt ? [coverArtRef(s.coverArt)] : [])),
|
||||
];
|
||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
||||
}, [results]);
|
||||
|
||||
const commit = (q: string) => {
|
||||
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
||||
};
|
||||
@@ -272,10 +287,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
{results!.albums.map(a => (
|
||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
||||
{a.coverArt ? (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={a.coverArt}
|
||||
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
||||
surface="dense"
|
||||
className="mobile-search-thumb"
|
||||
src={buildCoverArtUrl(a.coverArt, 80)}
|
||||
cacheKey={coverArtCacheKey(a.coverArt, 80)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
@@ -299,10 +315,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
{results!.songs.map(s => (
|
||||
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
|
||||
{s.coverArt ? (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={s.coverArt}
|
||||
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
||||
surface="dense"
|
||||
className="mobile-search-thumb"
|
||||
src={buildCoverArtUrl(s.coverArt, 80)}
|
||||
cacheKey={coverArtCacheKey(s.coverArt, 80)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildCoverArtUrl } from '../api/subsonicStreamUrl';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { getNowPlaying } from '../api/subsonicScrobble';
|
||||
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
|
||||
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
|
||||
@@ -162,7 +162,13 @@ export default function NowPlayingDropdown() {
|
||||
>
|
||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||
{stream.coverArt ? (
|
||||
<img src={buildCoverArtUrl(stream.coverArt, 100)} alt="Cover" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
<CoverArtImage
|
||||
coverArtId={stream.coverArt}
|
||||
displayCssPx={50}
|
||||
surface="sparse"
|
||||
alt="Cover"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<PlayCircle size={24} style={{ margin: '12px', color: 'var(--text-muted)' }} />
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { getSong } from '../api/subsonicLibrary';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Radio, Clock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import OrbitQueueHead from './OrbitQueueHead';
|
||||
|
||||
const ORBIT_QUEUE_COVER_LG_CSS_PX = 54;
|
||||
const ORBIT_QUEUE_COVER_SM_CSS_PX = 48;
|
||||
|
||||
/**
|
||||
* Orbit — guest-side queue view.
|
||||
*
|
||||
@@ -76,9 +78,10 @@ export default function OrbitGuestQueue() {
|
||||
</div>
|
||||
<div className="orbit-guest-queue__current-body">
|
||||
{currentSong?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(currentSong.coverArt, 96)}
|
||||
cacheKey={coverArtCacheKey(currentSong.coverArt, 96)}
|
||||
<CoverArtImage
|
||||
coverArtId={currentSong.coverArt}
|
||||
displayCssPx={ORBIT_QUEUE_COVER_LG_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
|
||||
/>
|
||||
@@ -112,9 +115,10 @@ export default function OrbitGuestQueue() {
|
||||
return (
|
||||
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
<CoverArtImage
|
||||
coverArtId={song.coverArt}
|
||||
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover"
|
||||
/>
|
||||
@@ -150,9 +154,10 @@ export default function OrbitGuestQueue() {
|
||||
className="orbit-guest-queue__item"
|
||||
>
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
<CoverArtImage
|
||||
coverArtId={song.coverArt}
|
||||
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover"
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -48,6 +48,7 @@ beforeEach(() => {
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_resume', () => undefined);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { queueSongStar } from '../store/pendingStarSync';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||
import { coverArtIdFromRadio } from '../cover/ids';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -13,7 +13,6 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import StarRating from './StarRating';
|
||||
@@ -139,13 +138,7 @@ export default function PlayerBar() {
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Cover art: prefer radio station art, fall back to track art.
|
||||
// Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns.
|
||||
const radioCoverSrc = useMemo(
|
||||
() => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '',
|
||||
[currentRadio?.coverArt, currentRadio?.id]
|
||||
);
|
||||
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
|
||||
const radioCoverArtId = currentRadio?.coverArt ? coverArtIdFromRadio(currentRadio.id) : undefined;
|
||||
// Preview takes visual priority over the queued track in the player-bar info
|
||||
// cell, but only when not in radio mode (radio has its own meta layout).
|
||||
const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null;
|
||||
@@ -156,14 +149,8 @@ export default function PlayerBar() {
|
||||
? currentTrack.artists
|
||||
: undefined;
|
||||
|
||||
const previewCover = useMemo(() => {
|
||||
if (!showPreviewMeta || !previewingTrack?.coverArt) return { src: '', cacheKey: '' };
|
||||
const id = previewingTrack.coverArt;
|
||||
return { src: buildCoverArtUrl(id, 128), cacheKey: coverArtCacheKey(id, 128) };
|
||||
}, [showPreviewMeta, previewingTrack?.coverArt]);
|
||||
const queueCover = usePlaybackCoverArt(showPreviewMeta ? undefined : displayCoverArt, 128);
|
||||
const coverSrc = showPreviewMeta ? previewCover.src : queueCover.src;
|
||||
const coverKey = showPreviewMeta ? previewCover.cacheKey : queueCover.cacheKey;
|
||||
usePlaybackCoverArt(showPreviewMeta ? undefined : displayCoverArt, 128);
|
||||
const coverArtId = showPreviewMeta ? previewingTrack?.coverArt : displayCoverArt;
|
||||
|
||||
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setVolume(parseFloat(e.target.value));
|
||||
@@ -208,10 +195,8 @@ export default function PlayerBar() {
|
||||
currentRadio={currentRadio}
|
||||
isRadio={isRadio}
|
||||
radioMeta={radioMeta}
|
||||
radioCoverSrc={radioCoverSrc}
|
||||
radioCoverKey={radioCoverKey}
|
||||
coverSrc={coverSrc}
|
||||
coverKey={coverKey}
|
||||
radioCoverArtId={radioCoverArtId}
|
||||
coverArtId={coverArtId}
|
||||
displayCoverArt={displayCoverArt}
|
||||
displayTitle={displayTitle}
|
||||
displayArtist={displayArtist}
|
||||
|
||||
@@ -42,7 +42,7 @@ import { usePlayerStore } from '@/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -52,6 +52,7 @@ beforeEach(() => {
|
||||
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
registerDefaultCoverInvokeHandlers();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
|
||||
@@ -8,7 +8,6 @@ import OrbitGuestQueue from './OrbitGuestQueue';
|
||||
import OrbitQueueHead from './OrbitQueueHead';
|
||||
import HostApprovalQueue from './HostApprovalQueue';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -77,11 +76,7 @@ function QueuePanelHostOrSolo() {
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const { src: currentCoverFetchUrl, cacheKey: currentCoverCacheKey } = usePlaybackCoverArt(
|
||||
currentTrack?.coverArt,
|
||||
128,
|
||||
);
|
||||
const currentCoverSrc = useCachedUrl(currentCoverFetchUrl, currentCoverCacheKey);
|
||||
const { src: currentCoverSrc } = usePlaybackCoverArt(currentTrack?.coverArt, 128);
|
||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||
|
||||
+24
-17
@@ -1,12 +1,13 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, ListPlus, Star } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import CachedImage from './CachedImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { enqueueAndPlay } from '../utils/playback/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
@@ -14,23 +15,28 @@ import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
interface SongCardProps {
|
||||
song: SubsonicSong;
|
||||
disableArtwork?: boolean;
|
||||
/** Layout-native cover square width in CSS px (rail cell). */
|
||||
displayCssPx?: number;
|
||||
/** @deprecated Use displayCssPx */
|
||||
artworkSize?: number;
|
||||
}
|
||||
|
||||
function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardProps) {
|
||||
function SongCard({
|
||||
song,
|
||||
disableArtwork = false,
|
||||
displayCssPx = COVER_DENSE_RAIL_CELL_CSS_PX,
|
||||
artworkSize,
|
||||
}: SongCardProps) {
|
||||
const layoutPx = artworkSize ?? displayCssPx;
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
// buildCoverArtUrl emits a salted URL; memoize to avoid churn on rerenders.
|
||||
const coverUrl = useMemo(
|
||||
() => (song.coverArt ? buildCoverArtUrl(song.coverArt, artworkSize) : ''),
|
||||
[song.coverArt, artworkSize],
|
||||
);
|
||||
const coverCacheKey = useMemo(
|
||||
() => (song.coverArt ? coverArtCacheKey(song.coverArt, artworkSize) : ''),
|
||||
[song.coverArt, artworkSize],
|
||||
);
|
||||
const coverHandle = useCoverArt(song.coverArt, layoutPx, {
|
||||
surface: 'dense',
|
||||
ensurePriority: 'middle',
|
||||
});
|
||||
const coverUrl = coverHandle.src;
|
||||
const psyDrag = useDragDrop();
|
||||
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
|
||||
|
||||
@@ -86,10 +92,11 @@ function SongCard({ song, disableArtwork = false, artworkSize = 200 }: SongCardP
|
||||
}}
|
||||
>
|
||||
<div className="song-card-cover">
|
||||
{!disableArtwork && coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverCacheKey}
|
||||
{!disableArtwork && song.coverArt ? (
|
||||
<CoverArtImage
|
||||
coverArtId={song.coverArt}
|
||||
displayCssPx={layoutPx}
|
||||
surface="dense"
|
||||
alt={`${song.album} Cover`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { GRID_COVER_WARM_LIMIT } from '../cover/layoutSizes';
|
||||
import { useWarmGridCovers } from '../hooks/useWarmGridCovers';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
|
||||
@@ -22,6 +24,12 @@ export type VirtualCardGridProps<T> = {
|
||||
gridGap?: string;
|
||||
/** When set, row virtualization uses this scroll container instead of the main route viewport. */
|
||||
scrollRootId?: string;
|
||||
/** Pre-peek disk WebP for the first viewport of cards (one IPC batch before cells ensure). */
|
||||
warmGridCovers?: {
|
||||
pickCoverArtId: (item: T) => string | null | undefined;
|
||||
displayCssPx: number;
|
||||
limit?: number;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -40,7 +48,25 @@ export function VirtualCardGrid<T>({
|
||||
wrapStyle,
|
||||
gridGap = 'var(--space-4)',
|
||||
scrollRootId,
|
||||
warmGridCovers,
|
||||
}: VirtualCardGridProps<T>): React.JSX.Element {
|
||||
const warmLimit = warmGridCovers?.limit ?? GRID_COVER_WARM_LIMIT;
|
||||
const warmItems = useMemo(() => {
|
||||
if (!warmGridCovers) return [];
|
||||
return items
|
||||
.slice(0, warmLimit)
|
||||
.map(item => ({ coverArt: warmGridCovers.pickCoverArtId(item) ?? null }));
|
||||
}, [items, warmGridCovers, warmLimit]);
|
||||
const warmPeekKey = useMemo(
|
||||
() => warmItems.map(i => i.coverArt ?? '').join('\u0001'),
|
||||
[warmItems],
|
||||
);
|
||||
useWarmGridCovers(warmItems, warmGridCovers?.displayCssPx ?? 0, {
|
||||
enabled: Boolean(warmGridCovers && warmGridCovers.displayCssPx > 0),
|
||||
limit: warmLimit,
|
||||
warmKey: warmPeekKey,
|
||||
});
|
||||
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const { gridCols, rowHeightEst } = useCardGridMetrics(wrapRef, true, rowVariant, layoutSignal);
|
||||
const cols = Math.max(1, gridCols);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -10,8 +10,9 @@ import { useOfflineStore } from '../../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../../store/offlineJobStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import CachedImage from '../CachedImage';
|
||||
import CoverLightbox from '../CoverLightbox';
|
||||
import { ArtistHeroCover } from '../../cover/artistHero';
|
||||
import { coverArtRef } from '../../cover/ref';
|
||||
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import StarRating from '../StarRating';
|
||||
|
||||
@@ -35,14 +36,9 @@ interface Props {
|
||||
openedLink: string | null;
|
||||
openLink: (url: string, key: string) => void;
|
||||
coverId: string;
|
||||
artistCover300Src: string;
|
||||
artistCover300Key: string;
|
||||
artistCover2000Src: string;
|
||||
coverRevision: number;
|
||||
headerCoverFailed: boolean;
|
||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
lightboxOpen: boolean;
|
||||
setLightboxOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
@@ -50,9 +46,7 @@ export default function ArtistDetailHero({
|
||||
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, artistCover300Src, artistCover300Key, artistCover2000Src,
|
||||
coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
lightboxOpen, setLightboxOpen,
|
||||
coverId, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -64,6 +58,12 @@ export default function ArtistDetailHero({
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
|
||||
const coverRef = useMemo(
|
||||
() => (coverId ? coverArtRef(coverId) : null),
|
||||
[coverId],
|
||||
);
|
||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: artist.name });
|
||||
|
||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||
|
||||
return (
|
||||
@@ -76,27 +76,24 @@ export default function ArtistDetailHero({
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{lightboxOpen && (
|
||||
<CoverLightbox
|
||||
src={artistCover2000Src}
|
||||
alt={artist.name}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{lightbox}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{coverId ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
onClick={openLightbox}
|
||||
aria-label={`${artist.name} Bild vergrößern`}
|
||||
>
|
||||
{!headerCoverFailed ? (
|
||||
<CachedImage
|
||||
<ArtistHeroCover
|
||||
key={coverRevision}
|
||||
src={artistCover300Src}
|
||||
cacheKey={artistCover300Key}
|
||||
artistId={id ?? artist.id}
|
||||
artistInfo={info}
|
||||
coverFallback={coverRef}
|
||||
displayCssPx={300}
|
||||
surface="sparse"
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setHeaderCoverFailed(true)}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import CachedImage from '../CachedImage';
|
||||
import React from 'react';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_ARTIST_LIST_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
export default function ArtistSuggestionTrackCover({ coverArt, album }: { coverArt: string; album: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 64), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 64), [coverArt]);
|
||||
return (
|
||||
<CachedImage
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
||||
surface="dense"
|
||||
alt={album}
|
||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import React from 'react';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { coverArtIdFromArtist } from '../../cover/ids';
|
||||
import {
|
||||
COVER_DENSE_ARTIST_LIST_CSS_PX,
|
||||
COVER_DENSE_GRID_MIN_CELL_CSS_PX,
|
||||
} from '../../cover/layoutSizes';
|
||||
import { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||
import { nameColor, nameInitial } from '../../utils/componentHelpers/artistsHelpers';
|
||||
|
||||
@@ -17,20 +21,14 @@ interface AvatarProps {
|
||||
*/
|
||||
export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
||||
const color = nameColor(artist.name);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const { coverSrc, coverKey } = useMemo(
|
||||
() => ({
|
||||
coverSrc: coverId ? buildCoverArtUrl(coverId, 300) : '',
|
||||
coverKey: coverId ? coverArtCacheKey(coverId, 300) : '',
|
||||
}),
|
||||
[coverId],
|
||||
);
|
||||
if (showImages && coverId) {
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
if (showImages && (artist.coverArt || artist.id)) {
|
||||
return (
|
||||
<div className="artist-card-avatar">
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
/>
|
||||
@@ -46,25 +44,18 @@ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
||||
|
||||
/**
|
||||
* Row-sized artist avatar for the list view. Same fallback rules as the
|
||||
* card variant, but smaller cover-art size (64px vs 300px) so list rows
|
||||
* don't pull oversized images from the server.
|
||||
* card variant, but smaller layout px so list rows don't pull oversized images.
|
||||
*/
|
||||
export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
|
||||
const color = nameColor(artist.name);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const { coverSrc, coverKey } = useMemo(
|
||||
() => ({
|
||||
coverSrc: coverId ? buildCoverArtUrl(coverId, 64) : '',
|
||||
coverKey: coverId ? coverArtCacheKey(coverId, 64) : '',
|
||||
}),
|
||||
[coverId],
|
||||
);
|
||||
if (showImages && coverId) {
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
if (showImages && (artist.coverArt || artist.id)) {
|
||||
return (
|
||||
<div className="artist-avatar">
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { coverArtIdFromRadio } from '../../cover/ids';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
interface RadioStationRowProps {
|
||||
title: string;
|
||||
@@ -76,9 +77,10 @@ function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }:
|
||||
<div className={`album-card${isActive ? ' radio-card-active' : ''}`}>
|
||||
<div className="album-card-cover">
|
||||
{s.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
|
||||
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArtIdFromRadio(s.id)}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={s.name}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
export interface TopFavoriteArtist {
|
||||
id: string;
|
||||
@@ -83,8 +83,6 @@ interface TopFavoriteArtistCardProps {
|
||||
|
||||
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
|
||||
const coverId = artist.coverArtId;
|
||||
const coverSrc = useMemo(() => coverId ? buildCoverArtUrl(coverId, 300) : '', [coverId]);
|
||||
const coverCacheKey = useMemo(() => coverId ? coverArtCacheKey(coverId, 300) : '', [coverId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -94,9 +92,10 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={coverSrc}
|
||||
cacheKey={coverCacheKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -2,10 +2,11 @@ import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cast, Globe, Heart, Square, Trash2, X } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { coverArtIdFromRadio } from '../../cover/ids';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
interface RadioCardProps {
|
||||
s: InternetRadioStation;
|
||||
@@ -85,9 +86,10 @@ export default function RadioCard({
|
||||
{/* Cover */}
|
||||
<div className="album-card-cover">
|
||||
{s.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
|
||||
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArtIdFromRadio(s.id)}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={s.name}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, { useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Camera, Cast, Loader2, X } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { coverArtIdFromRadio } from '../../cover/ids';
|
||||
|
||||
interface RadioEditModalProps {
|
||||
station: InternetRadioStation | null; // null = create new
|
||||
@@ -91,9 +91,10 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
|
||||
{coverPreview ? (
|
||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : !coverRemoved && station?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(`ra-${station.id}`, 256)}
|
||||
cacheKey={coverArtCacheKey(`ra-${station.id}`, 256)}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArtIdFromRadio(station.id)}
|
||||
displayCssPx={140}
|
||||
surface="sparse"
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Disc3, ExternalLink, Music } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
|
||||
interface DiscographyCardProps {
|
||||
artistId?: string;
|
||||
@@ -44,16 +44,22 @@ const DiscographyCard = memo(function DiscographyCard({ artistId, albums, curren
|
||||
<div className="np-dash-disc-grid">
|
||||
{visible.map(a => {
|
||||
const isActive = a.id === currentAlbumId;
|
||||
const fetchUrl = a.coverArt ? buildCoverArtUrl(a.coverArt, 200) : '';
|
||||
const key = a.coverArt ? coverArtCacheKey(a.coverArt, 200) : '';
|
||||
return (
|
||||
<div key={a.id}
|
||||
className={`np-dash-disc-tile${isActive ? ' active' : ''}`}
|
||||
onClick={() => onNavigate(`/album/${a.id}`)}
|
||||
data-tooltip={`${a.name}${a.year ? ` · ${a.year}` : ''}`}>
|
||||
<div className="np-dash-disc-cover">
|
||||
{fetchUrl && key
|
||||
? <CachedImage src={fetchUrl} cacheKey={key} alt={a.name} className="np-dash-disc-img" />
|
||||
{a.coverArt
|
||||
? (
|
||||
<CoverArtImage
|
||||
coverArtId={a.coverArt}
|
||||
displayCssPx={COVER_DENSE_RAIL_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={a.name}
|
||||
className="np-dash-disc-img"
|
||||
/>
|
||||
)
|
||||
: <div className="np-dash-disc-fallback"><Music size={18} /></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { InternetRadioStation, SubsonicAlbum, SubsonicOpenArtistRef } from
|
||||
import type { PlayerState, Track } from '../../store/playerStoreTypes';
|
||||
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
|
||||
import type { PreviewingTrack } from '../../store/previewStore';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import MarqueeText from '../MarqueeText';
|
||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||
@@ -22,10 +22,8 @@ interface Props {
|
||||
currentRadio: InternetRadioStation | null;
|
||||
isRadio: boolean;
|
||||
radioMeta: RadioMetadata;
|
||||
radioCoverSrc: string;
|
||||
radioCoverKey: string;
|
||||
coverSrc: string;
|
||||
coverKey: string;
|
||||
radioCoverArtId?: string;
|
||||
coverArtId?: string;
|
||||
displayCoverArt: string | undefined;
|
||||
displayTitle: string;
|
||||
displayArtist: string;
|
||||
@@ -46,8 +44,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function PlayerTrackInfo({
|
||||
currentTrack, currentRadio, isRadio, radioMeta, radioCoverSrc, radioCoverKey,
|
||||
coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
|
||||
currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
|
||||
coverArtId, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
|
||||
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
||||
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
|
||||
userRatingOverrides, toggleFullscreen,
|
||||
@@ -66,26 +64,29 @@ export function PlayerTrackInfo({
|
||||
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
|
||||
>
|
||||
{isRadio ? (
|
||||
currentRadio?.coverArt ? (
|
||||
<CachedImage
|
||||
radioCoverArtId ? (
|
||||
<CoverArtImage
|
||||
className="player-album-art"
|
||||
src={radioCoverSrc}
|
||||
cacheKey={radioCoverKey}
|
||||
alt={currentRadio.name}
|
||||
coverArtId={radioCoverArtId}
|
||||
displayCssPx={128}
|
||||
surface="sparse"
|
||||
alt={currentRadio?.name ?? ''}
|
||||
/>
|
||||
) : (
|
||||
<div className="player-album-art-placeholder">
|
||||
<Cast size={20} />
|
||||
</div>
|
||||
)
|
||||
) : displayCoverArt ? (
|
||||
<CachedImage
|
||||
) : coverArtId ? (
|
||||
<CoverArtImage
|
||||
className="player-album-art"
|
||||
src={coverSrc}
|
||||
cacheKey={coverKey}
|
||||
coverArtId={coverArtId}
|
||||
displayCssPx={128}
|
||||
surface="sparse"
|
||||
serverScope={showPreviewMeta ? { kind: 'active' } : { kind: 'playback' }}
|
||||
alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`}
|
||||
/>
|
||||
) : (
|
||||
) : (
|
||||
<div className="player-album-art-placeholder">
|
||||
<Music size={22} />
|
||||
</div>
|
||||
|
||||
@@ -2,21 +2,21 @@ import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Camera, Loader2, X } from 'lucide-react';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import CachedImage from '../CachedImage';
|
||||
import type { CoverArtId } from '../../cover/types';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
||||
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
||||
|
||||
interface EditModalProps {
|
||||
playlist: SubsonicPlaylist;
|
||||
customCoverId: string | null;
|
||||
customCoverFetchUrl: string | null;
|
||||
customCoverCacheKey: string | null;
|
||||
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
|
||||
coverQuadIds: (CoverArtId | null)[];
|
||||
onClose: () => void;
|
||||
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function PlaylistEditModal({
|
||||
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
|
||||
coverQuadUrls, onClose, onSave,
|
||||
playlist, customCoverId, coverQuadIds, onClose, onSave,
|
||||
}: EditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(playlist.name);
|
||||
@@ -78,18 +78,19 @@ export default function PlaylistEditModal({
|
||||
>
|
||||
{coverPreview ? (
|
||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
) : !coverRemoved && customCoverId ? (
|
||||
<CoverArtImage
|
||||
coverArtId={customCoverId}
|
||||
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
{coverQuadIds.map((coverId, i) =>
|
||||
coverId
|
||||
? <PlaylistSmartCoverCell key={i} coverId={coverId} />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,16 +12,17 @@ import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/p
|
||||
import {
|
||||
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
|
||||
} from '../../utils/componentHelpers/playlistDetailHelpers';
|
||||
import CachedImage from '../CachedImage';
|
||||
import type { CoverArtId } from '../../cover/types';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
||||
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
||||
|
||||
interface Props {
|
||||
playlist: SubsonicPlaylist;
|
||||
songs: SubsonicSong[];
|
||||
id: string | undefined;
|
||||
customCoverId: string | null;
|
||||
customCoverFetchUrl: string | null;
|
||||
customCoverCacheKey: string | null;
|
||||
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
|
||||
coverQuadIds: (CoverArtId | null)[];
|
||||
resolvedBgUrl: string | null;
|
||||
saving: boolean;
|
||||
searchOpen: boolean;
|
||||
@@ -48,7 +49,7 @@ interface Props {
|
||||
|
||||
export default function PlaylistHero({
|
||||
playlist, songs, id,
|
||||
customCoverId, customCoverFetchUrl, customCoverCacheKey, coverQuadUrls,
|
||||
customCoverId, coverQuadIds,
|
||||
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
||||
isCached, isDownloading, offlineProgress, activeServerId,
|
||||
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
||||
@@ -85,19 +86,20 @@ export default function PlaylistHero({
|
||||
className="playlist-hero-cover"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
>
|
||||
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
|
||||
<CachedImage
|
||||
src={customCoverFetchUrl}
|
||||
cacheKey={customCoverCacheKey}
|
||||
{customCoverId ? (
|
||||
<CoverArtImage
|
||||
coverArtId={customCoverId}
|
||||
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="playlist-cover-grid"
|
||||
style={{ objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="playlist-cover-grid">
|
||||
{coverQuadUrls.map((entry, i) =>
|
||||
entry
|
||||
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
|
||||
{coverQuadIds.map((coverId, i) =>
|
||||
coverId
|
||||
? <PlaylistSmartCoverCell key={i} coverId={coverId} />
|
||||
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ListPlus, X } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import CachedImage from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||
|
||||
function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]);
|
||||
return <CachedImage src={src} cacheKey={cacheKey} alt="" className="playlist-search-thumb" />;
|
||||
return (
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="playlist-search-thumb"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import CachedImage from '../CachedImage';
|
||||
import React from 'react';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
|
||||
/** 2×2 collage cell — half of clamp(120px, 15vw, 200px) playlist hero grid. */
|
||||
const PLAYLIST_QUAD_CELL_CSS_PX = 100;
|
||||
/** Full playlist hero / card cover square. */
|
||||
const PLAYLIST_MAIN_COVER_CSS_PX = 200;
|
||||
|
||||
export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverId, 200), [coverId]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverId, 200), [coverId]);
|
||||
return (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={PLAYLIST_QUAD_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
className="playlist-cover-cell"
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
|
||||
const src = useMemo(() => buildCoverArtUrl(coverArt, 256), [coverArt]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 256), [coverArt]);
|
||||
return (
|
||||
<CachedImage
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt={alt}
|
||||
className="album-card-cover-img"
|
||||
/>
|
||||
|
||||
@@ -201,7 +201,7 @@ export function QueueCurrentTrack({
|
||||
})()}
|
||||
<div className="queue-current-track-body">
|
||||
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
|
||||
{currentTrack.coverArt ? (
|
||||
{currentTrack.coverArt && currentCoverSrc ? (
|
||||
<img src={currentCoverSrc} alt="" loading="eager" />
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
|
||||
@@ -7,15 +7,11 @@ import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview';
|
||||
import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch';
|
||||
import CachedImage from '../CachedImage';
|
||||
import OverlayScrollArea from '../OverlayScrollArea';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import {
|
||||
buildCoverArtUrl,
|
||||
buildCoverArtUrlForServer,
|
||||
coverArtCacheKey,
|
||||
coverArtCacheKeyForServer,
|
||||
} from '../../api/subsonicStreamUrl';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
||||
import type { CoverServerScope } from '../../cover/types';
|
||||
|
||||
type ShareQueuePreviewModalProps = {
|
||||
open: boolean;
|
||||
@@ -30,6 +26,19 @@ type ShareQueuePreviewModalProps = {
|
||||
confirmBusyLabel?: string;
|
||||
};
|
||||
|
||||
function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
|
||||
if (coverServer) {
|
||||
return {
|
||||
kind: 'server',
|
||||
serverId: coverServer.id,
|
||||
url: coverServer.url,
|
||||
username: coverServer.username,
|
||||
password: coverServer.password,
|
||||
};
|
||||
}
|
||||
return { kind: 'active' };
|
||||
}
|
||||
|
||||
function QueuePreviewTrackRow({
|
||||
song,
|
||||
coverServer,
|
||||
@@ -37,18 +46,19 @@ function QueuePreviewTrackRow({
|
||||
song: SubsonicSong;
|
||||
coverServer?: ServerProfile | null;
|
||||
}) {
|
||||
const coverId = song.coverArt ?? '';
|
||||
const src = coverServer
|
||||
? buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId || song.id, 48)
|
||||
: buildCoverArtUrl(coverId || song.id, 48);
|
||||
const cacheKey = coverServer
|
||||
? coverArtCacheKeyForServer(coverServer.id, coverId || song.id, 48)
|
||||
: coverArtCacheKey(coverId || song.id, 48);
|
||||
const coverId = song.coverArt || song.id;
|
||||
|
||||
return (
|
||||
<li className="share-queue-preview-track">
|
||||
{coverId ? (
|
||||
<CachedImage className="share-queue-preview-track__thumb" src={src} cacheKey={cacheKey} alt="" />
|
||||
{song.coverArt ? (
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||
surface="dense"
|
||||
serverScope={shareCoverServerScope(coverServer)}
|
||||
className="share-queue-preview-track__thumb"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="share-queue-preview-track__icon">
|
||||
<Music size={16} />
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Disc3, Eye, Link2, ListPlus, Music, Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
buildCoverArtUrl,
|
||||
buildCoverArtUrlForServer,
|
||||
coverArtCacheKey,
|
||||
coverArtCacheKeyForServer,
|
||||
} from '../../api/subsonicStreamUrl';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchPayload';
|
||||
import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch';
|
||||
import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage';
|
||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
||||
import { coverArtIdFromArtist } from '../../cover/ids';
|
||||
import type { CoverServerScope } from '../../cover/types';
|
||||
import { useShareQueuePreview } from '../../hooks/useShareQueuePreview';
|
||||
import ShareQueuePreviewModal from './ShareQueuePreviewModal';
|
||||
|
||||
@@ -34,51 +32,56 @@ type ShareSearchResultsProps = {
|
||||
onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void;
|
||||
} & ShareSearchPreviewState;
|
||||
|
||||
function useShareCoverArt(coverId: string, size: number, coverServer: ServerProfile | null | undefined) {
|
||||
return useMemo(() => {
|
||||
if (coverServer) {
|
||||
return {
|
||||
src: buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId, size),
|
||||
cacheKey: coverArtCacheKeyForServer(coverServer.id, coverId, size),
|
||||
};
|
||||
}
|
||||
function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
|
||||
if (coverServer) {
|
||||
return {
|
||||
src: buildCoverArtUrl(coverId, size),
|
||||
cacheKey: coverArtCacheKey(coverId, size),
|
||||
kind: 'server',
|
||||
serverId: coverServer.id,
|
||||
url: coverServer.url,
|
||||
username: coverServer.username,
|
||||
password: coverServer.password,
|
||||
};
|
||||
}, [coverServer, coverId, size]);
|
||||
}
|
||||
return { kind: 'active' };
|
||||
}
|
||||
|
||||
function ShareAlbumThumb({
|
||||
coverArt,
|
||||
size,
|
||||
displayCssPx,
|
||||
coverServer,
|
||||
}: {
|
||||
coverArt: string;
|
||||
size: number;
|
||||
displayCssPx: number;
|
||||
coverServer?: ServerProfile | null;
|
||||
}) {
|
||||
const { src, cacheKey } = useShareCoverArt(coverArt, size, coverServer);
|
||||
const cls = size >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
|
||||
return <CachedImage className={cls} src={src} cacheKey={cacheKey} alt="" />;
|
||||
const cls = displayCssPx >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
|
||||
return (
|
||||
<CoverArtImage
|
||||
coverArtId={coverArt}
|
||||
displayCssPx={displayCssPx}
|
||||
surface="dense"
|
||||
serverScope={shareCoverServerScope(coverServer)}
|
||||
className={cls}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareArtistThumb({
|
||||
artist,
|
||||
size,
|
||||
displayCssPx,
|
||||
coverServer,
|
||||
}: {
|
||||
artist: Pick<SubsonicArtist, 'id' | 'coverArt'>;
|
||||
size: number;
|
||||
displayCssPx: number;
|
||||
coverServer?: ServerProfile | null;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const { src, cacheKey } = useShareCoverArt(coverId, size, coverServer);
|
||||
const coverId = coverArtIdFromArtist(artist);
|
||||
useEffect(() => { setFailed(false); }, [coverId]);
|
||||
|
||||
if (failed) {
|
||||
if (size >= 64) {
|
||||
if (displayCssPx >= 64) {
|
||||
return (
|
||||
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
||||
<Users size={20} />
|
||||
@@ -93,14 +96,16 @@ function ShareArtistThumb({
|
||||
}
|
||||
|
||||
const cls =
|
||||
size >= 64
|
||||
displayCssPx >= 64
|
||||
? 'mobile-search-thumb mobile-search-thumb--artist-round'
|
||||
: 'search-result-thumb';
|
||||
return (
|
||||
<CachedImage
|
||||
<CoverArtImage
|
||||
coverArtId={coverId}
|
||||
displayCssPx={displayCssPx}
|
||||
surface="dense"
|
||||
serverScope={shareCoverServerScope(coverServer)}
|
||||
className={cls}
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||
@@ -158,7 +163,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
|
||||
const { t } = useTranslation();
|
||||
const desktop = variant === 'desktop';
|
||||
const thumbSize = desktop ? 40 : 80;
|
||||
const thumbDisplayCssPx = desktop ? COVER_DENSE_SEARCH_CSS_PX : 80;
|
||||
const sectionCls = desktop ? 'search-section' : 'mobile-search-section';
|
||||
const labelCls = desktop ? 'search-section-label' : 'mobile-search-section-label';
|
||||
const mutedCls = desktop ? 'search-result-item search-result-item--muted' : 'mobile-search-item mobile-search-item--muted';
|
||||
@@ -225,7 +230,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
role={desktop ? 'option' : undefined}
|
||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||
>
|
||||
<ShareArtistThumb artist={shareArtist} size={thumbSize} coverServer={shareCoverServer} />
|
||||
<ShareArtistThumb artist={shareArtist} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||
<div className={infoWrap}>
|
||||
<div className={nameCls}>{shareArtist.name}</div>
|
||||
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('search.artists') : '')}</div>}
|
||||
@@ -267,7 +272,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
role={desktop ? 'option' : undefined}
|
||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||
>
|
||||
<ShareArtistThumb artist={shareComposer} size={thumbSize} coverServer={shareCoverServer} />
|
||||
<ShareArtistThumb artist={shareComposer} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||
<div className={infoWrap}>
|
||||
<div className={nameCls}>{shareComposer.name}</div>
|
||||
{showEntityKindSub && <div className={subCls}>{sub(!desktop ? t('sidebar.composers') : '')}</div>}
|
||||
@@ -314,7 +319,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||
>
|
||||
{shareAlbum.coverArt ? (
|
||||
<ShareAlbumThumb coverArt={shareAlbum.coverArt} size={thumbSize} coverServer={shareCoverServer} />
|
||||
<ShareAlbumThumb coverArt={shareAlbum.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||
) : (
|
||||
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
|
||||
)}
|
||||
@@ -365,7 +370,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||
>
|
||||
{shareTrackSong.coverArt ? (
|
||||
<ShareAlbumThumb coverArt={shareTrackSong.coverArt} size={thumbSize} coverServer={shareCoverServer} />
|
||||
<ShareAlbumThumb coverArt={shareTrackSong.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||
) : (
|
||||
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
|
||||
)}
|
||||
|
||||
@@ -78,10 +78,6 @@ export default function AnalyticsStrategySection() {
|
||||
return Array.from(known).filter(id => !activeServerIds.has(id));
|
||||
}, [strategyByServer, advancedParallelismByServer, activeServerIds]);
|
||||
|
||||
const anyAggressive = useMemo(() => {
|
||||
return servers.some(server => getStrategyForServer(server.id) === 'advanced');
|
||||
}, [servers, getStrategyForServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (servers.length === 0) return;
|
||||
let cancelled = false;
|
||||
@@ -257,10 +253,6 @@ export default function AnalyticsStrategySection() {
|
||||
icon={<BarChart3 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.analyticsStrategyDesc')}
|
||||
</p>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
|
||||
<thead>
|
||||
@@ -398,24 +390,9 @@ export default function AnalyticsStrategySection() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.85rem',
|
||||
padding: '0.65rem 0.75rem',
|
||||
borderRadius: 8,
|
||||
background: 'var(--surface-elevated, rgba(255,255,255,0.03))',
|
||||
border: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: '0.45rem', color: 'var(--text-secondary)' }}>
|
||||
{t('settings.analyticsStrategyPriorityTitle')}
|
||||
</div>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.1rem', fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
|
||||
<li>{t('settings.analyticsStrategyPriorityHigh')}</li>
|
||||
<li>{t('settings.analyticsStrategyPriorityMiddle')}</li>
|
||||
<li>{t('settings.analyticsStrategyPriorityLow')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: '0.9rem', lineHeight: 1.5 }}>
|
||||
{t('settings.analyticsStrategyDesc')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -445,18 +422,16 @@ export default function AnalyticsStrategySection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{anyAggressive && (
|
||||
<div
|
||||
className="settings-hint settings-hint-info"
|
||||
role="note"
|
||||
style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
|
||||
>
|
||||
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--color-warning, #f59e0b)' }} />
|
||||
<span style={{ fontSize: 12, lineHeight: 1.5 }}>
|
||||
{t('settings.analyticsStrategyAdvancedWarning')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="settings-hint settings-hint-info"
|
||||
role="note"
|
||||
style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
|
||||
>
|
||||
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--color-warning, #f59e0b)' }} />
|
||||
<span style={{ fontSize: 12, lineHeight: 1.5 }}>
|
||||
{t('settings.analyticsStrategyAdvancedWarning')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clearTarget &&
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { Image, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
coverCacheClearServer,
|
||||
coverCacheStatsServer,
|
||||
libraryCoverCatalogSize,
|
||||
libraryCoverProgress,
|
||||
} from '../../api/coverCache';
|
||||
import { clearDiskSrcCacheForServer } from '../../cover/diskSrcCache';
|
||||
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
|
||||
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import { formatBytes } from '../../utils/format/formatBytes';
|
||||
import { wakeLibraryCoverBackfill } from '../../utils/library/coverBackfillWake';
|
||||
import {
|
||||
COVER_CACHE_STRATEGIES,
|
||||
type CoverCacheStrategy,
|
||||
} from '../../utils/library/coverStrategy';
|
||||
|
||||
type ClearTarget = {
|
||||
serverId: string;
|
||||
indexKey: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ServerRowState = {
|
||||
bytes: number;
|
||||
entryCount: number;
|
||||
done: number;
|
||||
total: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export default function CoverCacheStrategySection() {
|
||||
const { t } = useTranslation();
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const { strategyByServer, setServerStrategy, getStrategyForServer } = useCoverStrategyStore();
|
||||
const [rowState, setRowState] = useState<Record<string, ServerRowState>>({});
|
||||
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
|
||||
const [clearingKey, setClearingKey] = useState<string | null>(null);
|
||||
|
||||
const activeIndexKeys = useMemo(
|
||||
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
|
||||
[servers],
|
||||
);
|
||||
const removedServerKeys = useMemo(() => {
|
||||
const known = new Set(Object.keys(strategyByServer));
|
||||
return Array.from(known).filter(key => !activeIndexKeys.has(key));
|
||||
}, [strategyByServer, activeIndexKeys]);
|
||||
|
||||
const refreshRow = useCallback(async (serverId: string, indexKey: string) => {
|
||||
const [stats, progress, catalog] = await Promise.all([
|
||||
coverCacheStatsServer(indexKey).catch(() => ({ bytes: 0, entryCount: 0 })),
|
||||
libraryCoverProgress(indexKey, serverId).catch(() => ({ done: 0, totalDistinct: 0, pending: 0 })),
|
||||
libraryCoverCatalogSize(serverId).catch(() => 0),
|
||||
]);
|
||||
const total = Math.max(progress.totalDistinct, catalog);
|
||||
setRowState(prev => ({
|
||||
...prev,
|
||||
[indexKey]: {
|
||||
bytes: stats.bytes,
|
||||
entryCount: stats.entryCount,
|
||||
done: progress.done,
|
||||
total,
|
||||
pending: Math.max(progress.pending, total > 0 ? total - progress.done : 0),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
void Promise.all(
|
||||
servers.map(server => refreshRow(server.id, serverIndexKeyForProfile(server))),
|
||||
);
|
||||
}, [servers, refreshRow]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
const id = window.setInterval(refreshAll, 15_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refreshAll]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubs: Array<() => void> = [];
|
||||
void (async () => {
|
||||
unsubs.push(await listen<{
|
||||
serverIndexKey?: string;
|
||||
done?: number;
|
||||
total?: number;
|
||||
pending?: number;
|
||||
bytes?: number;
|
||||
entryCount?: number;
|
||||
}>('cover:library-progress', e => {
|
||||
const key = e.payload.serverIndexKey;
|
||||
if (!key) return;
|
||||
setRowState(prev => {
|
||||
const cur = prev[key];
|
||||
if (!cur) return prev;
|
||||
const done = typeof e.payload.done === 'number' ? e.payload.done : cur.done;
|
||||
const total = typeof e.payload.total === 'number' ? e.payload.total : cur.total;
|
||||
return {
|
||||
...prev,
|
||||
[key]: {
|
||||
...cur,
|
||||
done,
|
||||
total,
|
||||
pending: e.payload.pending ?? Math.max(0, total - done),
|
||||
bytes: typeof e.payload.bytes === 'number' ? e.payload.bytes : cur.bytes,
|
||||
entryCount:
|
||||
typeof e.payload.entryCount === 'number' ? e.payload.entryCount : cur.entryCount,
|
||||
},
|
||||
};
|
||||
});
|
||||
}));
|
||||
unsubs.push(await listen<{ serverIndexKey?: string }>('cover:cache-cleared', e => {
|
||||
const key = e.payload.serverIndexKey;
|
||||
if (key) {
|
||||
setRowState(prev => ({
|
||||
...prev,
|
||||
[key]: { bytes: 0, entryCount: 0, done: 0, total: prev[key]?.total ?? 0, pending: prev[key]?.total ?? 0 },
|
||||
}));
|
||||
} else {
|
||||
refreshAll();
|
||||
}
|
||||
}));
|
||||
unsubs.push(await listen('cover:tier-ready', () => {
|
||||
refreshAll();
|
||||
}));
|
||||
})();
|
||||
return () => {
|
||||
for (const u of unsubs) u();
|
||||
};
|
||||
}, [refreshAll]);
|
||||
|
||||
const strategyLabel = (s: CoverCacheStrategy) => {
|
||||
switch (s) {
|
||||
case 'lazy':
|
||||
return t('settings.coverCacheStrategyLazy');
|
||||
case 'aggressive':
|
||||
return t('settings.coverCacheStrategyAggressive');
|
||||
}
|
||||
};
|
||||
|
||||
const progressLabel = (row: ServerRowState | undefined, strategy: CoverCacheStrategy) => {
|
||||
if (!row || strategy !== 'aggressive' || row.total <= 0) {
|
||||
return row ? t('settings.coverCacheStrategyDiskUsage', { size: formatBytes(row.bytes) }) : '—';
|
||||
}
|
||||
const percent = Math.max(0, Math.min(100, Math.round((row.done / row.total) * 100)));
|
||||
return t('settings.coverCacheStrategyProgressValue', {
|
||||
percent,
|
||||
done: row.done.toLocaleString(),
|
||||
total: row.total.toLocaleString(),
|
||||
size: formatBytes(row.bytes),
|
||||
});
|
||||
};
|
||||
|
||||
const handleStrategyChange = (serverId: string, strategy: CoverCacheStrategy) => {
|
||||
setServerStrategy(serverId, strategy);
|
||||
if (serverId === activeServerId) {
|
||||
wakeLibraryCoverBackfill();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCoverCache = async () => {
|
||||
if (!clearTarget) return;
|
||||
setClearingKey(clearTarget.indexKey);
|
||||
try {
|
||||
await coverCacheClearServer(clearTarget.indexKey);
|
||||
clearDiskSrcCacheForServer(clearTarget.indexKey);
|
||||
await refreshRow(clearTarget.serverId, clearTarget.indexKey);
|
||||
showToast(t('settings.coverCacheStrategyClearSuccess'), 4000, 'success');
|
||||
} catch {
|
||||
showToast(t('settings.coverCacheStrategyClearError'), 5000, 'error');
|
||||
} finally {
|
||||
setClearingKey(null);
|
||||
setClearTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
|
||||
<div className="settings-card">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 520 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyServerLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyProgressLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyActionsLabel')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map(server => {
|
||||
const strategy = getStrategyForServer(server.id);
|
||||
const key = serverIndexKeyForProfile(server);
|
||||
const row = rowState[key];
|
||||
const label = serverListDisplayLabel(server, servers);
|
||||
return (
|
||||
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{COVER_CACHE_STRATEGIES.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => handleStrategyChange(server.id, s)}
|
||||
>
|
||||
{strategyLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, lineHeight: 1.4 }}>
|
||||
{strategy === 'aggressive'
|
||||
? t('settings.coverCacheStrategyAggressiveDesc')
|
||||
: t('settings.coverCacheStrategyLazyDesc')}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{progressLabel(row, strategy)}
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() =>
|
||||
setClearTarget({ serverId: server.id, indexKey: key, label })}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{removedServerKeys.map(key => (
|
||||
<tr key={`removed-${key}`} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{key}
|
||||
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
<td style={{ padding: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() =>
|
||||
setClearTarget({ serverId: key, indexKey: key, label: key })}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: '0.9rem', lineHeight: 1.5 }}>
|
||||
{t('settings.coverCacheStrategyDesc')}
|
||||
</p>
|
||||
|
||||
{clearTarget && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '10px 14px',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.coverCacheStrategyClearTitle')}</div>
|
||||
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>
|
||||
{t('settings.coverCacheStrategyClearDesc', { server: clearTarget.label })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||
disabled={clearingKey !== null}
|
||||
onClick={() => void handleClearCoverCache()}
|
||||
>
|
||||
{t('settings.coverCacheStrategyClearConfirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
disabled={clearingKey !== null}
|
||||
onClick={() => setClearTarget(null)}
|
||||
>
|
||||
{t('settings.coverCacheStrategyClearCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import StarRating from '../StarRating';
|
||||
import AnalyticsStrategySection from './AnalyticsStrategySection';
|
||||
|
||||
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
|
||||
|
||||
export function LibraryTab() {
|
||||
|
||||
@@ -6,11 +6,10 @@ import { Download, FolderOpen, Trash2, X } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useHotCacheStore } from '../../store/hotCacheStore';
|
||||
import { useOfflineStore } from '../../store/offlineStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
|
||||
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import CoverCacheStrategySection from './CoverCacheStrategySection';
|
||||
|
||||
export function StorageTab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -75,26 +74,6 @@ export function StorageTab() {
|
||||
setClearing(false);
|
||||
}, [clearAllOffline, serverId, auth.offlineDownloadDir]);
|
||||
|
||||
const handleClearWaveformCache = useCallback(async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
const deleted = await invoke<number>('analysis_delete_all_waveforms');
|
||||
usePlayerStore.setState({
|
||||
waveformBins: null,
|
||||
});
|
||||
showToast(
|
||||
t('settings.waveformCacheCleared', { count: deleted }),
|
||||
3500,
|
||||
'success',
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(t('settings.waveformCacheClearFailed'), 4500, 'error');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const pickOfflineDir = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
|
||||
if (selected && typeof selected === 'string') {
|
||||
@@ -190,6 +169,7 @@ export function StorageTab() {
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
|
||||
</div>
|
||||
|
||||
{showClearConfirm ? (
|
||||
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
||||
@@ -212,19 +192,11 @@ export function StorageTab() {
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={handleClearWaveformCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.waveformCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<CoverCacheStrategySection />
|
||||
|
||||
{/* Buffering */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.nextTrackBufferingTitle')}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' },
|
||||
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
|
||||
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
||||
{ tab: 'storage', titleKey: 'settings.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server' },
|
||||
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
|
||||
{ tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering preload hot cache streaming' },
|
||||
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
|
||||
|
||||
Reference in New Issue
Block a user