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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user