mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
86ae462ad6
* 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).
109 lines
4.2 KiB
TypeScript
109 lines
4.2 KiB
TypeScript
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 };
|
|
}
|