Files
siftlode/frontend/src/lib/plexPlayerPrefs.ts
T
peter a0e9912886 refactor(plex): extract PlexPlayer pure helpers + control primitives
S4a-1 of the PlexPlayer decomposition. Move the module-level pure
helpers (prefs types/defaults, track-language resolution, subtitle
shadow CSS, wall-clock formatting, HLS_SUB_LAG, fmt) to
lib/plexPlayerPrefs.ts and the presentational building blocks
(Ctrl/Slider/Toggle/PanelHead) to plexPlayerControls.tsx. No hooks or
behaviour touched; PlexPlayer.tsx 1889->1661.
2026-08-12 00:06:21 +02:00

129 lines
5.9 KiB
TypeScript

import { formatDuration } from "./format";
// Audio/subtitle selections are persisted per-account by LANGUAGE, not by ordinal — the same track
// ordinal means different languages in different files, so we re-resolve the saved language to this
// item's track layout on load (and it carries across episodes/movies, like Plex's preferred-language).
export type AudioStream = { ord: number; language?: string | null; default: boolean };
export type SubStream = { ord: number; language?: string | null; text: boolean };
export function audioOrdForLang(streams: AudioStream[], lang: string): number | null {
if (!lang) return null; // "" = use the file's default first track (keeps direct-play, no forced HLS)
const i = streams.findIndex((s) => (s.language ?? "") === lang);
// absent (-1) or already the FIRST track (0, which the backend plays by default) → no override.
// (Match by index, NOT the `default` flag — some files mark a non-first track default, which made
// the restored language silently fall back to track 0.)
return i <= 0 ? null : streams[i]!.ord; // i > 0 here, so streams[i] exists
}
export function subOrdForLang(subs: SubStream[], lang: string): number | null {
if (!lang) return null; // "" = subtitles off
const m = subs.find((s) => s.text && (s.language ?? "") === lang);
return m ? m.ord : null; // preferred language not available here → off
}
// Per-account player preferences, persisted so they survive F5 and carry across items.
export type PlexPlayerPrefs = {
volume: number; // 0..1
muted: boolean;
audioLang: string; // "" = default first audio
subLang: string; // "" = subtitles off
subOffset: number; // seconds, +later / -earlier (client-side cue shift)
audioOffset: number; // seconds, +later / -earlier (server-side -itsoffset)
barAutoHide: boolean; // auto-hide the control bar during playback
seekStep: number; // plain ←/→ jump (s)
fineSeekStep: number; // Ctrl+←/→ jump (s)
subSize: number; // subtitle font size (%)
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)
};
export const DEFAULT_PREFS: PlexPlayerPrefs = {
volume: 1,
muted: false,
audioLang: "",
subLang: "",
subOffset: 0,
audioOffset: 0,
barAutoHide: true,
seekStep: 10,
fineSeekStep: 1,
subSize: 100,
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,
};
export 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).
export 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).
export 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
// bake this compensation into the shift. Only on the HLS path; direct-play uses the file's real clock.
export const HLS_SUB_LAG = 1.0;
// Seekbar clock — same H:MM:SS / M:SS shape as the shared formatDuration, but the live media clock
// can be NaN/negative before metadata loads, so clamp to 0 first (formatDuration never sees null here).
export function fmt(t: number): string {
return formatDuration(isFinite(t) && t >= 0 ? t : 0);
}