From c190c0e37574799830edd8750db392b939a0be00 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 8 Jul 2026 22:38:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20player=20polish=20=E2=80=94=20clo?= =?UTF-8?q?ck=20overlay,=20sub=20shadow,=20auto-skip,=20seekbar=20hover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- frontend/src/components/PlexPlayer.tsx | 252 +++++++++++++++++++++++-- frontend/src/i18n/locales/de/plex.json | 13 +- frontend/src/i18n/locales/en/plex.json | 13 +- frontend/src/i18n/locales/hu/plex.json | 13 +- 4 files changed, 275 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 0463506..da50469 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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(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(null); const menuOpenRef = useRef(false); const menuRef = useRef(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(null); + skipProgressRef.current = skipProgress; + const cancelledMarkerRef = useRef(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