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:
2026-07-23 22:56:42 +02:00
parent 63a36db116
commit 5130584d0a
4 changed files with 66 additions and 12 deletions
+61 -10
View File
@@ -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 (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: 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" : ""
}`}
/>
)}
+1 -1
View File
@@ -35,6 +35,6 @@
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.",
"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: volume · F: fullscreen",
"shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fullscreen",
"nativeControls": "YouTube controls active"
}
+1 -1
View File
@@ -35,6 +35,6 @@
"unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.",
"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ő: hangerő · F: teljes képernyő",
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes képernyő",
"nativeControls": "YouTube vezérlők aktívak"
}
+3
View File
@@ -50,6 +50,9 @@ export const LS = {
plexQ: "siftlode.plexQ",
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
// The YouTube modal player's volume/mute. A YT.Player instance is created per video and starts
// at full volume, so without this every next video is loud again (same idea as plexPlayerPrefs).
ytPlayerPrefs: "siftlode.ytPlayerPrefs",
notifHistory: "siftlode.notifications",
notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed",