From 62dbf35ea3e4e7292375ef6181f7e3319cbb26b5 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 25 Sep 2026 14:48:45 +0200 Subject: [PATCH] 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. --- docs/DESIGN.md | 4 + frontend/src/components/PlexPlayer.tsx | 79 ++++++++----------- .../src/components/plexPlayerControls.tsx | 6 +- frontend/src/i18n/locales/en/plex.json | 1 + frontend/src/i18n/locales/hu/plex.json | 1 + .../lib/plexPlayerHooks.controls.dom.test.tsx | 47 +++++++++++ frontend/src/lib/plexPlayerHooks.ts | 18 ++++- frontend/src/lib/plexPlayerPrefs.ts | 1 - 8 files changed, 106 insertions(+), 51 deletions(-) create mode 100644 frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 07c3b91c..4d6cd671 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -288,6 +288,10 @@ Wherever there is text, the user can select it and copy it, and no click action click-to-pause. Measured live: before this, a tap with the bar hidden both revealed it and paused. [Nielsen #3] - **The hide timer starts with playback,** not with the first touch (before, on touch, the bar stayed up until the first tap however long the video played). +- **The bars stay up while the pointer rests on them** (F22, owner); leaving starts a fresh 3 s. YouTube and + Plex Web do the same: before, the row faded out from under a mouse parked on Next. +- **Volume is the native `Slider`** (A6); the hand-drawn bar had no hover, focus, keyboard or screen-reader + state. A pointer drag gives the focus back, so Space and the arrows keep driving the player. - **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8] - **The Plex control set (C5, the owner's model):** one row that fits a portrait tablet. diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 90042584..049d448a 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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(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(null); - // Volume bar: a 0–100 value tooltip under the cursor (mirrors the seekbar), plus click/drag to set. - const [volHover, setVolHover] = useState(null); // 0..1 under the cursor - const volBarRef = useRef(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 */}
)} - {/* 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. */}
{ - 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 */} -
setVol(v, v === 0)} + onPointerUp={(e) => e.currentTarget.blur()} /> - {/* thumb */} -
- {/* hover value tooltip (0–100), like the seek bar */} - {volHover != null && ( -
- {Math.round(volHover * 100)} + {volHover && ( +
+ {Math.round((muted ? 0 : volume) * 100)}
)}
diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx index d7e1938b..60cd343f 100644 --- a/frontend/src/components/plexPlayerControls.tsx +++ b/frontend/src/components/plexPlayerControls.tsx @@ -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 (
{label} diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 6b51bb30..bae95a57 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 158fd1da..2c166d66 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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", diff --git a/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx b/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx new file mode 100644 index 00000000..f6eff430 --- /dev/null +++ b/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx @@ -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); + }); +}); diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 3dd1318a..e5c4f3e6 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -70,24 +70,36 @@ export function useFullscreen(wrapRef: RefObject): { // 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