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
@@ -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;
}