From 23070279a39b27da699e0e76049e670ab19658b8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 9 Jul 2026 01:12:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20player=20UAT=20round=202=20?= =?UTF-8?q?=E2=80=94=20mouse-back,=20+25%=20UI,=20seek=20drag,=20volume=20?= =?UTF-8?q?revert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- frontend/src/components/PlexPlayer.tsx | 106 ++++++++++++++++++++----- 1 file changed, 85 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 7a96947..1409a85 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -35,6 +35,7 @@ import { import { api, type PlexMarker } from "../lib/api"; import { useAccountPersistedObject } from "../lib/storage"; 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. const PlexInfo = lazy(() => import("./PlexInfo")); @@ -710,6 +711,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { else 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 -------------------------------------------------------------------------------- useEffect(() => { 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. 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(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); @@ -861,15 +878,29 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const pct = duration > 0 ? (abs / 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 (
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this player owns the only
)} + {/* 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) && ( + + )} + {/* Top bar */}
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" }`} > @@ -983,17 +1020,33 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { 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. */}
{ - const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); - seekTo(((e.clientX - r.left) / r.width) * duration); + onPointerDown={(e) => { + const el = e.currentTarget as HTMLElement; + 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 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)} > @@ -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}%` }} /> ))} -
+
@@ -1047,15 +1102,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { {muted || volume === 0 ? : } - {/* Custom volume bar: the track is a loudness gradient (blue = too quiet, green = normal, - red = too loud); the reached level is bright, the rest dimmed; hover shows the 0–100 + {/* Volume bar: plain accent fill on a neutral track (as before); hover shows the 0–100 value under the cursor; click/drag sets it. */}
{ - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + 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); }} @@ -1066,14 +1123,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { }} onMouseLeave={() => setVolHover(null)} > - {/* dim the louder (unreached) part so the bright section reads as the current level */} + {/* filled level */}
{/* thumb */}
{/* hover value tooltip (0–100), 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 ------------------------------------------------------------ function PanelHead({ children }: { children: ReactNode }) { return
{children}
;