fix(player): R5 S3 — fullscreen controls, volume memory, Ctrl+wheel zoom
- the interaction overlay yields fully in fullscreen: YouTube only reveals its control bar while the iframe itself sees the mouse, so after pressing F the native fullscreen button was unreachable - remember the volume per account (a YT.Player is created per video and always starts at 100), plus ArrowUp/ArrowDown volume keys that work in fullscreen - Ctrl/Cmd+wheel falls through to the browser's zoom instead of the volume
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
|
||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
||||
@@ -155,6 +156,21 @@ export default function PlayerModal({
|
||||
// navigable (previously the overlay ate clicks below the top items). Moving the pointer off the
|
||||
// video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts.
|
||||
const [nativeControls, setNativeControls] = useState(false);
|
||||
// True while OUR stage element is the fullscreen element. In fullscreen the interaction overlay
|
||||
// yields entirely (see the overlay's pointer-events below): YouTube's control bar only appears
|
||||
// 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);
|
||||
// Volume survives the modal, the next video and a reload: a YT.Player is created per video and
|
||||
// always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted
|
||||
// state (that's how YouTube's own control behaves), so there's no separate `muted` flag to
|
||||
// contradict it.
|
||||
const [playerPrefs, patchPlayerPrefs] = useAccountPersistedObject(LS.ytPlayerPrefs, {
|
||||
volume: 100,
|
||||
});
|
||||
// The window/wheel listeners below are bound once, so they must read the LIVE prefs.
|
||||
const prefsRef = useRef(playerPrefs);
|
||||
prefsRef.current = playerPrefs;
|
||||
|
||||
const focusModal = () => cardRef.current?.focus();
|
||||
const togglePlay = () => {
|
||||
@@ -175,15 +191,26 @@ export default function PlayerModal({
|
||||
window.clearTimeout(volTimerRef.current);
|
||||
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
|
||||
};
|
||||
/** 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: number, flash = true) => {
|
||||
const next = Math.max(0, Math.min(100, Math.round(level)));
|
||||
const p = playerRef.current;
|
||||
if (p && typeof p.setVolume === "function") {
|
||||
if (next > 0) p.unMute?.();
|
||||
p.setVolume(next);
|
||||
if (next === 0) p.mute?.();
|
||||
}
|
||||
patchPlayerPrefs({ volume: next });
|
||||
if (flash) flashVolume(next);
|
||||
};
|
||||
const nudgeVolume = (delta: number) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getVolume !== "function") return;
|
||||
const cur = p.isMuted?.() ? 0 : (p.getVolume?.() ?? 50);
|
||||
const next = Math.max(0, Math.min(100, Math.round(cur + delta)));
|
||||
if (next > 0) p.unMute?.();
|
||||
p.setVolume?.(next);
|
||||
if (next === 0) p.mute?.();
|
||||
flashVolume(next);
|
||||
// Read the live player (the user may have used YouTube's own slider), falling back to the
|
||||
// stored level when the player can't answer yet.
|
||||
const cur = p.isMuted?.() ? 0 : (p.getVolume?.() ?? prefsRef.current.volume);
|
||||
applyVolume(cur + delta);
|
||||
};
|
||||
|
||||
// The player can navigate to other videos (YouTube links in a description). The
|
||||
@@ -396,6 +423,12 @@ export default function PlayerModal({
|
||||
if (typing || tag === "button" || tag === "a") return;
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
|
||||
// fullscreen, where the wheel goes to YouTube's own iframe.
|
||||
if (typing) return;
|
||||
e.preventDefault();
|
||||
nudgeVolume(e.key === "ArrowUp" ? 5 : -5);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
|
||||
// previous/next video in the queue (the feed's order or a playlist).
|
||||
@@ -431,6 +464,9 @@ export default function PlayerModal({
|
||||
const el = dialogRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. Volume is the PLAIN wheel
|
||||
// (and ↑/↓); taking the modifier too left zoom unreachable while the player was open.
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
focusModal(); // re-grab focus if it had slipped into the native controls
|
||||
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
||||
@@ -439,6 +475,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).
|
||||
useEffect(() => {
|
||||
const onChange = () =>
|
||||
setIsFullscreen(
|
||||
!!document.fullscreenElement && document.fullscreenElement === stageRef.current,
|
||||
);
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
}, []);
|
||||
|
||||
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
|
||||
// bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the
|
||||
// iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings
|
||||
@@ -532,8 +578,12 @@ export default function PlayerModal({
|
||||
vq: "hd1080",
|
||||
},
|
||||
events: {
|
||||
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away.
|
||||
onReady: () => focusModal(),
|
||||
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
|
||||
// restore the remembered volume — a fresh player always starts at 100.
|
||||
onReady: () => {
|
||||
focusModal();
|
||||
applyVolume(prefsRef.current.volume, false);
|
||||
},
|
||||
onStateChange: (e: any) => {
|
||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||
if (e?.data === 1) {
|
||||
@@ -665,7 +715,8 @@ export default function PlayerModal({
|
||||
the iframe stealing keyboard focus. It deliberately leaves the top AND bottom edges
|
||||
uncovered so YouTube's native controls — the top-right cluster (volume / CC / settings)
|
||||
and the bottom bar (seek / More videos / fullscreen) — stay clickable. Hidden on error
|
||||
so the "Open on YouTube" CTA is clickable. */}
|
||||
so the "Open on YouTube" CTA is clickable, and fully yielded in fullscreen (YouTube
|
||||
reveals its control bar only while the iframe itself sees the mouse). */}
|
||||
{playerError == null && (
|
||||
<div
|
||||
onClick={() => {
|
||||
@@ -675,7 +726,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 ? "pointer-events-none" : ""
|
||||
nativeControls || isFullscreen ? "pointer-events-none" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user