mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(cover): artist artwork from fanart.tv (off by default) (#1137)
* feat(cover): add artist_artwork_lookup table + accessors
Image-scraper P0 (design-review §12): additive library-SQLite migration 013
plus get/upsert/clear-per-server accessors for the external artist-artwork
lookup (fanart.tv). Render never reads it; the on-demand cover ensure path
and the mbid_ambiguous 24h negative cache use it. server_id = serverIndexKey.
* feat(cover): add fanart.tv + getArtistInfo2 provider layer
Image-scraper P0 (§7/§19/§23): new cover_cache/external.rs — Rust-side
getArtistInfo2 tag-MBID resolution plus fanart.tv v3/music URL + first
artistbackground fetch (BYOK client_key sent in addition to the project
api_key per fanart.tv ToS, §22). Extract a shared build_subsonic_url helper
in fetch.rs (cover URL behaviour unchanged). Add a dedicated low-concurrency
fanart_http_sem so external HTTP never starves Navidrome (§26). URL builders
unit-tested; wired into ensure_inner next.
* feat(cover): wire fanart.tv external branch into ensure_inner
Image-scraper P0 (§16): on-demand artist `fanart` ensures try fanart.tv
before the Navidrome fallback. MBID resolved Rust-side via getArtistInfo2
(§23, tag MBID); on a miss it falls through WITHOUT a .fetch-failed marker so
Navidrome stays the display fallback (§28). External tiers are written as
{tier}-fanart.webp in the same entity dir (same cacheKind, §16) — 2000 + 512
(matryoshka §17); peek prefers them for the fanart surface. Dedicated
low-concurrency fanart lane (§26); .miss-fanart ~30min negative marker.
Additive IPC args (externalArtworkEnabled, surfaceKind), off by default and
gated by PSYSONIC_FANART_KEY — inert until a render surface opts in (P1).
Quality gate (§11), name->MusicBrainz (§19), and lookup-table writes are P1.
* feat(cover): fanart-first peek for the fanart surface
Image-scraper P0: for an artist `fanart` ensure, the early peek serves only
the external {tier}-fanart.webp tiers; if none exist yet it returns None so
ensure runs the external branch (fetch fanart) instead of short-circuiting on
a cached Navidrome tier. Realises "fanart prioritised" (§18) for the opt-in
surface; Navidrome stays the fallback inside the branch's miss path.
* chore(cover): dev-only artist-fanart spike helper
DEV-only window.psyFanartSpike(name) — resolves an artist by name and fires
the real cover_cache_ensure with externalArtworkEnabled+surfaceKind=fanart to
verify the P0 pipeline against a live server (with PSYSONIC_FANART_KEY set).
Not wired in production.
* feat(cover): §11 quality gate for the fanart surface
Before an external fanart fetch, check whether a Navidrome tier already on
disk is an HQ ~16:9 image (width >= 1280, aspect 1.6-2.0) and skip the fetch
if so — square artist portraits never satisfy it, so the common case still
fetches. Reads tier dimensions only (no full decode). Rust consts per the
design review. Unit-tested predicate.
* feat(cover): persist fanart resolution in artist_artwork_lookup (§12)
Wire the lookup table as both the MBID resolution cache and the negative
cache: a cached MBID skips the getArtistInfo2 round-trip; no_mbid/mbid_ambiguous
back off 24h and miss 30min from updated_at before re-querying. Writes
hit/miss/no_mbid with mbid/mbid_source/provider; transient network errors are
not cached. All store reads/writes run off the async executor via
spawn_blocking and no-op before login. Store reached via app.try_state::<LibraryRuntime>().
* feat(cover): compile-time fanart key fallback + album/name IPC args
A runtime PSYSONIC_FANART_KEY still wins (dev), else the key baked in at build
time via option_env! (release). Add additive artistName/albumTitle ensure args
as context for the §19 name->MusicBrainz fallback (inert until the render
passes them). Library backfill passes None.
* feat(settings): add External Artwork Scraper toggle under Integrations
Master toggle (themeStore, off by default per §20) in a new Integrations
subsection, alongside the other opt-in third-party categories. Contacts
fanart.tv only when enabled. i18n across all 9 locales.
* feat(cover): wire fanart background into the fullscreen player (§28)
New useArtistFanart hook resolves a fanart.tv 16:9 background via a dedicated
cover_cache_ensure (surfaceKind=fanart) — it bypasses the shared peek/disk-src
cache (the {tier}-fanart.webp surface is keyed differently) and reuses
coverDiskUrl for the asset URL. Fullscreen background priority is now
fanart -> Navidrome artist image (cover pipeline) -> album cover; the live
useFsArtistPortrait probe is deleted (§28). Additive ensure opts
(surfaceKind/artistName/albumTitle); externalArtworkEnabled is derived in
ensureArgsFromRef from the master toggle and restricted to the artist fanart
surface, so plain cover ensures are unaffected.
* feat(cover): generalize external surface to fanart + banner (§13)
surfaceKind='banner' fetches the fanart.tv musicbanner array -> {tier}-banner.webp
in the same entity dir; fanart stays the 16:9 artistbackground. The ensure
branch, peek, lookup rows (per-surface surface_kind), miss marker
(.miss-<surface>) and tier suffix are all surface-parameterised. The §11
quality gate stays fanart-only (the banner strip has its own aspect). Unit
test for the surface->fanart JSON key map.
* feat(artist): fanart banner on the artist-detail header (§13, Option B)
The artist-detail header gets an album-detail-style background layer: fanart.tv
banner (musicbanner) -> the 16:9 fanart background cropped to the strip ->
empty. Both via a shared useArtistExternalImage hook (useArtistBanner /
useArtistFanart); each fetches on demand and shares the Rust cache, so the
header and the fullscreen player warm each other's images. The header is its
own stacking context (isolation) so a z-index:-1 banner clips behind the avatar
+ meta with no content wrapper; the album-style framing (padding/radius/clip)
is applied only when a banner is shown, so the off-by-default case stays
pixel-identical.
* refactor(artist): reuse album-detail header structure for the fanart banner
The artist-detail header now uses the same album-detail-* container classes as
AlbumHeader (header/bg/overlay/content/hero) with the fanart banner as the
background; the Back button moves inside the header. A surgical
`artist-detail-bleed` cancels the artist page's .content-body padding so the
banner is full-bleed to the container edges, matching the album header exactly
instead of the earlier inset card. Reverts the experimental artist-specific bg
CSS.
* feat(cover): drop artist_artwork_lookup rows on clear-cover-cache (§12/B.4)
cover_cache_clear_server already removed the server's whole cover dir (so the
{tier}-fanart.webp / -banner.webp tiers + .miss-* markers go with it); also
clear the artist_artwork_lookup rows for that server (off-thread) so no stale
resolution state lingers. Automatic toggle-off purge deferred — turning the
toggle off already hides external artwork (render is gated), and explicit
cache-clear now cleans external state too.
* feat(cover): name->MusicBrainz album-confirmed MBID resolution (§19)
When getArtistInfo2 has no tag MBID and the ensure carries the artist name +
an album in context (fullscreen), one MusicBrainz release-search query resolves
the artist MBID: the primary artist across score>=90 releases wins, conflicting
ids -> mbid_ambiguous (24h backoff), none -> no_mbid. Sends the required
User-Agent; a single-permit musicbrainz_sem + >=1s spacing holds us under MB's
rate limit. mbid_source=musicbrainz persisted. Banner surface (no album
context) correctly skips this. Pure classify/escape helpers unit-tested.
* fix(cover): enable banner surface in ensureArgsFromRef
externalEnsureFields only set externalArtworkEnabled for surfaceKind 'fanart',
so the 'banner' surface never fired — the artist-detail header always fell back
to the fanart image instead of the fanart.tv musicbanner. Both external artist
surfaces (fanart/banner) now enable the external branch.
* feat(settings): optional BYOK personal fanart key field
Add an optional personal fanart.tv API key field to the External Artwork
Scraper block (shown when the toggle is on): a masked input, a saved/in-use
status line, and the simple note that it is sent in addition to the app key.
Persisted in themeStore and plumbed through cover_cache_ensure
(externalArtworkByok); Rust prefers the settings key, falling back to the
PSYSONIC_FANART_CLIENT_KEY dev env. i18n x9.
* fix(cover): resolve artist-page fanart image collision on navigation
The artist-detail header keyed its fanart/banner hooks on the route `id`,
which flips immediately on navigation while `artist`/`albums` refetch a beat
later. The mismatched ensure wrote the previous artist's image under the new
artist's id (e.g. Sepultura's image under Lordi's id).
- key on the loaded `artist.id`, not the route `id`, so id/name/album always
describe the same artist
- pick the §19 album context from an album that actually belongs to this
artist (`albums.find(a => a.artistId === artist.id)`), so a stale album can't
run a mismatched name→MusicBrainz query or cache a wrong `no_mbid`
- reset `src` on every input change in `useArtistExternalImage` so a previous
artist's image never lingers while the new one resolves
* fix(cover): strip trailing album qualifier before MusicBrainz lookup
Library titles like "Show No Mercy (2004 Remastered)" or "Album [Deluxe
Edition]" failed the §19 MusicBrainz release query, blocking name-confirmed
MBID resolution. `normalize_album_for_mb` strips a single trailing
parenthetical/bracketed qualifier; leading qualifiers (e.g. "(What's the
Story) Morning Glory?") are left intact. Unit-tested.
* fix(cover): don't cache no_mbid when album context is unavailable
The banner ensure could fire before the artist's albums loaded, with no album
in context. The old code cached `no_mbid` there and the 24h backoff then
blocked the later ensure that arrived *with* album context. Could-not-attempt
is not tried-and-failed: the no-album branch now returns without persisting.
* fix(cover): don't emit tier-ready for external fanart/banner surfaces
`try_external_fanart` emitted `cover:tier-ready` with the `{tier}-{surface}.webp`
path. That event is keyed by the canonical cover key (cacheKind/cacheEntityId/
tier, no surface), so the frontend `useCoverArtBridge` listener seeded the
Navidrome artist cover's disk-src cache with the external image — leaking
fanart/banner into the plain artist cover (avatar, fullscreen "navidrome-artist"
fallback) even with the scraper toggled off.
Remove the emit: the fanart/banner hooks read the path from the
`cover_cache_ensure` return value, so no event is needed. (No disk-level
overwrite — the suffixed files are never matched by `tier_exists`; this was
frontend disk-src-cache cross-contamination.)
* fix(cover): wait for the final external background before showing it, with fade-in
The fullscreen player and artist-detail header flashed several backgrounds in
sequence while the fanart resolved (upscaled album cover → Navidrome artist
image → fanart), and the artist header could show the fanart first and then
swap to the banner.
- the album cover is no longer a background source — it only feeds the
foreground thumbnail
- the external-artwork hooks return `{ src, pending }` so callers can tell
"still resolving" (hold back) from "resolved, no image" (fall back now)
- fullscreen background: scraper on → fanart, empty while it resolves, Navidrome
artist image only on a confirmed miss; scraper off → Navidrome artist image
- artist header: the banner is preferred — nothing shows while it resolves
(no fanart flash), fanart is the fallback only once the banner misses
- both backgrounds preload the chosen image and fade it in (`onLoad` plus a
`ref` `complete` check so an already-cached image, whose load event can fire
before React attaches the handler, still appears). The header fade is a
scoped inline opacity so the shared `album-detail-bg` class is untouched.
* ci(release): pass PSYSONIC_FANART_KEY into the macOS + Linux builds
* refactor(cover): extract external-artwork ensure into its own module
Pure code move: the on-demand fanart/banner fetch, the quality gate, the
surface-aware peek and the lookup-table cache move from cover_cache/mod.rs
into cover_cache/external_ensure.rs. Behaviour unchanged; mod.rs 1877 -> 1488.
* chore(cover): remove dev-only fanart spike helper
The real render wiring now exercises the external ensure branch, so the
dev-only window.psyFanartSpike helper is redundant.
* feat(cover): purge external artwork on opt-out (B3)
New cover_cache_purge_external command: when the External Artwork toggle is
turned off, drop every fetched {tier}-{provider}.webp, .miss-{provider}
marker and artist_artwork_lookup row across all configured servers, leaving
the canonical Navidrome covers intact. Opting out now removes the
third-party-sourced data instead of just hiding it (design-review §9/§12/B.4).
* docs: changelog, credits and what's new for artist fanart (PR #1137)
This commit is contained in:
+52
-2
@@ -1,5 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
||||
@@ -52,7 +53,36 @@ export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void {
|
||||
coverAutoDownloadEnabled = enabled;
|
||||
}
|
||||
|
||||
function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
||||
export type CoverEnsureOpts = {
|
||||
/** External-artwork surface intent — `'fanart'` for the 16:9 artist background (§28). */
|
||||
surfaceKind?: string;
|
||||
/** §19 name→MusicBrainz context: the artist display name + the album in context. */
|
||||
artistName?: string;
|
||||
albumTitle?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* External-artwork ensure fields (§28). `externalArtworkEnabled` is gated by the
|
||||
* master toggle AND restricted to the external artist surfaces (`fanart` /
|
||||
* `banner`), so plain album/artist cover ensures are never affected.
|
||||
*/
|
||||
function externalEnsureFields(ref: CoverArtRef, opts?: CoverEnsureOpts) {
|
||||
const surfaceKind = opts?.surfaceKind;
|
||||
const isExternalSurface = surfaceKind === 'fanart' || surfaceKind === 'banner';
|
||||
const theme = useThemeStore.getState();
|
||||
const externalArtworkEnabled =
|
||||
isExternalSurface && ref.cacheKind === 'artist' && theme.externalArtworkEnabled;
|
||||
return {
|
||||
externalArtworkEnabled,
|
||||
surfaceKind,
|
||||
artistName: opts?.artistName,
|
||||
albumTitle: opts?.albumTitle,
|
||||
// BYOK personal fanart.tv key (§22), only when the external branch will run.
|
||||
externalArtworkByok: externalArtworkEnabled ? theme.externalArtworkByok : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier, opts?: CoverEnsureOpts) {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const scope = ref.serverScope;
|
||||
if (scope.kind === 'server') {
|
||||
@@ -69,6 +99,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
||||
),
|
||||
username: scope.username,
|
||||
password: scope.password,
|
||||
...externalEnsureFields(ref, opts),
|
||||
};
|
||||
}
|
||||
const server =
|
||||
@@ -94,6 +125,7 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
||||
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
|
||||
username: server?.username ?? '',
|
||||
password: server?.password ?? '',
|
||||
...externalEnsureFields(ref, opts),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,9 +157,10 @@ export async function coverCacheEnsure(
|
||||
ref: CoverArtRef,
|
||||
tier: CoverArtTier,
|
||||
_priority?: string,
|
||||
opts?: CoverEnsureOpts,
|
||||
): Promise<CoverCacheEnsureResult> {
|
||||
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', {
|
||||
args: ensureArgsFromRef(ref, tier),
|
||||
args: ensureArgsFromRef(ref, tier, opts),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +189,23 @@ export async function coverCacheClearServer(serverIndexKey: string): Promise<voi
|
||||
return invoke('cover_cache_clear_server', { serverIndexKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-out purge: when the External Artwork toggle is turned off, drop every
|
||||
* fetched external image + `.miss-*` marker + lookup row across all configured
|
||||
* servers (Navidrome covers are left intact). Fire-and-forget; per-server
|
||||
* failures are swallowed so one unreachable server can't block the rest.
|
||||
*/
|
||||
export async function purgeExternalArtworkAllServers(): Promise<void> {
|
||||
const { servers } = useAuthStore.getState();
|
||||
await Promise.all(
|
||||
servers.map(s =>
|
||||
invoke('cover_cache_purge_external', {
|
||||
serverIndexKey: serverIndexKeyForProfile(s),
|
||||
}).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function coverCacheStatsServer(
|
||||
serverIndexKey: string,
|
||||
): Promise<Pick<CoverCacheStats, 'bytes' | 'entryCount'>> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAlbumDetailBack } from '../../hooks/useAlbumDetailBack';
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useArtistOfflineState } from '../../hooks/useArtistOfflineState';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { ArtistHeroCover } from '../../cover/artistHero';
|
||||
import { useArtistBanner, useArtistFanart } from '../../cover/useArtistFanart';
|
||||
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||
import type { CoverArtRef } from '../../cover/types';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
@@ -45,6 +46,48 @@ interface Props {
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Artist-detail header background (banner / fanart). Preloads the final image
|
||||
* and only then fades it in over the empty header — so the chosen image never
|
||||
* hard-cuts and no intermediate source flashes first. Reuses the shared
|
||||
* `album-detail-bg` / `-overlay` structure; the fade is a scoped inline opacity
|
||||
* so the class stays untouched for the album/playlist headers that share it.
|
||||
*
|
||||
* Mount with `key={url}` for a fresh element (and `loaded=false`) per source.
|
||||
* Both load paths are covered: `onLoad` for a network fetch, and the `ref`'s
|
||||
* `complete` check for an already-cached image whose `load` event can fire
|
||||
* before React attaches the handler.
|
||||
*/
|
||||
function ArtistHeaderBg({ url }: { url: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<>
|
||||
{/* Hidden preloader — drives `loaded`; the visible background is CSS. */}
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style={{ display: 'none' }}
|
||||
onLoad={() => setLoaded(true)}
|
||||
ref={(el) => {
|
||||
if (el?.complete) setLoaded(true);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{
|
||||
backgroundImage: `url(${url})`,
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity 0.4s ease',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{loaded && <div className="album-detail-overlay" aria-hidden="true" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
|
||||
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
|
||||
@@ -71,22 +114,53 @@ export default function ArtistDetailHero({
|
||||
|
||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: artist.name });
|
||||
|
||||
// Artist-detail header banner (§28, Option B): fanart.tv banner → the 16:9
|
||||
// fanart background cropped to the strip → empty (no regression when off).
|
||||
// Use the LOADED artist's id (not the route `id`), so the id, name and album
|
||||
// handed to the external-artwork hooks always describe the SAME artist. The
|
||||
// route `id` flips immediately on navigation while `artist`/`albums` refetch
|
||||
// a beat later — that mismatch previously wrote the previous artist's image
|
||||
// under the new artist's key (Sepultura's image under Lordi's id).
|
||||
const artistKey = artist.id;
|
||||
// An album from the artist's own list gives the §19 name→MusicBrainz fallback
|
||||
// the context it needs when the artist carries no Navidrome tag MBID.
|
||||
// Pick the first album that actually belongs to THIS artist. `albums` refetches
|
||||
// a beat after `artist` on navigation, so a stale album would run a mismatched
|
||||
// name→MusicBrainz query and could cache a wrong `no_mbid` for the new artist.
|
||||
const albumContext = albums.find((a) => a.artistId === artist.id)?.name;
|
||||
const banner = useArtistBanner(artistKey, {
|
||||
artistName: artist.name,
|
||||
albumTitle: albumContext,
|
||||
});
|
||||
const fanartBg = useArtistFanart(artistKey, {
|
||||
artistName: artist.name,
|
||||
albumTitle: albumContext,
|
||||
});
|
||||
// Banner is preferred: while it is still resolving, show nothing rather than
|
||||
// flashing the fanart background first and then swapping. Only once the banner
|
||||
// has resolved to a miss do we fall back to the 16:9 fanart (which is itself
|
||||
// '' while still loading). Empty when neither has an image. Off → both '',
|
||||
// not pending → empty, no regression.
|
||||
const headerBgUrl = banner.src || (banner.pending ? '' : fanartBg.src);
|
||||
|
||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => goBack()}
|
||||
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{lightbox}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{/* Same structure + classes as the album-detail header (AlbumHeader.tsx),
|
||||
with the fanart banner as the background instead of the album cover.
|
||||
`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} />
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => goBack()}>
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{coverId ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
@@ -274,6 +348,8 @@ export default function ArtistDetailHero({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { queueSongStar, queueSongRating } from '../../store/pendingStarSync';
|
||||
import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
|
||||
import { useAlbumCoverRef, useArtistCoverRef } from '../../cover/useLibraryCoverRef';
|
||||
import { usePlaybackCoverArt } from '../../hooks/usePlaybackCoverArt';
|
||||
import { useCachedUrl } from '../CachedImage';
|
||||
import { useFsArtistPortrait } from '../../hooks/useFsArtistPortrait';
|
||||
import { useArtistFanart } from '../../cover/useArtistFanart';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useFsIdleFade } from '../../hooks/useFsIdleFade';
|
||||
import { useQueueTrackAt } from '../../hooks/useQueueTracks';
|
||||
import WaveformSeek from '../WaveformSeek';
|
||||
@@ -22,6 +23,35 @@ import { FsTimeReadout } from './FsTimeReadout';
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen background image that eases in once its pixels are loaded, so a
|
||||
* new background fades up from the empty backdrop instead of hard-cutting.
|
||||
*
|
||||
* Mount it with `key={url}` so every source gets a fresh element (and a fresh
|
||||
* `loaded=false`). Both load paths are covered: `onLoad` for a network/disk
|
||||
* fetch, and the `ref`'s `complete` check for an already-cached image whose
|
||||
* `load` event can fire before React attaches the handler (e.g. skipping back
|
||||
* to a recently shown artist) — without it the background would stay black.
|
||||
*/
|
||||
function FsBackground({ url }: { url: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
if (!url) return <div className="fsp-bg fsp-bg--empty" aria-hidden="true" />;
|
||||
return (
|
||||
<img
|
||||
className={`fsp-bg${loaded ? ' is-loaded' : ''}`}
|
||||
src={url}
|
||||
onLoad={() => setLoaded(true)}
|
||||
ref={(el) => {
|
||||
if (el?.complete) setLoaded(true);
|
||||
}}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FullscreenPlayerStatic({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
@@ -56,17 +86,32 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
|
||||
// `usePlaybackCoverArt` still re-scopes it to the playback server.
|
||||
const playbackCoverRef =
|
||||
useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? undefined;
|
||||
// One high-res cover (cucadmuh's fullRes 2000px path) feeds both the background
|
||||
// fallback and the foreground thumbnail: crisp instead of the old low-res tier,
|
||||
// and a single fetch/decode shared by both.
|
||||
// One high-res cover (cucadmuh's fullRes 2000px path) feeds the foreground
|
||||
// thumbnail — crisp instead of the old low-res tier. It is no longer a
|
||||
// background source (see below).
|
||||
const cover = usePlaybackCoverArt(playbackCoverRef, 2000, { fullRes: true });
|
||||
// `true` = show the raw URL immediately while the blob resolves (same as FsArt).
|
||||
const coverUrl = useCachedUrl(cover.src, cover.cacheKey, true);
|
||||
const resolvedCoverUrl = coverUrl;
|
||||
const thumbUrl = coverUrl;
|
||||
// Artist photo is the background; fall back to the album cover.
|
||||
const artistBgUrl = useFsArtistPortrait(currentTrack?.artistId);
|
||||
const bgUrl = artistBgUrl || resolvedCoverUrl;
|
||||
// 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);
|
||||
const fanart = useArtistFanart(currentTrack?.artistId, {
|
||||
artistName: currentTrack?.artist,
|
||||
albumTitle: currentTrack?.album,
|
||||
});
|
||||
const artistCoverRef =
|
||||
useArtistCoverRef(currentTrack?.artistId, undefined, undefined, { libraryResolve: false }) ??
|
||||
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 nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||
|
||||
@@ -103,10 +148,8 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
|
||||
data-idle={isIdle}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{/* Static sharp background — no blur, no animation */}
|
||||
{bgUrl
|
||||
? <img className="fsp-bg" src={bgUrl} alt="" aria-hidden="true" draggable={false} />
|
||||
: <div className="fsp-bg fsp-bg--empty" aria-hidden="true" />}
|
||||
{/* Sharp background — no blur; eases in once its pixels are loaded. */}
|
||||
<FsBackground key={bgUrl} url={bgUrl} />
|
||||
<div className="fsp-scrim" aria-hidden="true" />
|
||||
<div className="fsp-vignette" aria-hidden="true" />
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Info, Sparkles, Wifi } from 'lucide-react';
|
||||
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 { MusicNetworkSection } from './musicNetwork/MusicNetworkSection';
|
||||
import { purgeExternalArtworkAllServers } from '../../api/coverCache';
|
||||
|
||||
export function IntegrationsTab() {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -137,6 +140,62 @@ export function IntegrationsTab() {
|
||||
</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')}
|
||||
>
|
||||
<div className="form-group">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
{/* Now-Playing Share (Navidrome) */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.nowPlayingEnabled')}
|
||||
|
||||
@@ -377,6 +377,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, free-form community themes (safety floor + state-reactive styling), and per-theme download counts with popularity/newest/name sorting; 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015, #1016, #1018, #1020, #1036)',
|
||||
'AutoDJ as a standalone playback mode — own toolbar button and audio-settings entry, mutually exclusive with Crossfade and Gapless via a shared transition helper, combined playlist save/load submenu, and boxed playback settings sections (PR #1124)',
|
||||
'Settings refactor — reusable boxed panels (SettingsGroup/SettingsToggle) rolled across every tab, Normalization and Track transitions promoted to their own Audio categories, a unified Queue Settings group on Personalisation, and the Audio Output Device category gated out on macOS (PR #1126, #1130)',
|
||||
'Artist artwork from fanart.tv — opt-in fullscreen background + artist-header banner, off by default (PR #1137)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { artistCoverRef } from './ref';
|
||||
import { coverDiskUrl } from './diskSrcCache';
|
||||
import { coverCacheEnsure } from '../api/coverCache';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
|
||||
/**
|
||||
* Resolve an external fanart.tv artist image to a webview-loadable asset URL for
|
||||
* the given surface (`fanart` = 16:9 background, `banner` = wide header strip).
|
||||
*
|
||||
* Returns `{ src, pending }`: `src` is `''` while resolving, when the toggle is
|
||||
* off, or when no image of that kind exists; `pending` is `true` only while the
|
||||
* ensure is in flight. `pending` lets callers tell "still fetching" (hold back
|
||||
* a fallback) apart from "resolved, no image" (fall back now).
|
||||
*
|
||||
* Deliberately bypasses the shared cover peek / disk-src cache: each surface has
|
||||
* its own `{tier}-{surface}.webp`, and `cover_cache_ensure` already peeks that
|
||||
* surface first and returns the cached path on a hit. All MBID resolution +
|
||||
* caching lives Rust-side; this hook just kicks the ensure and shows the path it
|
||||
* hands back. The cache is shared across callers, so e.g. the artist-detail
|
||||
* header and the fullscreen player warm each other's images.
|
||||
*/
|
||||
type ArtistImageCtx = { artistName?: string; albumTitle?: string };
|
||||
|
||||
export type ArtistImage = { src: string; pending: boolean };
|
||||
|
||||
function useArtistExternalImage(
|
||||
artistId: string | null | undefined,
|
||||
surface: 'fanart' | 'banner',
|
||||
ctx?: ArtistImageCtx,
|
||||
): ArtistImage {
|
||||
const enabled = useThemeStore((s) => s.externalArtworkEnabled);
|
||||
const [image, setImage] = useState<ArtistImage>({ src: '', pending: false });
|
||||
const artistName = ctx?.artistName;
|
||||
const albumTitle = ctx?.albumTitle;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !artistId) {
|
||||
// Nothing will resolve — not pending, so callers fall back immediately.
|
||||
setImage({ src: '', pending: false });
|
||||
return;
|
||||
}
|
||||
// Reset + mark pending so a previous artist's image never lingers and
|
||||
// callers hold their fallback until this resolves.
|
||||
setImage({ src: '', pending: true });
|
||||
let cancelled = false;
|
||||
const ref = artistCoverRef(artistId);
|
||||
void coverCacheEnsure(ref, 2000, 'high', { surfaceKind: surface, artistName, albumTitle })
|
||||
.then((res) => {
|
||||
if (!cancelled)
|
||||
setImage({ src: res.hit && res.path ? coverDiskUrl(res.path) : '', pending: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setImage({ src: '', pending: false });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, artistId, surface, artistName, albumTitle]);
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
/** fanart.tv 16:9 `artistbackground` (fullscreen player background). */
|
||||
export function useArtistFanart(
|
||||
artistId: string | null | undefined,
|
||||
ctx?: ArtistImageCtx,
|
||||
): ArtistImage {
|
||||
return useArtistExternalImage(artistId, 'fanart', ctx);
|
||||
}
|
||||
|
||||
/** fanart.tv wide `musicbanner` (artist-detail header strip). */
|
||||
export function useArtistBanner(
|
||||
artistId: string | null | undefined,
|
||||
ctx?: ArtistImageCtx,
|
||||
): ArtistImage {
|
||||
return useArtistExternalImage(artistId, 'banner', ctx);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getArtistInfo } from '../api/subsonicArtists';
|
||||
|
||||
/** Fetches the large artist image for the given artist id, returning '' until
|
||||
* the request resolves (or when there is no artist id). Falls through silently
|
||||
* on network failures — the caller should layer a cover-art fallback on top.
|
||||
*
|
||||
* Navidrome / Subsonic backends often return a `largeImageUrl` that 404s when
|
||||
* the artist has no scraped image (Last.fm placeholder), so the URL is
|
||||
* preflighted via an Image() probe and only exposed when it actually loads.
|
||||
* Otherwise the caller would render a broken-img glyph (`?`) on top of the
|
||||
* fullscreen background — see zunoz report on the Psysonic Discord. */
|
||||
export function useFsArtistPortrait(artistId: string | undefined): string {
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
useEffect(() => {
|
||||
setArtistBgUrl('');
|
||||
if (!artistId) return;
|
||||
let cancelled = false;
|
||||
let probe: HTMLImageElement | null = null;
|
||||
getArtistInfo(artistId).then(info => {
|
||||
if (cancelled || !info.largeImageUrl) return;
|
||||
const url = info.largeImageUrl;
|
||||
probe = new Image();
|
||||
probe.onload = () => { if (!cancelled) setArtistBgUrl(url); };
|
||||
probe.onerror = () => { /* leave empty so caller falls back to cover */ };
|
||||
probe.src = url;
|
||||
}).catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (probe) probe.onload = probe.onerror = null;
|
||||
};
|
||||
}, [artistId]);
|
||||
return artistBgUrl;
|
||||
}
|
||||
@@ -269,6 +269,12 @@ export const settings = {
|
||||
nowPlayingEnabled: 'Im Livefenster anzeigen',
|
||||
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 von fanart.tv für den Vollbild-Player und Künstlerseiten. Standardmäßig aus.',
|
||||
externalArtworkNote: 'Kontaktiert fanart.tv und sendet dabei die MusicBrainz-ID des Künstlers.',
|
||||
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.',
|
||||
externalArtworkByokSaved: 'Gespeichert — wird bei aktivem Scraper mit dem App-Schlüssel gesendet.',
|
||||
enableBandsintown: 'Bandsintown-Tourdaten',
|
||||
enableBandsintownDesc: 'Zeigt anstehende Konzerte des aktuellen Künstlers im Info-Tab. Daten werden über die öffentliche Bandsintown-API abgerufen.',
|
||||
lyricsServerFirst: 'Server-Lyrics bevorzugen',
|
||||
|
||||
@@ -272,6 +272,12 @@ export const settings = {
|
||||
nowPlayingEnabled: 'Show in Now Playing',
|
||||
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 from fanart.tv for the fullscreen player and artist pages. Off by default.',
|
||||
externalArtworkNote: "Contacts fanart.tv, sending the artist's MusicBrainz id.",
|
||||
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.',
|
||||
externalArtworkByokSaved: 'Saved — sent with the app key while the scraper is on.',
|
||||
enableBandsintown: 'Bandsintown tour dates',
|
||||
enableBandsintownDesc: 'Show upcoming concerts for the current artist in the Info tab. Data is fetched from the public Bandsintown API.',
|
||||
lyricsServerFirst: 'Prefer server lyrics',
|
||||
|
||||
@@ -268,6 +268,12 @@ export const settings = {
|
||||
nowPlayingEnabled: 'Mostrar en Reproduciendo Ahora',
|
||||
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 de artista en alta resolución desde fanart.tv para el reproductor a pantalla completa y las páginas de artista. Desactivado por defecto.',
|
||||
externalArtworkNote: 'Contacta con fanart.tv enviando el ID de MusicBrainz del artista.',
|
||||
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.',
|
||||
externalArtworkByokSaved: 'Guardado — se envía con la clave de la app mientras el scraper está activo.',
|
||||
enableBandsintown: 'Fechas de gira de Bandsintown',
|
||||
enableBandsintownDesc: 'Muestra próximos conciertos del artista actual en la pestaña Info. Los datos se obtienen de la API pública de Bandsintown.',
|
||||
lyricsServerFirst: 'Preferir letras del servidor',
|
||||
|
||||
@@ -256,6 +256,12 @@ export const settings = {
|
||||
nowPlayingEnabled: 'Afficher dans la fenêtre live',
|
||||
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 d'artiste en haute résolution depuis fanart.tv pour le lecteur plein écran et les pages d'artiste. Désactivé par défaut.",
|
||||
externalArtworkNote: "Contacte fanart.tv en envoyant l'identifiant MusicBrainz de l'artiste.",
|
||||
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.",
|
||||
externalArtworkByokSaved: "Enregistré — envoyé avec la clé de l'application lorsque le scraper est actif.",
|
||||
enableBandsintown: 'Dates de tournée Bandsintown',
|
||||
enableBandsintownDesc: 'Affiche les concerts à venir de l\'artiste actuel dans l\'onglet Info. Les données proviennent de l\'API publique Bandsintown.',
|
||||
lyricsServerFirst: 'Préférer les paroles du serveur',
|
||||
|
||||
@@ -255,6 +255,12 @@ export const settings = {
|
||||
nowPlayingEnabled: 'Vis i "Nå spiller"',
|
||||
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: 'Hent høyoppløselige artistbakgrunner fra fanart.tv for fullskjermspilleren og artistsider. Av som standard.',
|
||||
externalArtworkNote: 'Kontakter fanart.tv og sender artistens MusicBrainz-ID.',
|
||||
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.',
|
||||
externalArtworkByokSaved: 'Lagret — sendes med app-nøkkelen mens skraperen er på.',
|
||||
enableBandsintown: 'Bandsintown-turnédatoer',
|
||||
enableBandsintownDesc: 'Vis kommende konserter for gjeldende artist i Info-fanen. Data hentes fra det offentlige Bandsintown-API-et.',
|
||||
lyricsServerFirst: 'Foretrekk server-sangtekst',
|
||||
|
||||
@@ -256,6 +256,12 @@ export const settings = {
|
||||
nowPlayingEnabled: 'Weergeven in live-venster',
|
||||
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: "Haal hogeresolutie artiestachtergronden van fanart.tv op voor de schermvullende speler en artiestpagina's. Standaard uit.",
|
||||
externalArtworkNote: 'Neemt contact op met fanart.tv en verstuurt de MusicBrainz-id van de artiest.',
|
||||
externalArtworkByokTitle: 'Persoonlijke API-sleutel (BYOK)',
|
||||
externalArtworkByokDesc: 'Optioneel. Voer hier je eigen persoonlijke fanart.tv-API-sleutel in — deze wordt naast de app-sleutel verstuurd.',
|
||||
externalArtworkByokSaved: 'Opgeslagen — wordt met de app-sleutel verstuurd terwijl de scraper aan staat.',
|
||||
enableBandsintown: 'Bandsintown-tourdata',
|
||||
enableBandsintownDesc: 'Toon aankomende concerten van de huidige artiest in het Info-tabblad. Gegevens komen van de openbare Bandsintown-API.',
|
||||
lyricsServerFirst: 'Server-songtekst voorrang geven',
|
||||
|
||||
@@ -271,6 +271,12 @@ export const settings = {
|
||||
nowPlayingEnabled: 'Se afișează în Now Playing',
|
||||
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 de artist la rezoluție înaltă de pe fanart.tv pentru playerul pe tot ecranul și paginile de artist. Dezactivat implicit.',
|
||||
externalArtworkNote: 'Contactează fanart.tv, trimițând ID-ul MusicBrainz al artistului.',
|
||||
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.',
|
||||
externalArtworkByokSaved: 'Salvat — trimis cu cheia aplicației cât timp scraperul este activ.',
|
||||
enableBandsintown: 'Date de turneu Bandsintown',
|
||||
enableBandsintownDesc: 'Arată concertele următoare pentru artisul curent în tab-ul Informații. Datele sunt preluate din API-ul public Bandsintown.',
|
||||
lyricsServerFirst: 'Preferă versurile de pe server',
|
||||
|
||||
@@ -276,6 +276,12 @@ export const settings = {
|
||||
nowPlayingEnabledDesc:
|
||||
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
|
||||
nowPlayingPluginNote: 'Необходимо при использовании официального плагина Discord Rich Presence для Navidrome.',
|
||||
externalArtwork: 'Внешний сборщик обложек',
|
||||
externalArtworkDesc: 'Загружает фоновые изображения исполнителей в высоком разрешении с fanart.tv для полноэкранного плеера и страниц исполнителей. По умолчанию выключено.',
|
||||
externalArtworkNote: 'Обращается к fanart.tv, передавая MusicBrainz-идентификатор исполнителя.',
|
||||
externalArtworkByokTitle: 'Личный API-ключ (BYOK)',
|
||||
externalArtworkByokDesc: 'Необязательно. Введите здесь свой личный API-ключ fanart.tv — он отправляется вместе с ключом приложения.',
|
||||
externalArtworkByokSaved: 'Сохранено — отправляется с ключом приложения, пока скрапер включён.',
|
||||
enableBandsintown: 'Даты туров Bandsintown',
|
||||
enableBandsintownDesc: 'Показывает предстоящие концерты текущего исполнителя на вкладке «Инфо». Данные берутся из публичного API Bandsintown.',
|
||||
lyricsServerFirst: 'Предпочитать тексты с сервера',
|
||||
|
||||
@@ -255,6 +255,12 @@ export const settings = {
|
||||
nowPlayingEnabled: '在实时窗口中显示',
|
||||
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
|
||||
nowPlayingPluginNote: '使用官方 Navidrome Discord Rich Presence 插件时必需。',
|
||||
externalArtwork: '外部封面采集器',
|
||||
externalArtworkDesc: '从 fanart.tv 获取高分辨率的艺术家背景图,用于全屏播放器和艺术家页面。默认关闭。',
|
||||
externalArtworkNote: '会联系 fanart.tv,并发送艺术家的 MusicBrainz ID。',
|
||||
externalArtworkByokTitle: '个人 API 密钥(BYOK)',
|
||||
externalArtworkByokDesc: '可选。在此输入你自己的 fanart.tv 个人 API 密钥——它会与应用密钥一同发送。',
|
||||
externalArtworkByokSaved: '已保存——抓取器开启时随应用密钥一同发送。',
|
||||
enableBandsintown: 'Bandsintown 巡演日期',
|
||||
enableBandsintownDesc: '在信息标签页中显示当前艺术家的即将到来的演出。数据通过公开的 Bandsintown API 获取。',
|
||||
lyricsServerFirst: '优先使用服务器歌词',
|
||||
|
||||
@@ -43,6 +43,12 @@ interface ThemeState {
|
||||
setExpandReplayGain: (v: boolean) => void;
|
||||
floatingPlayerBar: boolean;
|
||||
setFloatingPlayerBar: (v: boolean) => void;
|
||||
/** Master toggle for external artist artwork (fanart.tv). Off by default (§20). */
|
||||
externalArtworkEnabled: boolean;
|
||||
setExternalArtworkEnabled: (v: boolean) => void;
|
||||
/** Optional personal fanart.tv API key (BYOK) — sent in addition to the app key (§22). */
|
||||
externalArtworkByok: string;
|
||||
setExternalArtworkByok: (v: string) => void;
|
||||
}
|
||||
|
||||
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
|
||||
@@ -86,6 +92,10 @@ export const useThemeStore = create<ThemeState>()(
|
||||
setExpandReplayGain: (v) => set({ expandReplayGain: v }),
|
||||
floatingPlayerBar: false,
|
||||
setFloatingPlayerBar: (v) => set({ floatingPlayerBar: v }),
|
||||
externalArtworkEnabled: false,
|
||||
setExternalArtworkEnabled: (v) => set({ externalArtworkEnabled: v }),
|
||||
externalArtworkByok: '',
|
||||
setExternalArtworkByok: (v) => set({ externalArtworkByok: v }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_theme',
|
||||
|
||||
@@ -27,9 +27,19 @@
|
||||
object-fit: cover;
|
||||
/* Bias toward the upper third so faces survive a 16:9 crop of square art. */
|
||||
object-position: center 30%;
|
||||
/* Eases in once the image has loaded (toggled via `.is-loaded`), so a new
|
||||
background fades up from the empty backdrop instead of hard-cutting. */
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.fsp-bg.is-loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fsp-bg--empty {
|
||||
/* The empty/loading backdrop is shown as-is (no image to fade in). */
|
||||
opacity: 1;
|
||||
background: radial-gradient(circle at 50% 35%, #1a1a2e, #06060e 70%);
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,15 @@
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* The album page wraps its header in a flush `.album-detail`; the artist page
|
||||
wraps in a padded `.content-body` (padding: var(--space-6)). This cancels that
|
||||
padding so the shared `.album-detail-header` reaches the container edges
|
||||
(top + sides) exactly like the album header — full-bleed, no spacing. */
|
||||
.album-detail-header.artist-detail-bleed {
|
||||
margin-top: calc(-1 * var(--space-6));
|
||||
margin-inline: calc(-1 * var(--space-6));
|
||||
}
|
||||
|
||||
.artist-detail-avatar {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
|
||||
Reference in New Issue
Block a user