From 86ae462ad661ef86fe141b3b18dd660ae06e4f1a Mon Sep 17 00:00:00 2001
From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
Date: Sat, 27 Jun 2026 00:42:32 +0200
Subject: [PATCH] feat(hero): album-artist backdrop, configurable per-surface
sources, and prefetch (#1193)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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 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).
---
CHANGELOG.md | 4 +-
WHATS_NEW.md | 5 +-
src/components/Hero.tsx | 130 +++++++++++-----
.../artistDetail/ArtistDetailHero.tsx | 29 ++--
.../FullscreenPlayerStatic.tsx | 19 +--
.../settings/BackdropSourceList.tsx | 141 ++++++++++++++++++
src/components/settings/IntegrationsTab.tsx | 43 ++++++
.../settings/backdropReorder.test.ts | 36 +++++
src/components/settings/backdropReorder.ts | 36 +++++
src/config/settingsCredits.ts | 1 +
src/cover/artistBackdrop.test.ts | 118 +++++++++++++++
src/cover/artistBackdrop.ts | 84 +++++++++++
src/cover/ensureQueue.ts | 53 +++++--
src/cover/heroBackdropMemory.test.ts | 42 ++++++
src/cover/heroBackdropMemory.ts | 45 ++++++
src/cover/useArtistFanart.ts | 13 ++
src/cover/useHeroBackdrop.ts | 108 ++++++++++++++
src/cover/warmDiskPeek.ts | 120 ++++++++++++---
src/locales/de/settings.ts | 12 +-
src/locales/en/settings.ts | 12 +-
src/locales/es/settings.ts | 12 +-
src/locales/fr/settings.ts | 12 +-
src/locales/hu/settings.ts | 12 +-
src/locales/ja/settings.ts | 10 ++
src/locales/nb/settings.ts | 12 +-
src/locales/nl/settings.ts | 12 +-
src/locales/pl/settings.ts | 12 +-
src/locales/ro/settings.ts | 12 +-
src/locales/ru/settings.ts | 12 +-
src/locales/zh/settings.ts | 12 +-
src/store/themeStore.ts | 43 +++++-
.../components/backdrop-source-list.css | 95 ++++++++++++
src/styles/components/index.css | 1 +
33 files changed, 1198 insertions(+), 110 deletions(-)
create mode 100644 src/components/settings/BackdropSourceList.tsx
create mode 100644 src/components/settings/backdropReorder.test.ts
create mode 100644 src/components/settings/backdropReorder.ts
create mode 100644 src/cover/artistBackdrop.test.ts
create mode 100644 src/cover/artistBackdrop.ts
create mode 100644 src/cover/heroBackdropMemory.test.ts
create mode 100644 src/cover/heroBackdropMemory.ts
create mode 100644 src/cover/useHeroBackdrop.ts
create mode 100644 src/styles/components/backdrop-source-list.css
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74d2a7d1..19cabc4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/WHATS_NEW.md b/WHATS_NEW.md
index bfac7156..0426ab76 100644
--- a/WHATS_NEW.md
+++ b/WHATS_NEW.md
@@ -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
diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx
index 661d5c71..92119000 100644
--- a/src/components/Hero.tsx
+++ b/src/components/Hero.tsx
@@ -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>(() =>
- 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 | 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(() => {
- if (latestUrlRef.current !== url) return;
- setLayers(prev => prev.filter(l => l.id === id));
- }, 900);
- return () => { clearTimeout(t1); clearTimeout(t2); };
+ cleanup = setTimeout(() => {
+ if (latestUrlRef.current !== url) return;
+ setLayers(prev => prev.filter(l => l.id === id));
+ }, 900);
+ };
+
+ // 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
+ // 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 | 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(() =>
@@ -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>({});
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 && }
- {enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && }
+ {showHeroBackdrop && }
+ {showHeroBackdrop && }
{/* key causes re-mount → animate-fade-in triggers on each album change */}
diff --git a/src/components/artistDetail/ArtistDetailHero.tsx b/src/components/artistDetail/ArtistDetailHero.tsx
index d7b6892d..b5fe12f7 100644
--- a/src/components/artistDetail/ArtistDetailHero.tsx
+++ b/src/components/artistDetail/ArtistDetailHero.tsx
@@ -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). */}