feat(plex): player polish — clock overlay, sub shadow, auto-skip, seekbar hover

- Wall clock is now a Clock settings tab: show/hide, 24h↔12h (am/pm), size, colour, and
  an optional date in two fixed styles (EN "08-JUL-2026 Wed" / HU "2026-JÚL-08 Sze").
- Subtitle text-shadow (blur strength + colour) in the Subtitle tab.
- Auto-skip intro/credits: per-user toggles + a shared 0–10s delay (default 5). Entering a
  marker whose auto-skip is on runs a countdown shown as a fill on the Skip button; on
  completion it skips (intro → marker end, credits → next item / binge). Esc/Backspace during
  the countdown cancels it and does NOT navigate back. 0s = immediate.
- Seekbar hover shows a timestamp tooltip that follows the cursor.
- i18n en/hu/de for the new strings.
This commit is contained in:
npeter83 2026-07-08 22:38:47 +02:00
parent 3c622fd44c
commit c190c0e375
4 changed files with 275 additions and 16 deletions

View file

@ -74,6 +74,17 @@ type PlexPlayerPrefs = {
subColor: string; // subtitle text color (hex)
subBg: string; // subtitle background ("transparent" or hex)
subPos: number; // subtitle vertical position (cue line %, higher = lower on screen)
subShadow: number; // subtitle text-shadow blur px (0 = off)
subShadowColor: string; // subtitle text-shadow color (hex)
clockShow: boolean; // top-right wall clock
clock24h: boolean; // 24h (European) vs 12h am/pm
clockSize: number; // clock font size (%)
clockColor: string; // clock color (hex)
clockDate: boolean; // show the date alongside the time
clockDateStyle: "en" | "hu"; // DD-MON-YYYY Ddd vs YYYY-MON-DD Ddd
autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate)
};
const DEFAULT_PREFS: PlexPlayerPrefs = {
volume: 1,
@ -90,8 +101,37 @@ const DEFAULT_PREFS: PlexPlayerPrefs = {
subColor: "#ffffff",
subBg: "transparent",
subPos: 88,
subShadow: 0,
subShadowColor: "#000000",
clockShow: true,
clock24h: true,
clockSize: 100,
clockColor: "#ffffff",
clockDate: false,
clockDateStyle: "hu",
autoSkipIntro: false,
autoSkipCredits: false,
autoSkipDelay: 5,
};
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
// 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 } {
const time = now.toLocaleTimeString(p.clock24h ? "en-GB" : "en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: !p.clock24h,
});
if (!p.clockDate) return { time, date: null };
const loc = p.clockDateStyle === "hu" ? "hu-HU" : "en-GB";
const dd = String(now.getDate()).padStart(2, "0");
const yyyy = now.getFullYear();
const mon = now.toLocaleString(loc, { month: "short" }).replace(/\./g, "").toUpperCase();
const dow = now.toLocaleString(loc, { weekday: "short" }).replace(/\./g, "");
const date = p.clockDateStyle === "hu" ? `${yyyy}-${mon}-${dd} ${dow}` : `${dd}-${mon}-${yyyy} ${dow}`;
return { time, date };
}
// hls.js plays our non-zero-start `-copyts` HLS stream ~1.0s BEHIND the media clock (measured by
// frame-capture: the first frame at source K lands at currentTime≈1.0, not 0 — constant, framerate- and
// segment-type-independent). Native-time subtitles (shifted by K) would therefore run 1s early, so we
@ -137,6 +177,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
const multiAudioRef = useRef(false);
multiAudioRef.current = (detail?.audio_streams.length ?? 0) > 1;
// Auto-skip countdown state, via refs so the keyboard handler (defined earlier than the skip
// machinery) can read/cancel it without a declaration-order dependency.
const skipProgressRef = useRef<number | null>(null);
const cancelAutoSkipRef = useRef<() => void>(() => {});
const [playing, setPlaying] = useState(false);
const [abs, setAbs] = useState(0); // absolute current time
@ -177,7 +221,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}, [prefs.subOffset]);
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle">("sync");
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">("sync");
const audioRef = useRef<number | null>(null);
const menuOpenRef = useRef(false);
const menuRef = useRef<HTMLDivElement>(null);
@ -557,6 +601,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{ key: "sync" as const, label: t("plex.player.sync") },
{ key: "playback" as const, label: t("plex.player.playback") },
...(textSubs.length > 0 ? [{ key: "subtitle" as const, label: t("plex.player.subStyle") }] : []),
{ key: "clock" as const, label: t("plex.player.clock") },
];
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
@ -679,7 +724,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
case "Backspace":
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
e.preventDefault();
if (menuOpenRef.current) {
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);
@ -687,7 +733,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else onClose();
break;
case "Escape":
if (menuOpenRef.current) {
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
@ -703,6 +750,54 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const activeMarker: PlexMarker | undefined = detail?.markers.find(
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const activeMarkerRef = useRef(activeMarker);
activeMarkerRef.current = activeMarker;
// Auto-skip intro/credits: entering a marker whose auto-skip is on runs a countdown (skipProgress
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — intro → jump to the
// marker end, credits → next item (binge). Esc/Backspace during the countdown cancels it (and must
// NOT navigate back). 0s delay = skip immediately.
const [skipProgress, setSkipProgress] = useState<number | null>(null);
skipProgressRef.current = skipProgress;
const cancelledMarkerRef = useRef<string | null>(null);
const doSkip = useCallback(
(m: PlexMarker) => {
setSkipProgress(null);
if (m.type === "credits" && nextId) go(nextId);
else seekTo(m.end_s);
},
[go, nextId, seekTo],
);
const cancelAutoSkip = useCallback(() => {
const m = activeMarkerRef.current;
if (m) cancelledMarkerRef.current = `${m.type}-${m.start_s}`; // don't re-arm this same marker
setSkipProgress(null);
}, []);
cancelAutoSkipRef.current = cancelAutoSkip;
useEffect(() => {
const m = activeMarker;
if (!m) return setSkipProgress(null);
const key = `${m.type}-${m.start_s}`;
const enabled = m.type === "intro" ? prefs.autoSkipIntro : prefs.autoSkipCredits;
if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null);
if (prefs.autoSkipDelay <= 0) return doSkip(m); // immediate
let elapsed = 0;
setSkipProgress(0);
const iv = window.setInterval(() => {
elapsed += 0.1;
const p = Math.min(1, elapsed / prefs.autoSkipDelay);
setSkipProgress(p);
if (p >= 1) {
window.clearInterval(iv);
doSkip(m);
}
}, 100);
return () => window.clearInterval(iv);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeMarker?.type, activeMarker?.start_s, prefs.autoSkipIntro, prefs.autoSkipCredits, prefs.autoSkipDelay, doSkip]);
// Seekbar hover: a timestamp tooltip that follows the cursor.
const [hover, setHover] = useState<{ x: number; t: number } | null>(null);
const pct = duration > 0 ? (abs / duration) * 100 : 0;
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
@ -718,7 +813,9 @@ 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};}`}</style>
<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>
<video
ref={videoRef}
className="max-h-full max-w-full w-auto h-auto"
@ -771,23 +868,43 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
)}
</div>
{/* Live wall clock — handy on a lean-back / HTPC screen. */}
<div className="ml-auto flex items-center gap-1.5 text-sm tabular-nums text-white/80 shrink-0">
<Clock className="w-4 h-4" />
{now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
{/* Live wall clock — handy on a lean-back / HTPC screen (toggle + style in the Clock tab). */}
{prefs.clockShow &&
(() => {
const c = clockParts(now, prefs);
return (
<div
className="ml-auto flex items-center gap-1.5 tabular-nums shrink-0"
style={{ color: prefs.clockColor, fontSize: `${prefs.clockSize}%` }}
>
<Clock className="h-4 w-4 opacity-70" />
<span>
{c.time}
{c.date ? ` · ${c.date}` : ""}
</span>
</div>
);
})()}
</div>
</div>
{/* Skip intro / credits */}
{/* Skip intro / credits (with the auto-skip countdown fill, if armed) */}
{activeMarker && (
<button
onClick={() => seekTo(activeMarker.end_s)}
className={`absolute right-6 bottom-28 px-4 py-2 rounded-lg bg-white/90 text-black text-sm font-medium hover:bg-white transition ${
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 ${
uiVisible ? "opacity-100" : "opacity-80"
}`}
>
{activeMarker.type === "intro" ? t("plex.player.skipIntro") : t("plex.player.skipCredits")}
{skipProgress != null && (
<span
className="absolute inset-y-0 left-0 bg-accent/40"
style={{ width: `${skipProgress * 100}%` }}
/>
)}
<span className="relative">
{activeMarker.type === "intro" ? t("plex.player.skipIntro") : t("plex.player.skipCredits")}
</span>
</button>
)}
@ -804,7 +921,22 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
seekTo(((e.clientX - r.left) / r.width) * duration);
}}
onMouseMove={(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 });
}}
onMouseLeave={() => setHover(null)}
>
{/* Hover timestamp tooltip — follows the cursor along the bar. */}
{hover && duration > 0 && (
<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: `${hover.x}px` }}
>
{fmt(hover.t)}
</div>
)}
<div className="absolute inset-y-0 left-0 bg-white/30 rounded-full" style={{ width: `${bufPct}%` }} />
{/* intro/credit marker regions */}
{detail?.markers.map((m, i) => (
@ -1010,6 +1142,27 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
checked={prefs.barAutoHide}
onChange={(v) => patchPrefs({ barAutoHide: v })}
/>
<Toggle
label={t("plex.player.autoSkipIntro")}
checked={prefs.autoSkipIntro}
onChange={(v) => patchPrefs({ autoSkipIntro: v })}
/>
<Toggle
label={t("plex.player.autoSkipCredits")}
checked={prefs.autoSkipCredits}
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
/>
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
<Slider
label={t("plex.player.autoSkipDelay")}
value={prefs.autoSkipDelay}
min={0}
max={10}
step={1}
suffix="s"
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
/>
)}
</>
)}
@ -1064,6 +1217,79 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
)}
</div>
</div>
<Slider
label={t("plex.player.subShadow")}
value={prefs.subShadow}
min={0}
max={10}
step={1}
suffix="px"
onChange={(v) => patchPrefs({ subShadow: v })}
/>
{prefs.subShadow > 0 && (
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[12px] text-white/80">{t("plex.player.subShadowColor")}</span>
<input
type="color"
value={prefs.subShadowColor}
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded bg-transparent"
/>
</div>
)}
</>
)}
{activeTab === "clock" && (
<>
<Toggle
label={t("plex.player.clockShow")}
checked={prefs.clockShow}
onChange={(v) => patchPrefs({ clockShow: v })}
/>
<Toggle
label={t("plex.player.clock24h")}
checked={prefs.clock24h}
onChange={(v) => patchPrefs({ clock24h: v })}
/>
<Slider
label={t("plex.player.clockSize")}
value={prefs.clockSize}
min={70}
max={200}
step={5}
suffix="%"
onChange={(v) => patchPrefs({ clockSize: v })}
/>
<div className="flex items-center justify-between px-1 py-1.5">
<span className="text-[12px] text-white/80">{t("plex.player.clockColor")}</span>
<input
type="color"
value={prefs.clockColor}
onChange={(e) => patchPrefs({ clockColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded bg-transparent"
/>
</div>
<Toggle
label={t("plex.player.clockDate")}
checked={prefs.clockDate}
onChange={(v) => patchPrefs({ clockDate: v })}
/>
{prefs.clockDate && (
<div className="flex gap-1 px-1 py-1">
{(["hu", "en"] as const).map((st) => (
<button
key={st}
onClick={() => patchPrefs({ clockDateStyle: st })}
className={`flex-1 rounded px-2 py-1 text-[11px] ${
prefs.clockDateStyle === st ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
}`}
>
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
</button>
))}
</div>
)}
</>
)}
</div>