fix(plex): player UAT round — resume, back, subtitles, skip, ETA, volume
- Resume: closing with Back now leaves the resume point correct. The position was saved server-side, but reopening the item in the same session read react-query's cached detail (old position); patch that cache on unmount so a reopen resumes where you left off (F5 was fine — empty cache after a reload). - Back cascade: the on-screen Back arrow and Backspace now close an open tuning/tracks menu, then the shortcuts sheet, then the info overlay, and only leave the player when nothing is layered on top (it used to jump straight to the feed with a panel still open). - Subtitle shadow: replace the single 0-offset blur (invisible even at max) with an eight-direction outline plus glow that scales with the slider — legible over any frame. - Skip Intro/Credits: ~25% larger button and text; add a crisp bottom progress bar to the auto-skip countdown; and freeze the countdown while the video is paused so it no longer auto-seeks a paused video. - Time readout: append the projected finish time (wall clock + time remaining) in the clock's own 24h/12h format. - Volume: replace the native slider with a bar that shows the 0-100 value under the cursor (like the seek bar) and a loudness gradient — blue (too quiet) → green (normal) → red (too loud).
This commit is contained in:
parent
c190c0e375
commit
089eab76e4
1 changed files with 120 additions and 29 deletions
|
|
@ -115,6 +115,22 @@ const DEFAULT_PREFS: PlexPlayerPrefs = {
|
|||
};
|
||||
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||
|
||||
// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even
|
||||
// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the
|
||||
// glyphs) plus a soft glow, all scaled by the slider — legible over anything, e.g. yellow text on a
|
||||
// snow-white frame. Empty string when the slider is 0 (shadow off).
|
||||
function subShadowCss(px: number, color: string): string {
|
||||
if (px <= 0) return "";
|
||||
const o = px;
|
||||
const b = Math.max(1, Math.round(px / 2));
|
||||
const dirs: [number, number][] = [
|
||||
[o, 0], [-o, 0], [0, o], [0, -o],
|
||||
[o, o], [o, -o], [-o, o], [-o, -o],
|
||||
];
|
||||
const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(",");
|
||||
return `text-shadow:${outline},0 0 ${o * 2}px ${color};`;
|
||||
}
|
||||
|
||||
// Top-right wall clock text: time (24h European or 12h am/pm) + optional date in one of two fixed
|
||||
// styles — EN "08-JUL-2026 Wed" or HU "2026-JÚL-08 Sze" (uppercase short month, short weekday).
|
||||
function clockParts(now: Date, p: PlexPlayerPrefs): { time: string; date: string | null } {
|
||||
|
|
@ -431,6 +447,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
window.clearInterval(iv);
|
||||
window.removeEventListener("pagehide", onHide);
|
||||
flush();
|
||||
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
|
||||
// the SAME session read react-query's CACHED detail — whose position_seconds predated this
|
||||
// watch — so the resume landed at the old spot (the progress GET could also race the POST).
|
||||
// Patch the cached detail here so a reopen resumes from where we left off. (F5 was always fine:
|
||||
// a reload starts with an empty cache and fetches the fresh position.) Safe to do only on
|
||||
// unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
|
||||
if (absRef.current > 0 && durationRef.current > 0) {
|
||||
const pos = Math.floor(absRef.current);
|
||||
qc.setQueryData(["plex-item", id], (old) =>
|
||||
old ? { ...old, position_seconds: pos } : old,
|
||||
);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||
};
|
||||
|
|
@ -669,6 +697,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
}, 3000);
|
||||
}, []);
|
||||
|
||||
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
|
||||
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
|
||||
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
|
||||
// with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel.
|
||||
const handleBack = useCallback(() => {
|
||||
if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// --- keyboard --------------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
|
|
@ -722,15 +763,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
setInfoOpen((v) => !v);
|
||||
break;
|
||||
case "Backspace":
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||
e.preventDefault();
|
||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
|
||||
else if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
setTracksOpen(false);
|
||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else onClose();
|
||||
else handleBack();
|
||||
break;
|
||||
case "Escape":
|
||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||
|
|
@ -745,7 +781,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, toggleMute, cycleAudio, cycleSubtitle, detail, prevId, nextId, onClose, wake]);
|
||||
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, toggleMute, cycleAudio, cycleSubtitle, detail, prevId, nextId, onClose, wake, handleBack]);
|
||||
|
||||
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||
|
|
@ -784,6 +820,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
let elapsed = 0;
|
||||
setSkipProgress(0);
|
||||
const iv = window.setInterval(() => {
|
||||
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
|
||||
// deliberately-paused video. It resumes from where it stopped when playback resumes.
|
||||
if (videoRef.current?.paused) return;
|
||||
elapsed += 0.1;
|
||||
const p = Math.min(1, elapsed / prefs.autoSkipDelay);
|
||||
setSkipProgress(p);
|
||||
|
|
@ -798,6 +837,15 @@ 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);
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
const pct = duration > 0 ? (abs / duration) * 100 : 0;
|
||||
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
|
||||
|
|
@ -813,9 +861,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
>
|
||||
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
|
||||
player owns the only <video> on screen. */}
|
||||
<style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};${
|
||||
prefs.subShadow > 0 ? `text-shadow:0 0 ${prefs.subShadow}px ${prefs.subShadowColor},0 0 ${prefs.subShadow}px ${prefs.subShadowColor};` : ""
|
||||
}}`}</style>
|
||||
<style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};${subShadowCss(
|
||||
prefs.subShadow,
|
||||
prefs.subShadowColor,
|
||||
)}}`}</style>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="max-h-full max-w-full w-auto h-auto"
|
||||
|
|
@ -855,7 +904,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/15" title={t("plex.player.back")}>
|
||||
<button onClick={handleBack} className="p-1.5 rounded-lg hover:bg-white/15" title={t("plex.player.back")}>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
|
|
@ -892,15 +941,23 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
{activeMarker && (
|
||||
<button
|
||||
onClick={() => doSkip(activeMarker)}
|
||||
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 ${
|
||||
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 ${
|
||||
uiVisible ? "opacity-100" : "opacity-80"
|
||||
}`}
|
||||
>
|
||||
{skipProgress != null && (
|
||||
<>
|
||||
{/* draining fill across the whole button… */}
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 bg-accent/40"
|
||||
className="absolute inset-y-0 left-0 bg-accent/50"
|
||||
style={{ width: `${skipProgress * 100}%` }}
|
||||
/>
|
||||
{/* …plus a crisp bottom bar so the countdown is unmistakable */}
|
||||
<span
|
||||
className="absolute bottom-0 left-0 h-1 bg-accent"
|
||||
style={{ width: `${skipProgress * 100}%` }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="relative">
|
||||
{activeMarker.type === "intro" ? t("plex.player.skipIntro") : t("plex.player.skipCredits")}
|
||||
|
|
@ -974,27 +1031,61 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 group/vol">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
|
||||
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
|
||||
</Ctrl>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
const nv = Number(e.target.value);
|
||||
setVol(nv, nv === 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
|
||||
value under the cursor; click/drag sets it. */}
|
||||
<div
|
||||
ref={volBarRef}
|
||||
className="relative h-1.5 w-24 cursor-pointer rounded-full"
|
||||
style={{ background: "linear-gradient(to right,#3b82f6 0%,#22c55e 30%,#22c55e 68%,#ef4444 100%)" }}
|
||||
onPointerDown={(e) => {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const v = volAt(e.clientX);
|
||||
setVol(v, v === 0);
|
||||
}}
|
||||
className="w-20 accent-accent"
|
||||
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)}
|
||||
>
|
||||
{/* dim the louder (unreached) part so the bright section reads as the current level */}
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 rounded-full bg-black/55"
|
||||
style={{ left: `${(muted ? 0 : volume) * 100}%` }}
|
||||
/>
|
||||
{/* thumb */}
|
||||
<div
|
||||
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow"
|
||||
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 bg-black/90 px-1.5 py-0.5 text-[11px] tabular-nums text-white"
|
||||
style={{ left: `${volHover * 100}%` }}
|
||||
>
|
||||
{Math.round(volHover * 100)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs tabular-nums text-white/90">
|
||||
{fmt(abs)} / {fmt(duration)}
|
||||
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
|
||||
{/* Projected finish time (wall clock now + time remaining), in the clock's own format —
|
||||
pushes later while paused, since the remaining time then rides the advancing clock. */}
|
||||
{duration > 0 && (
|
||||
<span className="ml-1.5 text-white/45">
|
||||
({clockParts(new Date(now.getTime() + Math.max(0, duration - abs) * 1000), prefs).time})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue