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 &&