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; }
}
+23
View File
@@ -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 -->
+4
View File
@@ -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) {
+177
View File
@@ -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);
}
+3
View File
@@ -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",
+3
View File
@@ -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": "Остановлено",