feat(hero): album-artist backdrop, configurable per-surface sources, and prefetch (#1193)

* refactor(cover): extract shared pickArtistBackdrop priority helper

* feat(hero): show the album artist's fanart as the mainstage hero backdrop

* feat(settings): configurable per-surface artist backdrop sources

Each artist-backdrop surface (mainstage hero, artist-detail header, fullscreen player) gets its own enable toggle + an ordered, individually-toggleable source list, configured under External Artwork Scraper on the Integrations tab (shown when the scraper is on). Reorder via the shared useDragSource/psy-drop drag infra plus keyboard-accessible up/down buttons; each source has its own on/off.

The shared chooser pickArtistBackdrop is generalised to resolveBackdrop + backdropFromConfig (ordered candidate list with the same pending/miss/centred-framing semantics), so all three surfaces resolve identically. themeStore persist bumped to v2; defaults reproduce today's order, so there is no visible change without user action.

Gating decoupled: the three surfaces are gated solely by their own per-surface flag, not by enableCoverArtBackground (which stays scoped to album/playlist-header cover blur). Reorder maths extracted to a pure, unit-tested module. i18n en + de (other locales TODO before PR).

Tests: resolver (10) + reorder (7).

* feat(cover): make the ensure queue surface-aware for artist backdrops

coverEnsureQueued now threads optional CoverEnsureOpts through to the Rust ensure and weaves the external surface into the in-flight key, so the fanart and banner surfaces of one artist no longer collide on one download chain. External surfaces also bypass the disk-src memory short-circuit (their {tier}-{surface}.webp never seeds those caches, and the canonical cover must not read as a hit). New thin ensureArtistBackdropQueued wrapper. Backward-compatible: plain covers append nothing to the key; the 5 queue tests stay green.

* fix(cover): reset the artist external-image hook synchronously on artist change

The hook reset src in an effect (one render late), so for the render between an artist change and that effect a consumer read the *previous* artist's resolved image. The mainstage hero then froze (and cached into per-album memory) a neighbouring slide's banner. Reset synchronously via the React adjust-state-on-prop-change pattern. Also removes brief stale-image flashes on the artist-detail header and fullscreen player.

* feat(hero): prefetch artist backdrops and show-ready-now / upgrade-on-re-entry

warmHomeMainstageCovers now prefetches each hero slide's artist backdrop (banner/fanart) at static slide-index priorities (idx1=high, idx0=low, rest=middle; no reprioritise on navigation), then predecodes every slide already on disk. useHeroBackdrop shows the best source ready at entry (Navidrome on a cold first visit, the prefetched/cached external one on re-entry) and freezes that source choice for the visit so nothing swaps mid-dwell; the url is derived live from the frozen choice, with a per-album disk memory for re-entry. HeroBg now crossfades only after the image bytes load (onLoad/complete gate + onError + fallback). Inert when the scraper is off. Tests: per-album memory (5).

* docs(changelog): configurable artist backdrops + mainstage hero (PR #1193)

CHANGELOG Added entry, settingsCredits line, and the What's New Artist-artwork highlight extended to cover the mainstage hero backdrop and per-place source config.

* docs(changelog): fold mainstage hero + per-place backdrops into the fanart entry (PR #1193)

Merge the configurable-backdrops changes into the existing 'Artist artwork from fanart.tv' block (now PR #1137 and #1193) instead of a separate entry.

* fix(hero): revert HeroBg load-gate that blanked the app (Maximum update depth)

The byte-load gate I added drove the crossfade reveal from an inline img ref + onLoad that re-fires on every render and scheduled a setTimeout each call; frequent re-renders (playback, marquee) stacked nested updates until React threw 'Maximum update depth exceeded' — and with no ErrorBoundary the whole window blanked. Reverted HeroBg to the proven timer-based crossfade. The hero only ever receives ready/predecoded urls, so the gate was cosmetic.

* fix(hero): gate the HeroBg crossfade on image load to stop the slide flicker

The bare 20 ms reveal faded a layer in before its bytes were ready (notably the Navidrome raw url), so a slide change flickered. Now an Image() preloader in the [url] effect reveals the layer on load (or a cached complete check), with a fallback. Everything is scheduled once per url — no per-render <img> ref/onLoad — so unlike the reverted gate it can't stack nested updates.

* feat(i18n): translate the backdrop-source settings into the remaining 10 locales

Adds the per-surface backdrop config strings (es, fr, nl, zh, nb, ru, ro, ja, hu, pl) and extends externalArtworkDesc to mention the mainstage hero where the key exists (ja has none → falls back to en).
This commit is contained in:
Psychotoxical
2026-06-27 00:42:32 +02:00
committed by GitHub
parent 014d57c53c
commit 86ae462ad6
33 changed files with 1198 additions and 110 deletions
+3 -1
View File
@@ -68,9 +68,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Artist artwork from fanart.tv
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137)**
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1137](https://github.com/Psychotoxical/psysonic/pull/1137) and PR [#1193](https://github.com/Psychotoxical/psysonic/pull/1193)**
* New opt-in **External Artwork Scraper** (Settings → Integrations, off by default): artist imagery from fanart.tv — a 16:9 background on the fullscreen player and a wide banner on the artist page — with Navidrome staying the canonical cover. Optional personal key; turning it off removes the fetched images again.
* The **mainstage hero** on the home screen now shows the album artist's backdrop too, matching the fullscreen player and artist page.
* Choose, per place (mainstage hero, artist page, fullscreen player), which images to use as the background and in what order — drag to reorder or switch a source off, under the same setting. The hero also preloads the upcoming backdrops so they appear without a long blank.
### Remember the equalizer per audio output device
+3 -2
View File
@@ -47,9 +47,10 @@ Within each section, order by **user impact** (most noticeable first) — not PR
- Under **Settings → Audio → Native Hi-Res**, choose a **blend rate** (44.1 / 88.2 / 96 kHz) for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate — mixed 88.2 ↔ 44.1 kHz handovers no longer tear mid-transition.
### Artist artwork — richer artist and fullscreen views
### Artist artwork — richer home, artist, and fullscreen views
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player and a banner across the top of the artist page. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
- Switch on **External Artwork Scraper** under **Settings → Integrations** to pull artist imagery from fanart.tv: a wide backdrop on the fullscreen player, a banner across the top of the artist page, and now the artist's backdrop behind the home screen's **mainstage** too. Off by default, your Navidrome covers stay in charge, and turning it back off removes the fetched images again.
- Choose which images each place uses as its background, and in what order — drag to reorder or switch a source off — right under the same setting. The mainstage also loads the next backdrops ahead of time so they appear without a blank gap.
### Equalizer — a profile per output device
+89 -35
View File
@@ -6,8 +6,12 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useCoverArt } from '../cover/useCoverArt';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { useArtistBanner, useArtistFanart } from '../cover/useArtistFanart';
import { usePlaybackCoverArt } from '../cover/usePlaybackCoverArt';
import { artistCoverRef } from '../cover/ref';
import { useHeroBackdrop } from '../cover/useHeroBackdrop';
import { useCachedUrl } from './CachedImage';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -19,7 +23,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { albumArtistDisplayName, deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
@@ -30,11 +34,15 @@ 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 }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
// Crossfading background — same layer pattern as FullscreenPlayer. Each layer
// carries its own `position` (the banner stays centered, portrait-ish fanart /
// artist covers raise the focal point), so a crossfade never re-frames the
// outgoing image. `position` is keyed off `url` (it only changes when the url
// changes) so the effect dep stays `[url]`.
function HeroBg({ url, position }: { url: string; position?: string }) {
const [layers, setLayers] = useState<
Array<{ url: string; position?: string; id: number; visible: boolean }>
>(() => (url ? [{ url, position, id: 0, visible: true }] : []));
const counter = useRef(url ? 1 : 0);
const latestUrlRef = useRef(url);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
@@ -49,16 +57,46 @@ function HeroBg({ url }: { url: string }) {
return;
}
const id = counter.current++;
setLayers(prev => [...prev, { url, id, visible: false }]);
const t1 = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => [...prev, { url, position, id, visible: false }]);
let revealed = false;
let cleanup: ReturnType<typeof setTimeout> | undefined;
const reveal = () => {
if (revealed || latestUrlRef.current !== url) return;
revealed = true;
// Crossfade this layer in; the others fade out, then get dropped.
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
}, 20);
const t2 = setTimeout(() => {
cleanup = setTimeout(() => {
if (latestUrlRef.current !== url) return;
setLayers(prev => prev.filter(l => l.id === id));
}, 900);
return () => { clearTimeout(t1); clearTimeout(t2); };
};
// Reveal only once the bytes are decoded, so the crossfade never fades in a
// blank / half-loaded image (the flicker the bare 20 ms timer had). The
// preload + scheduling happen exactly once here per url — no per-render
// <img> ref / onLoad, so this can't stack updates like the reverted attempt.
const pre = new Image();
pre.decoding = 'async';
pre.src = url;
let fallback: ReturnType<typeof setTimeout> | undefined;
if (pre.complete && pre.naturalWidth > 0) {
reveal();
} else {
pre.onload = reveal;
pre.onerror = reveal;
fallback = setTimeout(reveal, 1500);
}
return () => {
if (fallback) clearTimeout(fallback);
if (cleanup) clearTimeout(cleanup);
pre.onload = null;
pre.onerror = null;
};
// `position` is intentionally omitted — it tracks `url` 1:1, and adding it
// would spawn a duplicate layer if it ever changed without the url.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
return (
@@ -68,7 +106,7 @@ function HeroBg({ url }: { url: string }) {
key={layer.id}
className="hero-bg-image"
src={layer.url}
style={{ opacity: layer.visible ? 1 : 0 }}
style={{ opacity: layer.visible ? 1 : 0, objectPosition: layer.position }}
aria-hidden="true"
alt=""
loading="eager"
@@ -91,7 +129,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const mainstageBackdrop = useThemeStore(s => s.backdrops.mainstageHero);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() =>
@@ -303,23 +341,41 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
}, [album?.id]);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
// Per-album fallback so a cache miss on the current slide does not flash empty,
// but never reuse another album's art (that caused bg/foreground desync on fast nav).
const stableBgByAlbum = useRef<Record<string, string>>({});
const albumId = album?.id;
useEffect(() => {
if (bgHandle.src && albumId) {
stableBgByAlbum.current[albumId] = bgHandle.src;
}
}, [bgHandle.src, albumId]);
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
const heroBgUrl = bgHandle.src || (albumId ? stableBgByAlbum.current[albumId] ?? '' : '');
// Mainstage hero backdrop — the album artist's fanart, resolved exactly like
// the artist-detail header (banner → 16:9 fanart → Navidrome artist cover).
// Fed entirely from the album already in hand (artist id + name + album title),
// so there is no getArtist/getAlbum round-trip: the MBID lookup + fanart fetch
// live Rust-side in cover_cache, and the artist cover ref needs only the id.
const heroArtist = useMemo(
() => (album ? deriveAlbumArtistRefs(album)[0] : undefined),
[album],
);
const heroArtistId = heroArtist?.id;
const heroBanner = useArtistBanner(heroArtistId, {
artistName: heroArtist?.name,
albumTitle: album?.name,
});
const heroFanart = useArtistFanart(heroArtistId, {
artistName: heroArtist?.name,
albumTitle: album?.name,
});
const heroArtistCoverRef = useMemo(
() => (heroArtistId ? artistCoverRef(heroArtistId) : undefined),
[heroArtistId],
);
const ndArtist = usePlaybackCoverArt(heroArtistCoverRef, HERO_BG_CSS_PX, { fullRes: true });
const ndArtistUrl = useCachedUrl(ndArtist.src, ndArtist.cacheKey, true);
const heroBackdrop = useHeroBackdrop(
mainstageBackdrop.sources,
{ banner: heroBanner, fanart: heroFanart, navidrome: ndArtistUrl },
albumId,
);
const showHeroBackdrop =
mainstageBackdrop.enabled &&
!perfFlags.disableMainstageHeroBackdrop &&
heroInView;
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => { if (albumId) playAlbum(albumId); },
onLongPress: () => { if (albumId) playAlbumShuffled(albumId); },
@@ -336,10 +392,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={() => navigateToAlbum(album.id)}
style={{ cursor: 'pointer' }}
>
{/* React Compiler refs rule: heroBgUrl reads a ref to mirror the latest cover URL; it is not part of reactive render data. */}
{/* eslint-disable-next-line react-hooks/refs */}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={heroBgUrl} />}
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <div className="hero-overlay" aria-hidden="true" />}
{showHeroBackdrop && <HeroBg url={heroBackdrop.url} position={heroBackdrop.position} />}
{showHeroBackdrop && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content" key={album.id}>
@@ -8,10 +8,12 @@ import {
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
import { useOfflineStore } from '../../store/offlineStore';
import { useAuthStore } from '../../store/authStore';
import { useThemeStore } from '../../store/themeStore';
import { useArtistOfflineState } from '../../hooks/useArtistOfflineState';
import { useIsMobile } from '../../hooks/useIsMobile';
import { ArtistHeroCover } from '../../cover/artistHero';
import { useArtistBanner, useArtistFanart } from '../../cover/useArtistFanart';
import { backdropFromConfig } from '../../cover/artistBackdrop';
import { usePlaybackCoverArt } from '../../cover/usePlaybackCoverArt';
import { useCachedUrl } from '../CachedImage';
import { useCoverLightboxSrc } from '../../cover/lightbox';
@@ -149,19 +151,16 @@ export default function ArtistDetailHero({
// player resolves its artist background (`coverRef` is the artist cover ref).
const ndArtist = usePlaybackCoverArt(coverRef ?? undefined, 2000, { fullRes: true });
const ndArtistUrl = useCachedUrl(ndArtist.src, ndArtist.cacheKey, true);
// Header background priority (§28): banner → fanart → Navidrome artist cover.
// Each external surface now reports a genuine miss (`src === ''`, not the ND
// cover), so the chain can step through cleanly: while a stage is still
// resolving we show nothing rather than flashing a lower-priority image; on a
// confirmed miss we drop to the next stage. Off → external surfaces are '',
// not pending → we fall straight through to the Navidrome cover.
const headerBgUrl =
banner.src ||
(banner.pending ? '' : fanartBg.src || (fanartBg.pending ? '' : ndArtistUrl));
// The banner is a purpose-built wide strip → keep it centered. The fanart /
// Navidrome artist images are portrait-ish → raise the focal point so heads
// stay in frame on wide viewports.
const headerBgPosition = banner.src ? undefined : 'center 30%';
// Header background priority (§28): banner → fanart → Navidrome artist cover,
// now user-configurable per surface. Shared with the mainstage hero via
// backdropFromConfig so the two headers resolve and frame identically.
const artistDetailBackdrop = useThemeStore((s) => s.backdrops.artistDetailHero);
const headerBackdrop = backdropFromConfig(artistDetailBackdrop.sources, {
banner,
fanart: fanartBg,
navidrome: ndArtistUrl,
});
const showHeaderBackdrop = artistDetailBackdrop.enabled;
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
@@ -174,7 +173,9 @@ export default function ArtistDetailHero({
`artist-detail-bleed` breaks out of the artist page's .content-body
padding so it is full-bleed like the album page (flush .album-detail). */}
<div className="album-detail-header artist-detail-bleed">
<ArtistHeaderBg key={headerBgUrl} url={headerBgUrl} position={headerBgPosition} />
{showHeaderBackdrop && (
<ArtistHeaderBg key={headerBackdrop.url} url={headerBackdrop.url} position={headerBackdrop.position} />
)}
<div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={() => goBack()}>
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
@@ -10,6 +10,7 @@ import { useAlbumCoverRef, useArtistCoverRef } from '../../cover/useLibraryCover
import { usePlaybackCoverArt } from '../../hooks/usePlaybackCoverArt';
import { useCachedUrl } from '../CachedImage';
import { useArtistFanart } from '../../cover/useArtistFanart';
import { backdropFromConfig } from '../../cover/artistBackdrop';
import { useThemeStore } from '../../store/themeStore';
import { useFsIdleFade } from '../../hooks/useFsIdleFade';
import { useQueueTrackAt } from '../../hooks/useQueueTracks';
@@ -94,12 +95,13 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
const coverUrl = useCachedUrl(cover.src, cover.cacheKey, true);
const thumbUrl = coverUrl;
// Background (§28). The album cover is deliberately NOT a background source —
// it only ever feeds the foreground thumbnail. With the scraper on, the
// fanart.tv 16:9 image is the background; while it resolves the background
// stays empty (no album/artist flash), and on a confirmed miss it falls back
// to the Navidrome artist image. With the scraper off, the Navidrome artist
// image is the background straight away.
const externalEnabled = useThemeStore((s) => s.externalArtworkEnabled);
// it only ever feeds the foreground thumbnail. The user-configurable source
// list (fanart / Navidrome artist image, no banner) drives the rest: with the
// scraper on and fanart first, the fanart.tv 16:9 image is the background and
// while it resolves the background stays empty (no album/artist flash), then
// falls back per the list; with the scraper off the fanart source reports a
// non-pending miss, so the chain steps straight to the Navidrome artist image.
const fsBackdropCfg = useThemeStore((s) => s.backdrops.fullscreenPlayer);
const fanart = useArtistFanart(currentTrack?.artistId, {
artistName: currentTrack?.artist,
albumTitle: currentTrack?.album,
@@ -109,9 +111,8 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
undefined;
const artistImage = usePlaybackCoverArt(artistCoverRef, 2000, { fullRes: true });
const artistImgUrl = useCachedUrl(artistImage.src, artistImage.cacheKey, true);
const bgUrl = externalEnabled
? fanart.src || (fanart.pending ? '' : artistImgUrl)
: artistImgUrl;
const fsBackdrop = backdropFromConfig(fsBackdropCfg.sources, { fanart, navidrome: artistImgUrl });
const bgUrl = fsBackdropCfg.enabled ? fsBackdrop.url : '';
const nextTrack = useQueueTrackAt(queueIndex + 1);
@@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import type { BackdropSource, BackdropSourcePref } from '../../cover/artistBackdrop';
import type { BackdropSurface } from '../../store/themeStore';
import { moveSourceTo, dropSourceBefore } from './backdropReorder';
const DRAG_TYPE = 'backdrop-source';
interface RowPayload {
type: typeof DRAG_TYPE;
surface: BackdropSurface;
index: number;
}
interface Props {
surface: BackdropSurface;
sources: BackdropSourcePref[];
labelFor: (s: BackdropSource) => string;
onChange: (next: BackdropSourcePref[]) => void;
moveUpLabel: string;
moveDownLabel: string;
}
/**
* Ordered, individually-toggleable backdrop source list for one surface. Drag a
* row by its grip to reorder (priority = top-to-bottom), or use the / buttons
* for a keyboard-accessible reorder; the switch on each row drops a source out
* of the resolution chain without losing its place. Uses the shared
* `useDragSource` / `psy-drop` drag infrastructure (text/plain payloads).
*/
export function BackdropSourceList({ surface, sources, labelFor, onChange, moveUpLabel, moveDownLabel }: Props) {
const { isDragging, payload } = useDragDrop();
const [dropIdx, setDropIdx] = useState<number | null>(null);
// Is the in-flight drag a row from *this* list? (Don't react to other drags.)
let draggingHere = false;
if (isDragging && payload) {
try {
const p = JSON.parse(payload.data);
draggingHere = p.type === DRAG_TYPE && p.surface === surface;
} catch { /* not our payload */ }
}
const apply = (next: BackdropSourcePref[] | null) => { if (next) onChange(next); };
const setEnabled = (i: number, enabled: boolean) =>
onChange(sources.map((s, idx) => (idx === i ? { ...s, enabled } : s)));
return (
<ul className="backdrop-source-list" role="list">
{sources.map((pref, i) => (
<BackdropSourceRow
key={pref.source}
surface={surface}
index={i}
count={sources.length}
label={labelFor(pref.source)}
enabled={pref.enabled}
isDropTarget={draggingHere && dropIdx === i}
onHover={() => { if (draggingHere) setDropIdx(i); }}
onDropFrom={(from) => { apply(dropSourceBefore(sources, from, i)); setDropIdx(null); }}
onToggle={(en) => setEnabled(i, en)}
onMoveUp={() => apply(moveSourceTo(sources, i, i - 1))}
onMoveDown={() => apply(moveSourceTo(sources, i, i + 1))}
moveUpLabel={moveUpLabel}
moveDownLabel={moveDownLabel}
/>
))}
</ul>
);
}
interface RowProps {
surface: BackdropSurface;
index: number;
count: number;
label: string;
enabled: boolean;
isDropTarget: boolean;
onHover: () => void;
onDropFrom: (fromIndex: number) => void;
onToggle: (enabled: boolean) => void;
onMoveUp: () => void;
onMoveDown: () => void;
moveUpLabel: string;
moveDownLabel: string;
}
function BackdropSourceRow({
surface, index, count, label, enabled, isDropTarget,
onHover, onDropFrom, onToggle, onMoveUp, onMoveDown, moveUpLabel, moveDownLabel,
}: RowProps) {
const rowRef = useRef<HTMLLIElement>(null);
const grip = useDragSource(() => ({
data: JSON.stringify({ type: DRAG_TYPE, surface, index } satisfies RowPayload),
label,
}));
// A `psy-drop` released over this row carries the dragged row's index.
useEffect(() => {
const el = rowRef.current;
if (!el) return;
const handler = (e: Event) => {
try {
const d = JSON.parse((e as CustomEvent).detail?.data ?? '{}');
if (d.type === DRAG_TYPE && d.surface === surface && typeof d.index === 'number' && d.index !== index) {
onDropFrom(d.index);
}
} catch { /* malformed payload */ }
};
el.addEventListener('psy-drop', handler);
return () => el.removeEventListener('psy-drop', handler);
}, [surface, index, onDropFrom]);
const cls = [
'backdrop-source-row',
isDropTarget ? 'backdrop-source-row--drop' : '',
enabled ? '' : 'backdrop-source-row--off',
].filter(Boolean).join(' ');
return (
<li ref={rowRef} className={cls} onMouseMove={onHover}>
<span className="backdrop-source-grip" {...grip} aria-hidden="true">
<GripVertical size={16} />
</span>
<span className="backdrop-source-name">{label}</span>
<span className="backdrop-source-actions">
<button type="button" className="backdrop-source-move" onClick={onMoveUp} disabled={index === 0} aria-label={`${moveUpLabel}: ${label}`}>
<ChevronUp size={15} />
</button>
<button type="button" className="backdrop-source-move" onClick={onMoveDown} disabled={index === count - 1} aria-label={`${moveDownLabel}: ${label}`}>
<ChevronDown size={15} />
</button>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={enabled} onChange={(e) => onToggle(e.target.checked)} />
<span className="toggle-track" />
</label>
</span>
</li>
);
}
@@ -6,6 +6,9 @@ import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { SettingsSubCard, SettingsField } from './SettingsSubCard';
import { BackdropSourceList } from './BackdropSourceList';
import type { BackdropSurface } from '../../store/themeStore';
import type { BackdropSource } from '../../cover/artistBackdrop';
import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection';
import { purgeExternalArtworkAllServers } from '../../api/coverCache';
@@ -14,6 +17,18 @@ export function IntegrationsTab() {
const auth = useAuthStore();
const theme = useThemeStore();
const backdropSurfaces: { key: BackdropSurface; label: string }[] = [
{ key: 'mainstageHero', label: t('settings.backdropSurfaceMainstage') },
{ key: 'artistDetailHero', label: t('settings.backdropSurfaceArtistDetail') },
{ key: 'fullscreenPlayer', label: t('settings.backdropSurfaceFullscreen') },
];
const backdropSourceLabel = (s: BackdropSource): string =>
s === 'banner'
? t('settings.backdropSourceBanner')
: s === 'fanart'
? t('settings.backdropSourceFanart')
: t('settings.backdropSourceNavidrome');
return (
<>
<div
@@ -194,6 +209,34 @@ export function IntegrationsTab() {
</SettingsSubCard>
</SettingsGroup>
)}
{theme.externalArtworkEnabled && (
<SettingsGroup
title={t('settings.backdropSourcesTitle')}
desc={t('settings.backdropSourcesSub')}
>
<SettingsSubCard>
{backdropSurfaces.map(({ key, label }) => (
<div className="backdrop-surface-block" key={key}>
<SettingsToggle
label={label}
checked={theme.backdrops[key].enabled}
onChange={(v) => theme.setBackdropEnabled(key, v)}
/>
{theme.backdrops[key].enabled && (
<BackdropSourceList
surface={key}
sources={theme.backdrops[key].sources}
labelFor={backdropSourceLabel}
onChange={(next) => theme.setBackdropSources(key, next)}
moveUpLabel={t('settings.backdropMoveUp')}
moveDownLabel={t('settings.backdropMoveDown')}
/>
)}
</div>
))}
</SettingsSubCard>
</SettingsGroup>
)}
</div>
</SettingsSubSection>
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { moveSourceTo, dropSourceBefore } from './backdropReorder';
import type { BackdropSource, BackdropSourcePref } from '../../cover/artistBackdrop';
const S = (...names: BackdropSource[]): BackdropSourcePref[] =>
names.map((source) => ({ source, enabled: true }));
const ids = (arr: BackdropSourcePref[] | null) => arr?.map((p) => p.source);
describe('moveSourceTo (↑/↓ buttons — land at exact index)', () => {
it('moves an item up to a lower index', () => {
expect(ids(moveSourceTo(S('navidrome', 'banner', 'fanart'), 2, 0))).toEqual(['fanart', 'navidrome', 'banner']);
});
it('moves an item down to a higher index', () => {
expect(ids(moveSourceTo(S('banner', 'fanart', 'navidrome'), 0, 1))).toEqual(['fanart', 'banner', 'navidrome']);
});
it('returns null on a no-op or out-of-range move', () => {
expect(moveSourceTo(S('banner', 'fanart'), 1, 1)).toBeNull();
expect(moveSourceTo(S('banner', 'fanart'), 0, 5)).toBeNull();
});
});
describe('dropSourceBefore (drag-drop — insert before target row)', () => {
it('drops before a lower row (dragging up)', () => {
expect(ids(dropSourceBefore(S('banner', 'fanart', 'navidrome'), 2, 0))).toEqual(['navidrome', 'banner', 'fanart']);
});
it('drops before a higher row (dragging down), accounting for the vacated slot', () => {
// drag `banner` onto `navidrome` → lands between fanart and navidrome
expect(ids(dropSourceBefore(S('banner', 'fanart', 'navidrome'), 0, 2))).toEqual(['fanart', 'banner', 'navidrome']);
});
it('dropping just before the next row leaves the order unchanged', () => {
expect(ids(dropSourceBefore(S('banner', 'fanart', 'navidrome'), 0, 1))).toEqual(['banner', 'fanart', 'navidrome']);
});
it('returns null when dropping a row onto itself', () => {
expect(dropSourceBefore(S('banner', 'fanart'), 1, 1)).toBeNull();
});
});
@@ -0,0 +1,36 @@
import type { BackdropSourcePref } from '../../cover/artistBackdrop';
/**
* Move the source at `from` so it lands at exactly index `to` the / buttons,
* i.e. a swap with the neighbour. Returns a new array, or `null` for an
* out-of-range or no-op move (so the caller can skip a redundant update).
*/
export function moveSourceTo(
sources: BackdropSourcePref[],
from: number,
to: number,
): BackdropSourcePref[] | null {
if (from === to || from < 0 || to < 0 || from >= sources.length || to >= sources.length) return null;
const next = sources.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/**
* Insert the source at `from` immediately before the row at `beforeIndex` the
* drag-drop case, matching the "line above this row" drop indicator. The -1 when
* dragging downward accounts for the slot the dragged item vacated. Returns a
* new array, or `null` for an out-of-range or no-op move.
*/
export function dropSourceBefore(
sources: BackdropSourcePref[],
from: number,
beforeIndex: number,
): BackdropSourcePref[] | null {
if (from === beforeIndex || from < 0 || from >= sources.length) return null;
const next = sources.slice();
const [item] = next.splice(from, 1);
next.splice(from < beforeIndex ? beforeIndex - 1 : beforeIndex, 0, item);
return next;
}
+1
View File
@@ -391,6 +391,7 @@ const CONTRIBUTOR_ENTRIES = [
'Theme scheduler — follow the OS light/dark setting as an alternative to the time-based day/night schedule (PR #1163)',
'Compact buttons — appearance toggle that switches action and toolbar buttons to icon-only across detail headers and browse views, app-wide (PR #1189)',
'Playlist sort — sort a playlist by date added (newest/oldest) or any track column from a visible sort dropdown (PR #1191)',
'Configurable artist backdrops — per-place source order + on/off (mainstage hero, artist page, fullscreen), with the mainstage hero showing the artist backdrop and prefetching upcoming ones (PR #1193)',
],
},
{
+118
View File
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest';
import {
resolveBackdrop,
backdropFromConfig,
type BackdropSourcePref,
} from './artistBackdrop';
const img = (src: string, pending = false) => ({ src, pending });
// Default full priority: banner → fanart → navidrome, all enabled.
const order: BackdropSourcePref[] = [
{ source: 'banner', enabled: true },
{ source: 'fanart', enabled: true },
{ source: 'navidrome', enabled: true },
];
describe('resolveBackdrop', () => {
it('takes the first resolved candidate and respects its framing', () => {
expect(
resolveBackdrop([
{ src: 'banner.webp', centered: true },
{ src: 'fanart.webp', centered: false },
]),
).toEqual({ url: 'banner.webp', position: undefined });
});
it('holds empty while a higher-priority candidate is still resolving', () => {
expect(
resolveBackdrop([
{ src: '', pending: true, centered: true },
{ src: 'fanart.webp', centered: false },
]),
).toEqual({ url: '', position: 'center 30%' });
});
it('steps past a confirmed miss to the next candidate', () => {
expect(
resolveBackdrop([
{ src: '', pending: false, centered: true },
{ src: 'fanart.webp', centered: false },
]),
).toEqual({ url: 'fanart.webp', position: 'center 30%' });
});
it('yields no backdrop when nothing resolves', () => {
expect(resolveBackdrop([{ src: '', centered: false }])).toEqual({
url: '',
position: 'center 30%',
});
});
});
describe('backdropFromConfig', () => {
it('reproduces the legacy banner → fanart → navidrome priority', () => {
expect(backdropFromConfig(order, { banner: img('b.webp'), fanart: img('f.webp'), navidrome: 'nd.webp' })).toEqual({
url: 'b.webp',
position: undefined,
});
expect(backdropFromConfig(order, { banner: img('', false), fanart: img('f.webp'), navidrome: 'nd.webp' })).toEqual({
url: 'f.webp',
position: 'center 30%',
});
expect(backdropFromConfig(order, { banner: img('', false), fanart: img('', false), navidrome: 'nd.webp' })).toEqual({
url: 'nd.webp',
position: 'center 30%',
});
});
it('holds empty while a still-pending source is highest priority', () => {
expect(backdropFromConfig(order, { banner: img('', true), fanart: img('f.webp'), navidrome: 'nd.webp' })).toEqual({
url: '',
position: 'center 30%',
});
});
it('honours a reordered priority (navidrome first)', () => {
const navFirst: BackdropSourcePref[] = [
{ source: 'navidrome', enabled: true },
{ source: 'fanart', enabled: true },
{ source: 'banner', enabled: true },
];
expect(backdropFromConfig(navFirst, { banner: img('b.webp'), fanart: img('f.webp'), navidrome: 'nd.webp' })).toEqual({
url: 'nd.webp',
position: 'center 30%',
});
});
it('skips a disabled source', () => {
const noBanner: BackdropSourcePref[] = [
{ source: 'banner', enabled: false },
{ source: 'fanart', enabled: true },
{ source: 'navidrome', enabled: true },
];
expect(backdropFromConfig(noBanner, { banner: img('b.webp'), fanart: img('f.webp'), navidrome: 'nd.webp' })).toEqual({
url: 'f.webp',
position: 'center 30%',
});
});
it('skips a source the caller does not supply (fullscreen has no banner)', () => {
const fsOrder: BackdropSourcePref[] = [
{ source: 'fanart', enabled: true },
{ source: 'navidrome', enabled: true },
];
expect(backdropFromConfig(fsOrder, { fanart: img('', false), navidrome: 'nd.webp' })).toEqual({
url: 'nd.webp',
position: 'center 30%',
});
});
it('yields no backdrop when every source is disabled', () => {
const allOff: BackdropSourcePref[] = order.map((s) => ({ ...s, enabled: false }));
expect(backdropFromConfig(allOff, { banner: img('b.webp'), fanart: img('f.webp'), navidrome: 'nd.webp' })).toEqual({
url: '',
position: 'center 30%',
});
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { ArtistImage } from './useArtistFanart';
export interface ArtistBackdrop {
/** URL to render, or '' while a higher-priority surface is still resolving. */
url: string;
/**
* `object-position` / `background-position` override; `undefined` keeps the
* shared centered default.
*/
position?: string;
}
/** A backdrop image source a surface can draw from, in user-configurable order. */
export type BackdropSource = 'banner' | 'fanart' | 'navidrome';
/** One entry in a surface's ordered source list: which source, and whether it
* participates. Array order is the resolution priority. */
export interface BackdropSourcePref {
source: BackdropSource;
enabled: boolean;
}
/** A resolved candidate for {@link resolveBackdrop}. `centered` marks the wide
* banner strip (kept centered); everything else raises the focal point. */
export interface BackdropCandidate {
src: string;
/** True while the source is still fetching — hold rather than flash a lower one. */
pending?: boolean;
/** Banner = true (centered); portrait-ish fanart / artist covers = false. */
centered?: boolean;
}
/**
* Walk an ordered candidate list and pick the backdrop:
* - a resolved `src` wins immediately (its `centered` flag sets the framing);
* - a still-`pending` candidate holds an empty url rather than flashing a
* lower-priority surface beneath it;
* - a confirmed miss (`src === ''`, not pending) steps to the next candidate.
* When nothing resolves the url is '' with the portrait focal default framing
* only goes centered when a *banner* actually wins.
*/
export function resolveBackdrop(candidates: BackdropCandidate[]): ArtistBackdrop {
for (const c of candidates) {
if (c.src) return { url: c.src, position: c.centered ? undefined : 'center 30%' };
if (c.pending) return { url: '', position: 'center 30%' };
}
return { url: '', position: 'center 30%' };
}
/** Per-source resolved values handed to {@link backdropFromConfig}. A source the
* caller cannot offer (e.g. no banner on the fullscreen player) is omitted. */
export interface BackdropSourceValues {
banner?: ArtistImage;
fanart?: ArtistImage;
/** Navidrome artist cover — a plain url, never pending. */
navidrome?: string;
}
/**
* Build the candidate list from a surface's ordered+filtered source prefs and
* the resolved values, then resolve it. Disabled prefs and sources the caller
* did not supply are skipped, so the same helper drives every surface the
* fullscreen player simply omits `banner` from both the prefs and the values.
*
* Shared by the mainstage hero, the artist-detail header and the fullscreen
* player so all three resolve and frame their backdrop identically.
*/
export function backdropFromConfig(
sources: BackdropSourcePref[],
values: BackdropSourceValues,
): ArtistBackdrop {
const candidates: BackdropCandidate[] = [];
for (const { source, enabled } of sources) {
if (!enabled) continue;
if (source === 'banner' && values.banner) {
candidates.push({ src: values.banner.src, pending: values.banner.pending, centered: true });
} else if (source === 'fanart' && values.fanart) {
candidates.push({ src: values.fanart.src, pending: values.fanart.pending, centered: false });
} else if (source === 'navidrome' && values.navidrome !== undefined) {
candidates.push({ src: values.navidrome, pending: false, centered: false });
}
}
return resolveBackdrop(candidates);
}
+43 -10
View File
@@ -1,4 +1,4 @@
import { coverCacheEnsure } from '../api/coverCache';
import { coverCacheEnsure, type CoverEnsureOpts } from '../api/coverCache';
import { getDiskSrc } from './diskSrcCache';
import { getDiskSrcForGrid } from './diskSrcLookup';
import { coverIndexKeyFromRef } from './storageKeys';
@@ -11,6 +11,8 @@ type EnsureJob = {
priority: CoverPrefetchPriority;
/** Larger = closer to viewport / more recently bumped — dequeued first within the same priority band. */
orderKey: number;
/** External-artwork ensure context (fanart/banner surfaces); undefined for plain covers. */
opts?: CoverEnsureOpts;
resolve: (r: { hit: boolean; path: string }) => void;
};
@@ -35,8 +37,12 @@ const backlogDrainListeners = new Set<() => void>();
type EnsureInvokeResult = { hit: boolean; path: string };
function coverInflightKey(ref: CoverArtRef): string {
return `${coverIndexKeyFromRef(ref)}:${ref.cacheKind}:${ref.cacheEntityId}`;
function coverInflightKey(ref: CoverArtRef, surfaceKind?: string): string {
const base = `${coverIndexKeyFromRef(ref)}:${ref.cacheKind}:${ref.cacheEntityId}`;
// External surfaces (fanart/banner) are distinct downloads for the same artist
// id, so they must not share one in-flight chain. Plain covers append nothing,
// keeping every existing key byte-identical.
return surfaceKind ? `${base}:${surfaceKind}` : base;
}
/** One active Rust ensure per cover art id — waiters attach without consuming invoke slots. */
@@ -66,13 +72,14 @@ function invokeEnsureForCover(
ref: CoverArtRef,
tier: CoverArtTier,
priority: CoverPrefetchPriority,
opts?: CoverEnsureOpts,
): Promise<EnsureInvokeResult> {
const key = coverInflightKey(ref);
const key = coverInflightKey(ref, opts?.surfaceKind);
const existing = coverArtInFlight.get(key);
if (existing) return existing;
const flight = withEnsureTimeout(
coverCacheEnsure(ref, tier, priority).then(r => ({ hit: r.hit, path: r.path })),
coverCacheEnsure(ref, tier, priority, opts).then(r => ({ hit: r.hit, path: r.path })),
).finally(() => {
if (coverArtInFlight.get(key) === flight) coverArtInFlight.delete(key);
});
@@ -88,7 +95,7 @@ function attachQueuedJobsToActiveFlights(): void {
let i = 0;
while (i < queue.length) {
const job = queue[i]!;
const flight = coverArtInFlight.get(coverInflightKey(job.ref));
const flight = coverArtInFlight.get(coverInflightKey(job.ref, job.opts?.surfaceKind));
if (!flight) {
i += 1;
continue;
@@ -165,7 +172,7 @@ function pump(): void {
if (coverTrafficBackgroundPaused() && candidate.priority !== 'high') {
break;
}
if (coverArtInFlight.has(coverInflightKey(candidate.ref))) continue;
if (coverArtInFlight.has(coverInflightKey(candidate.ref, candidate.opts?.surfaceKind))) continue;
pickIdx = i;
break;
}
@@ -174,7 +181,7 @@ function pump(): void {
const job = queue.splice(pickIdx, 1)[0]!;
inflight += 1;
inflightStorageKeys.add(job.storageKey);
void invokeEnsureForCover(job.ref, job.tier, job.priority)
void invokeEnsureForCover(job.ref, job.tier, job.priority, job.opts)
.then(r => settleJob(job, r))
.catch(() => settleJob(job, { hit: false, path: '' }))
.finally(() => {
@@ -307,8 +314,12 @@ export function coverEnsureQueued(
ref: CoverArtRef,
tier: CoverArtTier,
priority: CoverPrefetchPriority,
opts?: CoverEnsureOpts,
): Promise<{ hit: boolean; path: string }> {
if (ensureMemoryHit(storageKey, ref, tier)) {
// External surfaces (fanart/banner) bypass the disk-src memory short-circuit:
// their `{tier}-{surface}.webp` never seeds those caches, and the artist's
// canonical cover sitting in the grid cache must not be mistaken for a hit.
if (!opts?.surfaceKind && ensureMemoryHit(storageKey, ref, tier)) {
return Promise.resolve({ hit: true, path: '' });
}
@@ -333,7 +344,7 @@ export function coverEnsureQueued(
pump();
return;
}
queue.push({ storageKey, ref, tier, priority, orderKey, resolve });
queue.push({ storageKey, ref, tier, priority, orderKey, opts, resolve });
sortQueue();
trimQueue();
pump();
@@ -342,3 +353,25 @@ export function coverEnsureQueued(
ensureInflight.set(storageKey, p);
return p;
}
/**
* Queue an external artist-backdrop ensure (fanart/banner surface) at a given
* priority, reusing the same dedupe / reprioritise / memory-pressure trim as
* grid covers. The surface is woven into the storage + in-flight keys so the
* two surfaces of one artist do not collide. Always tier 2000 (external
* surfaces are 2000-only). Resolves `{ hit, path }`; `path` is the on-disk
* `{2000}-{surface}.webp` to hand to `coverDiskUrl`.
*/
export function ensureArtistBackdropQueued(
storageKey: string,
ref: CoverArtRef,
surface: 'fanart' | 'banner',
priority: CoverPrefetchPriority,
ctx?: { artistName?: string; albumTitle?: string },
): Promise<{ hit: boolean; path: string }> {
return coverEnsureQueued(storageKey, ref, 2000, priority, {
surfaceKind: surface,
artistName: ctx?.artistName,
albumTitle: ctx?.albumTitle,
});
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
recordHeroBackdropUpgrade,
getHeroBackdropUpgrade,
__test_resetHeroBackdropMemory,
} from './heroBackdropMemory';
describe('heroBackdropMemory', () => {
beforeEach(() => __test_resetHeroBackdropMemory());
it('records and reads back per album + surface', () => {
recordHeroBackdropUpgrade('a1', 'banner', 'b.webp');
recordHeroBackdropUpgrade('a1', 'fanart', 'f.webp');
expect(getHeroBackdropUpgrade('a1')).toEqual({ banner: 'b.webp', fanart: 'f.webp' });
});
it('ignores an empty album id or url', () => {
recordHeroBackdropUpgrade(undefined, 'banner', 'b.webp');
recordHeroBackdropUpgrade('a1', 'banner', '');
expect(getHeroBackdropUpgrade('a1')).toBeUndefined();
});
it('returns undefined for unknown / undefined album', () => {
expect(getHeroBackdropUpgrade('nope')).toBeUndefined();
expect(getHeroBackdropUpgrade(undefined)).toBeUndefined();
});
it('evicts the oldest entries beyond the cap (FIFO)', () => {
for (let i = 0; i < 50; i += 1) recordHeroBackdropUpgrade(`a${i}`, 'banner', `u${i}`);
expect(getHeroBackdropUpgrade('a0')).toBeUndefined(); // evicted
expect(getHeroBackdropUpgrade('a1')).toBeUndefined(); // evicted
expect(getHeroBackdropUpgrade('a49')).toEqual({ banner: 'u49' });
});
it('refreshes recency on re-touch so the most-recently-used evicts last', () => {
for (let i = 0; i < 48; i += 1) recordHeroBackdropUpgrade(`a${i}`, 'banner', `u${i}`);
recordHeroBackdropUpgrade('a0', 'fanart', 'f0'); // re-touch a0 → moves to newest
recordHeroBackdropUpgrade('a48', 'banner', 'u48'); // pushes the oldest (a1) out
expect(getHeroBackdropUpgrade('a0')).toEqual({ banner: 'u0', fanart: 'f0' }); // survived
expect(getHeroBackdropUpgrade('a1')).toBeUndefined(); // oldest → evicted
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Per-album memory of the external backdrop images (fanart/banner) that have
* resolved to disk, keyed by album id. Both the prefetch warmer
* (`warmHeroArtistBackdrops`) and the live hero hook (`useHeroBackdrop`) record
* into it; on **re-entry** to a slide the hook reads it synchronously so the
* higher-priority source paints at once instead of falling back to Navidrome
* again while its async ensure re-resolves (cucadmuh's `{albumId → upgradedUrl}`).
*
* Bounded FIFO the hero only cycles a handful of albums, but feed refreshes
* rotate the set over a session.
*/
type ExternalSurface = 'banner' | 'fanart';
type Entry = Partial<Record<ExternalSurface, string>>;
const MAX_ENTRIES = 48;
const mem = new Map<string, Entry>();
export function recordHeroBackdropUpgrade(
albumId: string | undefined,
surface: ExternalSurface,
url: string,
): void {
if (!albumId || !url) return;
const existing = mem.get(albumId);
if (existing?.[surface] === url) return;
const entry: Entry = { ...(existing ?? {}), [surface]: url };
// Refresh insertion order so the most recently touched album evicts last.
mem.delete(albumId);
mem.set(albumId, entry);
while (mem.size > MAX_ENTRIES) {
const oldest = mem.keys().next().value;
if (oldest === undefined) break;
mem.delete(oldest);
}
}
export function getHeroBackdropUpgrade(albumId: string | undefined): Entry | undefined {
return albumId ? mem.get(albumId) : undefined;
}
/** @internal Vitest-only. */
export function __test_resetHeroBackdropMemory(): void {
mem.clear();
}
+13
View File
@@ -34,6 +34,19 @@ function useArtistExternalImage(
const artistName = ctx?.artistName;
const albumTitle = ctx?.albumTitle;
// Reset synchronously when the target artist/surface changes, so a consumer
// never reads the *previous* artist's resolved image for the one render
// between the change and the effect below. Without this the hero froze (or
// cached into per-album memory) a neighbouring slide's banner. This is the
// React "adjust state on prop change" pattern — the set-state-during-render
// restarts the render, so `image` is already cleared this pass.
const resetKey = `${enabled ? '1' : '0'}|${artistId ?? ''}|${surface}`;
const [seenKey, setSeenKey] = useState(resetKey);
if (seenKey !== resetKey) {
setSeenKey(resetKey);
setImage({ src: '', pending: Boolean(enabled && artistId) });
}
useEffect(() => {
if (!enabled || !artistId) {
// Nothing will resolve — not pending, so callers fall back immediately.
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react';
import type { ArtistBackdrop, BackdropSource, BackdropSourcePref } from './artistBackdrop';
import type { ArtistImage } from './useArtistFanart';
import { getHeroBackdropUpgrade, recordHeroBackdropUpgrade } from './heroBackdropMemory';
export interface HeroBackdropLive {
banner: ArtistImage;
fanart: ArtistImage;
/** Navidrome artist cover url — local/fast, the instant default. */
navidrome: string;
}
const PORTRAIT = 'center 30%';
interface ReadySrcs {
banner: string;
fanart: string;
navidrome: string;
}
/** The chosen source plus the per-album disk-memory url for it, snapshotted at
* freeze time so the render path never reads the memory map. */
interface FrozenChoice {
source: BackdropSource;
memUrl: string;
}
/**
* First enabled source (in configured order) that has a ready url live, or
* from the per-album disk memory (re-entry, while the live ensure re-resolves).
* Returns the source *choice*, not a url, so the caller can keep deriving the
* live url for it (self-correcting) rather than freezing a possibly-stale value.
*/
function pickReadyChoice(
sources: BackdropSourcePref[],
srcs: ReadySrcs,
mem: ReturnType<typeof getHeroBackdropUpgrade>,
): FrozenChoice | null {
for (const { source, enabled } of sources) {
if (!enabled) continue;
if (source === 'banner' && (srcs.banner || mem?.banner)) return { source, memUrl: mem?.banner ?? '' };
if (source === 'fanart' && (srcs.fanart || mem?.fanart)) return { source, memUrl: mem?.fanart ?? '' };
if (source === 'navidrome' && srcs.navidrome) return { source, memUrl: '' };
}
return null;
}
/**
* Hero mainstage backdrop with the "show-ready-now, upgrade-on-re-entry" policy
* (cucadmuh, 2026-06): on entering a slide, pick the best source ready **at that
* moment** (Navidrome on a cold first visit; the prefetched/cached external one
* on re-entry) and **freeze that choice** for the visit a higher-priority
* source resolving mid-dwell is recorded for next time but never swaps in. The
* url is derived live from the frozen source (so a Navidrome cover that resolves
* a beat late still self-corrects, instead of freezing a stale neighbour).
*/
export function useHeroBackdrop(
sources: BackdropSourcePref[],
live: HeroBackdropLive,
albumId: string | undefined,
): ArtistBackdrop {
const bannerSrc = live.banner.src;
const fanartSrc = live.fanart.src;
const navidromeSrc = live.navidrome;
// Remember every external source that resolves for this album, so a later
// re-entry can paint it immediately from disk. Safe now that the source hooks
// reset synchronously on artist change (no stale neighbour leaks in here).
useEffect(() => {
if (bannerSrc) recordHeroBackdropUpgrade(albumId, 'banner', bannerSrc);
if (fanartSrc) recordHeroBackdropUpgrade(albumId, 'fanart', fanartSrc);
}, [albumId, bannerSrc, fanartSrc]);
const [frozen, setFrozen] = useState<FrozenChoice | null>(null);
const frozenFor = useRef<string | undefined>(undefined);
// Freeze the source choice at entry; re-run as the live sources resolve until
// the first ready choice, then bail (frozen) so nothing swaps mid-dwell.
useEffect(() => {
if (!albumId) {
// React Compiler set-state-in-effect rule: resets the frozen choice on teardown.
// eslint-disable-next-line react-hooks/set-state-in-effect
setFrozen(null);
frozenFor.current = undefined;
return;
}
if (frozenFor.current === albumId) return;
const choice = pickReadyChoice(
sources,
{ banner: bannerSrc, fanart: fanartSrc, navidrome: navidromeSrc },
getHeroBackdropUpgrade(albumId),
);
if (choice) {
setFrozen(choice);
frozenFor.current = albumId;
}
}, [albumId, sources, bannerSrc, fanartSrc, navidromeSrc]);
// Live url for the frozen source (mem snapshot fills the gap while an external
// source re-resolves on re-entry).
let url = '';
if (frozen) {
if (frozen.source === 'banner') url = bannerSrc || frozen.memUrl;
else if (frozen.source === 'fanart') url = fanartSrc || frozen.memUrl;
else url = navidromeSrc;
}
return { url, position: frozen?.source === 'banner' ? undefined : PORTRAIT };
}
+98 -22
View File
@@ -1,12 +1,16 @@
import { coverCachePeekBatch } from '../api/coverCache';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { coverEnsureQueued } from './ensureQueue';
import { coverEnsureQueued, ensureArtistBackdropQueued } from './ensureQueue';
import { getDiskSrcForGrid, rememberGridDiskSrc } from './diskSrcLookup';
import { albumCoverRef } from './ref';
import { albumCoverRef, artistCoverRef } from './ref';
import { coverDiskUrl } from './diskSrcCache';
import { resolveAlbumCoverRefFromLibrary } from './resolveEntryLibrary';
import { coverStorageKeyFromRef } from './storageKeys';
import { resolveCoverDisplayTier } from './tiers';
import type { CoverArtRef, CoverArtTier, CoverSurfaceKind } from './types';
import type { CoverArtRef, CoverArtTier, CoverPrefetchPriority, CoverSurfaceKind } from './types';
import { useThemeStore } from '../store/themeStore';
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
import { getHeroBackdropUpgrade, recordHeroBackdropUpgrade } from './heroBackdropMemory';
export type CoverWarmItem = {
ref: CoverArtRef;
@@ -206,6 +210,96 @@ export async function warmHomeMainstageCovers(snapshot: {
200,
8,
);
// Hero artist backdrops (fanart/banner) — prefetch the upcoming slides at
// slide-index priorities so the higher-priority source is on disk before its
// slide is shown. Inert when the scraper is off.
void warmHeroArtistBackdrops(snapshot.heroAlbums);
}
const HERO_BACKDROP_TIER: CoverArtTier = 2000;
/** Static priority at open: slide 1 = next auto-advance (10 s) `high`; slide 0
* is already shown via Navidrome `low`; the rest are lookahead `middle`.
* No recompute on navigation (cucadmuh, 2026-06). */
function heroSlidePriority(idx: number): CoverPrefetchPriority {
return idx === 1 ? 'high' : idx === 0 ? 'low' : 'middle';
}
/**
* Prefetch each hero slide's artist backdrop (the configured external surfaces:
* `banner` / `fanart`) at its slide-index priority, reusing the standard ensure
* queue (`ensureArtistBackdropQueued` same dedupe / trim as grid covers). Each
* disk hit is recorded in the per-album memory so {@link useHeroBackdrop} can
* paint it on entry. Inert when the scraper is off, the mainstage surface is
* disabled, or no external source is enabled.
*/
export async function warmHeroArtistBackdrops(
heroAlbums: ReadonlyArray<SubsonicAlbum>,
): Promise<void> {
const theme = useThemeStore.getState();
if (!theme.externalArtworkEnabled) return;
const cfg = theme.backdrops.mainstageHero;
if (!cfg.enabled) return;
const surfaces = cfg.sources
.filter(s => s.enabled && (s.source === 'banner' || s.source === 'fanart'))
.map(s => s.source as 'banner' | 'fanart');
if (surfaces.length === 0) return;
await Promise.allSettled(
heroAlbums.flatMap((album, idx) => {
const artist = deriveAlbumArtistRefs(album)[0];
if (!artist?.id || !album.id) return [];
const ref = artistCoverRef(artist.id);
const priority = heroSlidePriority(idx);
const albumId = album.id;
return surfaces.map(surface => {
const key = `${coverStorageKeyFromRef(ref, HERO_BACKDROP_TIER)}:${surface}`;
return ensureArtistBackdropQueued(key, ref, surface, priority, {
artistName: artist.name,
albumTitle: album.name,
}).then(res => {
if (res.hit && res.path) {
recordHeroBackdropUpgrade(albumId, surface, coverDiskUrl(res.path));
}
});
});
}),
);
void predecodeHeroBackdrops(heroAlbums);
}
/** Decode the on-disk hero backdrop of every slide already warmed, so the first
* `HeroBg` paint is hitch-free (Frank, 2026-06: every slide on disk). */
async function predecodeHeroBackdrops(heroAlbums: ReadonlyArray<SubsonicAlbum>): Promise<void> {
if (typeof window === 'undefined') return;
const urls: string[] = [];
for (const album of heroAlbums) {
const mem = getHeroBackdropUpgrade(album.id);
const url = mem?.banner ?? mem?.fanart;
if (url) urls.push(url);
}
if (urls.length === 0) return;
await Promise.allSettled(urls.map(decodeImage));
}
/** Browser-decode an image so it is paint-ready from cache, never throwing. */
function decodeImage(src: string): Promise<void> {
return new Promise<void>(resolve => {
const img = new Image();
img.decoding = 'async';
img.src = src;
if (img.complete) {
resolve();
return;
}
img.onload = () => resolve();
img.onerror = () => resolve();
if ('decode' in img) {
void (img as HTMLImageElement).decode().then(resolve).catch(resolve);
}
});
}
async function predecodeWarmAlbums(
@@ -227,23 +321,5 @@ async function predecodeWarmAlbums(
}
if (urls.length === 0) return;
await Promise.allSettled(
urls.map(
src =>
new Promise<void>(resolve => {
const img = new Image();
img.decoding = 'async';
img.src = src;
if (img.complete) {
resolve();
return;
}
img.onload = () => resolve();
img.onerror = () => resolve();
if ('decode' in img) {
void (img as HTMLImageElement).decode().then(resolve).catch(resolve);
}
}),
),
);
await Promise.allSettled(urls.map(decodeImage));
}
+11 -1
View File
@@ -299,7 +299,7 @@ export const settings = {
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
nowPlayingPluginNote: 'Notwendig bei Verwendung des offiziellen Navidrome-Discord-Rich-Presence-Plugins.',
externalArtwork: 'Externe Artwork Scraper',
externalArtworkDesc: 'Lädt hochauflösende Künstler-Hintergründe und -Banner von fanart.tv für den Vollbild-Player und Künstlerseiten. Standardmäßig aus.',
externalArtworkDesc: 'Lädt hochauflösende Künstler-Hintergründe und -Banner von fanart.tv für den Mainstage-Hero, Künstlerseiten und den Vollbild-Player. Standardmäßig aus.',
externalArtworkNote: 'Wenn aktiv, wird der Künstlername (und der des aktuellen Albums) an musicbrainz.org gesendet, um den Künstler zu identifizieren, danach werden Bilder von fanart.tv geladen — bedarfsgesteuert und nur lesend; deine Bibliotheks-Tags werden nie verändert. Die Bilder stammen von fanart.tv und seinen Mitwirkenden.',
externalArtworkByokTitle: 'Persönlicher API-Schlüssel (BYOK)',
externalArtworkByokDesc: 'Optional. Trag hier deinen eigenen fanart.tv-API-Schlüssel ein — er wird zusätzlich zum App-Schlüssel gesendet.',
@@ -682,6 +682,16 @@ export const settings = {
visualOptionsTitle: 'Visuelle Optionen',
coverArtBackground: 'Cover-Hintergrund',
coverArtBackgroundSub: 'Zeigt verschwommenes Cover als Hintergrund in Album/Playlist-Kopfzeilen',
backdropSourcesTitle: 'Hintergründe',
backdropSourcesSub: 'Lege fest, welches Bild jede Fläche als Hintergrund nutzt und in welcher Reihenfolge sie probiert werden. Ziehe zum Umsortieren oder schalte eine Quelle aus, um sie zu überspringen.',
backdropSurfaceMainstage: 'Mainstage-Hero',
backdropSurfaceArtistDetail: 'Künstlerseiten-Kopf',
backdropSurfaceFullscreen: 'Vollbild-Player',
backdropSourceBanner: 'Banner',
backdropSourceFanart: 'Fanart-Künstlerbild',
backdropSourceNavidrome: 'Navidrome-Bild',
backdropMoveUp: 'Nach oben',
backdropMoveDown: 'Nach unten',
playlistCoverPhoto: 'Playlist-Coverfoto',
playlistCoverPhotoSub: 'Zeigt Coverfoto-Raster in der Playlist-Detailansicht',
showBitrate: 'Bitrate anzeigen',
+11 -1
View File
@@ -302,7 +302,7 @@ export const settings = {
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
nowPlayingPluginNote: 'Required when using the official Navidrome Discord Rich Presence plugin.',
externalArtwork: 'External Artwork Scraper',
externalArtworkDesc: 'Fetch high-resolution artist backgrounds and banners from fanart.tv for the fullscreen player and artist pages. Off by default.',
externalArtworkDesc: 'Fetch high-resolution artist backgrounds and banners from fanart.tv for the mainstage hero, artist pages and the fullscreen player. Off by default.',
externalArtworkNote: 'When on, it sends the artist (and current album) name to musicbrainz.org to identify the artist, then fetches artwork from fanart.tv — on demand and read-only; your library tags are never changed. Artwork is provided by fanart.tv and its contributors.',
externalArtworkByokTitle: 'Personal API key (BYOK)',
externalArtworkByokDesc: 'Optional. Enter your own fanart.tv personal API key here — it is sent in addition to the app key.',
@@ -749,6 +749,16 @@ export const settings = {
visualOptionsTitle: 'Visual Options',
coverArtBackground: 'Cover Art Background',
coverArtBackgroundSub: 'Show blurred cover art as background in album/playlist headers',
backdropSourcesTitle: 'Backgrounds',
backdropSourcesSub: 'Pick which image each surface uses as its background, and the order to try them in. Drag to reorder, or switch a source off to skip it.',
backdropSurfaceMainstage: 'Mainstage hero',
backdropSurfaceArtistDetail: 'Artist page header',
backdropSurfaceFullscreen: 'Fullscreen player',
backdropSourceBanner: 'Banner',
backdropSourceFanart: 'Fanart artist image',
backdropSourceNavidrome: 'Navidrome image',
backdropMoveUp: 'Move up',
backdropMoveDown: 'Move down',
playlistCoverPhoto: 'Playlist Cover Photo',
playlistCoverPhotoSub: 'Show cover photo grid in playlist detail view',
showBitrate: 'Show Bitrate',
+11 -1
View File
@@ -298,7 +298,7 @@ export const settings = {
nowPlayingEnabledDesc: 'Transmite tu pista actual a la vista de oyentes en vivo del servidor. Desactiva para dejar de enviar datos de reproducción.',
nowPlayingPluginNote: 'Necesario al usar el plugin oficial de Discord Rich Presence de Navidrome.',
externalArtwork: 'Buscador de imágenes externo',
externalArtworkDesc: 'Obtén fondos y banners de artista en alta resolución desde fanart.tv para el reproductor a pantalla completa y las páginas de artista. Desactivado por defecto.',
externalArtworkDesc: 'Obtén fondos y banners de artista en alta resolución desde fanart.tv para el escenario principal, las páginas de artista y el reproductor a pantalla completa. Desactivado por defecto.',
externalArtworkNote: 'Cuando está activado, envía el nombre del artista (y del álbum actual) a musicbrainz.org para identificar al artista y luego obtiene las imágenes desde fanart.tv — bajo demanda y solo lectura; las etiquetas de tu biblioteca nunca se modifican. Las imágenes las proporcionan fanart.tv y sus colaboradores.',
externalArtworkByokTitle: 'Clave API personal (BYOK)',
externalArtworkByokDesc: 'Opcional. Introduce aquí tu propia clave API personal de fanart.tv — se envía junto con la clave de la app.',
@@ -681,6 +681,16 @@ export const settings = {
visualOptionsTitle: 'Opciones Visuales',
coverArtBackground: 'Fondo de Portada',
coverArtBackgroundSub: 'Mostrar portada desenfocada como fondo en encabezados de álbumes y listas de reproducción',
backdropSourcesTitle: 'Fondos',
backdropSourcesSub: 'Elige qué imagen usa cada zona como fondo y en qué orden probarlas. Arrastra para reordenar o desactiva una fuente para omitirla.',
backdropSurfaceMainstage: 'Escenario principal',
backdropSurfaceArtistDetail: 'Cabecera de página de artista',
backdropSurfaceFullscreen: 'Reproductor a pantalla completa',
backdropSourceBanner: 'Banner',
backdropSourceFanart: 'Imagen de artista de Fanart',
backdropSourceNavidrome: 'Imagen de Navidrome',
backdropMoveUp: 'Subir',
backdropMoveDown: 'Bajar',
playlistCoverPhoto: 'Foto de Portada de Playlist',
playlistCoverPhotoSub: 'Mostrar cuadrícula de fotos de portada en la vista detallada de playlists',
showBitrate: 'Mostrar Bitrate',
+11 -1
View File
@@ -286,7 +286,7 @@ export const settings = {
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
nowPlayingPluginNote: 'Requis lors de l\'utilisation du plugin officiel Discord Rich Presence de Navidrome.',
externalArtwork: "Scraper d'illustrations externe",
externalArtworkDesc: "Récupère des arrière-plans et bannières d'artiste en haute résolution depuis fanart.tv pour le lecteur plein écran et les pages d'artiste. Désactivé par défaut.",
externalArtworkDesc: "Récupère des arrière-plans et bannières d'artiste en haute résolution depuis fanart.tv pour la scène principale, les pages d'artiste et le lecteur plein écran. Désactivé par défaut.",
externalArtworkNote: "Lorsque activé, envoie le nom de l'artiste (et de l'album en cours) à musicbrainz.org pour identifier l'artiste, puis récupère les images depuis fanart.tv — à la demande et en lecture seule ; les tags de votre bibliothèque ne sont jamais modifiés. Les images sont fournies par fanart.tv et ses contributeurs.",
externalArtworkByokTitle: 'Clé API personnelle (BYOK)',
externalArtworkByokDesc: "Facultatif. Saisissez ici votre propre clé API personnelle fanart.tv — elle est envoyée en plus de la clé de l'application.",
@@ -669,6 +669,16 @@ export const settings = {
visualOptionsTitle: 'Options Visuelles',
coverArtBackground: "Fond d'Art de Poche",
coverArtBackgroundSub: "Afficher la pochette floutée comme fond dans les en-têtes d'albums et de playlists",
backdropSourcesTitle: 'Arrière-plans',
backdropSourcesSub: 'Choisissez quelle image chaque zone utilise comme arrière-plan, et dans quel ordre les essayer. Glissez pour réorganiser ou désactivez une source pour l\'ignorer.',
backdropSurfaceMainstage: 'Scène principale',
backdropSurfaceArtistDetail: 'En-tête de la page d\'artiste',
backdropSurfaceFullscreen: 'Lecteur plein écran',
backdropSourceBanner: 'Bannière',
backdropSourceFanart: 'Image d\'artiste Fanart',
backdropSourceNavidrome: 'Image Navidrome',
backdropMoveUp: 'Monter',
backdropMoveDown: 'Descendre',
playlistCoverPhoto: 'Photo de Couverture de Playlist',
playlistCoverPhotoSub: 'Afficher la grille de photos de couverture dans la vue détaillée des playlists',
showBitrate: 'Afficher le Débit',
+11 -1
View File
@@ -301,7 +301,7 @@ export const settings = {
nowPlayingEnabledDesc: 'Az éppen lejátszott szám közvetítése a szerver élő hallgatói nézetébe. Kapcsold ki a lejátszási adatok küldésének leállításához.',
nowPlayingPluginNote: 'Szükséges a hivatalos Navidrome Discord Rich Presence bővítmény használatakor.',
externalArtwork: 'Külső borítólehúzó',
externalArtworkDesc: 'Nagy felbontású előadói hátterek és bannerek lekérése a fanart.tv-ről a teljes képernyős lejátszóhoz és az előadói oldalakhoz. Alapértelmezetten kikapcsolva.',
externalArtworkDesc: 'Nagy felbontású előadói hátterek és bannerek lekérése a fanart.tv-ről a főszínpadhoz, az előadói oldalakhoz és a teljes képernyős lejátszóhoz. Alapértelmezetten kikapcsolva.',
externalArtworkNote: 'Bekapcsolva elküldi az előadó (és az aktuális album) nevét a musicbrainz.org-ra az előadó azonosításához, majd borítókat kér le a fanart.tv-ről — igény szerint és csak olvasásra; a könyvtárad címkéi soha nem módosulnak. A borítókat a fanart.tv és közreműködői biztosítják.',
externalArtworkByokTitle: 'Személyes API-kulcs (BYOK)',
externalArtworkByokDesc: 'Opcionális. Add meg itt a saját fanart.tv személyes API-kulcsodat — az alkalmazás kulcsa mellett kerül elküldésre.',
@@ -748,6 +748,16 @@ export const settings = {
visualOptionsTitle: 'Vizuális beállítások',
coverArtBackground: 'Borító háttér',
coverArtBackgroundSub: 'Elmosott borító megjelenítése háttérként az album/lejátszási lista fejlécekben',
backdropSourcesTitle: 'Hátterek',
backdropSourcesSub: 'Válaszd ki, melyik képet használja az egyes felületek háttérként, és milyen sorrendben próbálja őket. Húzással átrendezhető, vagy kapcsolj ki egy forrást a kihagyásához.',
backdropSurfaceMainstage: 'Főszínpad',
backdropSurfaceArtistDetail: 'Előadói oldal fejléce',
backdropSurfaceFullscreen: 'Teljes képernyős lejátszó',
backdropSourceBanner: 'Banner',
backdropSourceFanart: 'Fanart előadói kép',
backdropSourceNavidrome: 'Navidrome kép',
backdropMoveUp: 'Mozgatás fel',
backdropMoveDown: 'Mozgatás le',
playlistCoverPhoto: 'Lejátszási lista borítóképe',
playlistCoverPhotoSub: 'Borítókép-rács megjelenítése a lejátszási lista részletes nézetében',
showBitrate: 'Bitráta megjelenítése',
+10
View File
@@ -742,6 +742,16 @@ export const settings = {
visualOptionsTitle: '表示オプション',
coverArtBackground: 'カバーアート背景',
coverArtBackgroundSub: 'アルバム/プレイリストヘッダーの背景にぼかしたカバーアートを表示',
backdropSourcesTitle: '背景',
backdropSourcesSub: '各エリアが背景に使う画像と、試す順番を選びます。ドラッグで並べ替え、ソースをオフにするとスキップします。',
backdropSurfaceMainstage: 'Mainstage',
backdropSurfaceArtistDetail: 'アーティストページのヘッダー',
backdropSurfaceFullscreen: 'フルスクリーンプレーヤー',
backdropSourceBanner: 'バナー',
backdropSourceFanart: 'Fanart アーティスト画像',
backdropSourceNavidrome: 'Navidrome 画像',
backdropMoveUp: '上へ',
backdropMoveDown: '下へ',
playlistCoverPhoto: 'プレイリストカバー写真',
playlistCoverPhotoSub: 'プレイリスト詳細ビューにカバー写真グリッドを表示',
showBitrate: 'ビットレートを表示',
+11 -1
View File
@@ -285,7 +285,7 @@ export const settings = {
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
nowPlayingPluginNote: 'Nødvendig ved bruk av det offisielle Navidrome Discord Rich Presence-tillegget.',
externalArtwork: 'Ekstern bildehenter',
externalArtworkDesc: 'Henter artistbakgrunner og -bannere i høy oppløsning fra fanart.tv for fullskjermspilleren og artistsider. Av som standard.',
externalArtworkDesc: 'Henter artistbakgrunner og -bannere i høy oppløsning fra fanart.tv for hovedscenen, artistsider og fullskjermspilleren. Av som standard.',
externalArtworkNote: 'Når på, sendes artistnavnet (og navnet på gjeldende album) til musicbrainz.org for å identifisere artisten, deretter hentes bilder fra fanart.tv — ved behov og skrivebeskyttet; bibliotektaggene dine endres aldri. Bildene leveres av fanart.tv og bidragsyterne.',
externalArtworkByokTitle: 'Personlig API-nøkkel (BYOK)',
externalArtworkByokDesc: 'Valgfritt. Skriv inn din egen personlige fanart.tv-API-nøkkel her — den sendes i tillegg til app-nøkkelen.',
@@ -668,6 +668,16 @@ export const settings = {
visualOptionsTitle: 'Visuelle Alternativer',
coverArtBackground: 'Cover-bakgrunn',
coverArtBackgroundSub: 'Vis uskarpt cover som bakgrunn i album/playlist-overskrifter',
backdropSourcesTitle: 'Bakgrunner',
backdropSourcesSub: 'Velg hvilket bilde hvert område bruker som bakgrunn, og i hvilken rekkefølge de prøves. Dra for å endre rekkefølge, eller slå av en kilde for å hoppe over den.',
backdropSurfaceMainstage: 'Hovedscene',
backdropSurfaceArtistDetail: 'Artistsideoverskrift',
backdropSurfaceFullscreen: 'Fullskjermsspiller',
backdropSourceBanner: 'Banner',
backdropSourceFanart: 'Fanart-artistbilde',
backdropSourceNavidrome: 'Navidrome-bilde',
backdropMoveUp: 'Flytt opp',
backdropMoveDown: 'Flytt ned',
playlistCoverPhoto: 'Playlist-coverfoto',
playlistCoverPhotoSub: 'Vis coverfoto-rutenett i playlist-detailedvisning',
showBitrate: 'Vis Bitrate',
+11 -1
View File
@@ -286,7 +286,7 @@ export const settings = {
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
nowPlayingPluginNote: 'Vereist bij gebruik van de officiële Navidrome Discord Rich Presence-plug-in.',
externalArtwork: 'Externe artwork-scraper',
externalArtworkDesc: "Haalt hogeresolutie artiestachtergronden en -banners van fanart.tv op voor de schermvullende speler en artiestpagina's. Standaard uit.",
externalArtworkDesc: "Haalt hogeresolutie artiestachtergronden en -banners van fanart.tv op voor het hoofdpodium, artiestpagina's en de schermvullende speler. Standaard uit.",
externalArtworkNote: 'Indien aan, wordt de naam van de artiest (en het huidige album) naar musicbrainz.org gestuurd om de artiest te identificeren, daarna worden afbeeldingen van fanart.tv opgehaald — op aanvraag en alleen-lezen; je bibliotheektags worden nooit gewijzigd. Afbeeldingen worden geleverd door fanart.tv en zijn bijdragers.',
externalArtworkByokTitle: 'Persoonlijke API-sleutel (BYOK)',
externalArtworkByokDesc: 'Optioneel. Voer hier je eigen persoonlijke fanart.tv-API-sleutel in — deze wordt naast de app-sleutel verstuurd.',
@@ -669,6 +669,16 @@ export const settings = {
visualOptionsTitle: 'Visuele Opties',
coverArtBackground: 'Cover Achtergrond',
coverArtBackgroundSub: 'Toon vervaagde cover als achtergrond in album/playlist headers',
backdropSourcesTitle: 'Achtergronden',
backdropSourcesSub: 'Kies welke afbeelding elk gebied als achtergrond gebruikt en in welke volgorde ze geprobeerd worden. Sleep om te herordenen of zet een bron uit om die over te slaan.',
backdropSurfaceMainstage: 'Hoofdpodium',
backdropSurfaceArtistDetail: 'Artiestpagina-kop',
backdropSurfaceFullscreen: 'Volledig scherm-speler',
backdropSourceBanner: 'Banner',
backdropSourceFanart: 'Fanart-artiestafbeelding',
backdropSourceNavidrome: 'Navidrome-afbeelding',
backdropMoveUp: 'Omhoog',
backdropMoveDown: 'Omlaag',
playlistCoverPhoto: 'Playlist Coverfoto',
playlistCoverPhotoSub: 'Toon coverfoto raster in playlist detailweergave',
showBitrate: 'Toon Bitrate',
+11 -1
View File
@@ -302,7 +302,7 @@ export const settings = {
nowPlayingEnabledDesc: 'Transmituj aktualnie odtwarzany utwór do widoku słuchacza na żywo na serwerze. Wyłącz, aby zatrzymać wysyłanie danych o odtwarzaniu.',
nowPlayingPluginNote: 'Wymagane przy użyciu oficjalnego pluginu Discord Rich Presence dla Navidrome.',
externalArtwork: 'Zewnętrzny scraper grafik',
externalArtworkDesc: 'Pobieraj wysokiej rozdzielczości tła i banery wykonawców z fanart.tv dla odtwarzacza pełnoekranowego i stron wykonawców. Domyślnie wyłączone.',
externalArtworkDesc: 'Pobieraj wysokiej rozdzielczości tła i banery wykonawców z fanart.tv dla sceny głównej, stron wykonawców i odtwarzacza pełnoekranowego. Domyślnie wyłączone.',
externalArtworkNote: 'Po włączeniu wysyła nazwę wykonawcy (i bieżącego albumu) do musicbrainz.org, aby zidentyfikować wykonawcę, a następnie pobiera grafiki z fanart.tv — na żądanie i tylko do odczytu; tagi Twojej biblioteki nigdy nie są zmieniane. Grafiki dostarcza fanart.tv i jego współtwórcy.',
externalArtworkByokTitle: 'Osobisty klucz API (BYOK)',
externalArtworkByokDesc: 'Opcjonalne. Wpisz tutaj własny osobisty klucz API fanart.tv — będzie wysyłany razem z kluczem aplikacji.',
@@ -749,6 +749,16 @@ export const settings = {
visualOptionsTitle: 'Opcje wizualne',
coverArtBackground: 'Tło okładki',
coverArtBackgroundSub: 'Pokazuj rozmytą okładkę jako tło w nagłówkach albumów i playlist',
backdropSourcesTitle: 'Tła',
backdropSourcesSub: 'Wybierz, którego obrazu każdy obszar używa jako tła i w jakiej kolejności je próbować. Przeciągnij, aby zmienić kolejność, lub wyłącz źródło, aby je pominąć.',
backdropSurfaceMainstage: 'Scena główna',
backdropSurfaceArtistDetail: 'Nagłówek strony artysty',
backdropSurfaceFullscreen: 'Odtwarzacz pełnoekranowy',
backdropSourceBanner: 'Baner',
backdropSourceFanart: 'Obraz artysty z Fanart',
backdropSourceNavidrome: 'Obraz z Navidrome',
backdropMoveUp: 'W górę',
backdropMoveDown: 'W dół',
playlistCoverPhoto: 'Okładka playlisty',
playlistCoverPhotoSub: 'Pokazuj siatkę okładek w widoku szczegółów playlisty',
showBitrate: 'Pokazuj Bitrate',
+11 -1
View File
@@ -301,7 +301,7 @@ export const settings = {
nowPlayingEnabledDesc: 'Difuzează piesa redată curent către vizualizatorul de ascultare live a serverului. Dezactivează pentru a opri trimiterea datelor de redare.',
nowPlayingPluginNote: 'Necesar la utilizarea pluginului oficial Discord Rich Presence de la Navidrome.',
externalArtwork: 'Scraper de imagini extern',
externalArtworkDesc: 'Preia fundaluri și bannere de artist la rezoluție înaltă de pe fanart.tv pentru playerul pe tot ecranul și paginile de artist. Dezactivat implicit.',
externalArtworkDesc: 'Preia fundaluri și bannere de artist la rezoluție înaltă de pe fanart.tv pentru scena principală, paginile de artist și playerul pe tot ecranul. Dezactivat implicit.',
externalArtworkNote: 'Când este activat, trimite numele artistului (și al albumului curent) către musicbrainz.org pentru a identifica artistul, apoi preia imagini de pe fanart.tv — la cerere și doar pentru citire; etichetele din biblioteca ta nu sunt niciodată modificate. Imaginile sunt furnizate de fanart.tv și colaboratorii săi.',
externalArtworkByokTitle: 'Cheie API personală (BYOK)',
externalArtworkByokDesc: 'Opțional. Introdu aici propria ta cheie API personală fanart.tv — este trimisă pe lângă cheia aplicației.',
@@ -684,6 +684,16 @@ export const settings = {
visualOptionsTitle: 'Opțiuni vizuale',
coverArtBackground: 'Arta de fundal a copertei',
coverArtBackgroundSub: 'Arată arta de copertă blurată ca fundal în antetele albumelor/playlisturilor',
backdropSourcesTitle: 'Fundaluri',
backdropSourcesSub: 'Alege ce imagine folosește fiecare zonă ca fundal și în ce ordine să fie încercate. Trage pentru a reordona sau dezactivează o sursă pentru a o sări.',
backdropSurfaceMainstage: 'Scena principală',
backdropSurfaceArtistDetail: 'Antetul paginii artistului',
backdropSurfaceFullscreen: 'Player pe tot ecranul',
backdropSourceBanner: 'Banner',
backdropSourceFanart: 'Imagine de artist Fanart',
backdropSourceNavidrome: 'Imagine Navidrome',
backdropMoveUp: 'Mută sus',
backdropMoveDown: 'Mută jos',
playlistCoverPhoto: 'Poza de copertă a playlistului',
playlistCoverPhotoSub: 'Arată poza grilă de copertă în afișajul de detalii al playlistului',
showBitrate: 'Arată Bitrate',
+11 -1
View File
@@ -306,7 +306,7 @@ export const settings = {
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
nowPlayingPluginNote: 'Необходимо при использовании официального плагина Discord Rich Presence для Navidrome.',
externalArtwork: 'Внешний сборщик обложек',
externalArtworkDesc: 'Загружает фоновые изображения и баннеры исполнителей в высоком разрешении с fanart.tv для полноэкранного плеера и страниц исполнителей. По умолчанию выключено.',
externalArtworkDesc: 'Загружает фоновые изображения и баннеры исполнителей в высоком разрешении с fanart.tv для раздела «Для вас», страниц исполнителей и полноэкранного плеера. По умолчанию выключено.',
externalArtworkNote: 'Когда включено, отправляет имя исполнителя (и текущего альбома) на musicbrainz.org для идентификации исполнителя, затем загружает изображения с fanart.tv — по запросу и только для чтения; теги вашей библиотеки никогда не изменяются. Изображения предоставлены fanart.tv и его участниками.',
externalArtworkByokTitle: 'Личный API-ключ (BYOK)',
externalArtworkByokDesc: 'Необязательно. Введите здесь свой личный API-ключ fanart.tv — он отправляется вместе с ключом приложения.',
@@ -769,6 +769,16 @@ export const settings = {
visualOptionsTitle: 'Визуальные настройки',
coverArtBackground: 'Фон обложки',
coverArtBackgroundSub: 'Показывать размытую обложку как фон в заголовках альбомов и плейлистов',
backdropSourcesTitle: 'Фоны',
backdropSourcesSub: 'Выберите, какое изображение каждая область использует как фон и в каком порядке их пробовать. Перетаскивайте для изменения порядка или выключите источник, чтобы пропустить его.',
backdropSurfaceMainstage: '«Для вас»',
backdropSurfaceArtistDetail: 'Шапка страницы исполнителя',
backdropSurfaceFullscreen: 'Полноэкранный плеер',
backdropSourceBanner: 'Баннер',
backdropSourceFanart: 'Изображение исполнителя Fanart',
backdropSourceNavidrome: 'Изображение Navidrome',
backdropMoveUp: 'Вверх',
backdropMoveDown: 'Вниз',
playlistCoverPhoto: 'Обложка плейлиста',
playlistCoverPhotoSub: 'Показывать сетку обложек в детальном виде плейлиста',
showBitrate: 'Показывать битрейт',
+11 -1
View File
@@ -285,7 +285,7 @@ export const settings = {
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
nowPlayingPluginNote: '使用官方 Navidrome Discord Rich Presence 插件时必需。',
externalArtwork: '外部封面采集器',
externalArtworkDesc: '从 fanart.tv 获取高分辨率的艺术家背景图和横幅,用于全屏播放器和艺术家页面。默认关闭。',
externalArtworkDesc: '从 fanart.tv 获取高分辨率的艺术家背景图和横幅,用于主舞台、艺术家页面和全屏播放器。默认关闭。',
externalArtworkNote: '启用后,会将艺术家(及当前专辑)名称发送至 musicbrainz.org 以识别艺术家,然后从 fanart.tv 获取图片——按需且只读;你的音乐库标签永不会被修改。图片由 fanart.tv 及其贡献者提供。',
externalArtworkByokTitle: '个人 API 密钥(BYOK',
externalArtworkByokDesc: '可选。在此输入你自己的 fanart.tv 个人 API 密钥——它会与应用密钥一同发送。',
@@ -668,6 +668,16 @@ export const settings = {
visualOptionsTitle: '视觉选项',
coverArtBackground: '封面背景',
coverArtBackgroundSub: '在专辑/播放列表标题中显示模糊的封面作为背景',
backdropSourcesTitle: '背景',
backdropSourcesSub: '选择每个区域使用哪张图片作为背景,以及尝试的顺序。拖动可重新排序,或关闭某个来源以跳过它。',
backdropSurfaceMainstage: '主舞台',
backdropSurfaceArtistDetail: '艺术家页面标题',
backdropSurfaceFullscreen: '全屏播放器',
backdropSourceBanner: '横幅',
backdropSourceFanart: 'Fanart 艺术家图片',
backdropSourceNavidrome: 'Navidrome 图片',
backdropMoveUp: '上移',
backdropMoveDown: '下移',
playlistCoverPhoto: '播放列表封面照片',
playlistCoverPhotoSub: '在播放列表详细视图中显示封面照片网格',
showBitrate: '显示比特率',
+40 -3
View File
@@ -1,5 +1,29 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { BackdropSource, BackdropSourcePref } from '../cover/artistBackdrop';
/** Surfaces that can render an artist backdrop, each independently configurable. */
export type BackdropSurface = 'mainstageHero' | 'artistDetailHero' | 'fullscreenPlayer';
/** A surface's backdrop config: an on/off and an ordered source priority list. */
export interface SurfaceBackdropConfig {
enabled: boolean;
sources: BackdropSourcePref[];
}
const allEnabled = (sources: BackdropSource[]): BackdropSourcePref[] =>
sources.map((source) => ({ source, enabled: true }));
/**
* Defaults preserve today's behaviour: both heroes resolve banner fanart
* Navidrome; the fullscreen player has no banner (a wide strip suits neither a
* portrait nor a square stage), so it offers fanart Navidrome only.
*/
const DEFAULT_BACKDROPS: Record<BackdropSurface, SurfaceBackdropConfig> = {
mainstageHero: { enabled: true, sources: allEnabled(['banner', 'fanart', 'navidrome']) },
artistDetailHero: { enabled: true, sources: allEnabled(['banner', 'fanart', 'navidrome']) },
fullscreenPlayer: { enabled: true, sources: allEnabled(['fanart', 'navidrome']) },
};
/** Themes that ship bundled with the app and can never be uninstalled. */
export type BuiltinTheme =
@@ -37,8 +61,14 @@ interface ThemeState {
setTimeDayStart: (v: string) => void;
timeNightStart: string;
setTimeNightStart: (v: string) => void;
/** Master umbrella for artist backdrops; the per-surface configs below apply
* only while this is on. */
enableCoverArtBackground: boolean;
setEnableCoverArtBackground: (v: boolean) => void;
/** Per-surface backdrop on/off + ordered, individually toggleable sources. */
backdrops: Record<BackdropSurface, SurfaceBackdropConfig>;
setBackdropEnabled: (surface: BackdropSurface, enabled: boolean) => void;
setBackdropSources: (surface: BackdropSurface, sources: BackdropSourcePref[]) => void;
enablePlaylistCoverPhoto: boolean;
setEnablePlaylistCoverPhoto: (v: boolean) => void;
showBitrate: boolean;
@@ -101,6 +131,11 @@ export const useThemeStore = create<ThemeState>()(
setTimeNightStart: (v) => set({ timeNightStart: v }),
enableCoverArtBackground: true,
setEnableCoverArtBackground: (v) => set({ enableCoverArtBackground: v }),
backdrops: DEFAULT_BACKDROPS,
setBackdropEnabled: (surface, enabled) =>
set((s) => ({ backdrops: { ...s.backdrops, [surface]: { ...s.backdrops[surface], enabled } } })),
setBackdropSources: (surface, sources) =>
set((s) => ({ backdrops: { ...s.backdrops, [surface]: { ...s.backdrops[surface], sources } } })),
enablePlaylistCoverPhoto: true,
setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }),
showBitrate: true,
@@ -120,10 +155,12 @@ export const useThemeStore = create<ThemeState>()(
}),
{
name: 'psysonic_theme',
version: 1,
version: 2,
// Identity migrate: preserve persisted state from older versions as-is.
// Theme-id repair for removed / store-only themes now happens in the
// pre-React bootstrap migration (see utils/themes/themeMigration).
// v2 adds `backdrops`; a v1 state simply lacks the key, so the default
// merge seeds it (every surface enabled, legacy source order) — no
// transform needed. Theme-id repair for removed / store-only themes still
// happens in the pre-React bootstrap migration (see utils/themes/themeMigration).
migrate: (persistedState) => persistedState,
}
)
@@ -0,0 +1,95 @@
/* Backdrop source priority lists (Appearance Cover Art Background).
Each row reorders via the shared psy-drop drag system (grip) or the /
buttons; the per-row switch drops a source out of the resolution chain
without losing its place. */
/* One surface (Mainstage / Artist page / Fullscreen): its enable toggle and,
when on, its source list. */
.backdrop-surface-block {
margin-top: 10px;
}
.backdrop-surface-block:first-of-type {
margin-top: 6px;
}
.backdrop-source-list {
list-style: none;
margin: 6px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.backdrop-source-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-card);
position: relative;
}
.backdrop-source-row--off .backdrop-source-name {
opacity: 0.5;
}
/* Drop target: an accent line across the top edge, mirroring the sidebar reorder. */
.backdrop-source-row--drop {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.backdrop-source-row--drop::before {
content: '';
position: absolute;
left: 6px;
right: 6px;
top: -3px;
height: 2px;
border-radius: 2px;
background: var(--accent);
}
.backdrop-source-grip {
display: inline-flex;
align-items: center;
color: var(--text-muted);
cursor: grab;
flex-shrink: 0;
}
.backdrop-source-grip:active {
cursor: grabbing;
}
.backdrop-source-name {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--text-primary);
}
.backdrop-source-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.backdrop-source-move {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
}
.backdrop-source-move:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.backdrop-source-move:disabled {
opacity: 0.35;
cursor: default;
}
+1
View File
@@ -95,3 +95,4 @@
@import './orbit-session-top-strip.css';
@import './back-to-top.css';
@import './error-boundary.css';
@import './backdrop-source-list.css';