fix(player): dock the big-view control bar in its own strip (UAT follow-up)

The overlaid, auto-hiding bar fought YouTube's seek bar for the same bottom
pixels: while ours showed it covered YT's seek + buttons, and reaching them
meant moving off the area and waiting for our bar to fade — a fiddly, "random"-
feeling dance keyed on mouse movement.

Now the bar is DOCKED and always visible: the video area is inset by the bar's
height (a wrapper around the mount, since YT.Player replaces the mount element
with its iframe and would drop a class set on it), so the bar sits in its own
strip and never overlaps the video. YouTube's seek/controls stay reachable by
hovering the video's bottom edge, just above ours. No auto-hide, no guessing,
no fight. Verified in Chrome: iframe.bottom == bar.top (no overlap), bar opacity 1.
This commit is contained in:
2026-07-26 03:18:15 +02:00
parent 51193105a7
commit 375c814bb6
+22 -40
View File
@@ -197,10 +197,6 @@ export default function PlayerModal({
// 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
@@ -589,27 +585,6 @@ 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(() => {
@@ -855,10 +830,20 @@ export default function PlayerModal({
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
through our overlay. */}
{/* In the big views the video area is inset by the control bar's height (`bottom-14`) so
the bar below never overlaps it. The inset lives on THIS wrapper, not on `mountRef`:
YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent),
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
className={
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
</div>
{/* 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.
@@ -881,21 +866,18 @@ export default function PlayerModal({
/>
)}
{/* 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. */}
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
this bar's height (see mountRef's `bottom-14`), so the bar NEVER overlaps YouTube's seek
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
the exit button also re-arms our shortcuts. Add-to-playlist / download are hidden in true
fullscreen — their popovers portal to <body>, outside the fullscreen element. */}
{(maximized || isFullscreen) && (
<div
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => {
setControlsVisible(true);
window.clearTimeout(controlsHideTimerRef.current);
}}
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"
}`}
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-center gap-x-3 overflow-x-auto border-t border-white/10 bg-neutral-950/95 px-3 text-sm text-white"
>
{hasQueue && (
<>