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:
npeter83 2026-07-08 23:48:09 +02:00
parent c190c0e375
commit 089eab76e4

View file

@ -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 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 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 && (
<span
className="absolute inset-y-0 left-0 bg-accent/40"
style={{ width: `${skipProgress * 100}%` }}
/>
<>
{/* draining fill across the whole button… */}
<span
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 0100
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 (0100), 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">