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}
;