fix(player): the Plex bars hold while hovered, volume is the native slider, tooltips no longer re-layer the row (E28)

- F22: the control bar faded out from under a mouse parked on it; the hide timer
  now waits while the pointer is on either band and re-arms on leave (test seen
  RED by disabling the hold).
- The hand-drawn volume bar broke DESIGN.md A6 (native sliders): it had no hover,
  focus, keyboard or screen-reader state. It is the Slider primitive now; a
  pointer drag blurs it so Space and the arrows keep driving the player.
- The control tooltips are composited from mount (will-change: opacity): a fade
  that promoted one on hover re-layered its neighbours under the player's
  scale(1.25), and the whole row shimmered.
This commit is contained in:
2026-09-25 14:48:45 +02:00
parent e9ccd9bc98
commit 62dbf35ea3
8 changed files with 106 additions and 51 deletions
+33 -46
View File
@@ -31,7 +31,6 @@ import { api, HttpError } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import {
audioOrdForLang,
clamp01,
clockParts,
DEFAULT_PREFS,
fmt,
@@ -85,6 +84,7 @@ import {
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Slider } from "./ui/NativeControls";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -237,7 +237,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
const { uiVisible, wake } = useControlsVisibility({ barAutoHideRef, menuOpenRef, videoRef });
const { uiVisible, wake, holdOnHover } = useControlsVisibility({
barAutoHideRef,
menuOpenRef,
videoRef,
});
// The pointer resting on either band holds the controls up (F22); both bands wear these.
const holdWhileHovered = {
onPointerEnter: () => holdOnHover(true),
onPointerLeave: () => holdOnHover(false),
};
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
@@ -1060,15 +1069,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// preview this position; the real seek fires once on release (avoids restarting the session on
// every out-of-buffer step of the drag). A plain click is a down+up at one spot → seeks there.
const [scrub, setScrub] = useState<number | null>(null);
// Volume bar: a 0–100 value tooltip under the cursor (mirrors the seekbar), plus click/drag to set.
const [volHover, setVolHover] = useState<number | null>(null); // 0..1 under the cursor
const volBarRef = useRef<HTMLDivElement>(null);
const volAt = useCallback((clientX: number) => {
const el = volBarRef.current;
if (!el) return 0;
const r = el.getBoundingClientRect();
return clamp01((clientX - r.left) / r.width);
}, []);
// The volume slider shows its level while the pointer is on it.
const [volHover, setVolHover] = useState(false);
const pct = duration > 0 ? (abs / duration) * 100 : 0;
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
@@ -1230,6 +1232,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Top bar */}
<div
{...holdWhileHovered}
className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0"
}`}
@@ -1307,6 +1310,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Bottom controls */}
<div
{...holdWhileHovered}
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
@@ -1419,44 +1423,27 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<Volume2 className="w-5 h-5" />
)}
</Ctrl>
{/* Volume bar: plain accent fill on a neutral track (as before); hover shows the 0–100
value under the cursor; click/drag sets it. */}
{/* The browser's own range, like every other slider (DESIGN.md A6): its hover, focus,
keyboard and screen-reader support come with it — the hand-drawn bar it replaces had
none of them. The level shows above it while hovered. A pointer drag hands the focus
back so Space and the arrows keep driving the player, not the slider. */}
<div
ref={volBarRef}
className="relative h-1.5 w-24 cursor-pointer rounded-full bg-white/25"
onPointerDown={(e) => {
try {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
} catch {
/* capture unavailable — dragging still works via buttons state */
}
const v = volAt(e.clientX);
setVol(v, v === 0);
}}
onPointerMove={(e) => {
const v = volAt(e.clientX);
setVolHover(v);
if (e.buttons & 1) setVol(v, v === 0); // dragging with the primary button held
}}
onMouseLeave={() => setVolHover(null)}
className="relative w-24"
onPointerEnter={() => setVolHover(true)}
onPointerLeave={() => setVolHover(false)}
>
{/* filled level */}
<div
className="absolute inset-y-0 left-0 rounded-full bg-accent"
style={{ width: `${(muted ? 0 : volume) * 100}%` }}
<Slider
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
aria-label={t("plex.player.volume")}
onChange={(v) => setVol(v, v === 0)}
onPointerUp={(e) => e.currentTarget.blur()}
/>
{/* thumb */}
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent shadow-sm"
style={{ left: `${(muted ? 0 : volume) * 100}%` }}
/>
{/* hover value tooltip (0–100), like the seek bar */}
{volHover != null && (
<div
className="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white"
style={{ left: `${volHover * 100}%` }}
>
{Math.round(volHover * 100)}
{volHover && (
<div className="pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-sm bg-black/90 px-1.5 py-0.5 text-2xs tabular-nums text-white">
{Math.round((muted ? 0 : volume) * 100)}
</div>
)}
</div>
@@ -133,6 +133,10 @@ export function Ctrl({
}) {
const pos =
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
// `will-change-[opacity]` keeps each tooltip on its own compositor layer from the start. Without it
// the browser promotes a tooltip only while its fade runs, and inside the player's scale(1.25)
// the neighbouring glyphs got re-layered with it: every button in the row shimmered as the
// pointer crossed one (owner, E28 audit).
return (
<div className="relative flex group/ctrl">
<IconButton
@@ -147,7 +151,7 @@ export function Ctrl({
{children}
</IconButton>
<span
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 transition group-hover/ctrl:opacity-100`}
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 will-change-[opacity] transition group-hover/ctrl:opacity-100`}
>
{label}
</span>
+1
View File
@@ -171,6 +171,7 @@
"prev": "Previous episode",
"next": "Next episode",
"mute": "Mute (m)",
"volume": "Volume",
"fullscreen": "Fullscreen (f)",
"download": "Download original file",
"tracks": "Audio & subtitles",
+1
View File
@@ -171,6 +171,7 @@
"prev": "Előző rész",
"next": "Következő rész",
"mute": "Némítás (m)",
"volume": "Hangerő",
"fullscreen": "Teljes képernyő (f)",
"download": "Eredeti fájl letöltése",
"tracks": "Audió és felirat",
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
//
// The control bar must not hide while the pointer rests on it. It used to: the 3 s timer only knew
// "paused" and "a menu is open", so with the mouse parked on the row (about to press Next) the bar
// faded out from under it (E28 audit F22). YouTube and Plex Web both hold the bar while hovered.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useControlsVisibility } from "./plexPlayerHooks";
const setup = () => {
const video = { paused: false } as HTMLVideoElement;
return renderHook(() =>
useControlsVisibility({
barAutoHideRef: { current: true },
menuOpenRef: { current: false },
videoRef: { current: video },
}),
);
};
describe("useControlsVisibility", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("hides 3 s after the last activity while playing", () => {
const { result } = setup();
act(() => result.current.wake());
act(() => vi.advanceTimersByTime(3000));
expect(result.current.uiVisible).toBe(false);
});
it("stays up while the pointer is over the bar, and re-arms the timer when it leaves", () => {
const { result } = setup();
act(() => {
result.current.wake();
result.current.holdOnHover(true);
});
act(() => vi.advanceTimersByTime(10_000));
expect(result.current.uiVisible).toBe(true);
act(() => result.current.holdOnHover(false));
act(() => vi.advanceTimersByTime(2900));
expect(result.current.uiVisible).toBe(true); // a fresh 3 s from the leave, not from the wake
act(() => vi.advanceTimersByTime(100));
expect(result.current.uiVisible).toBe(false);
});
});
+15 -3
View File
@@ -70,24 +70,36 @@ export function useFullscreen(wrapRef: RefObject<HTMLElement | null>): {
// 3s hide timer. Reads the caller's live refs so the timer callback sees fresh state without a
// closure rebuild: barAutoHide (per-user "never auto-hide" setting), whether a menu is open (keep the
// bar up so it can't vanish mid-choice), and the <video> paused state (a paused video keeps the bar).
// `holdOnHover` is the pointer resting on the chrome: the bar stays while it is there (as on YouTube
// and Plex Web — E28 F22, it used to fade out from under a mouse parked on Next), and leaving it
// starts a fresh 3 s.
export function useControlsVisibility(opts: {
barAutoHideRef: RefObject<boolean>;
menuOpenRef: RefObject<boolean>;
videoRef: RefObject<HTMLVideoElement | null>;
}): { uiVisible: boolean; wake: () => void } {
}): { uiVisible: boolean; wake: () => void; holdOnHover: (over: boolean) => void } {
const { barAutoHideRef, menuOpenRef, videoRef } = opts;
const [uiVisible, setUiVisible] = useState(true);
const hideTimer = useRef<number | null>(null);
const hovered = useRef(false);
const wake = useCallback(() => {
setUiVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
hideTimer.current = window.setTimeout(() => {
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current)
setUiVisible(false);
}, 3000);
}, [barAutoHideRef, menuOpenRef, videoRef]);
return { uiVisible, wake };
const holdOnHover = useCallback(
(over: boolean) => {
hovered.current = over;
if (!over) wake();
},
[wake],
);
return { uiVisible, wake, holdOnHover };
}
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
-1
View File
@@ -74,7 +74,6 @@ export const DEFAULT_PREFS: PlexPlayerPrefs = {
autoSkipDelay: 5,
upNext: true,
};
export const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even
// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the