fix(player): F maximises without the Fullscreen API, so YouTube's own controls work

Root cause, measured in Chrome/Brave/Firefox alike (so: 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 on the
iframe element — the YouTube player never learns it is fullscreen, so its own
fullscreen button stays in the "enter" state and clicking it changes nothing
visible. Every other YouTube control works, which is exactly what the user saw.

So we stop taking the lock: F now toggles a CSS layer (`player-stage--max`) that
fills the viewport. The page stays non-fullscreen, so the embed's own fullscreen
button behaves natively, and our own overlays and shortcuts keep working because
our DOM is what's on screen. Escape steps out of maximise before closing.

Verified end to end in real Chrome:
- F -> stage 1920x889, document.fullscreenElement null (no lock taken)
- YouTube's own fullscreen button -> real fullscreen (innerHeight 889 -> 1024,
  fullscreenElement becomes the IFRAME) and its icon flips to "exit"
- clicking it again -> back to the maximised view, our class still applied
- wheel volume while maximised: 100 -> 85, with the level flash visible

Drops the "YouTube fullscreen" toolbar button added earlier today: with the lock
gone, YouTube's own button does that job correctly.
This commit is contained in:
2026-07-24 02:46:42 +02:00
parent d8d82e47d0
commit 45654fc7ec
4 changed files with 41 additions and 64 deletions
+28 -57
View File
@@ -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({
>
<div
ref={stageRef}
className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
className={`player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden ${
maximized ? "player-stage--max" : ""
}`}
>
{/* 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({
</div>
)}
{/* 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. */}
<button
onClick={enterNativeFullscreen}
title={t("player.nativeFullscreenHint")}
aria-label={t("player.nativeFullscreen")}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
>
<Maximize className="w-4 h-4" />
</button>
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
{!navigated && (
-2
View File
@@ -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"
}
-2
View File
@@ -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"
}
+13 -3
View File
@@ -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;