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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user