fix(player): give YouTube's own fullscreen a working path + round-6 findings

The reported bug: after F, the embed's own fullscreen button is dead. Root cause
measured in Chrome — the browser's fullscreen lock sits on OUR wrapper, so
YouTube's button would need a nested request from a cross-origin frame. Handing
the lock to the iframe fixes it, but `iframe.requestFullscreen()` only resolves
from a CLICK (from a key press the promise stays pending forever, and awaiting
it spends the activation so a fallback is denied too).

So: F keeps fullscreening our wrapper (unchanged, our overlays stay visible) and
a new toolbar button hands the lock to the iframe — verified in real Chrome:
document.fullscreenElement is the IFRAME at 1920x1024, i.e. a plain YouTube
fullscreen where its own controls work.

Round-6 review findings, all fixed:
- one _rate_limiter + one _cancel_poll shared by the yt-dlp hook and run_ffmpeg
  (was three hand-rolled throttles; cancel latency differed 2s vs 1s)
- stepVolume takes {delta, fallback} — two adjacent number args were silently
  transposable
- volume gestures before the player is ready are honoured instead of dropped
- normalizeVolume's fallback is typed unknown (its guard was unreachable) and
  applyVolume takes a number again
- the throttle tests inject a clock instead of patching global time.monotonic
This commit is contained in:
2026-07-24 02:16:58 +02:00
parent c4942eb590
commit d8d82e47d0
7 changed files with 149 additions and 69 deletions
+73 -11
View File
@@ -11,6 +11,7 @@ import {
ChevronLeft,
ChevronRight,
ExternalLink,
Maximize,
Repeat,
Repeat1,
Settings,
@@ -209,11 +210,49 @@ 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.
*
* 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.
*
* 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.
*/
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 = () => {
const el = stageRef.current;
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen?.();
else el.requestFullscreen?.();
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 flashVolume = (level: number) => {
setVolumeUi(level);
@@ -228,8 +267,9 @@ export default function PlayerModal({
};
/** Push a level (0–100) to the player and remember it; 0 also mutes (a player left at zero
* volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */
const applyVolume = (level: unknown, flash = true) => {
// An unusable level (a player that answered NaN) falls back to the LAST GOOD one, never to the
const applyVolume = (level: number, flash = true) => {
// Callers hand over an already-normalised level (stepVolume / the stored value), but the
// player can still answer NaN, so re-normalise here against the LAST GOOD level — never the
// default: jumping a quiet session to full blast is the surprise this feature exists to avoid.
const next = normalizeVolume(level, volumeRef.current);
const p = playerRef.current;
@@ -246,8 +286,10 @@ export default function PlayerModal({
if (flash) flashVolume(next);
};
const nudgeVolume = (delta: number) => {
// No early return when the player isn't ready yet: the level lives in `volumeRef` and onReady
// applies it, so a wheel spin in the first second after opening (the natural reaction to a
// video that starts loud) now lands instead of being silently dropped.
const p = playerRef.current;
if (!p || typeof p.getVolume !== "function") return;
// Whose level do we step from? Our own ref, EXCEPT when the gesture starts fresh — then read
// the player, because the user may have moved YouTube's own slider in the meantime.
// `getVolume()` is answered across the iframe boundary and lags our `setVolume` by a beat, so
@@ -256,8 +298,10 @@ export default function PlayerModal({
const now = Date.now();
const continuing = now - lastNudgeAtRef.current < 1000;
lastNudgeAtRef.current = now;
const cur = continuing ? volumeRef.current : p.isMuted?.() ? 0 : p.getVolume?.();
applyVolume(stepVolume(cur, delta, volumeRef.current), true);
const live =
p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null;
const cur = continuing || live === null ? volumeRef.current : live;
applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current }));
};
// The player can navigate to other videos (YouTube links in a description). The
@@ -530,10 +574,16 @@ export default function PlayerModal({
return () => el.removeEventListener("wheel", onWheel);
}, []);
// Track whether we're the fullscreen element (F, our button, or the browser's own Esc exit).
// Track whether the fullscreen element is ours — the player iframe (see fullscreenTarget) or,
// before the player exists, the stage. Covers F, our button, YouTube's own button and the
// browser's Esc exit alike, since all of them land on one of those two elements.
useEffect(() => {
const onChange = () => {
const ours = !!document.fullscreenElement && document.fullscreenElement === stageRef.current;
const fsEl = document.fullscreenElement;
// Refs only — this listener is bound once, so it must not close over render values.
const p = playerRef.current;
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events
};
@@ -1067,10 +1117,22 @@ 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="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"
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"
/>
)}
{!navigated && (