feat(player): fullscreen "Listening Room" mode

Toggleable theater-scale player view that takes over the viewport
and amplifies the existing Studio Reference aesthetic — same fonts,
same copper/ink palette, just dialed up for immersive listening.

Layout & typography:
- Two-column centerfold: massive vinyl stage left (clamp(., 72vh, 720px)),
  editorial column right with Fraunces italic title at clamp(48px, 6.4vw,
  112px), Geist Mono console-style metadata strip, oversized timecodes,
  full-width amplitude spectrum.
- Mobile / portrait flips to vertical theater (vinyl top, masthead+
  transport below) at <=900px or any portrait orientation.

Ambient bloom:
- Duplicate of #album-art rendered behind everything at blur(110px)
  saturate(1.6) opacity(0.42) — paints the room in the record's color.
  Slow 28s drift animation. Light-theme variant at lower opacity.
- MutationObserver keeps bloom art in sync as tracks change.
- Vignette + edge darkening + subtle paper-grain veil frame the stage.

Interaction:
- Header button (corner-arrows-out icon) toggles; pressing 'F' anywhere
  outside text inputs also toggles; ESC exits.
- Native Fullscreen API requested as best-effort sugar on top of the
  CSS overlay (works on TV / tablet); CSS overlay alone covers the
  CSS-only fallback case (iOS Safari, embedded webviews).
- fullscreenchange listener mirrors OS-level exit back into the overlay.
- Auto-hide chrome + cursor after 2.5s idle, restored on mousemove.
- Focus moves to play/pause on enter; restored to invoking element on
  exit.
- Hides mini-player, tab bar, header, folio marks, and other tabs while
  active.

Motion:
- 320ms fade-in for the stage, 600ms vinyl rise, 1.4s bloom-in,
  staggered 80ms ladder for kicker -> title -> byline -> album -> meta
  -> spectrum -> transport. prefers-reduced-motion disables all.

i18n:
- player.fullscreen / player.fullscreen.exit / player.fullscreen.exit_short
  added to en.json and ru.json.

Files: index.html (header button + fs-chrome strip + fs-bloom layer),
styles.css (~360-line fullscreen block at end, scoped to body.is-
fullscreen-player), player.js (toggle + init + idle/key/observer
plumbing, ~170 lines), app.js (import + window export + init call).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 14:47:53 +03:00
parent 2a474ea52c
commit 59840a1190
6 changed files with 668 additions and 0 deletions
+458
View File
@@ -8667,3 +8667,461 @@ select option {
.mini-album-art { width: 34px !important; height: 34px !important; }
.mini-control-btn { width: 36px !important; height: 36px !important; }
}
/* ════════════════════════════════════════════════════════════════
FULLSCREEN PLAYER — "Listening Room"
Activated via body.is-fullscreen-player. Uses position:fixed to
take over the viewport (works without native Fullscreen API) and
re-projects the existing player markup at theater scale: oversized
Fraunces italic title, ambient album-art bloom painting the room,
auto-hiding chrome, dramatic vinyl-stage focus.
════════════════════════════════════════════════════════════════ */
/* Fullscreen-only chrome (top strip with edition mark + exit button)
and ambient bloom layer are inert outside fullscreen mode. */
.fs-chrome,
.fs-bloom {
display: none;
pointer-events: none;
}
/* While in fullscreen: take over the viewport, hide everything outside
the player container, and project the player onto a dark stage. */
body.is-fullscreen-player {
overflow: hidden;
}
/* Hide the world */
body.is-fullscreen-player > .container > header,
body.is-fullscreen-player > .container > .tab-bar,
body.is-fullscreen-player > .container > .update-banner,
body.is-fullscreen-player > .container > .connection-banner,
body.is-fullscreen-player > .container > [data-tab-content]:not(#panel-player),
body.is-fullscreen-player > .folio,
body.is-fullscreen-player > #mini-player {
display: none !important;
}
/* Promote the player container to fixed-overlay rank */
body.is-fullscreen-player .player-container {
position: fixed;
inset: 0;
z-index: 9000;
margin: 0;
padding: 0;
background:
radial-gradient(ellipse at 30% 35%, rgba(224, 128, 56, 0.05) 0%, transparent 55%),
radial-gradient(ellipse at center, var(--bg-paper) 0%, var(--bg-deep) 75%);
display: grid;
place-items: stretch;
overflow: hidden;
box-shadow: none !important;
border: 0 !important;
/* Soft entry fade — content layers stagger in via .now-playing animations below */
animation: fs-fade-in 320ms var(--ease-out) both;
}
@keyframes fs-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ─── Ambient bloom: paint the room in the album's color ─────── */
body.is-fullscreen-player .fs-bloom {
display: block;
position: absolute;
inset: -8%;
z-index: 0;
pointer-events: none;
overflow: hidden;
opacity: 0;
animation: fs-bloom-in 1400ms var(--ease-out) 120ms forwards;
}
@keyframes fs-bloom-in {
from { opacity: 0; transform: scale(1.08); }
to { opacity: 0.42; transform: scale(1); }
}
:root[data-theme="light"] body.is-fullscreen-player .fs-bloom {
animation-name: fs-bloom-in-light;
}
@keyframes fs-bloom-in-light {
from { opacity: 0; transform: scale(1.08); }
to { opacity: 0.22; transform: scale(1); }
}
body.is-fullscreen-player .fs-bloom #fs-bloom-art {
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(110px) saturate(1.6);
transform: scale(1.18);
animation: fs-bloom-drift 28s ease-in-out infinite alternate;
}
@keyframes fs-bloom-drift {
from { transform: scale(1.18) translate3d(-1.5%, -1%, 0); }
to { transform: scale(1.22) translate3d(2%, 1.5%, 0); }
}
/* Subtle paper-grain veil over the bloom — keeps it from looking flat. */
body.is-fullscreen-player .player-container::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background-image:
radial-gradient(circle at 20% 80%, rgba(0, 0, 0, 0.35) 0%, transparent 40%),
radial-gradient(circle at 80% 20%, rgba(0, 0, 0, 0.30) 0%, transparent 45%);
mix-blend-mode: multiply;
}
/* Vignette + top/bottom edge darkening — frames the listening room */
body.is-fullscreen-player .player-container::after {
content: "";
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background:
linear-gradient(to bottom,
rgba(0, 0, 0, 0.45) 0%,
transparent 12%,
transparent 88%,
rgba(0, 0, 0, 0.55) 100%),
radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.55) 100%);
}
:root[data-theme="light"] body.is-fullscreen-player .player-container::after {
background:
linear-gradient(to bottom,
rgba(0, 0, 0, 0.10) 0%,
transparent 14%,
transparent 86%,
rgba(0, 0, 0, 0.15) 100%),
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.12) 100%);
}
/* ─── Floating chrome (edition mark + exit) — auto-hides on idle ─ */
body.is-fullscreen-player .fs-chrome {
display: flex;
position: absolute;
top: 0; left: 0; right: 0;
z-index: 50;
align-items: center;
justify-content: space-between;
padding: 22px 36px;
pointer-events: auto;
opacity: 1;
transition: opacity 320ms var(--ease), transform 320ms var(--ease);
transform: translateY(0);
}
body.is-fullscreen-player.fs-chrome-hidden .fs-chrome {
opacity: 0;
transform: translateY(-12px);
pointer-events: none;
}
body.is-fullscreen-player.fs-chrome-hidden { cursor: none; }
.fs-chrome-mark {
display: flex;
align-items: baseline;
gap: 10px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--ink-mute);
}
.fs-chrome-edition {
color: var(--copper);
font-weight: 500;
}
.fs-chrome-sep { color: var(--ink-faint); }
.fs-chrome-exit {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 14px 8px 12px;
background: rgba(14, 13, 11, 0.55);
border: 1px solid var(--rule-strong);
color: var(--ink-soft);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
cursor: pointer;
border-radius: 0;
backdrop-filter: blur(8px) saturate(1.2);
transition: color 160ms var(--ease), border-color 160ms var(--ease), background 160ms var(--ease);
}
.fs-chrome-exit:hover {
color: var(--ink);
border-color: var(--copper);
background: rgba(14, 13, 11, 0.75);
}
.fs-chrome-exit svg { color: var(--copper); }
.fs-chrome-kbd {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.16em;
padding: 2px 6px;
border: 1px solid var(--rule-strong);
color: var(--ink-mute);
background: transparent;
border-radius: 0;
}
:root[data-theme="light"] .fs-chrome-exit {
background: rgba(255, 255, 255, 0.7);
}
/* ─── Stage: massive vinyl + masthead at theater scale ─────────── */
body.is-fullscreen-player .now-playing,
body.is-fullscreen-player .player-layout {
position: relative;
z-index: 5;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: clamp(40px, 6vw, 96px);
margin: 0 !important;
padding: clamp(80px, 10vh, 140px) clamp(40px, 6vw, 96px) clamp(40px, 6vh, 80px);
align-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
align-content: center;
}
/* Vinyl stage — pin to comfortable theater size */
body.is-fullscreen-player .album-art-container.vinyl-stage {
width: min(72vh, 100%);
max-width: 720px;
margin-inline: auto;
aspect-ratio: 1;
background:
radial-gradient(ellipse at center,
rgba(26, 22, 17, 0.85) 0%,
transparent 70%);
filter: drop-shadow(0 30px 80px rgba(0, 0, 0, 0.55));
animation: fs-stage-rise 600ms var(--ease-out) both;
}
@keyframes fs-stage-rise {
from { opacity: 0; transform: scale(0.94) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
:root[data-theme="light"] body.is-fullscreen-player .album-art-container.vinyl-stage {
background:
radial-gradient(ellipse at center,
rgba(255, 255, 255, 0.65) 0%,
transparent 75%);
filter: drop-shadow(0 30px 60px rgba(0, 0, 0, 0.18));
}
/* ─── Masthead: editorial centerfold ───────────────────────────── */
body.is-fullscreen-player .track-masthead {
position: relative;
max-width: 720px;
padding-right: clamp(0px, 2vw, 24px);
/* Stagger ladder: each child slides up with a small delay */
--fs-stagger: 80ms;
}
body.is-fullscreen-player .track-masthead > * {
animation: fs-rise 700ms var(--ease-out) both;
}
body.is-fullscreen-player .track-masthead > .kicker { animation-delay: calc(var(--fs-stagger) * 1); }
body.is-fullscreen-player .track-masthead > .track-title { animation-delay: calc(var(--fs-stagger) * 2); }
body.is-fullscreen-player .track-masthead > .track-byline{ animation-delay: calc(var(--fs-stagger) * 3); }
body.is-fullscreen-player .track-masthead > .track-album { animation-delay: calc(var(--fs-stagger) * 4); }
body.is-fullscreen-player .track-masthead > .meta-grid { animation-delay: calc(var(--fs-stagger) * 5); }
body.is-fullscreen-player .track-masthead > .spectrum { animation-delay: calc(var(--fs-stagger) * 6); }
body.is-fullscreen-player .track-masthead > .transport { animation-delay: calc(var(--fs-stagger) * 7); }
@keyframes fs-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
/* Kicker — editorial small-caps with hairline rules */
body.is-fullscreen-player .now-playing .kicker {
margin-bottom: clamp(18px, 2.4vh, 32px);
font-size: 11px;
letter-spacing: 0.36em;
}
/* The headline — Fraunces italic, oversized, centerfold scale */
body.is-fullscreen-player .now-playing .track-title {
font-family: var(--serif);
font-style: italic;
font-weight: 400;
font-size: clamp(48px, 6.4vw, 112px);
line-height: 0.95;
letter-spacing: -0.015em;
margin: 0 0 clamp(14px, 1.8vh, 22px);
color: var(--ink);
text-wrap: balance;
}
body.is-fullscreen-player .now-playing .track-title em {
font-style: italic;
}
/* Artist + album — refined editorial tier */
body.is-fullscreen-player .now-playing .track-byline {
font-family: var(--sans);
font-weight: 500;
font-size: clamp(16px, 1.4vw, 22px);
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-soft);
margin-bottom: 8px;
}
body.is-fullscreen-player .now-playing .track-album {
font-family: var(--serif);
font-style: italic;
font-weight: 300;
font-size: clamp(15px, 1.2vw, 20px);
color: var(--ink-mute);
margin-bottom: clamp(20px, 2.4vh, 36px);
}
/* Meta grid — promote to a console-readout strip */
body.is-fullscreen-player .now-playing .meta-grid {
grid-template-columns: 1fr 1fr;
gap: clamp(20px, 2vw, 36px);
padding-block: clamp(14px, 1.8vh, 22px);
border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
margin-bottom: clamp(20px, 2.4vh, 32px);
}
body.is-fullscreen-player .now-playing .meta-cell .label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.32em;
color: var(--ink-faint);
}
body.is-fullscreen-player .now-playing .meta-cell .value {
font-family: var(--mono);
font-size: clamp(13px, 1vw, 16px);
letter-spacing: 0.08em;
color: var(--ink);
}
/* Spectrum — taller, full-width amplitude bars */
body.is-fullscreen-player .now-playing .spectrum {
height: clamp(60px, 9vh, 120px);
margin-bottom: clamp(22px, 2.6vh, 36px);
opacity: 0.95;
}
/* Transport — give it room to breathe */
body.is-fullscreen-player .now-playing .transport {
gap: clamp(20px, 2.6vh, 32px);
}
/* Progress row: huge mono timecodes */
body.is-fullscreen-player .now-playing .progress-row {
gap: clamp(16px, 1.8vw, 28px);
}
body.is-fullscreen-player .now-playing .progress-row .timecode,
body.is-fullscreen-player .now-playing .timecode {
font-family: var(--mono);
font-size: clamp(20px, 2.2vw, 32px);
font-weight: 300;
letter-spacing: 0.04em;
min-width: 6ch;
}
body.is-fullscreen-player .now-playing .progress-track,
body.is-fullscreen-player .now-playing .progress-bar {
height: 4px;
}
body.is-fullscreen-player .now-playing .progress-fill {
box-shadow: 0 0 18px var(--copper-glow);
}
/* Controls — large, generous spacing */
body.is-fullscreen-player .now-playing .controls {
gap: clamp(14px, 1.6vw, 22px);
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
}
body.is-fullscreen-player .now-playing .btn-trans {
width: clamp(48px, 4.5vw, 64px) !important;
height: clamp(48px, 4.5vw, 64px) !important;
}
body.is-fullscreen-player .now-playing .btn-trans.primary {
width: clamp(64px, 6vw, 88px) !important;
height: clamp(64px, 6vw, 88px) !important;
}
body.is-fullscreen-player .now-playing .btn-trans svg {
width: clamp(20px, 1.8vw, 26px);
height: clamp(20px, 1.8vw, 26px);
}
body.is-fullscreen-player .now-playing .btn-trans.primary svg {
width: clamp(26px, 2.4vw, 34px);
height: clamp(26px, 2.4vw, 34px);
}
/* VU cluster — slightly enlarged, still right-anchored */
body.is-fullscreen-player .now-playing .vu-cluster {
margin-left: auto;
gap: clamp(10px, 1vw, 16px);
}
body.is-fullscreen-player .now-playing .vu-meter {
width: clamp(70px, 6.5vw, 96px);
height: clamp(36px, 3.6vh, 50px);
}
/* Header button: active state when fullscreen is on */
.header-btn#fullscreenToggle.active {
background: var(--bg-card);
color: var(--copper);
border-color: var(--copper);
}
/* ─── Mobile / portrait variant: vertical theater ──────────────── */
@media (max-width: 900px), (orientation: portrait) {
body.is-fullscreen-player .now-playing,
body.is-fullscreen-player .player-layout {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, auto);
gap: clamp(20px, 4vh, 40px);
padding: clamp(60px, 8vh, 96px) clamp(20px, 5vw, 40px) clamp(24px, 4vh, 48px);
align-content: start;
}
body.is-fullscreen-player .album-art-container.vinyl-stage {
width: min(70vw, 56vh);
max-width: 480px;
}
body.is-fullscreen-player .now-playing .track-title {
font-size: clamp(36px, 8.5vw, 64px);
}
body.is-fullscreen-player .now-playing .controls {
justify-content: center;
}
body.is-fullscreen-player .now-playing .vu-cluster {
margin-left: 0;
margin-right: auto;
}
body.is-fullscreen-player .fs-chrome {
padding: 14px 18px;
}
}
/* Reduced motion: kill the entry animations and bloom drift */
@media (prefers-reduced-motion: reduce) {
body.is-fullscreen-player .player-container,
body.is-fullscreen-player .fs-bloom,
body.is-fullscreen-player .album-art-container.vinyl-stage,
body.is-fullscreen-player .track-masthead > * {
animation: none !important;
}
body.is-fullscreen-player .fs-bloom { opacity: 0.42; }
body.is-fullscreen-player .fs-bloom #fs-bloom-art { animation: none !important; }
:root[data-theme="light"] body.is-fullscreen-player .fs-bloom { opacity: 0.22; }
}