Files
Psychotoxical-psysonic/src/styles/components/fullscreen-player-static.css
T
Psychotoxical b950d4704b 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)
2026-06-20 21:04:21 +02:00

523 lines
12 KiB
CSS

/* ── Static fullscreen player (lean, media-center layout) ──────────────────
No backdrop-filter, no blur, no infinite animations. Background is a sharp
image; the darkening gradient + vignette are plain static overlays. Only the
seekbar and clock update at runtime, and they own their state. */
.fsp {
position: fixed;
inset: 0;
z-index: 1000;
background: #06060e;
color: #fff;
overflow: hidden;
user-select: none;
--fsp-cover-size: clamp(150px, 24vh, 250px);
}
.fsp[data-idle='true'] {
cursor: none;
}
/* ── Background ── */
.fsp-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
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%);
}
/* Static text-contrast scrim + edge vignette — baked look, no runtime effect. */
.fsp-scrim {
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(to top, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0) 38%),
linear-gradient(to right, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0) 55%),
linear-gradient(to bottom, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0) 22%);
}
.fsp-vignette {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(ellipse 75% 75% at 50% 45%, transparent 55%, rgba(0, 0, 0, 0.55) 100%);
}
/* ── Top bar ── */
.fsp-top {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 3;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
padding-right: calc(var(--space-6) + 52px); /* leave room for the close button */
}
.fsp-nowplaying {
display: flex;
flex-direction: column;
gap: 2px;
}
.fsp-nowplaying-label {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.92);
}
.fsp-nowplaying-pos {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
.fsp-clock {
font-size: 15px;
color: rgba(255, 255, 255, 0.92);
font-variant-numeric: tabular-nums;
}
/* ── Close (fades when idle) ── */
.fsp-close {
position: absolute;
top: var(--space-4);
right: var(--space-4);
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-base), background var(--transition-fast), color var(--transition-fast);
}
.fsp[data-idle='false'] .fsp-close,
.fsp:hover .fsp-close {
opacity: 1;
}
.fsp-close:hover {
background: rgba(0, 0, 0, 0.6);
color: #fff;
}
/* ── Bottom cluster ── */
.fsp-foot {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: 0 var(--space-6) var(--space-5);
/* Soft backing for the control row only — the text has its own hard bar. */
background: linear-gradient(to top, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.18) 26%, rgba(0, 0, 0, 0) 52%);
}
/* The black bar: hugs the text + cover with equal padding above and below.
The cover is bottom-aligned to the text and pokes above the bar's top. */
.fsp-info-row {
position: relative;
min-width: 0;
/* Span the full width (cancel the foot's side padding); text stays inset. */
margin: 0 calc(-1 * var(--space-6));
padding: var(--space-4) var(--space-6);
background: rgba(0, 0, 0, 0.5);
}
/* Big cover, bottom-flush with the text block; its top pokes above the bar. */
.fsp-cover {
position: absolute;
left: var(--space-6);
bottom: var(--space-4);
z-index: 1;
width: var(--fsp-cover-size);
height: var(--fsp-cover-size);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.75);
}
.fsp-cover-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.fsp-cover-img--empty {
background: var(--bg-hover);
}
.fsp-info-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 5px;
/* Clear the overlapping cover on the left. */
padding-left: calc(var(--fsp-cover-size) + var(--space-5));
}
.fsp-title {
margin: 0;
font-size: 46px;
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.7);
}
.fsp-artist {
margin: 0;
font-size: 25px;
font-weight: 500;
color: rgba(255, 255, 255, 0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}
.fsp-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
color: rgba(255, 255, 255, 0.78);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.fsp-meta-dot {
opacity: 0.55;
}
.fsp-stars {
display: inline-flex;
gap: 2px;
color: var(--accent);
}
.fsp-star-btn {
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
background: none;
color: inherit;
cursor: pointer;
line-height: 0;
transition: transform 80ms ease;
}
.fsp-star-btn:hover {
transform: scale(1.15);
}
.fsp-next {
margin: 3px 0 0;
font-size: 16px;
color: rgba(255, 255, 255, 0.65);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
/* ── Controls row: transport · time · actions ── */
.fsp-controls {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--space-4);
/* Own semi-transparent bar, a bit darker than the info bar above for contrast.
Full-width: cancel the foot's side padding like .fsp-info-row. Negative top
margin cancels the foot's flex gap so this bar touches the info bar above
(the gap to the waveform below stays). */
margin: calc(-1 * var(--space-4)) calc(-1 * var(--space-6)) 0;
padding: var(--space-3) var(--space-6);
background: rgba(0, 0, 0, 0.88);
}
.fsp-transport {
display: flex;
align-items: center;
gap: var(--space-2);
justify-self: start;
}
.fsp-actions {
display: flex;
align-items: center;
gap: var(--space-2);
justify-self: end;
}
.fsp-time {
justify-self: center;
font-size: 18px;
color: rgba(255, 255, 255, 0.9);
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
white-space: nowrap;
}
.fsp-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.fsp-btn-sm {
width: 38px;
height: 38px;
color: rgba(255, 255, 255, 0.6);
}
.fsp-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.fsp-btn.active {
color: var(--accent);
}
/* Bottom-right action cluster: larger tap targets than the transport's small buttons. */
.fsp-actions .fsp-btn-sm {
width: 46px;
height: 46px;
}
/* Reused FsPlayBtn (renders .fs-btn.fs-btn-play) — scope its look to this player. */
.fsp .playback-transport-play-wrap {
position: relative;
display: inline-flex;
}
/* Match the plain transport buttons — no white circle. */
.fsp .fs-btn-play {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin: 0;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
}
.fsp .fs-btn-play:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
/* ── True waveform seekbar (WaveformSeek) — full-width media-center bar ──
Replaces the old thin FsSeekbar. Time stays centered in the control row
(FsTimeReadout). The taller canvas makes the foot grow upward, shifting the
info/control rows up. */
.fsp .waveform-seek-container {
width: 100%;
}
/* WaveformSeek hardcodes a 24px canvas inline for the player bar; override it
here for the fullscreen view. Its ResizeObserver redraws at the new size. */
.fsp .waveform-seek-container canvas {
height: clamp(32px, 5vh, 52px) !important;
}
/* ── "Up next" overlay (FsQueueModal) — semi-transparent popover anchored
bottom-right at the queue button, no blur. Transparent backdrop just catches
the outside click to close. ── */
.fsq-backdrop {
position: absolute;
inset: 0;
z-index: 5;
background: transparent;
}
.fsq-panel {
position: absolute;
right: var(--space-6);
bottom: 163px; /* flush with the top of the black info frame */
width: min(420px, 40%);
max-height: 62vh;
display: flex;
flex-direction: column;
background: rgba(18, 18, 22, 0.82);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius-lg);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.fsq-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.fsq-title {
font-size: 15px;
font-weight: 600;
color: #fff;
}
.fsq-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
.fsq-close:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.fsq-list {
overflow-y: auto;
padding: var(--space-2) 0;
}
.fsq-empty {
padding: var(--space-5);
text-align: center;
color: rgba(255, 255, 255, 0.55);
font-size: 13px;
}
.fsq-item {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-5);
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
text-align: left;
}
.fsq-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.fsq-item-pos {
width: 24px;
flex-shrink: 0;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
.fsq-item-info {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.fsq-item-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.fsq-item-artist {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
}
.fsq-item-dur {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
/* ── Scrolling lyrics overlay (reuses FsLyricsApple) — semi-transparent, no blur.
FsLyricsApple's .fsa-lyrics-container is position:absolute inset:0, so it fills
this box. ── */
.fsp-lyrics-overlay {
position: absolute;
top: var(--space-6);
left: 14%;
right: 14%;
bottom: 360px; /* ends above the song-info / cover block; tune */
z-index: 4;
background: rgba(0, 0, 0, 0.88);
border-radius: var(--radius-lg);
overflow: hidden;
}
/* Centered lyrics text in the fullscreen overlay (FsLyricsApple defaults to
left-aligned for the old layout). */
.fsp-lyrics-overlay .fsa-lyrics-container {
text-align: center;
}
.fsp-lyrics-overlay .fsa-lyric-line {
text-align: center;
transform-origin: center center;
}