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
+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);
}