mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
86ae462ad6
* refactor(cover): extract shared pickArtistBackdrop priority helper
* feat(hero): show the album artist's fanart as the mainstage hero backdrop
* feat(settings): configurable per-surface artist backdrop sources
Each artist-backdrop surface (mainstage hero, artist-detail header, fullscreen player) gets its own enable toggle + an ordered, individually-toggleable source list, configured under External Artwork Scraper on the Integrations tab (shown when the scraper is on). Reorder via the shared useDragSource/psy-drop drag infra plus keyboard-accessible up/down buttons; each source has its own on/off.
The shared chooser pickArtistBackdrop is generalised to resolveBackdrop + backdropFromConfig (ordered candidate list with the same pending/miss/centred-framing semantics), so all three surfaces resolve identically. themeStore persist bumped to v2; defaults reproduce today's order, so there is no visible change without user action.
Gating decoupled: the three surfaces are gated solely by their own per-surface flag, not by enableCoverArtBackground (which stays scoped to album/playlist-header cover blur). Reorder maths extracted to a pure, unit-tested module. i18n en + de (other locales TODO before PR).
Tests: resolver (10) + reorder (7).
* feat(cover): make the ensure queue surface-aware for artist backdrops
coverEnsureQueued now threads optional CoverEnsureOpts through to the Rust ensure and weaves the external surface into the in-flight key, so the fanart and banner surfaces of one artist no longer collide on one download chain. External surfaces also bypass the disk-src memory short-circuit (their {tier}-{surface}.webp never seeds those caches, and the canonical cover must not read as a hit). New thin ensureArtistBackdropQueued wrapper. Backward-compatible: plain covers append nothing to the key; the 5 queue tests stay green.
* fix(cover): reset the artist external-image hook synchronously on artist change
The hook reset src in an effect (one render late), so for the render between an artist change and that effect a consumer read the *previous* artist's resolved image. The mainstage hero then froze (and cached into per-album memory) a neighbouring slide's banner. Reset synchronously via the React adjust-state-on-prop-change pattern. Also removes brief stale-image flashes on the artist-detail header and fullscreen player.
* feat(hero): prefetch artist backdrops and show-ready-now / upgrade-on-re-entry
warmHomeMainstageCovers now prefetches each hero slide's artist backdrop (banner/fanart) at static slide-index priorities (idx1=high, idx0=low, rest=middle; no reprioritise on navigation), then predecodes every slide already on disk. useHeroBackdrop shows the best source ready at entry (Navidrome on a cold first visit, the prefetched/cached external one on re-entry) and freezes that source choice for the visit so nothing swaps mid-dwell; the url is derived live from the frozen choice, with a per-album disk memory for re-entry. HeroBg now crossfades only after the image bytes load (onLoad/complete gate + onError + fallback). Inert when the scraper is off. Tests: per-album memory (5).
* docs(changelog): configurable artist backdrops + mainstage hero (PR #1193)
CHANGELOG Added entry, settingsCredits line, and the What's New Artist-artwork highlight extended to cover the mainstage hero backdrop and per-place source config.
* docs(changelog): fold mainstage hero + per-place backdrops into the fanart entry (PR #1193)
Merge the configurable-backdrops changes into the existing 'Artist artwork from fanart.tv' block (now PR #1137 and #1193) instead of a separate entry.
* fix(hero): revert HeroBg load-gate that blanked the app (Maximum update depth)
The byte-load gate I added drove the crossfade reveal from an inline img ref + onLoad that re-fires on every render and scheduled a setTimeout each call; frequent re-renders (playback, marquee) stacked nested updates until React threw 'Maximum update depth exceeded' — and with no ErrorBoundary the whole window blanked. Reverted HeroBg to the proven timer-based crossfade. The hero only ever receives ready/predecoded urls, so the gate was cosmetic.
* fix(hero): gate the HeroBg crossfade on image load to stop the slide flicker
The bare 20 ms reveal faded a layer in before its bytes were ready (notably the Navidrome raw url), so a slide change flickered. Now an Image() preloader in the [url] effect reveals the layer on load (or a cached complete check), with a fallback. Everything is scheduled once per url — no per-render <img> ref/onLoad — so unlike the reverted gate it can't stack nested updates.
* feat(i18n): translate the backdrop-source settings into the remaining 10 locales
Adds the per-surface backdrop config strings (es, fr, nl, zh, nb, ru, ro, ja, hu, pl) and extends externalArtworkDesc to mention the mainstage hero where the key exists (ja has none → falls back to en).
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
import { useTranslation } from 'react-i18next';
|
|
import { AlertTriangle, Check, Image as ImageIcon, Info, Sparkles, Wifi } from 'lucide-react';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { useThemeStore } from '../../store/themeStore';
|
|
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';
|
|
|
|
export function IntegrationsTab() {
|
|
const { t } = useTranslation();
|
|
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
|
|
className="settings-privacy-notice"
|
|
role="note"
|
|
aria-label={t('settings.integrationsPrivacyTitle')}
|
|
>
|
|
<AlertTriangle size={16} className="settings-privacy-notice-icon" aria-hidden="true" />
|
|
<div>
|
|
<div className="settings-privacy-notice-title">{t('settings.integrationsPrivacyTitle')}</div>
|
|
<div
|
|
className="settings-privacy-notice-body"
|
|
// Enthaelt <strong> aus dem i18n-String — der Inhalt ist statisch
|
|
// und kommt nur aus unseren Locales, kein User-Input.
|
|
dangerouslySetInnerHTML={{ __html: t('settings.integrationsPrivacyBody') }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Music Network — scrobbling + enrichment across multiple services */}
|
|
<MusicNetworkSection />
|
|
|
|
{/* Discord Rich Presence */}
|
|
<SettingsSubSection
|
|
title={t('settings.discordRichPresence')}
|
|
icon={<Sparkles size={16} />}
|
|
>
|
|
<div className="settings-card">
|
|
<div
|
|
style={{ fontWeight: 700, fontSize: 13, lineHeight: 1.5, marginBottom: 'var(--space-3)', color: 'var(--text-secondary)' }}
|
|
>
|
|
{t('settings.discordRichPresenceNotice')}
|
|
</div>
|
|
<SettingsGroup title={t('settings.discordRichPresence')}>
|
|
<SettingsToggle
|
|
desc={t('settings.discordRichPresenceDesc')}
|
|
ariaLabel={t('settings.discordRichPresence')}
|
|
checked={auth.discordRichPresence}
|
|
onChange={auth.setDiscordRichPresence}
|
|
/>
|
|
</SettingsGroup>
|
|
{auth.discordRichPresence && (
|
|
<>
|
|
<SettingsGroup title={t('settings.discordCoverTitle')} desc={t('settings.discordCoverDesc')}>
|
|
<SettingsToggle
|
|
label={t('settings.discordCoverNone')}
|
|
checked={auth.discordCoverSource === 'none'}
|
|
onChange={c => auth.setDiscordCoverSource(c ? 'none' : 'server')}
|
|
/>
|
|
<div className="settings-section-divider" />
|
|
<SettingsToggle
|
|
label={t('settings.discordCoverServer')}
|
|
checked={auth.discordCoverSource === 'server'}
|
|
onChange={c => auth.setDiscordCoverSource(c ? 'server' : 'none')}
|
|
/>
|
|
<div className="settings-section-divider" />
|
|
<SettingsToggle
|
|
label={t('settings.discordCoverApple')}
|
|
checked={auth.discordCoverSource === 'apple'}
|
|
onChange={c => auth.setDiscordCoverSource(c ? 'apple' : 'none')}
|
|
/>
|
|
</SettingsGroup>
|
|
|
|
<SettingsGroup title={t('settings.discordTemplates')} desc={t('settings.discordTemplatesDesc')}>
|
|
<SettingsSubCard>
|
|
<SettingsField label={t('settings.discordTemplateName')}>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={auth.discordTemplateName}
|
|
onChange={e => auth.setDiscordTemplateName(e.target.value)}
|
|
placeholder="{title}"
|
|
/>
|
|
</SettingsField>
|
|
<SettingsField label={t('settings.discordTemplateDetails')}>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={auth.discordTemplateDetails}
|
|
onChange={e => auth.setDiscordTemplateDetails(e.target.value)}
|
|
placeholder="{artist}"
|
|
/>
|
|
</SettingsField>
|
|
<SettingsField label={t('settings.discordTemplateState')}>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={auth.discordTemplateState}
|
|
onChange={e => auth.setDiscordTemplateState(e.target.value)}
|
|
placeholder="{title}"
|
|
/>
|
|
</SettingsField>
|
|
<SettingsField label={t('settings.discordTemplateLargeText')}>
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={auth.discordTemplateLargeText}
|
|
onChange={e => auth.setDiscordTemplateLargeText(e.target.value)}
|
|
placeholder="{album}"
|
|
/>
|
|
</SettingsField>
|
|
</SettingsSubCard>
|
|
</SettingsGroup>
|
|
</>
|
|
)}
|
|
</div>
|
|
</SettingsSubSection>
|
|
|
|
{/* Bandsintown */}
|
|
<SettingsSubSection
|
|
title={t('settings.enableBandsintown')}
|
|
icon={<Info size={16} />}
|
|
>
|
|
<div className="settings-card">
|
|
<SettingsGroup>
|
|
<SettingsToggle
|
|
desc={t('settings.enableBandsintownDesc')}
|
|
ariaLabel={t('settings.enableBandsintown')}
|
|
checked={auth.enableBandsintown}
|
|
onChange={auth.setEnableBandsintown}
|
|
/>
|
|
</SettingsGroup>
|
|
</div>
|
|
</SettingsSubSection>
|
|
|
|
{/* External artist artwork (fanart.tv) */}
|
|
<SettingsSubSection
|
|
title={t('settings.externalArtwork')}
|
|
icon={<ImageIcon size={16} />}
|
|
>
|
|
<div className="settings-card">
|
|
<SettingsGroup>
|
|
<SettingsToggle
|
|
desc={t('settings.externalArtworkDesc')}
|
|
note={t('settings.externalArtworkNote')}
|
|
ariaLabel={t('settings.externalArtwork')}
|
|
checked={theme.externalArtworkEnabled}
|
|
onChange={v => {
|
|
theme.setExternalArtworkEnabled(v);
|
|
// Opt-out: purge the fetched external images + lookup rows so
|
|
// turning the scraper off actually removes the third-party data,
|
|
// not just hides it (design-review §9/§12/B.4).
|
|
if (!v) void purgeExternalArtworkAllServers();
|
|
}}
|
|
/>
|
|
</SettingsGroup>
|
|
{theme.externalArtworkEnabled && (
|
|
<SettingsGroup
|
|
title={t('settings.externalArtworkByokTitle')}
|
|
desc={t('settings.externalArtworkByokDesc')}
|
|
>
|
|
<SettingsSubCard>
|
|
<SettingsField>
|
|
<input
|
|
className="input"
|
|
type="password"
|
|
value={theme.externalArtworkByok}
|
|
onChange={e => theme.setExternalArtworkByok(e.target.value)}
|
|
placeholder="fanart.tv personal API key"
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
/>
|
|
{theme.externalArtworkByok.trim() && (
|
|
<div
|
|
style={{
|
|
fontSize: 12,
|
|
color: 'var(--text-muted)',
|
|
marginTop: 6,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<Check size={13} /> {t('settings.externalArtworkByokSaved')}
|
|
</div>
|
|
)}
|
|
</SettingsField>
|
|
</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>
|
|
|
|
{/* Now-Playing Share (Navidrome) */}
|
|
<SettingsSubSection
|
|
title={t('settings.nowPlayingEnabled')}
|
|
icon={<Wifi size={16} />}
|
|
>
|
|
<div className="settings-card">
|
|
<SettingsGroup>
|
|
<SettingsToggle
|
|
desc={t('settings.nowPlayingEnabledDesc')}
|
|
note={t('settings.nowPlayingPluginNote')}
|
|
ariaLabel={t('settings.nowPlayingEnabled')}
|
|
checked={auth.nowPlayingEnabled}
|
|
onChange={auth.setNowPlayingEnabled}
|
|
/>
|
|
</SettingsGroup>
|
|
</div>
|
|
</SettingsSubSection>
|
|
</>
|
|
);
|
|
}
|