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:
parent
dcdf48a811
commit
23070279a3
1 changed files with 85 additions and 21 deletions
|
|
@ -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 0–100 value tooltip under the cursor (mirrors the seekbar), plus click/drag to set.
|
// 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 [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 0–100
|
||||||
red = too loud); the reached level is bright, the rest dimmed; hover shows the 0–100
|
|
||||||
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) => {
|
||||||
(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);
|
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 (0–100), like the seek bar */}
|
{/* 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 ------------------------------------------------------------
|
// --- 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>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue