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,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -35,7 +34,18 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
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 {
|
||||
exitFullscreen,
|
||||
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.
|
||||
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
|
||||
// (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
|
||||
@@ -181,12 +71,6 @@ const HLS_SUB_LAG = 1.0;
|
||||
// the queue instead of the item's own episode neighbours.
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
// `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);
|
||||
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