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:
parent
3c622fd44c
commit
c190c0e375
4 changed files with 275 additions and 16 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -138,7 +138,18 @@
|
|||
"subPos": "Position",
|
||||
"subColor": "Textfarbe",
|
||||
"subBg": "Hintergrund",
|
||||
"subBgNone": "Keiner"
|
||||
"subBgNone": "Keiner",
|
||||
"clock": "Uhr",
|
||||
"clockShow": "Uhr anzeigen",
|
||||
"clock24h": "24-Stunden",
|
||||
"clockSize": "Größe",
|
||||
"clockColor": "Farbe",
|
||||
"clockDate": "Datum anzeigen",
|
||||
"subShadow": "Schatten",
|
||||
"subShadowColor": "Schattenfarbe",
|
||||
"autoSkipIntro": "Intro autom. überspringen",
|
||||
"autoSkipCredits": "Abspann autom. überspringen",
|
||||
"autoSkipDelay": "Auto-Skip-Verzögerung"
|
||||
},
|
||||
"info": {
|
||||
"title": "Medieninfo",
|
||||
|
|
|
|||
|
|
@ -138,7 +138,18 @@
|
|||
"subPos": "Position",
|
||||
"subColor": "Text color",
|
||||
"subBg": "Background",
|
||||
"subBgNone": "None"
|
||||
"subBgNone": "None",
|
||||
"clock": "Clock",
|
||||
"clockShow": "Show clock",
|
||||
"clock24h": "24-hour",
|
||||
"clockSize": "Size",
|
||||
"clockColor": "Color",
|
||||
"clockDate": "Show date",
|
||||
"subShadow": "Shadow",
|
||||
"subShadowColor": "Shadow color",
|
||||
"autoSkipIntro": "Auto-skip intro",
|
||||
"autoSkipCredits": "Auto-skip credits",
|
||||
"autoSkipDelay": "Auto-skip delay"
|
||||
},
|
||||
"info": {
|
||||
"title": "Media info",
|
||||
|
|
|
|||
|
|
@ -138,7 +138,18 @@
|
|||
"subPos": "Pozíció",
|
||||
"subColor": "Szövegszín",
|
||||
"subBg": "Háttér",
|
||||
"subBgNone": "Nincs"
|
||||
"subBgNone": "Nincs",
|
||||
"clock": "Óra",
|
||||
"clockShow": "Óra megjelenítése",
|
||||
"clock24h": "24 órás",
|
||||
"clockSize": "Méret",
|
||||
"clockColor": "Szín",
|
||||
"clockDate": "Dátum",
|
||||
"subShadow": "Árnyék",
|
||||
"subShadowColor": "Árnyék színe",
|
||||
"autoSkipIntro": "Intro auto-átugrás",
|
||||
"autoSkipCredits": "Stáblista auto-átugrás",
|
||||
"autoSkipDelay": "Auto-skip késleltetés"
|
||||
},
|
||||
"info": {
|
||||
"title": "Média infó",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue