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 (0100) 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 && (
+2
View File
@@ -36,5 +36,7 @@
"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,5 +36,7 @@
"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"
}
+8 -7
View File
@@ -34,24 +34,25 @@ describe("normalizeVolume", () => {
it("uses the default when the fallback is unusable too", () => {
expect(normalizeVolume(NaN, NaN)).toBe(DEFAULT_VOLUME);
expect(normalizeVolume(null, undefined as unknown as number)).toBe(DEFAULT_VOLUME);
expect(normalizeVolume(null, undefined)).toBe(DEFAULT_VOLUME);
expect(normalizeVolume(null, "80")).toBe(DEFAULT_VOLUME);
});
});
describe("stepVolume", () => {
it("steps up and down from the current level", () => {
expect(stepVolume(60, 5, 100)).toBe(65);
expect(stepVolume(60, -5, 100)).toBe(55);
expect(stepVolume(60, { delta: 5, fallback: 100 })).toBe(65);
expect(stepVolume(60, { delta: -5, fallback: 100 })).toBe(55);
});
it("clamps at both ends instead of wrapping", () => {
expect(stepVolume(98, 5, 100)).toBe(100);
expect(stepVolume(3, -5, 100)).toBe(0);
expect(stepVolume(98, { delta: 5, fallback: 100 })).toBe(100);
expect(stepVolume(3, { delta: -5, fallback: 100 })).toBe(0);
});
it("steps from the fallback when the player answers garbage", () => {
// A spin must not lose notches to a stale/unusable reading — it continues from what we last set.
expect(stepVolume(undefined, -5, 90)).toBe(85);
expect(stepVolume(NaN, -5, 90)).toBe(85);
expect(stepVolume(undefined, { delta: -5, fallback: 90 })).toBe(85);
expect(stepVolume(NaN, { delta: -5, fallback: 90 })).toBe(85);
});
});
+9 -6
View File
@@ -16,15 +16,18 @@ const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)));
* being `0` (silently muting the player) is exactly the coercion to avoid. `fallback` is the last
* level known to work; it is itself validated, so a corrupt value can never travel through it.
*/
export function normalizeVolume(raw: unknown, fallback: number = DEFAULT_VOLUME): number {
export function normalizeVolume(raw: unknown, fallback: unknown = DEFAULT_VOLUME): number {
if (typeof raw === "number" && Number.isFinite(raw)) return clamp(raw);
if (typeof fallback === "number" && Number.isFinite(fallback)) return clamp(fallback);
return DEFAULT_VOLUME;
}
/** One volume step (wheel notch / arrow key) from `current`, falling back to `fallback` when the
* player answered something unusable. */
export function stepVolume(current: unknown, delta: number, fallback: number): number {
const base = normalizeVolume(current, fallback);
return clamp(base + delta);
/** One volume step (wheel notch / arrow key) from `current`.
*
* `delta` and `fallback` ride in an options object on purpose: as two adjacent `number`
* parameters they were transposable without a type error, and swapping them silently steps the
* volume BY the current level. */
export function stepVolume(current: unknown, opts: { delta: number; fallback: unknown }): number {
const base = normalizeVolume(current, opts.fallback);
return clamp(base + opts.delta);
}