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:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@
|
||||
<button class="header-btn" onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle">
|
||||
<svg id="fullscreen-icon-enter" viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h5v2H7v3H5V5zm9 0h5v5h-2V7h-3V5zm0 14v-2h3v-3h2v5h-5zM5 14h2v3h3v2H5v-5z"/></svg>
|
||||
<svg id="fullscreen-icon-exit" viewBox="0 0 24 24" style="display:none"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
|
||||
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
|
||||
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
|
||||
@@ -158,6 +162,25 @@
|
||||
</div>
|
||||
|
||||
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
||||
<!-- Fullscreen-only chrome: floating top strip with kicker + exit. Auto-hides on idle. -->
|
||||
<div class="fs-chrome" id="fsChrome" aria-hidden="true">
|
||||
<div class="fs-chrome-mark">
|
||||
<span class="fs-chrome-edition" data-i18n="header.edition">Studio Reference</span>
|
||||
<span class="fs-chrome-sep">·</span>
|
||||
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
|
||||
</div>
|
||||
<button class="fs-chrome-exit" onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
<span data-i18n="player.fullscreen.exit_short">Exit</span>
|
||||
<kbd class="fs-chrome-kbd">ESC</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ambient album-art bloom: paints the room in the record's color while in fullscreen -->
|
||||
<div class="fs-bloom" id="fsBloom" aria-hidden="true">
|
||||
<img id="fs-bloom-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="">
|
||||
</div>
|
||||
|
||||
<section class="now-playing player-layout">
|
||||
|
||||
<!-- Vinyl stage: cardstock sleeve + disc peeking out, plus tonearm -->
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
|
||||
loadAudioDevices, onAudioDeviceChanged,
|
||||
setupProgressDrag, updateUI, updatePlaybackState, stopPositionInterpolation,
|
||||
togglePlayerFullscreen, initPlayerFullscreen,
|
||||
} from './player.js';
|
||||
|
||||
// Layer 2: WebSocket
|
||||
@@ -99,6 +100,8 @@ Object.assign(window, {
|
||||
toggleVisualizer,
|
||||
// Background
|
||||
toggleDynamicBackground,
|
||||
// Fullscreen
|
||||
togglePlayerFullscreen,
|
||||
// Auth
|
||||
authenticate, clearToken, manualReconnect,
|
||||
// Locale
|
||||
@@ -156,6 +159,7 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize theme and accent color
|
||||
initTheme();
|
||||
initAccentColor();
|
||||
initPlayerFullscreen();
|
||||
|
||||
// Register service worker for PWA installability
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -873,3 +873,180 @@ function updateMuteIcon(muted) {
|
||||
const cluster = document.querySelector('.now-playing .vu-cluster');
|
||||
if (cluster) cluster.classList.toggle('muted', muted);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Fullscreen player mode — Listening Room
|
||||
//
|
||||
// Two-layer model:
|
||||
// 1. CSS overlay (`body.is-fullscreen-player`) — works everywhere,
|
||||
// reuses existing player markup, takes over the viewport via
|
||||
// position:fixed.
|
||||
// 2. Native Fullscreen API on top — true OS-level fullscreen when
|
||||
// the user agent allows it. The CSS class is the source of truth;
|
||||
// the native API is best-effort sugar.
|
||||
// ============================================================
|
||||
|
||||
let fsChromeIdleTimer = null;
|
||||
const FS_CHROME_IDLE_MS = 2500;
|
||||
let fsLastFocusedElement = null;
|
||||
let fsBloomSyncObserver = null;
|
||||
|
||||
function syncFullscreenBloomArt() {
|
||||
const src = document.getElementById('album-art');
|
||||
const bloom = document.getElementById('fs-bloom-art');
|
||||
if (!src || !bloom) return;
|
||||
if (src.src && src.src !== bloom.src) bloom.src = src.src;
|
||||
}
|
||||
|
||||
function showFsChrome() {
|
||||
document.body.classList.remove('fs-chrome-hidden');
|
||||
if (fsChromeIdleTimer) clearTimeout(fsChromeIdleTimer);
|
||||
if (document.body.classList.contains('is-fullscreen-player')) {
|
||||
fsChromeIdleTimer = setTimeout(() => {
|
||||
document.body.classList.add('fs-chrome-hidden');
|
||||
}, FS_CHROME_IDLE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onFsMouseMove() {
|
||||
showFsChrome();
|
||||
}
|
||||
|
||||
function onFsKeyDown(e) {
|
||||
// ESC exits regardless of focus location (native API also dispatches its own,
|
||||
// but we handle the CSS-only fallback case here).
|
||||
if (e.key === 'Escape' && document.body.classList.contains('is-fullscreen-player')) {
|
||||
e.preventDefault();
|
||||
exitPlayerFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function onGlobalFsHotkey(e) {
|
||||
// 'F' toggles fullscreen — but never when user is typing into a field.
|
||||
if (e.key !== 'f' && e.key !== 'F') return;
|
||||
const tag = (e.target && e.target.tagName) || '';
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (e.target && e.target.isContentEditable) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
togglePlayerFullscreen();
|
||||
}
|
||||
|
||||
function onNativeFullscreenChange() {
|
||||
// If the user pressed ESC at the OS level or otherwise exited native
|
||||
// fullscreen, mirror the state in our CSS overlay.
|
||||
const hasNative = !!document.fullscreenElement;
|
||||
const hasOverlay = document.body.classList.contains('is-fullscreen-player');
|
||||
if (!hasNative && hasOverlay) {
|
||||
// User left native fullscreen — also drop the overlay so the UI
|
||||
// returns to its normal state in one motion.
|
||||
exitPlayerFullscreen({ skipNativeExit: true });
|
||||
}
|
||||
}
|
||||
|
||||
function updateFullscreenButtonIcons(active) {
|
||||
const enter = document.getElementById('fullscreen-icon-enter');
|
||||
const exit = document.getElementById('fullscreen-icon-exit');
|
||||
if (enter) enter.style.display = active ? 'none' : '';
|
||||
if (exit) exit.style.display = active ? '' : 'none';
|
||||
const btn = document.getElementById('fullscreenToggle');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', active);
|
||||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
export function enterPlayerFullscreen() {
|
||||
if (document.body.classList.contains('is-fullscreen-player')) return;
|
||||
|
||||
// If we're not on the player tab, jump to it first so the markup is visible.
|
||||
if (activeTab !== 'player') switchTab('player');
|
||||
|
||||
fsLastFocusedElement = document.activeElement;
|
||||
document.body.classList.add('is-fullscreen-player');
|
||||
setMiniPlayerVisible(false);
|
||||
updateFullscreenButtonIcons(true);
|
||||
syncFullscreenBloomArt();
|
||||
|
||||
// Watch for album-art swaps so the bloom keeps up.
|
||||
const src = document.getElementById('album-art');
|
||||
if (src && 'MutationObserver' in window) {
|
||||
if (fsBloomSyncObserver) fsBloomSyncObserver.disconnect();
|
||||
fsBloomSyncObserver = new MutationObserver(syncFullscreenBloomArt);
|
||||
fsBloomSyncObserver.observe(src, { attributes: true, attributeFilter: ['src'] });
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onFsMouseMove, { passive: true });
|
||||
document.addEventListener('keydown', onFsKeyDown);
|
||||
showFsChrome();
|
||||
|
||||
// Move keyboard focus onto the play/pause button so Space/Enter immediately
|
||||
// controls playback once the user enters the room.
|
||||
const playBtn = document.getElementById('btn-play-pause');
|
||||
if (playBtn) playBtn.focus({ preventScroll: true });
|
||||
|
||||
// Best-effort native fullscreen. Failure is silent — the CSS overlay
|
||||
// already gives the user the immersive view.
|
||||
const target = document.documentElement;
|
||||
if (target.requestFullscreen && !document.fullscreenElement) {
|
||||
target.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
|
||||
}
|
||||
|
||||
localStorage.setItem('fullscreenPlayerEnabled', 'true');
|
||||
}
|
||||
|
||||
export function exitPlayerFullscreen({ skipNativeExit = false } = {}) {
|
||||
if (!document.body.classList.contains('is-fullscreen-player')) return;
|
||||
|
||||
document.body.classList.remove('is-fullscreen-player', 'fs-chrome-hidden');
|
||||
updateFullscreenButtonIcons(false);
|
||||
|
||||
if (fsChromeIdleTimer) {
|
||||
clearTimeout(fsChromeIdleTimer);
|
||||
fsChromeIdleTimer = null;
|
||||
}
|
||||
if (fsBloomSyncObserver) {
|
||||
fsBloomSyncObserver.disconnect();
|
||||
fsBloomSyncObserver = null;
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onFsMouseMove);
|
||||
document.removeEventListener('keydown', onFsKeyDown);
|
||||
|
||||
if (!skipNativeExit && document.fullscreenElement && document.exitFullscreen) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
// Re-evaluate mini-player visibility against scroll position.
|
||||
if (activeTab === 'player') {
|
||||
const playerContainer = document.querySelector('.player-container');
|
||||
if (playerContainer) {
|
||||
const rect = playerContainer.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
setMiniPlayerVisible(!inView);
|
||||
}
|
||||
} else {
|
||||
setMiniPlayerVisible(true);
|
||||
}
|
||||
|
||||
// Restore focus to whatever invoked the toggle.
|
||||
if (fsLastFocusedElement && typeof fsLastFocusedElement.focus === 'function') {
|
||||
try { fsLastFocusedElement.focus({ preventScroll: true }); } catch (_) {}
|
||||
}
|
||||
fsLastFocusedElement = null;
|
||||
|
||||
localStorage.removeItem('fullscreenPlayerEnabled');
|
||||
}
|
||||
|
||||
export function togglePlayerFullscreen() {
|
||||
if (document.body.classList.contains('is-fullscreen-player')) {
|
||||
exitPlayerFullscreen();
|
||||
} else {
|
||||
enterPlayerFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
export function initPlayerFullscreen() {
|
||||
document.addEventListener('keydown', onGlobalFsHotkey);
|
||||
document.addEventListener('fullscreenchange', onNativeFullscreenChange);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"player.vinyl": "Vinyl mode",
|
||||
"player.visualizer": "Audio visualizer",
|
||||
"player.background": "Dynamic background",
|
||||
"player.fullscreen": "Fullscreen player",
|
||||
"player.fullscreen.exit": "Exit fullscreen",
|
||||
"player.fullscreen.exit_short": "Exit",
|
||||
"state.playing": "Playing",
|
||||
"state.paused": "Paused",
|
||||
"state.stopped": "Stopped",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"player.vinyl": "Режим винила",
|
||||
"player.visualizer": "Аудио визуализатор",
|
||||
"player.background": "Динамический фон",
|
||||
"player.fullscreen": "Полноэкранный режим",
|
||||
"player.fullscreen.exit": "Выйти из полного экрана",
|
||||
"player.fullscreen.exit_short": "Выйти",
|
||||
"state.playing": "Воспроизведение",
|
||||
"state.paused": "Пауза",
|
||||
"state.stopped": "Остановлено",
|
||||
|
||||
Reference in New Issue
Block a user