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.
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
|
||||||
type WheelEvent as ReactWheelEvent,
|
type WheelEvent as ReactWheelEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
@@ -35,7 +34,18 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, HttpError, type PlexMarker } from "../lib/api";
|
import { api, HttpError, type PlexMarker } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
import {
|
||||||
|
audioOrdForLang,
|
||||||
|
clamp01,
|
||||||
|
clockParts,
|
||||||
|
DEFAULT_PREFS,
|
||||||
|
fmt,
|
||||||
|
HLS_SUB_LAG,
|
||||||
|
subOrdForLang,
|
||||||
|
subShadowCss,
|
||||||
|
type PlexPlayerPrefs,
|
||||||
|
} from "../lib/plexPlayerPrefs";
|
||||||
|
import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls";
|
||||||
import {
|
import {
|
||||||
exitFullscreen,
|
exitFullscreen,
|
||||||
fullscreenElement,
|
fullscreenElement,
|
||||||
@@ -51,126 +61,6 @@ import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
|||||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||||
|
|
||||||
// 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).
|
|
||||||
type AudioStream = { ord: number; language?: string | null; default: boolean };
|
|
||||||
type SubStream = { ord: number; language?: string | null; text: boolean };
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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.
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
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
|
|
||||||
// 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
|
|
||||||
// bake this compensation into the shift. Only on the HLS path; direct-play uses the file's real clock.
|
|
||||||
const HLS_SUB_LAG = 1.0;
|
|
||||||
|
|
||||||
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
||||||
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
|
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
|
||||||
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
||||||
@@ -181,12 +71,6 @@ const HLS_SUB_LAG = 1.0;
|
|||||||
// the queue instead of the item's own episode neighbours.
|
// the queue instead of the item's own episode neighbours.
|
||||||
type Props = { itemId: string; onClose: () => void; queue?: string[] };
|
type Props = { itemId: string; onClose: () => void; queue?: string[] };
|
||||||
|
|
||||||
// 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).
|
|
||||||
function fmt(t: number): string {
|
|
||||||
return formatDuration(isFinite(t) && t >= 0 ? t : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
|
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
|
||||||
@@ -1775,115 +1659,3 @@ function BackClose({ onBack }: { onBack: () => void }) {
|
|||||||
useBackToClose(onBack);
|
useBackToClose(onBack);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- settings-panel building blocks ------------------------------------------------------------
|
|
||||||
function PanelHead({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="px-1 pt-2 pb-1 text-2xs uppercase tracking-wide text-white/50">{children}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function Slider({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step,
|
|
||||||
suffix,
|
|
||||||
onChange,
|
|
||||||
onReset,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
step: number;
|
|
||||||
suffix?: string;
|
|
||||||
onChange: (v: number) => void;
|
|
||||||
onReset?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="px-1 py-1">
|
|
||||||
<div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80">
|
|
||||||
<span>{label}</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="tabular-nums text-white/60">
|
|
||||||
{Number.isInteger(value) ? value : value.toFixed(1)}
|
|
||||||
{suffix ?? ""}
|
|
||||||
</span>
|
|
||||||
{onReset && (
|
|
||||||
<button onClick={onReset} className="text-white/40 hover:text-white" aria-label="reset">
|
|
||||||
<RotateCcw className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
step={step}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
className="w-full accent-accent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function Toggle({
|
|
||||||
label,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
|
|
||||||
<span className="text-[0.75rem] text-white/80">{label}</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
className="h-4 w-4 accent-accent"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
|
|
||||||
// viewport, unlike a native title tooltip on the bottom control bar).
|
|
||||||
function Ctrl({
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
align = "center",
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
|
|
||||||
align?: "start" | "center" | "end";
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const pos =
|
|
||||||
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
|
|
||||||
return (
|
|
||||||
<div className="relative flex group/ctrl">
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={label}
|
|
||||||
className="p-1 hover:text-accent disabled:opacity-30"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 transition group-hover/ctrl:opacity-100`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
|
// --- settings-panel building blocks ------------------------------------------------------------
|
||||||
|
export function PanelHead({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="px-1 pt-2 pb-1 text-2xs uppercase tracking-wide text-white/50">{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Slider({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
suffix,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
suffix?: string;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-1 py-1">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="tabular-nums text-white/60">
|
||||||
|
{Number.isInteger(value) ? value : value.toFixed(1)}
|
||||||
|
{suffix ?? ""}
|
||||||
|
</span>
|
||||||
|
{onReset && (
|
||||||
|
<button onClick={onReset} className="text-white/40 hover:text-white" aria-label="reset">
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Toggle({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[0.75rem] text-white/80">{label}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
className="h-4 w-4 accent-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
|
||||||
|
// viewport, unlike a native title tooltip on the bottom control bar).
|
||||||
|
export function Ctrl({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
align = "center",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const pos =
|
||||||
|
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
|
||||||
|
return (
|
||||||
|
<div className="relative flex group/ctrl">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
className="p-1 hover:text-accent disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 transition group-hover/ctrl:opacity-100`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user