diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 42f964b..a3d4025 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -11,7 +11,6 @@ import { ChevronLeft, ChevronRight, ExternalLink, - Maximize, Repeat, Repeat1, Settings, @@ -178,6 +177,10 @@ export default function PlayerModal({ // while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it // the native fullscreen button — permanently hidden after pressing F. const [isFullscreen, setIsFullscreen] = useState(false); + // Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen. + const [maximized, setMaximized] = useState(false); + const maximizedRef = useRef(false); + maximizedRef.current = maximized; // Mirrored into a ref because the keydown handler is bound once (deps: [onClose]) and would // otherwise read `false` forever — the house pattern here (see navRef/volumeRef). const isFullscreenRef = useRef(false); @@ -210,50 +213,22 @@ export default function PlayerModal({ p.pauseVideo?.(); // 1 === playing else p.playVideo?.(); }; - /** The element F (and our own button) fullscreens: the PLAYER IFRAME, not our stage wrapper. + /** F = maximise, deliberately WITHOUT the Fullscreen API. * - * Fullscreening the wrapper looked equivalent — the video filled the screen either way — but it - * left the browser's fullscreen lock on an element YouTube knows nothing about, so YouTube's own - * fullscreen button had to make a NESTED request from inside a cross-origin frame while we held - * that lock. That is the user-reported bug: after F, the embed's own fullscreen control is dead. - * Targeting the iframe makes our shortcut and YouTube's button the same operation on the same - * element — i.e. exactly a plain YouTube embed, where the button demonstrably works. + * The measured reason (Chrome/Brave/Firefox alike, so it is spec behaviour, not a browser bug): + * a cross-origin embed reads its fullscreen state from ITS OWN document. While WE hold the + * browser's fullscreen lock — on our wrapper or even on the iframe element — the YouTube player + * never learns it is fullscreen, so its own fullscreen button stays in the "enter" state and a + * click on it changes nothing visible. That is the reported bug: every other YouTube control + * (gear, CC, volume) works, only its fullscreen button looks dead. * - * Trade-off, deliberate: our on-player volume flash and the "YouTube controls active" badge sit - * OUTSIDE the iframe, so they aren't painted in fullscreen — YouTube's own volume UI covers that - * case. The stage stays as the fallback for the window between mount and the player being ready. + * So we don't take the lock at all: `maximized` is a CSS layer that fills the viewport. The page + * stays non-fullscreen, which means the embed's own fullscreen button works exactly as it does + * anywhere else — and OUR shortcuts and overlays (wheel volume, the level flash, the queue bar) + * keep working, because our DOM is what's on screen. The one thing we give up is hiding the + * browser's own chrome; YouTube's button provides that, correctly, when the user wants it. */ - const playerIframe = (): HTMLElement | null => { - const p = playerRef.current; - const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null; - return (frame as HTMLElement | null) ?? null; - }; - /** F / our stage-fullscreen: puts OUR wrapper on screen, so the volume flash and the queue - * chrome stay visible. YouTube's own fullscreen button is inert while we hold that lock — use - * `enterNativeFullscreen` (the toolbar button) when you want YouTube's own controls. */ - const toggleFullscreen = () => { - if (document.fullscreenElement) document.exitFullscreen?.(); - else stageRef.current?.requestFullscreen?.(); - }; - /** True YouTube fullscreen: hand the browser's fullscreen lock to the PLAYER IFRAME, so the - * embed behaves exactly like any YouTube embed — its own control bar, its own fullscreen - * button, its quality menu, all live. - * - * Why a button and not the F key: measured in Chrome (2026-07-24), `iframe.requestFullscreen()` - * resolves from a CLICK but stays pending forever when it comes from a key press — the request - * must be delegated to the out-of-process frame, and a keyboard gesture in the parent document - * doesn't carry that delegation. Awaiting it and falling back is not an option either: the await - * spends the transient activation, so the second request is denied and the key does nothing. - * A click is the gesture that provably works, so this is a click-only affordance. */ - const enterNativeFullscreen = () => { - const frame = playerIframe(); - if (!frame?.requestFullscreen) return; - if (document.fullscreenElement) document.exitFullscreen?.(); - frame.requestFullscreen?.().catch(() => { - // Denied (an old browser, a policy) — keep the old behaviour rather than nothing at all. - stageRef.current?.requestFullscreen?.(); - }); - }; + const toggleFullscreen = () => setMaximized((m) => !m); const flashVolume = (level: number) => { setVolumeUi(level); window.clearTimeout(volTimerRef.current); @@ -494,6 +469,12 @@ export default function PlayerModal({ if (e.key === "Escape") { // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. if (document.fullscreenElement) return; + // Same for our own maximise: Esc steps back out of it before it closes the player. + if (maximizedRef.current) { + e.preventDefault(); + setMaximized(false); + return; + } // Defer to any Modal opened above the player (e.g. the download dialog): its Escape closes // it, ours would otherwise also fire and close the player underneath it (U-C). if (modalCount() > 0) return; @@ -809,7 +790,9 @@ export default function PlayerModal({ >
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed through our overlay. */} @@ -832,7 +815,7 @@ export default function PlayerModal({ title={t("player.shortcutsHint")} aria-label={t("player.shortcutsHint")} className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${ - nativeControls || isFullscreen ? "pointer-events-none" : "" + nativeControls || isFullscreen || maximized ? "pointer-events-none" : "" }`} /> )} @@ -1117,22 +1100,10 @@ export default function PlayerModal({
)} - {/* True YouTube fullscreen — see enterNativeFullscreen. Deliberately a CLICK-only - affordance (the iframe fullscreen request is only reliable from a click), and the - only way to reach YouTube's own control bar + fullscreen button; F fullscreens our - wrapper instead, which keeps our overlays but leaves YouTube's controls inert. */} - {!navigated && ( )} {!navigated && ( diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json index e24adc6..3023af9 100644 --- a/frontend/src/i18n/locales/en/player.json +++ b/frontend/src/i18n/locales/en/player.json @@ -36,7 +36,5 @@ "embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.", "openOnYoutube": "Open on YouTube", "shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fullscreen", - "nativeFullscreen": "YouTube fullscreen", - "nativeFullscreenHint": "YouTube fullscreen — the player's own controls (quality, subtitles, its fullscreen button). F fills the screen with our player instead.", "nativeControls": "YouTube controls active" } diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json index 11e32e1..c2b4b36 100644 --- a/frontend/src/i18n/locales/hu/player.json +++ b/frontend/src/i18n/locales/hu/player.json @@ -36,7 +36,5 @@ "embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az auto-generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.", "openOnYoutube": "Megnyitás YouTube-on", "shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes képernyő", - "nativeFullscreen": "YouTube teljes képernyő", - "nativeFullscreenHint": "YouTube teljes képernyő — a lejátszó saját vezérlőivel (minőség, felirat, saját teljes képernyő gomb). Az F a mi lejátszónkkal tölti ki a képernyőt.", "nativeControls": "YouTube vezérlők aktívak" } diff --git a/frontend/src/index.css b/frontend/src/index.css index 75f3062..5cf0599 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -579,15 +579,25 @@ html[data-scheme="matrix"][data-theme="light"] { } } -/* The in-app player stage, when fullscreened via the F key / native button, fills the screen - instead of keeping its 16:9 letterbox (and drops the rounded top corners). */ -.player-stage:fullscreen { +/* The in-app player stage filling the screen: either because something fullscreened it (YouTube's + own button hands the lock to the iframe, not to us — but keep this for any browser that does), + or via our own F-key maximise. `--max` deliberately does NOT use the Fullscreen API: while we + hold the browser's lock, the cross-origin YouTube embed never learns it is fullscreen and its own + fullscreen button stops working (see PlayerModal.toggleFullscreen). */ +.player-stage:fullscreen, +.player-stage--max { width: 100vw; height: 100vh; aspect-ratio: auto; border-radius: 0; } +.player-stage--max { + position: fixed; + inset: 0; + z-index: 9999; /* the tuner level — above the modal chrome it is nested in */ +} + /* Thin, theme-aware scrollbars */ * { scrollbar-color: var(--border) transparent;