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 && (