feat(plex): player UAT round 2 — mouse-back, +25% UI, seek drag, volume revert

- Mouse/browser Back button now behaves like Backspace/Esc: while a
  menu, the shortcuts sheet, the info overlay or an auto-skip countdown
  is up, Back closes/cancels that (via a history entry) instead of
  dropping straight to the grid; with nothing open it still leaves.
- Whole player UI is 25% larger for lean-back/HTPC legibility, applied
  as transform: scale(1.25) on an 80vw x 80vh root (fills the viewport
  exactly). transform, not zoom — under zoom, event clientX and
  getBoundingClientRect fall into different spaces and the seek/volume/
  hover math breaks; transform keeps them aligned. Skip buttons ride
  this scale (reverted their separate enlargement so they're +25% too).
- Seek bar: drag the head to scrub, not just click-to-jump; the head
  previews the target and the seek commits on release (pointer capture),
  so a long drag doesn't restart the stream on every step.
- Volume bar: dropped the loudness gradient — back to the plain accent
  fill; the 0-100 hover value tooltip stays.
- Harden both bars' pointer-capture calls (try/catch) so a capture
  failure can't abort the click/drag.
This commit is contained in:
npeter83 2026-07-09 01:12:10 +02:00
parent dcdf48a811
commit 23070279a3

View file

@ -35,6 +35,7 @@ import {
import { api, type PlexMarker } from "../lib/api"; import { api, type PlexMarker } from "../lib/api";
import { useAccountPersistedObject } from "../lib/storage"; import { useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { useBackToClose } from "../lib/history";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo")); const PlexInfo = lazy(() => import("./PlexInfo"));
@ -710,6 +711,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else onClose(); else onClose();
}, [onClose]); }, [onClose]);
// The mouse "back" button (and the browser Back) fire history popstate — which would pop the whole
// player subview and drop straight to the grid, even with a panel or an auto-skip countdown up.
// While any of those is showing we register a history entry (BackClose below) whose Back handler
// runs this: cancel the countdown, else close the topmost panel — matching ⌫ / Esc exactly.
const backIntent = useCallback(() => {
if (skipProgressRef.current != null) {
cancelAutoSkipRef.current();
return;
}
handleBack();
}, [handleBack]);
// --- keyboard -------------------------------------------------------------------------------- // --- keyboard --------------------------------------------------------------------------------
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@ -849,6 +862,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Seekbar hover: a timestamp tooltip that follows the cursor. // Seekbar hover: a timestamp tooltip that follows the cursor.
const [hover, setHover] = useState<{ x: number; t: number } | null>(null); const [hover, setHover] = useState<{ x: number; t: number } | null>(null);
// Seekbar scrubbing: the absolute time under the dragging finger. While non-null the head/thumb
// 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 0100 value tooltip under the cursor (mirrors the seekbar), plus click/drag to set. // Volume bar: a 0100 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 [volHover, setVolHover] = useState<number | null>(null); // 0..1 under the cursor
const volBarRef = useRef<HTMLDivElement>(null); const volBarRef = useRef<HTMLDivElement>(null);
@ -861,15 +878,29 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const pct = duration > 0 ? (abs / duration) * 100 : 0; const pct = duration > 0 ? (abs / duration) * 100 : 0;
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0; const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
// While scrubbing the head follows the finger (preview); otherwise it tracks playback.
const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;
return ( return (
<div <div
ref={wrapRef} ref={wrapRef}
className="fixed inset-0 z-50 bg-black flex items-center justify-center select-none" className="fixed left-0 top-0 z-50 bg-black flex items-center justify-center select-none"
onMouseMove={wake} onMouseMove={wake}
onClick={wake} onClick={wake}
onWheel={onWheel} onWheel={onWheel}
style={{ cursor: uiVisible ? "default" : "none" }} // Everything in the player is 25% larger for lean-back / HTPC legibility. Laid out at 80vw×80vh
// and scaled ×1.25 from the top-left so it fills the viewport exactly (no overflow, controls stay
// on-screen). We use `transform: scale` rather than `zoom` deliberately: under `zoom`, event
// clientX stays in CSS pixels while getBoundingClientRect returns zoomed pixels, so the seek/
// volume/hover math (clientX rect.left)/rect.width breaks; with `transform` both are in the same
// viewport space, so that ratio stays correct. Skip buttons ride this too → +25% like everything.
style={{
cursor: uiVisible ? "default" : "none",
width: "80vw",
height: "80vh",
transform: "scale(1.25)",
transformOrigin: "top left",
}}
> >
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this {/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
player owns the only <video> on screen. */} player owns the only <video> on screen. */}
@ -909,6 +940,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div> </div>
)} )}
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
{(menuOpen || tracksOpen || helpOpen || infoOpen || skipProgress != null) && (
<BackClose onBack={backIntent} />
)}
{/* Top bar */} {/* Top bar */}
<div <div
className={`absolute top-0 inset-x-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${ className={`absolute top-0 inset-x-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${
@ -953,7 +990,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{activeMarker && ( {activeMarker && (
<button <button
onClick={() => doSkip(activeMarker)} onClick={() => doSkip(activeMarker)}
className={`absolute right-6 bottom-28 overflow-hidden rounded-lg bg-white/90 px-5 py-2.5 text-[1.09rem] font-medium text-black transition hover:bg-white ${ className={`absolute right-6 bottom-28 overflow-hidden rounded-lg bg-white/90 px-4 py-2 text-sm font-medium text-black transition hover:bg-white ${
uiVisible ? "opacity-100" : "opacity-80" uiVisible ? "opacity-100" : "opacity-80"
}`} }`}
> >
@ -983,17 +1020,33 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none" uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
}`} }`}
> >
{/* Seek bar */} {/* Seek bar click to jump, or drag the head (pointer capture) to scrub; the seek commits on
release so a long drag doesn't restart the stream on every step. */}
<div <div
className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2" className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
onClick={(e) => { onPointerDown={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); const el = e.currentTarget as HTMLElement;
seekTo(((e.clientX - r.left) / r.width) * duration); try {
el.setPointerCapture(e.pointerId);
} catch {
/* capture unavailable — dragging still works via buttons state */
}
const r = el.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
setScrub((x / r.width) * duration);
}} }}
onMouseMove={(e) => { onPointerMove={(e) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - r.left, r.width)); const x = Math.max(0, Math.min(e.clientX - r.left, r.width));
setHover({ x, t: (x / r.width) * duration }); const tt = (x / r.width) * duration;
setHover({ x, t: tt });
if (e.buttons & 1) setScrub(tt); // dragging with the primary button held
}}
onPointerUp={() => {
if (scrub != null) {
seekTo(scrub);
setScrub(null);
}
}} }}
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
> >
@ -1015,10 +1068,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
style={{ left: `${(m.start_s / duration) * 100}%`, width: `${((m.end_s - m.start_s) / duration) * 100}%` }} style={{ left: `${(m.start_s / duration) * 100}%`, width: `${((m.end_s - m.start_s) / duration) * 100}%` }}
/> />
))} ))}
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${pct}%` }} /> <div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${headPct}%` }} />
<div <div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent opacity-0 group-hover:opacity-100" className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent transition-opacity ${
style={{ left: `${pct}%` }} scrub != null ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
style={{ left: `${headPct}%` }}
/> />
</div> </div>
@ -1047,15 +1102,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}> <Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />} {muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</Ctrl> </Ctrl>
{/* Custom volume bar: the track is a loudness gradient (blue = too quiet, green = normal, {/* Volume bar: plain accent fill on a neutral track (as before); hover shows the 0100
red = too loud); the reached level is bright, the rest dimmed; hover shows the 0100
value under the cursor; click/drag sets it. */} value under the cursor; click/drag sets it. */}
<div <div
ref={volBarRef} ref={volBarRef}
className="relative h-1.5 w-24 cursor-pointer rounded-full" className="relative h-1.5 w-24 cursor-pointer rounded-full bg-white/25"
style={{ background: "linear-gradient(to right,#3b82f6 0%,#22c55e 30%,#22c55e 68%,#ef4444 100%)" }}
onPointerDown={(e) => { onPointerDown={(e) => {
try {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
} catch {
/* capture unavailable — dragging still works via buttons state */
}
const v = volAt(e.clientX); const v = volAt(e.clientX);
setVol(v, v === 0); setVol(v, v === 0);
}} }}
@ -1066,14 +1123,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}} }}
onMouseLeave={() => setVolHover(null)} onMouseLeave={() => setVolHover(null)}
> >
{/* dim the louder (unreached) part so the bright section reads as the current level */} {/* filled level */}
<div <div
className="absolute inset-y-0 right-0 rounded-full bg-black/55" className="absolute inset-y-0 left-0 rounded-full bg-accent"
style={{ left: `${(muted ? 0 : volume) * 100}%` }} style={{ width: `${(muted ? 0 : volume) * 100}%` }}
/> />
{/* thumb */} {/* thumb */}
<div <div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow" className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent shadow"
style={{ left: `${(muted ? 0 : volume) * 100}%` }} style={{ left: `${(muted ? 0 : volume) * 100}%` }}
/> />
{/* hover value tooltip (0100), like the seek bar */} {/* hover value tooltip (0100), like the seek bar */}
@ -1501,6 +1558,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
); );
} }
// Renders nothing; while mounted it claims one history entry so the browser/mouse Back button runs
// `onBack` (close the panel / cancel the skip) instead of navigating out of the player.
function BackClose({ onBack }: { onBack: () => void }) {
useBackToClose(onBack);
return null;
}
// --- settings-panel building blocks ------------------------------------------------------------ // --- settings-panel building blocks ------------------------------------------------------------
function PanelHead({ children }: { children: ReactNode }) { function PanelHead({ children }: { children: ReactNode }) {
return <div className="px-1 pt-2 pb-1 text-[11px] uppercase tracking-wide text-white/50">{children}</div>; return <div className="px-1 pt-2 pb-1 text-[11px] uppercase tracking-wide text-white/50">{children}</div>;