mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
b950d4704b
* 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)
523 lines
12 KiB
CSS
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;
|
|
}
|