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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user