diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index f3ac199..f010c1b 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -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; } +} diff --git a/media_server/static/index.html b/media_server/static/index.html index a4eb082..05601c4 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -100,6 +100,10 @@ + + + + + +
diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index f4a6144..1d87d2e 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -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) { diff --git a/media_server/static/js/player.js b/media_server/static/js/player.js index b2ff4a7..101a6f3 100644 --- a/media_server/static/js/player.js +++ b/media_server/static/js/player.js @@ -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); +} diff --git a/media_server/static/locales/en.json b/media_server/static/locales/en.json index bdd8f0e..15cefe4 100644 --- a/media_server/static/locales/en.json +++ b/media_server/static/locales/en.json @@ -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", diff --git a/media_server/static/locales/ru.json b/media_server/static/locales/ru.json index d6ce60d..340a5cd 100644 --- a/media_server/static/locales/ru.json +++ b/media_server/static/locales/ru.json @@ -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": "Остановлено",