fix(cover): Windows thumbnails, tier fallback, PNG decode, coverArt id (#878)

* fix(cover): tier fallback for sparse surfaces and Windows asset URLs

Sparse UI (player bar, queue) now reads disk covers via the same tier
ladder as dense grids, so a warm 800.webp satisfies a 128px request.
Reject non-asset convertFileSrc results on Windows, widen Tauri asset
scope, and seed ladder keys on cover:tier-ready. applyDiskPath uses
seedGridDiskSrcCache only to avoid notify/subscriber infinite loops.

* fix(artist): top-track thumb uses album coverArt already warm in grid

Song coverArt ids often differ from album cover ids (e.g. Octastorium in
the grid vs empty track thumb). Prefer the album row's coverArt on artist
pages and ensure high priority for 32px dense cells.

* fix(cover): albumId for playback/queue; no broken img until disk URL ready

Prefer albumId over track-id coverArt (Navidrome). Wire queue to CoverArtImage
with playback scope. CoverArtImage renders a placeholder div until asset src
exists to avoid the browser broken-image icon.

* fix(test): add song id to resolveArtistPageSongCoverArtId fixture

Pick<SubsonicSong, …> requires id; fixes tsc in CI/build.

* fix(cover): resolve albumId for Now Playing and artist top tracks

Prefer albumId when album.coverArt echoes track id; use sparse surface
on artist suggestion thumbs; apply resolveSubsonicSongCoverArtId across
playback surfaces (Now Playing, fullscreen, mobile, mini).

* fix(cover): decode PNG from Subsonic before WebP tier encode

Enable `png` in the image crate — some servers return PNG cover art;
failed decode left `.fetch-failed` and empty thumbs for those albums.

* refactor(cover): consolidate cover id resolution and align tests

Move resolveSubsonicSongCoverArtId helpers to src/cover/resolveCoverArtId.ts
with resolvePlaybackTrackCoverArtId for player surfaces; co-locate tests;
fix FullscreenPlayer expectations for albumId-first resolution.

* docs: CHANGELOG and credits for PR #878

* fix(cover): keep per-track coverArt when distinct from song id

Address PR #878 review (b): albumId only when coverArt is missing or
echoes track id; pin case with unit test; comment isRawFsPath symmetry.

* chore(cover): address PR #878 review nits (scope, tests, rename)

Narrow asset scope to cover-cache dirs only; add diskSrcCache Windows-path
tests; rename ArtistTopTrackCover; CHANGELOG symptom-first wording.

* fix(cover): restore asset scope to app data dirs (Windows regression)

$APPDATA/cover-cache/** did not match Tauri scope resolution — covers
were blocked after load. Use $APPDATA/** and $APPLOCALDATA/** (no $DATA).

* fix(cover): Windows asset URLs — restore DATA scope, path normalize

Regression after review nits: dropped $DATA/** and strict isAssetProtocolUrl
blocked valid http://asset.localhost URLs on Windows. Normalize C:/ paths
before convertFileSrc; CoverArtImage/Hero hide broken img on load error.

* fix(cover): disk peek fallbacks when cache folder id differs

Small surfaces resolve albumId while cover-cache often stores WebP under
track id or album.coverArt from the grid. Peek batch now tries legacy ids;
playback scope resolves server index key by URL key, not UUID-only lookup.

* fix(cover): Navidrome al-* vs mf-* disk id mismatch

UI used mf-* coverArtId while library backfill only cached al-* folders.
Prefer album id for display/peek when coverArt is mf-*; backfill now
queues both distinct album_id and cover_art_id values.

* fix(cover): mf→al disk peek when mf folder missing in cache

Navidrome Subsonic often returns mf-* coverArtId while backfill only
creates al-* folders. Peek mf first, then al-* from hints; load albumId
from library when Subsonic omits it; ensure fallback uses al-* id.

* feat(cover): CoverArtRef, segment disk layout, library-index backfill

Normalize cover caching around stable entity ids from the local library
and Navidrome fetch ids. Disk paths live in psysonic_core::cover_cache_layout
(album/<entityId>/); UI uses CoverArtRef with cacheEntityId + fetchCoverArtId.

- Remove SQLite/mf peek helpers (diskPeekIds, peekCoverOnDisk, mergeDiskIdHints)
- Backfill reads album/artist rows from library SQLite (bare Navidrome ids ok)
- Use stored cover_art_id for HTTP; per-disc dirs only when discs differ
- Migrate call sites to albumCoverRef / albumCoverRefForPlayback

* feat(cover): central CoverEntry resolver (artist, album, track)

Add resolveEntry.ts and Rust CoverEntry helpers as the single source of
truth for cache_entity_id vs fetch_cover_art_id. ref.ts delegates to them;
resolveCoverArtId becomes a thin compatibility shim.

* feat(cover): resolve cover entries from local library index

Add library_resolve_cover_entry IPC and cover_resolve.rs so album,
artist, and track covers use SQLite cover_art_id + disc detection.
TypeScript helpers in resolveEntryLibrary.ts prefer the index over
live API fields when rows exist.

* feat(cover): library-first hooks for grids and playback UI

Add useAlbumCoverRef, useArtistCoverRef, useTrackCoverRef, and
usePlaybackTrackCoverRef — sync fallback then SQLite index upgrade.
Wire album/artist cards, album header, song card, and all player
surfaces to resolve covers from the local library when indexed.

* feat(cover): complete library-first migration across all UI surfaces

Add Album/Artist/TrackCoverArtImage, useLibraryCoverPrefetch, and batch
resolve helpers. Migrate grids, search, home, playback sidecars, warm
peek, playlists, and share flows to hooks that upgrade from SQLite.
Backfill normalizes album rows through cover_resolve; document paths in
COVER_PATHS.md. Radio remains a deliberate non-library exception.

* fix(cover): stop render loop from unstable serverScope in library hooks

Default param `{ kind: 'active' }` created a new object every render, so
every grid cell re-ran library_resolve IPC and setState in a loop. Use
COVER_SCOPE_ACTIVE singleton, coverScopeKey deps, and guarded sync updates.

* chore(cover): remove COVER_PATHS.md from app tree (lives in workdocs)

Audit doc is team spec — see workdocs 2026-05-cover-art-pipeline/cover-paths-audit.md.

* fix(cover): unstick library backfill after route changes (PR #870 regression)

useCoverNavigationPriority cleanup called beginNavigation instead of end,
leaking navigationHoldDepth so ui_priority_hold never released and backfill
never downloaded. Also skip disk check after cover_resolve normalization.

* fix(cover): segment progress, cap backfill CPU, include artists in catalog

Progress and disk size now scan album/ and artist/ segments (canonical 800.webp).
Prune legacy flat server/al-* dirs on startup and backfill pass.

Backfill: max 2 concurrent ensures; JPEG decode and WebP encode run on the
blocking pool behind a shared 2-permit semaphore so Tokio workers stay cool.

Artists were missing because the catalog only read the empty artist table;
add distinct artist_id from track and album rows. Paginate with a composite
(kind, id) cursor so album and artist rows are not skipped.

* fix(cover): drop legacy prune; backfill per-disc and artist catalog

Remove prune_legacy_* and cover_cache_catalog_entry — layout is only
cover_dir (album|artist segments); stale flat dirs clear on LAYOUT_STAMP change.

Backfill: artists from track/album artist_id; expand albums to per-CD mf-* slots
when discs differ; fix resolve_album_cover_entry when album row is missing.

* fix(cover): reduce library IPC storms and fix multi-disc player art

Skip per-row library_resolve on live search and artist album grids; warm
grids from API coverArt after mount instead of blocking layout. Dedupe and
cap concurrent library_resolve calls. Restore per-disc cache keys in the
player and queue when track mf-* art differs from the album bucket.

* fix(cover): skip library resolve on advanced and full search rows

Use API coverArt for album/artist rails and lazy viewport artwork so
result pages do not fire hundreds of library_resolve IPC calls at once.

* fix(cover): default libraryResolve off for browse grids and rails

Skip per-card library_resolve on album/artist/song browse UI by default;
keep it on album/artist headers, playback queue rows, and orbit approval.

* fix(cover): split UI/backfill CPU pools and restore mainstage hero carousel

Library backfill no longer shares the 2-permit JPEG/WebP semaphore with
visible cover ensures. Hero initializes albums from props, re-binds scroll
visibility after mount, updates backdrop on slide change, and uses library
resolve for correct cover art on the banner.

* fix(analysis): resume full-library scan after candidates phase

Reset the SQL cursor when entering full-library mode so tracks with
partial analysis are not skipped. Tighten TS backfill completion and
CPU queue watermarking; align cover-cache key tests with album-scoped
storage keys.

* fix(library): remove useless map_err in cover_resolve (clippy)

CI treats clippy::useless-conversion as error on rusqlite optional() chains.

* fix(cover): satisfy clippy on cover_cache_ensure IPC args

Pass CoverCacheEnsureArgs as a single Tauri parameter instead of nine
positional fields; align frontend invoke payload with { args }.
This commit is contained in:
cucadmuh
2026-05-28 03:15:08 +03:00
committed by GitHub
parent ee5068c98c
commit df3533bb5a
124 changed files with 3640 additions and 887 deletions
+11 -6
View File
@@ -9,9 +9,10 @@ import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { coverStorageKeyFromRef } from '../cover/storageKeys';
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';
@@ -38,6 +39,8 @@ interface AlbumCardProps {
observeScrollRootId?: string;
/** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */
ensurePriority?: CoverPrefetchPriority;
/** Artist/detail grids: API `coverArt` is enough — skip per-card library_resolve IPC. */
libraryResolve?: boolean;
}
function AlbumCard({
@@ -53,6 +56,7 @@ function AlbumCard({
observeScrollRootId,
ensurePriority,
linkQuery,
libraryResolve = false,
}: AlbumCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -65,11 +69,12 @@ function AlbumCard({
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const psyDrag = useDragDrop();
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
const dragCoverKey = useMemo(() => {
if (!album.coverArt) return '';
if (!coverRef) return '';
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
return coverStorageKey({ kind: 'active' }, album.coverArt, tier);
}, [album.coverArt, displayCssPx]);
return coverStorageKeyFromRef(coverRef, tier);
}, [coverRef, displayCssPx]);
const isNewAlbum = isAlbumRecentlyAdded(album.created);
const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]);
@@ -112,9 +117,9 @@ function AlbumCard({
}}
>
<div className="album-card-cover">
{!disableArtwork && album.coverArt ? (
{!disableArtwork && coverRef ? (
<CoverArtImage
coverArtId={album.coverArt}
coverRef={coverRef}
displayCssPx={displayCssPx}
surface="dense"
alt={`${album.name} Cover`}
+4 -7
View File
@@ -4,7 +4,7 @@ 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 { CoverArtImage } from '../cover/CoverArtImage';
import { coverArtRef } from '../cover/ref';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { useCoverLightboxSrc } from '../cover/lightbox';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -121,10 +121,7 @@ export default function AlbumHeader({
const isMobile = useIsMobile();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const coverRef = useMemo(
() => (coverArtId ? coverArtRef(coverArtId) : null),
[coverArtId],
);
const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true });
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
alt: `${info.name} Cover`,
});
@@ -167,7 +164,7 @@ export default function AlbumHeader({
<ChevronLeft size={16} /> {t('albumDetail.back')}
</button>
<div className="album-detail-hero">
{coverArtId ? (
{coverRef ? (
<button
className="album-detail-cover-btn"
onClick={openLightbox}
@@ -176,7 +173,7 @@ export default function AlbumHeader({
>
<CoverArtImage
className="album-detail-cover"
coverArtId={coverArtId}
coverRef={coverRef}
displayCssPx={400}
surface="sparse"
alt={`${info.name} Cover`}
+4
View File
@@ -24,6 +24,8 @@ interface Props {
initialArtworkBudget?: number;
/** Appended to `/album/:id` links, e.g. `lossless=1`. */
albumLinkQuery?: string;
/** Search/browse rows: API `coverArt` only — no per-card library_resolve IPC. */
libraryResolve?: boolean;
}
export default function AlbumRow({
@@ -41,6 +43,7 @@ export default function AlbumRow({
windowArtworkByViewport = false,
initialArtworkBudget = 8,
albumLinkQuery,
libraryResolve = false,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
@@ -171,6 +174,7 @@ export default function AlbumRow({
album={a}
showRating={showRating}
linkQuery={albumLinkQuery}
libraryResolve={libraryResolve}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
+7 -5
View File
@@ -4,27 +4,29 @@ import { useNavigate } from 'react-router-dom';
import { Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { CoverArtImage } from '../cover/CoverArtImage';
import { coverArtIdFromArtist } from '../cover/ids';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
interface Props {
artist: SubsonicArtist;
/** Appended to `/artist/:id`, e.g. `lossless=1`. */
linkQuery?: string;
/** Search/browse rows: API `coverArt` only — no per-card library_resolve IPC. */
libraryResolve?: boolean;
}
export default function ArtistCardLocal({ artist, linkQuery }: Props) {
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const coverId = coverArtIdFromArtist(artist);
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
const href = linkQuery ? `/artist/${artist.id}?${linkQuery}` : `/artist/${artist.id}`;
return (
<div className="artist-card" onClick={() => navigate(href)}>
<div className="artist-card-avatar">
{artist.coverArt || artist.id ? (
{coverRef ? (
<CoverArtImage
coverArtId={coverId}
coverRef={coverRef}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name}
+13 -2
View File
@@ -10,9 +10,13 @@ interface Props {
moreLink?: string;
moreText?: string;
artistLinkQuery?: string;
/** Search results: use API coverArt ids only. */
libraryResolve?: boolean;
}
export default function ArtistRow({ title, artists, moreLink, moreText, artistLinkQuery }: Props) {
export default function ArtistRow({
title, artists, moreLink, moreText, artistLinkQuery, libraryResolve = false,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
@@ -55,7 +59,14 @@ export default function ArtistRow({ title, artists, moreLink, moreText, artistLi
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} linkQuery={artistLinkQuery} />)}
{artists.map(a => (
<ArtistCardLocal
key={a.id}
artist={a}
linkQuery={artistLinkQuery}
libraryResolve={libraryResolve}
/>
))}
{moreLink && (
<div className="album-card-more" onClick={() => navigate(moreLink)}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
+8 -8
View File
@@ -7,8 +7,8 @@ import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Music } from 'lucide-react';
import { coverArtRef } from '../cover/ref';
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
import { coverImgSrc } from '../cover/imgSrc';
import { useCoverArt } from '../cover/useCoverArt';
import { primeAlbumCoversForDisplay } from '../cover/warmDiskPeek';
@@ -500,11 +500,10 @@ export default function BecauseYouLikeRail({
return () => { cancelled = true; };
}, [pool, activeServerId, disableArtwork, poolKey]);
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]);
useLibraryCoverPrefetch(
disableArtwork || recs.length === 0 ? [] : [{ albums: recs, priority: 'high' }],
[recs, disableArtwork],
);
if (pool.length === 0) {
return <div ref={containerRef} />;
@@ -562,7 +561,8 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
const { t } = useTranslation();
const navigate = useNavigate();
const enqueue = usePlayerStore(s => s.enqueue);
const coverHandle = useCoverArt(album.coverArt, BECAUSE_CARD_COVER_CSS_PX, {
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
+8 -5
View File
@@ -103,25 +103,28 @@ describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
// The `false` arg is load-bearing — it avoids a double crossfade by
// routing through the cache-only path. Pin it.
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
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 => c[2] === false);
expect(coverCall).toBeDefined();
expect(typeof coverCall?.[1]).toBe('string');
expect(String(coverCall?.[1])).toContain('art-1');
expect(String(coverCall?.[1])).toContain('album-1');
});
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
usePlayerStore.setState({
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
});
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
const calls = vi.mocked(useCachedUrl).mock.calls;
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);
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
});
});
+13 -8
View File
@@ -1,6 +1,7 @@
import { queueSongStar } from '../store/pendingStarSync';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { playbackCoverArtForId } from '../utils/playback/playbackServer';
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import {
SkipBack, SkipForward,
@@ -50,11 +51,12 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const duration = currentTrack?.duration ?? 0;
// 300px for the small art box; 500px for the right-side portrait fallback.
const artCover = usePlaybackCoverArt(currentTrack?.coverArt, 300);
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
const artUrl = artCover.src;
const artKey = artCover.cacheKey;
const portraitCover = usePlaybackCoverArt(currentTrack?.coverArt, 500);
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
const coverUrl = portraitCover.src;
const coverKey = portraitCover.cacheKey;
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
@@ -79,14 +81,17 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
// around the current index keeps it warm).
const queueIndex = usePlayerStore(s => s.queueIndex);
const nextTrack = useQueueTrackAt(queueIndex + 1);
const nextCoverArt = queueIndex >= 0 ? (nextTrack?.coverArt ?? null) : null;
const queueServerId = usePlayerStore(s => s.queueServerId);
const activeServerId = useAuthStore(s => s.activeServerId);
useEffect(() => {
if (!nextCoverArt) return;
const { src: url, cacheKey: key } = playbackCoverArtForId(nextCoverArt, 300);
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
nextTrack.albumId,
nextTrack.coverArt,
300,
);
getCachedBlob(url, key).catch(() => {});
}, [nextCoverArt, queueServerId, activeServerId]);
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
// Lyrics settings popover state
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
+16 -6
View File
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useCoverArt } from '../cover/useCoverArt';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playback/playAlbum';
@@ -74,7 +75,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() =>
albumsProp?.length ? albumsProp : [],
);
const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const windowHidden = useWindowVisibility();
@@ -125,6 +128,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
heroScrollRootRef.current =
scrollRoot ?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null);
updateHeroVisibility();
// Layout may settle after first paint (hero mounts after albums hydrate from props).
const layoutRaf = window.requestAnimationFrame(() => updateHeroVisibility());
const root = heroScrollRootRef.current;
const onScroll = () => {
if (visibilityRafRef.current != null) return;
@@ -148,8 +153,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
window.cancelAnimationFrame(visibilityRafRef.current);
visibilityRafRef.current = null;
}
window.cancelAnimationFrame(layoutRaf);
};
}, [updateHeroVisibility]);
}, [updateHeroVisibility, albums.length]);
useEffect(() => {
const updateBlurState = () => {
@@ -259,7 +265,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
});
}, [album?.id]);
const bgHandle = useCoverArt(album?.coverArt, HERO_BG_CSS_PX, {
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
@@ -267,7 +274,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
// 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 (bgHandle.src) stableBgUrl.current = bgHandle.src;
const albumId = album?.id;
useEffect(() => {
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
}, [bgHandle.src, albumId]);
if (!album) return <div className="hero-placeholder" />;
@@ -285,9 +295,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content" key={album.id}>
{album.coverArt && !isMobile && (
{heroCoverRef && !isMobile && (
<CoverArtImage
coverArtId={album.coverArt}
coverRef={heroCoverRef}
displayCssPx={HERO_FG_CSS_PX}
surface="dense"
ensurePriority="high"
+5 -3
View File
@@ -10,6 +10,7 @@ import {
suggestionKey,
} from '../utils/orbit';
import { CoverArtImage } from '../cover/CoverArtImage';
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
const HOST_APPROVAL_COVER_CSS_PX = 36;
@@ -85,9 +86,10 @@ export default function HostApprovalQueue() {
const key = suggestionKey(q);
return (
<div key={key} className="host-approval__item">
{song?.coverArt ? (
<CoverArtImage
coverArtId={song.coverArt}
{song ? (
<TrackCoverArtImage
song={song}
libraryResolve
displayCssPx={HOST_APPROVAL_COVER_CSS_PX}
surface="dense"
alt=""
+36 -25
View File
@@ -28,11 +28,12 @@ import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { useTranslation } from 'react-i18next';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
import type { SubsonicSong } from '../api/subsonicTypes';
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
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 { albumCoverRefForSong } from '../cover/ref';
import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
@@ -40,31 +41,54 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network';
function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
return (
<CoverArtImage
coverArtId={coverArt}
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
className="search-result-thumb"
alt=""
ensurePriority="high"
/>
);
}
function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> }) {
const coverRef = React.useMemo(
() => (song.albumId?.trim() ? albumCoverRefForSong(song) : undefined),
[song.id, song.albumId, song.coverArt, song.discNumber],
);
if (!coverRef) return null;
return (
<CoverArtImage
coverRef={coverRef}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
className="search-result-thumb"
alt=""
ensurePriority="high"
/>
);
}
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false);
const coverId = coverArtIdFromArtist(artist);
useEffect(() => { setFailed(false); }, [coverId]);
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
return (
<CoverArtImage
coverArtId={coverId}
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
className="search-result-thumb"
alt=""
loading="eager"
ensurePriority="high"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
onError={() => setFailed(true)}
/>
@@ -422,19 +446,6 @@ 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 ? [
{
@@ -674,7 +685,7 @@ export default function LiveSearch() {
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<LiveSearchAlbumThumb coverArt={a.coverArt} />
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
@@ -710,7 +721,7 @@ export default function LiveSearch() {
}}
role="option" aria-selected={activeIndex === i}>
{(s.coverArt ?? s.albumId) ? (
<LiveSearchAlbumThumb coverArt={s.coverArt ?? s.albumId!} />
<LiveSearchSongThumb song={s} />
) : (
<div className="search-result-icon"><Music size={14} /></div>
)}
+3 -1
View File
@@ -1,4 +1,5 @@
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
import { useEffect, useMemo, useRef, useState } from 'react';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
@@ -140,7 +141,8 @@ export default function MiniPlayer() {
}, [queueOpen, state.queueIndex]);
const { track, isPlaying } = state;
const { src: miniCoverSrc, cacheKey: miniCoverKey } = usePlaybackCoverArt(track?.coverArt, 300);
const miniCoverRef = usePlaybackTrackCoverRef(track ?? undefined);
const { src: miniCoverSrc, cacheKey: miniCoverKey } = usePlaybackCoverArt(miniCoverRef, 300);
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
+3 -1
View File
@@ -1,5 +1,6 @@
import { queueSongStar } from '../store/pendingStarSync';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
import type { Track } from '../store/playerStoreTypes';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
@@ -223,7 +224,8 @@ export default function MobilePlayerView() {
const duration = currentTrack?.duration ?? 0;
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(currentTrack?.coverArt, 800);
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(playbackCoverRef, 800);
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Dynamic background color extracted from cover art
+38 -28
View File
@@ -1,7 +1,7 @@
import { search } from '../api/subsonicSearch';
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } 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';
@@ -9,10 +9,10 @@ import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
import { CoverArtImage } from '../cover/CoverArtImage';
import { coverArtIdFromArtist } from '../cover/ids';
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
import { coverArtRef } from '../cover/ref';
import { albumCoverRefForSong } from '../cover/ref';
import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
@@ -38,10 +38,31 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
/** Mobile search row thumb — larger than desktop live search (32px). */
const MOBILE_SEARCH_THUMB_CSS_PX = 80;
function MobileSearchSongThumb({
song,
}: {
song: Pick<SearchResults['songs'][number], 'id' | 'albumId' | 'coverArt' | 'discNumber'>;
}) {
const coverRef = useMemo(
() => (song.albumId?.trim() ? albumCoverRefForSong(song) : undefined),
[song.id, song.albumId, song.coverArt, song.discNumber],
);
if (!coverRef) return null;
return (
<CoverArtImage
coverRef={coverRef}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb"
alt=""
ensurePriority="high"
/>
);
}
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false);
const coverId = coverArtIdFromArtist(artist);
useEffect(() => { setFailed(false); }, [coverId]);
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) {
return (
<div className="mobile-search-avatar mobile-search-avatar--circle">
@@ -50,8 +71,10 @@ function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id'
);
}
return (
<CoverArtImage
coverArtId={coverId}
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
libraryResolve={false}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb mobile-search-thumb--artist-round"
@@ -103,16 +126,6 @@ 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));
};
@@ -287,12 +300,15 @@ 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 ? (
<CoverArtImage
coverArtId={a.coverArt}
<AlbumCoverArtImage
albumId={a.id}
coverArt={a.coverArt}
libraryResolve={false}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb"
alt=""
ensurePriority="high"
/>
) : (
<div className="mobile-search-avatar">
@@ -314,14 +330,8 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
<div className="mobile-search-section-label">{t('search.songs')}</div>
{results!.songs.map(s => (
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
{s.coverArt ? (
<CoverArtImage
coverArtId={s.coverArt}
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
surface="dense"
className="mobile-search-thumb"
alt=""
/>
{s.albumId && (s.coverArt ?? s.albumId) ? (
<MobileSearchSongThumb song={s} />
) : (
<div className="mobile-search-avatar">
<Music size={20} />
+9 -3
View File
@@ -1,4 +1,5 @@
import { CoverArtImage } from '../cover/CoverArtImage';
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
import { getNowPlaying } from '../api/subsonicScrobble';
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
@@ -161,9 +162,14 @@ export default function NowPlayingDropdown() {
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
{stream.coverArt ? (
<CoverArtImage
coverArtId={stream.coverArt}
{stream.albumId && stream.coverArt ? (
<TrackCoverArtImage
song={{
id: stream.id,
albumId: stream.albumId,
coverArt: stream.coverArt,
discNumber: undefined,
}}
displayCssPx={50}
surface="sparse"
alt="Cover"
+10 -7
View File
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Radio, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { CoverArtImage } from '../cover/CoverArtImage';
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
import OrbitQueueHead from './OrbitQueueHead';
const ORBIT_QUEUE_COVER_LG_CSS_PX = 54;
@@ -78,8 +78,9 @@ export default function OrbitGuestQueue() {
</div>
<div className="orbit-guest-queue__current-body">
{currentSong?.coverArt ? (
<CoverArtImage
coverArtId={currentSong.coverArt}
<TrackCoverArtImage
song={currentSong}
libraryResolve
displayCssPx={ORBIT_QUEUE_COVER_LG_CSS_PX}
surface="dense"
alt=""
@@ -115,8 +116,9 @@ export default function OrbitGuestQueue() {
return (
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
{song?.coverArt ? (
<CoverArtImage
coverArtId={song.coverArt}
<TrackCoverArtImage
song={song}
libraryResolve
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
surface="dense"
alt=""
@@ -154,8 +156,9 @@ export default function OrbitGuestQueue() {
className="orbit-guest-queue__item"
>
{song?.coverArt ? (
<CoverArtImage
coverArtId={song.coverArt}
<TrackCoverArtImage
song={song}
libraryResolve
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
surface="dense"
alt=""
+4 -5
View File
@@ -1,6 +1,6 @@
import { queueSongStar } from '../store/pendingStarSync';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import { coverArtIdFromRadio } from '../cover/ids';
import { resolvePlaybackTrackCoverArtId } from '../cover/resolveCoverArtId';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -142,15 +142,15 @@ export default function PlayerBar() {
// 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;
const displayCoverArt = showPreviewMeta ? previewingTrack!.coverArt : currentTrack?.coverArt;
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
const displayArtistRefs = !showPreviewMeta && currentTrack?.artists && currentTrack.artists.length > 0
? currentTrack.artists
: undefined;
usePlaybackCoverArt(showPreviewMeta ? undefined : displayCoverArt, 128);
const coverArtId = showPreviewMeta ? previewingTrack?.coverArt : displayCoverArt;
const coverArtId = showPreviewMeta
? previewingTrack?.coverArt
: resolvePlaybackTrackCoverArtId(currentTrack);
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVolume(parseFloat(e.target.value));
@@ -197,7 +197,6 @@ export default function PlayerBar() {
radioMeta={radioMeta}
radioCoverArtId={radioCoverArtId}
coverArtId={coverArtId}
displayCoverArt={displayCoverArt}
displayTitle={displayTitle}
displayArtist={displayArtist}
displayArtistRefs={displayArtistRefs}
-3
View File
@@ -31,7 +31,6 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
import { QueueList } from './queuePanel/QueueList';
import { QueueTabBar } from './queuePanel/QueueTabBar';
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
export default function QueuePanel() {
const orbitRole = useOrbitStore(s => s.role);
@@ -79,7 +78,6 @@ function QueuePanelHostOrSolo() {
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
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);
@@ -255,7 +253,6 @@ function QueuePanelHostOrSolo() {
{currentTrack && !isNowPlayingCollapsed && (
<QueueCurrentTrack
currentTrack={currentTrack}
currentCoverSrc={currentCoverSrc}
userRatingOverrides={userRatingOverrides}
orbitAttributionLabel={orbitAttributionLabel}
navigate={navigatePlaybackLibrary}
+5 -4
View File
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useCoverArt } from '../cover/useCoverArt';
import { useTrackCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../cover/layoutSizes';
import { enqueueAndPlay } from '../utils/playback/playSong';
import { useDragDrop } from '../contexts/DragDropContext';
@@ -32,8 +33,8 @@ function SongCard({
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const coverArtId = song.coverArt ?? song.albumId;
const coverHandle = useCoverArt(coverArtId, layoutPx, {
const coverRef = useTrackCoverRef(song, undefined, { libraryResolve: false });
const coverHandle = useCoverArt(coverRef, layoutPx, {
surface: 'dense',
ensurePriority: 'middle',
});
@@ -93,9 +94,9 @@ function SongCard({
}}
>
<div className="song-card-cover">
{!disableArtwork && coverArtId ? (
{!disableArtwork && coverRef ? (
<CoverArtImage
coverArtId={coverArtId}
coverRef={coverRef}
displayCssPx={layoutPx}
surface="dense"
alt={`${song.album} Cover`}
@@ -11,8 +11,8 @@ import { useOfflineJobStore } from '../../store/offlineJobStore';
import { useAuthStore } from '../../store/authStore';
import { useIsMobile } from '../../hooks/useIsMobile';
import { ArtistHeroCover } from '../../cover/artistHero';
import { coverArtRef } from '../../cover/ref';
import { useCoverLightboxSrc } from '../../cover/lightbox';
import type { CoverArtRef } from '../../cover/types';
import LastfmIcon from '../LastfmIcon';
import StarRating from '../StarRating';
@@ -36,6 +36,7 @@ interface Props {
openedLink: string | null;
openLink: (url: string, key: string) => void;
coverId: string;
coverRef: CoverArtRef | null;
coverRevision: number;
headerCoverFailed: boolean;
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
@@ -46,7 +47,7 @@ export default function ArtistDetailHero({
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
handleImageUpload, playAllLoading, radioLoading, uploading,
openedLink, openLink,
coverId, coverRevision, headerCoverFailed, setHeaderCoverFailed,
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -58,10 +59,6 @@ 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)}`;
@@ -1,23 +1,25 @@
import React, { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
import { resolveArtistPageSongCoverArtId } from '../../cover/resolveCoverArtId';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
import { songToTrack } from '../../utils/playback/songToTrack';
import { formatTrackTime } from '../../utils/format/formatDuration';
import ArtistSuggestionTrackCover from './ArtistSuggestionTrackCover';
import ArtistTopTrackCover from './ArtistTopTrackCover';
interface Props {
topSongs: SubsonicSong[];
albums: SubsonicAlbum[];
marginTop: string;
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
losslessOnly?: boolean;
}
export default function ArtistDetailTopTracks({
topSongs, marginTop, playTopSongWithContinuation, losslessOnly = false,
topSongs, albums, marginTop, playTopSongWithContinuation, losslessOnly = false,
}: Props) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
@@ -92,9 +94,19 @@ export default function ArtistDetailTopTracks({
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
{song.coverArt && (
<ArtistSuggestionTrackCover coverArt={song.coverArt} album={song.album} />
)}
{(() => {
const albumRow = song.albumId
? albums.find(a => a.id === song.albumId)
: albums.find(a => a.name === song.album);
const coverId = resolveArtistPageSongCoverArtId(song, albums);
return coverId && song.albumId ? (
<ArtistTopTrackCover
albumId={song.albumId}
coverArt={coverId}
album={song.album}
/>
) : null;
})()}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div className="track-title">{song.title}</div>
</div>
@@ -1,16 +0,0 @@
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 }) {
return (
<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'; }}
/>
);
}
@@ -0,0 +1,25 @@
import React from 'react';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '../../cover/layoutSizes';
export default function ArtistTopTrackCover({
albumId,
coverArt,
album,
}: {
albumId: string;
coverArt: string;
album: string;
}) {
return (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
displayCssPx={COVER_ARTIST_TOP_TRACK_CSS_PX}
surface="sparse"
ensurePriority="high"
alt={album}
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
/>
);
}
+7 -8
View File
@@ -1,7 +1,6 @@
import React from 'react';
import type { SubsonicArtist } from '../../api/subsonicTypes';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { coverArtIdFromArtist } from '../../cover/ids';
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
import {
COVER_DENSE_ARTIST_LIST_CSS_PX,
COVER_DENSE_GRID_MIN_CELL_CSS_PX,
@@ -21,12 +20,12 @@ interface AvatarProps {
*/
export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
const color = nameColor(artist.name);
const coverId = coverArtIdFromArtist(artist);
if (showImages && (artist.coverArt || artist.id)) {
return (
<div className="artist-card-avatar">
<CoverArtImage
coverArtId={coverId}
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name}
@@ -48,12 +47,12 @@ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
*/
export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
const color = nameColor(artist.name);
const coverId = coverArtIdFromArtist(artist);
if (showImages && (artist.coverArt || artist.id)) {
return (
<div className="artist-avatar">
<CoverArtImage
coverArtId={coverId}
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
surface="dense"
alt={artist.name}
+2 -1
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
import type { InternetRadioStation } from '../../api/subsonicTypes';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { albumCoverRef } from '../../cover/ref';
import { coverArtIdFromRadio } from '../../cover/ids';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
@@ -78,7 +79,7 @@ function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }:
<div className="album-card-cover">
{s.coverArt ? (
<CoverArtImage
coverArtId={coverArtIdFromRadio(s.id)}
coverRef={albumCoverRef(coverArtIdFromRadio(s.id), coverArtIdFromRadio(s.id))}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={s.name}
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
export interface TopFavoriteArtist {
@@ -92,8 +93,9 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
>
<div className="artist-card-avatar">
{coverId ? (
<CoverArtImage
coverArtId={coverId}
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArtId}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name}
+2 -1
View File
@@ -5,6 +5,7 @@ import { open } from '@tauri-apps/plugin-shell';
import type { InternetRadioStation } from '../../api/subsonicTypes';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { albumCoverRef } from '../../cover/ref';
import { coverArtIdFromRadio } from '../../cover/ids';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
@@ -87,7 +88,7 @@ export default function RadioCard({
<div className="album-card-cover">
{s.coverArt ? (
<CoverArtImage
coverArtId={coverArtIdFromRadio(s.id)}
coverRef={albumCoverRef(coverArtIdFromRadio(s.id), coverArtIdFromRadio(s.id))}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={s.name}
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Camera, Cast, Loader2, X } from 'lucide-react';
import type { InternetRadioStation } from '../../api/subsonicTypes';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { albumCoverRef } from '../../cover/ref';
import { coverArtIdFromRadio } from '../../cover/ids';
interface RadioEditModalProps {
@@ -92,7 +93,7 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && station?.coverArt ? (
<CoverArtImage
coverArtId={coverArtIdFromRadio(station.id)}
coverRef={albumCoverRef(coverArtIdFromRadio(station.id), coverArtIdFromRadio(station.id))}
displayCssPx={140}
surface="sparse"
alt=""
@@ -2,7 +2,7 @@ import React, { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Disc3, ExternalLink, Music } from 'lucide-react';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../../cover/layoutSizes';
interface DiscographyCardProps {
@@ -52,8 +52,9 @@ const DiscographyCard = memo(function DiscographyCard({ artistId, albums, curren
<div className="np-dash-disc-cover">
{a.coverArt
? (
<CoverArtImage
coverArtId={a.coverArt}
<AlbumCoverArtImage
albumId={a.id}
coverArt={a.coverArt}
displayCssPx={COVER_DENSE_RAIL_CELL_CSS_PX}
surface="dense"
alt={a.name}
+17 -6
View File
@@ -1,6 +1,8 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
import { CoverArtImage } from '../../cover/CoverArtImage';
import type { CoverArtRef } from '../../cover/types';
import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm';
import LastfmIcon from '../LastfmIcon';
import { formatTrackTime } from '../../utils/format/formatDuration';
@@ -19,7 +21,7 @@ interface HeroProps {
lfmLoved: boolean;
lfmLoveEnabled: boolean;
activeLyricsTab: boolean;
coverUrl: string;
coverRef?: CoverArtRef;
onNavigate: (path: string) => void;
onToggleStar: () => void;
onToggleLfmLove: () => void;
@@ -40,7 +42,7 @@ function renderStars(rating?: number) {
);
}
const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverUrl, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
const { t } = useTranslation();
const rating = userRatingOverride ?? track.userRating;
const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000;
@@ -49,9 +51,18 @@ const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, l
return (
<div className="np-dash-hero">
<div className="np-dash-hero-cover">
{coverUrl
? <img src={coverUrl} alt="" className="np-cover" />
: <div className="np-cover np-cover-fallback"><Music size={64} /></div>}
{coverRef ? (
<CoverArtImage
className="np-cover"
coverRef={coverRef}
displayCssPx={280}
surface="sparse"
ensurePriority="high"
alt=""
/>
) : (
<div className="np-cover np-cover-fallback"><Music size={64} /></div>
)}
</div>
<div className="np-dash-hero-body">
<div className="np-dash-hero-title">{track.title}</div>
@@ -160,4 +171,4 @@ const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, l
);
});
export default Hero;
export default Hero;
+16 -7
View File
@@ -6,6 +6,9 @@ import type { PlayerState, Track } from '../../store/playerStoreTypes';
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
import type { PreviewingTrack } from '../../store/previewStore';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { albumCoverRef } from '../../cover/ref';
import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
import LastfmIcon from '../LastfmIcon';
import MarqueeText from '../MarqueeText';
import { OpenArtistRefInline } from '../OpenArtistRefInline';
@@ -24,7 +27,6 @@ interface Props {
radioMeta: RadioMetadata;
radioCoverArtId?: string;
coverArtId?: string;
displayCoverArt: string | undefined;
displayTitle: string;
displayArtist: string;
/** When set (OpenSubsonic `artists` on the playing track), render split links like album track rows. */
@@ -45,13 +47,20 @@ interface Props {
export function PlayerTrackInfo({
currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
coverArtId, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
coverArtId, displayTitle, displayArtist, displayArtistRefs,
showPreviewMeta, previewingTrack, isStarred, toggleStar,
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
userRatingOverrides, toggleFullscreen,
navigate, openContextMenu, t,
}: Props) {
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
const playbackCoverRef = usePlaybackTrackCoverRef(
showPreviewMeta ? null : currentTrack ?? undefined,
);
const previewCoverRef = useAlbumCoverRef(
showPreviewMeta ? coverArtId : null,
showPreviewMeta ? coverArtId : null,
);
const layoutItems = usePlayerBarLayoutStore(s => s.items);
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
layoutItems.find(i => i.id === id)?.visible !== false;
@@ -64,10 +73,10 @@ export function PlayerTrackInfo({
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
>
{isRadio ? (
radioCoverArtId ? (
radioCoverArtId && currentRadio ? (
<CoverArtImage
className="player-album-art"
coverArtId={radioCoverArtId}
coverRef={albumCoverRef(radioCoverArtId, radioCoverArtId)}
displayCssPx={128}
surface="sparse"
alt={currentRadio?.name ?? ''}
@@ -77,13 +86,13 @@ export function PlayerTrackInfo({
<Cast size={20} />
</div>
)
) : coverArtId ? (
) : !isRadio && (showPreviewMeta ? coverArtId : playbackCoverRef) ? (
<CoverArtImage
className="player-album-art"
coverArtId={coverArtId}
coverRef={showPreviewMeta ? previewCoverRef! : playbackCoverRef!}
displayCssPx={128}
surface="sparse"
serverScope={showPreviewMeta ? { kind: 'active' } : { kind: 'playback' }}
ensurePriority="high"
alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`}
/>
) : (
@@ -4,6 +4,7 @@ import { Camera, Loader2, X } from 'lucide-react';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import type { CoverArtId } from '../../cover/types';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
@@ -79,8 +80,9 @@ export default function PlaylistEditModal({
{coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && customCoverId ? (
<CoverArtImage
coverArtId={customCoverId}
<AlbumCoverArtImage
albumId={customCoverId}
coverArt={customCoverId}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
alt=""
+4 -2
View File
@@ -14,6 +14,7 @@ import {
} from '../../utils/componentHelpers/playlistDetailHelpers';
import type { CoverArtId } from '../../cover/types';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
@@ -87,8 +88,9 @@ export default function PlaylistHero({
onClick={() => setEditingMeta(true)}
>
{customCoverId ? (
<CoverArtImage
coverArtId={customCoverId}
<AlbumCoverArtImage
albumId={customCoverId}
coverArt={customCoverId}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
alt=""
@@ -5,14 +5,15 @@ import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { songToTrack } from '../../utils/playback/songToTrack';
import { formatTrackTime } from '../../utils/format/formatDuration';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
import { AddToPlaylistSubmenu } from '../ContextMenu';
function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) {
function PlaylistSearchResultThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
return (
<CoverArtImage
coverArtId={coverArt}
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
alt=""
@@ -133,7 +134,7 @@ export default function PlaylistSongSearchPanel({
return next;
})}
/>
<PlaylistSearchResultThumb coverArt={song.coverArt ?? ''} />
<PlaylistSearchResultThumb albumId={song.albumId} coverArt={song.coverArt ?? ''} />
<div className="playlist-search-info">
<span className="playlist-search-title">{song.title}</span>
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
@@ -1,5 +1,5 @@
import React from 'react';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
/** 2×2 collage cell — half of clamp(120px, 15vw, 200px) playlist hero grid. */
const PLAYLIST_QUAD_CELL_CSS_PX = 100;
@@ -8,8 +8,9 @@ const PLAYLIST_MAIN_COVER_CSS_PX = 200;
export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
return (
<CoverArtImage
coverArtId={coverId}
<AlbumCoverArtImage
albumId={coverId}
coverArt={coverId}
displayCssPx={PLAYLIST_QUAD_CELL_CSS_PX}
surface="dense"
className="playlist-cover-cell"
@@ -20,8 +21,9 @@ export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
return (
<CoverArtImage
coverArtId={coverArt}
<AlbumCoverArtImage
albumId={coverArt}
coverArt={coverArt}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
alt={alt}
@@ -14,11 +14,12 @@ import { formatQueueBpmTech, formatQueueMoodLabels } from '../../utils/library/t
import { useQueueTrackEnrichment } from '../../hooks/useQueueTrackEnrichment';
import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
import { usePlayerStore } from '../../store/playerStore';
interface Props {
currentTrack: Track;
currentCoverSrc: string;
userRatingOverrides: Record<string, number>;
orbitAttributionLabel: (trackId: string) => string | null;
navigate: (to: string) => void | Promise<void>;
@@ -42,7 +43,7 @@ interface Props {
}
export function QueueCurrentTrack({
currentTrack, currentCoverSrc, userRatingOverrides, orbitAttributionLabel,
currentTrack, userRatingOverrides, orbitAttributionLabel,
navigate, playbackSource, normalizationEngine, normalizationEngineLive,
normalizationNowDb, normalizationTargetLufs, authLoudnessTargetLufs,
loudnessPreAnalysisAttenuationDb, expandReplayGain, setExpandReplayGain,
@@ -50,6 +51,7 @@ export function QueueCurrentTrack({
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
}: Props) {
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
const coverRef = usePlaybackTrackCoverRef(currentTrack);
const enrichment = useQueueTrackEnrichment(currentTrack.id);
const bpmTech = formatQueueBpmTech(enrichment, t);
const moodLine = formatQueueMoodLabels(enrichment.moodLabels, t);
@@ -201,8 +203,15 @@ export function QueueCurrentTrack({
})()}
<div className="queue-current-track-body">
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
{currentTrack.coverArt && currentCoverSrc ? (
<img src={currentCoverSrc} alt="" loading="eager" />
{coverRef ? (
<CoverArtImage
coverRef={coverRef}
displayCssPx={128}
surface="sparse"
ensurePriority="high"
alt=""
loading="eager"
/>
) : (
<div className="fallback"><Music size={32} /></div>
)}
@@ -11,7 +11,8 @@ import OverlayScrollArea from '../OverlayScrollArea';
import { usePlayerStore } from '../../store/playerStore';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
import type { CoverServerScope } from '../../cover/types';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../../cover/types';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
type ShareQueuePreviewModalProps = {
open: boolean;
@@ -36,7 +37,7 @@ function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerS
password: coverServer.password,
};
}
return { kind: 'active' };
return COVER_SCOPE_ACTIVE;
}
function QueuePreviewTrackRow({
@@ -51,11 +52,12 @@ function QueuePreviewTrackRow({
return (
<li className="share-queue-preview-track">
{song.coverArt ? (
<CoverArtImage
coverArtId={coverId}
<AlbumCoverArtImage
albumId={song.albumId ?? coverId}
coverArt={song.coverArt}
serverScope={shareCoverServerScope(coverServer)}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
serverScope={shareCoverServerScope(coverServer)}
className="share-queue-preview-track__thumb"
alt=""
/>
+17 -14
View File
@@ -9,10 +9,10 @@ import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchP
import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch';
import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
import { coverArtIdFromArtist } from '../../cover/ids';
import type { CoverServerScope } from '../../cover/types';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../../cover/types';
import { useShareQueuePreview } from '../../hooks/useShareQueuePreview';
import ShareQueuePreviewModal from './ShareQueuePreviewModal';
@@ -42,25 +42,28 @@ function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerS
password: coverServer.password,
};
}
return { kind: 'active' };
return COVER_SCOPE_ACTIVE;
}
function ShareAlbumThumb({
albumId,
coverArt,
displayCssPx,
coverServer,
}: {
albumId: string;
coverArt: string;
displayCssPx: number;
coverServer?: ServerProfile | null;
}) {
const cls = displayCssPx >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
return (
<CoverArtImage
coverArtId={coverArt}
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
serverScope={shareCoverServerScope(coverServer)}
displayCssPx={displayCssPx}
surface="dense"
serverScope={shareCoverServerScope(coverServer)}
className={cls}
alt=""
/>
@@ -77,8 +80,7 @@ function ShareArtistThumb({
coverServer?: ServerProfile | null;
}) {
const [failed, setFailed] = useState(false);
const coverId = coverArtIdFromArtist(artist);
useEffect(() => { setFailed(false); }, [coverId]);
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) {
if (displayCssPx >= 64) {
@@ -100,11 +102,12 @@ function ShareArtistThumb({
? 'mobile-search-thumb mobile-search-thumb--artist-round'
: 'search-result-thumb';
return (
<CoverArtImage
coverArtId={coverId}
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
serverScope={shareCoverServerScope(coverServer)}
displayCssPx={displayCssPx}
surface="dense"
serverScope={shareCoverServerScope(coverServer)}
className={cls}
alt=""
loading="eager"
@@ -319,7 +322,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
aria-selected={desktop ? activeIndex === 0 : undefined}
>
{shareAlbum.coverArt ? (
<ShareAlbumThumb coverArt={shareAlbum.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
<ShareAlbumThumb albumId={shareAlbum.id} coverArt={shareAlbum.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
) : (
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
)}
@@ -370,7 +373,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
aria-selected={desktop ? activeIndex === 0 : undefined}
>
{shareTrackSong.coverArt ? (
<ShareAlbumThumb coverArt={shareTrackSong.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
<ShareAlbumThumb albumId={shareTrackSong.albumId} coverArt={shareTrackSong.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
) : (
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
)}