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));
|
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
|
// 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).
|
// 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 } {
|
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.clearInterval(iv);
|
||||||
window.removeEventListener("pagehide", onHide);
|
window.removeEventListener("pagehide", onHide);
|
||||||
flush();
|
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-browse"] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||||
};
|
};
|
||||||
|
|
@ -669,6 +697,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
}, 3000);
|
}, 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 --------------------------------------------------------------------------------
|
// --- keyboard --------------------------------------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
|
@ -722,15 +763,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
setInfoOpen((v) => !v);
|
setInfoOpen((v) => !v);
|
||||||
break;
|
break;
|
||||||
case "Backspace":
|
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();
|
e.preventDefault();
|
||||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
|
if (skipProgressRef.current != null) cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
|
||||||
else if (menuOpenRef.current) {
|
else handleBack();
|
||||||
setMenuOpen(false);
|
|
||||||
setTracksOpen(false);
|
|
||||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
|
||||||
else if (infoOpenRef.current) setInfoOpen(false);
|
|
||||||
else onClose();
|
|
||||||
break;
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||||
|
|
@ -745,7 +781,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("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(
|
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
||||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
(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;
|
let elapsed = 0;
|
||||||
setSkipProgress(0);
|
setSkipProgress(0);
|
||||||
const iv = window.setInterval(() => {
|
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;
|
elapsed += 0.1;
|
||||||
const p = Math.min(1, elapsed / prefs.autoSkipDelay);
|
const p = Math.min(1, elapsed / prefs.autoSkipDelay);
|
||||||
setSkipProgress(p);
|
setSkipProgress(p);
|
||||||
|
|
@ -798,6 +837,15 @@ 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);
|
||||||
|
// 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 pct = duration > 0 ? (abs / duration) * 100 : 0;
|
||||||
const bufPct = duration > 0 ? (seekableEnd / 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
|
{/* 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. */}
|
||||||
<style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};${
|
<style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};${subShadowCss(
|
||||||
prefs.subShadow > 0 ? `text-shadow:0 0 ${prefs.subShadow}px ${prefs.subShadowColor},0 0 ${prefs.subShadow}px ${prefs.subShadowColor};` : ""
|
prefs.subShadow,
|
||||||
}}`}</style>
|
prefs.subShadowColor,
|
||||||
|
)}}`}</style>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="max-h-full max-w-full w-auto h-auto"
|
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">
|
<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" />
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -892,15 +941,23 @@ 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-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"
|
uiVisible ? "opacity-100" : "opacity-80"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{skipProgress != null && (
|
{skipProgress != null && (
|
||||||
<span
|
<>
|
||||||
className="absolute inset-y-0 left-0 bg-accent/40"
|
{/* draining fill across the whole button… */}
|
||||||
style={{ width: `${skipProgress * 100}%` }}
|
<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">
|
<span className="relative">
|
||||||
{activeMarker.type === "intro" ? t("plex.player.skipIntro") : t("plex.player.skipCredits")}
|
{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}>
|
<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>
|
||||||
<input
|
{/* Custom volume bar: the track is a loudness gradient (blue = too quiet, green = normal,
|
||||||
type="range"
|
red = too loud); the reached level is bright, the rest dimmed; hover shows the 0–100
|
||||||
min={0}
|
value under the cursor; click/drag sets it. */}
|
||||||
max={1}
|
<div
|
||||||
step={0.05}
|
ref={volBarRef}
|
||||||
value={muted ? 0 : volume}
|
className="relative h-1.5 w-24 cursor-pointer rounded-full"
|
||||||
onChange={(e) => {
|
style={{ background: "linear-gradient(to right,#3b82f6 0%,#22c55e 30%,#22c55e 68%,#ef4444 100%)" }}
|
||||||
const nv = Number(e.target.value);
|
onPointerDown={(e) => {
|
||||||
setVol(nv, nv === 0);
|
(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>
|
||||||
|
|
||||||
<div className="text-xs tabular-nums text-white/90">
|
<div className="text-xs tabular-nums text-white/90">
|
||||||
{fmt(abs)} / {fmt(duration)}
|
{fmt(abs)} / {fmt(duration)}
|
||||||
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
|
<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>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue