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 && (
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user