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:
@@ -197,10 +197,6 @@ export default function PlayerModal({
|
|||||||
// history layer only while active). Mirrors the Esc ladder in the keydown handler below.
|
// history layer only while active). Mirrors the Esc ladder in the keydown handler below.
|
||||||
useBackToExit(isFullscreen, exitFullscreen);
|
useBackToExit(isFullscreen, exitFullscreen);
|
||||||
useBackToExit(maximized, () => setMaximized(false));
|
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
|
// 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
|
// 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
|
// 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);
|
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
|
// 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.
|
// this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -855,10 +830,20 @@ export default function PlayerModal({
|
|||||||
>
|
>
|
||||||
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
||||||
through our overlay. */}
|
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
|
<div
|
||||||
ref={mountRef}
|
className={
|
||||||
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
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
|
{/* 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
|
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.
|
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
|
{/* 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
|
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
|
||||||
(a <body> portal would not). Auto-hides on idle; stopPropagation so a click on it doesn't
|
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
|
||||||
toggle playback. The exit button also re-arms our shortcuts (pull focus back off the
|
this bar's height (see mountRef's `bottom-14`), so the bar NEVER overlaps YouTube's seek
|
||||||
iframe). Add-to-playlist / download are hidden in true fullscreen — their popovers portal
|
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
|
||||||
to <body>, outside the fullscreen element, so they'd open invisibly. */}
|
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) && (
|
{(maximized || isFullscreen) && (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseEnter={() => {
|
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"
|
||||||
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"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{hasQueue && (
|
{hasQueue && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user