mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
+3
-1
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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%',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ビットレートを表示',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Показывать битрейт',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -95,3 +95,4 @@
|
||||
@import './orbit-session-top-strip.css';
|
||||
@import './back-to-top.css';
|
||||
@import './error-boundary.css';
|
||||
@import './backdrop-source-list.css';
|
||||
|
||||
Reference in New Issue
Block a user