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({ >