fix(player): first-class maximise/fullscreen view (UAT round)

UAT feedback on the F-maximise view — three fixes:
- Own control bar in the big views (maximise + our fullscreen): previous/next +
  N/M, auto-advance, loop, add-to-playlist, download, mark-watched, exit, and a
  true-fullscreen toggle. Auto-hides on idle. Add/download are hidden in true
  fullscreen (their popovers portal to <body>, outside the fullscreen element).
- Wheel volume works in the big views again: the centre interaction overlay is
  now armed in maximise/fullscreen (it only yields to YouTube's own settings
  menu), so the wheel bubbles to our handler instead of the bare iframe. F also
  offers true fullscreen that WE own (Fullscreen API on the stage), so wheel/
  arrow volume and our controls work inside it too — YouTube's own fullscreen
  button, which we never surface, is not involved.
- Back (mouse/browser) steps out one level at a time via nested history layers
  (useBackToExit): fullscreen -> maximise -> the modal -> closed, instead of
  closing the whole player at once.

Verified in Chrome: wheel volume in maximise + fullscreen, the control bar, and
the full Back ladder (fullscreen -> maximise -> modal -> closed).
This commit is contained in:
2026-07-26 02:48:46 +02:00
parent 475bd7fef8
commit 51193105a7
5 changed files with 228 additions and 32 deletions
+197 -28
View File
@@ -11,6 +11,8 @@ import {
ChevronLeft,
ChevronRight,
ExternalLink,
Maximize,
Minimize,
Minimize2,
Repeat,
Repeat1,
@@ -35,8 +37,13 @@ import {
relativeTime,
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement, onFullscreenChange } from "../lib/fullscreen";
import { useBackToClose } from "../lib/history";
import {
exitFullscreen,
fullscreenElement,
onFullscreenChange,
requestFullscreen,
} from "../lib/fullscreen";
import { useBackToClose, useBackToExit } from "../lib/history";
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "../lib/playerVolume";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { useScrollFade } from "../lib/useScrollFade";
@@ -186,6 +193,14 @@ export default function PlayerModal({
// 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);
// Nested Back: peel fullscreen → maximise → close, one browser/mouse Back at a time (each is a
// history layer only while active). Mirrors the Esc ladder in the keydown handler below.
useBackToExit(isFullscreen, exitFullscreen);
useBackToExit(maximized, () => setMaximized(false));
// The big-view control bar + exit auto-hide when the pointer rests (like a native player) and
// reappear on movement; always shown while in the modal card.
const [controlsVisible, setControlsVisible] = useState(true);
const controlsHideTimerRef = useRef<number | undefined>(undefined);
// 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
@@ -231,6 +246,19 @@ export default function PlayerModal({
* browser's own chrome; YouTube's button provides that, correctly, when the user wants it.
*/
const toggleFullscreen = () => setMaximized((m) => !m);
// True browser fullscreen that WE own (the stage element). Unlike YouTube's own fullscreen it keeps
// OUR overlay + control bar on screen, so wheel/arrow volume and our controls work inside it too.
// We never surface YouTube's own fullscreen button, so the "a cross-origin embed's own button looks
// dead while we hold the lock" problem that made F avoid the Fullscreen API does not arise.
const toggleTrueFullscreen = () => {
if (fullscreenElement()) exitFullscreen();
else if (stageRef.current) requestFullscreen(stageRef.current);
};
// Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise.
const exitBigView = () => {
if (fullscreenElement()) exitFullscreen();
setMaximized(false);
};
const flashVolume = (level: number) => {
setVolumeUi(level);
window.clearTimeout(volTimerRef.current);
@@ -561,6 +589,27 @@ export default function PlayerModal({
return () => el.removeEventListener("wheel", onWheel);
}, []);
// Auto-hide the big-view control bar after the pointer rests; any movement over the stage reveals
// it again. Only active in the big views (maximise / our fullscreen); the modal keeps it shown.
useEffect(() => {
if (!maximized && !isFullscreen) {
setControlsVisible(true);
return;
}
const reveal = () => {
setControlsVisible(true);
window.clearTimeout(controlsHideTimerRef.current);
controlsHideTimerRef.current = window.setTimeout(() => setControlsVisible(false), 2600);
};
reveal();
const el = stageRef.current;
el?.addEventListener("mousemove", reveal);
return () => {
el?.removeEventListener("mousemove", reveal);
window.clearTimeout(controlsHideTimerRef.current);
};
}, [maximized, isFullscreen]);
// Track the browser's fullscreen state. WE never request it any more (F maximises via CSS), so
// this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc.
useEffect(() => {
@@ -810,12 +859,14 @@ export default function PlayerModal({
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) and stops
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, and fully yielded in fullscreen (YouTube
reveals its control bar only while the iframe itself sees the mouse). */}
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
volume (the wheel event bubbles from here to the modal's listener — over the bare
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable.
Armed in the big views too (that is what keeps wheel volume working after F, the reported
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
settings menu (focus moves into the iframe), so that menu is navigable end to end.
Hidden on error so the "Open on YouTube" CTA is clickable. */}
{playerError == null && (
<div
onClick={() => {
@@ -825,31 +876,149 @@ 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 || maximized ? "pointer-events-none" : ""
nativeControls ? "pointer-events-none" : ""
}`}
/>
)}
{/* The only way OUT of the maximised view that survives a click on the video. Reaching
YouTube's controls means clicking into the cross-origin iframe, which takes keyboard
focus with it — our window-level F/Esc then never fire, and while the stage fills the
viewport the re-arm paths (stage mouseleave, window focus) need the pointer to leave
the browser entirely. So the exit lives on screen, in OUR DOM: clicking it both
un-maximises and pulls focus back, which re-arms every shortcut. */}
{maximized && (
<button
onClick={(e) => {
e.stopPropagation();
setMaximized(false);
setNativeControls(false);
focusModal();
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
controls are off-screen. It's IN the stage, so it stays visible in true fullscreen too
(a <body> portal would not). Auto-hides on idle; stopPropagation so a click on it doesn't
toggle playback. The exit button also re-arms our shortcuts (pull focus back off the
iframe). Add-to-playlist / download are hidden in true fullscreen — their popovers portal
to <body>, outside the fullscreen element, so they'd open invisibly. */}
{(maximized || isFullscreen) && (
<div
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => {
setControlsVisible(true);
window.clearTimeout(controlsHideTimerRef.current);
}}
title={t("player.exitMaximize")}
aria-label={t("player.exitMaximize")}
className="absolute top-3 right-3 z-menu inline-flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm transition hover:bg-black/80 hover:text-white"
className={`absolute inset-x-0 bottom-0 z-menu flex flex-wrap items-center justify-center gap-x-3 gap-y-1.5 px-3 pb-3 pt-10 text-sm text-white bg-gradient-to-t from-black/85 via-black/45 to-transparent transition-opacity duration-200 ${
controlsVisible ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<Minimize2 className="h-3.5 w-3.5" />
{t("player.exitMaximize")}
</button>
{hasQueue && (
<>
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
>
<SkipBack className="h-4 w-4" />
<span className="hidden sm:inline">{t("player.previous")}</span>
</button>
<span className="tabular-nums text-white/70">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
>
<span className="hidden sm:inline">{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
<span className="h-4 w-px bg-white/20" />
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
autoMode !== "off"
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className="hidden md:inline">
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
loopMode !== "off"
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className="hidden md:inline">
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
<span className="h-4 w-px bg-white/20" />
</>
)}
{!navigated && !isFullscreen && (
<AddToPlaylist
videoId={active.id}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
)}
{!navigated && !isFullscreen && (
<DownloadButton
videoId={active.id}
title={active.title}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
)}
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
watched
? "bg-white/25 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{watched ? <CheckCheck className="h-4 w-4" /> : <Check className="h-4 w-4" />}
<span className="hidden md:inline">
{watched ? t("player.watched") : t("player.markWatched")}
</span>
</button>
)}
<span className="h-4 w-px bg-white/20" />
<button
onClick={toggleTrueFullscreen}
title={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
aria-label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
>
{isFullscreen ? (
<Minimize className="h-4 w-4" />
) : (
<Maximize className="h-4 w-4" />
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
exitBigView();
setNativeControls(false);
focusModal();
}}
title={t("player.exitMaximize")}
aria-label={t("player.exitMaximize")}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-white/80 hover:bg-white/10 hover:text-white transition"
>
<Minimize2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
</button>
</div>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
+3 -1
View File
@@ -37,5 +37,7 @@
"openOnYoutube": "Open on YouTube",
"shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fill the window",
"exitMaximize": "Exit full window (Esc)",
"nativeControls": "YouTube controls active"
"nativeControls": "YouTube controls active",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit fullscreen"
}
+3 -1
View File
@@ -37,5 +37,7 @@
"openOnYoutube": "Megnyitás YouTube-on",
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes ablak",
"exitMaximize": "Kilépés a teljes ablakból (Esc)",
"nativeControls": "YouTube vezérlők aktívak"
"nativeControls": "YouTube vezérlők aktívak",
"fullscreen": "Teljes képernyő",
"exitFullscreen": "Kilépés a teljes képernyőből"
}
+23
View File
@@ -114,3 +114,26 @@ export function useBackToClose(onClose: () => void): void {
};
}, []);
}
/** A Back-closable layer that exists only WHILE `active`. Stacks on top of `useBackToClose` and any
* other active `useBackToExit` (LIFO) so nested views peel off one Back at a time — e.g. a player's
* fullscreen sits above its maximise, which sits above the player itself: Back exits fullscreen,
* then maximise, then closes the player. Leaving a layer by its own control (button/Esc) flips
* `active` false, whose cleanup drops the entry and its history state on the next tick — exactly the
* useBackToClose lifecycle, just gated on a flag. `onExit` runs on a genuine Back while active. */
export function useBackToExit(active: boolean, onExit: () => void): void {
const cb = useRef(onExit);
cb.current = onExit;
useEffect(() => {
if (!active) return;
ensureOverlayListener();
const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
overlayStack.push(entry);
scheduleReconcile();
return () => {
const idx = overlayStack.indexOf(entry);
if (idx !== -1) overlayStack.splice(idx, 1);
scheduleReconcile();
};
}, [active]);
}
+2 -2
View File
@@ -20,8 +20,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
summary:
"A sturdier player — press F to go big, arrow-key volume that's remembered — plus downloads and subscription sync that hold up when something goes wrong.",
features: [
"Player: press F to maximise the video to a full-window view, and use the ↑/↓ arrow keys to change the volume — your level is now remembered per account. Ctrl+scroll zooms the page as usual again instead of being captured by the player.",
"The maximised view no longer blocks YouTube's own fullscreen button or on-video controls, keyboard shortcuts keep working after you click into the video, and exiting the view returns keyboard focus to the app. Both the YouTube and the local/Plex player behave the same way.",
"Player: press F to maximise the video to a full-window view — now with its own control bar (previous/next, auto-advance, loop, add-to-playlist, download, mark watched) that auto-hides while you watch, plus a button for true fullscreen. Scroll-wheel and ↑/↓ volume work in every mode (the full window and fullscreen too), and your volume is remembered per account.",
"Volume: Ctrl+scroll zooms the page as usual again instead of being captured by the player. Keyboard shortcuts keep working after you click into the video, and the mouse/browser Back button now steps out one level at a time (fullscreen → full window → the normal player → closed) instead of closing the whole player at once.",
],
fixes: [
"Downloads: fixed a class of downloads that could fail with an internal error (a produced file the downloader couldn't locate), and mp4 downloads are no longer needlessly re-encoded — they finish faster and keep their original quality.",