On an iPad the bar covered the text whenever it was up: the browser places a native cue by its line, which nothing animates. The track now stays loaded as hidden and PlexSubtitles draws the active cues, measured against the bar and lifted above it in 200 ms, back down when it hides. The viewer's size, colour, background and outline settings style the text directly. Also fixes a test typing slip from the search pill change.
130 lines
5.9 KiB
TypeScript
130 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)
|
|
upNext: boolean; // at episode end (with a next item) show an up-next card + countdown before advancing
|
|
};
|
|
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,
|
|
upNext: true,
|
|
};
|
|
|
|
// 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. A `text-shadow` value; undefined when the slider is 0 (shadow off).
|
|
export function subShadow(px: number, color: string): string | undefined {
|
|
if (px <= 0) return undefined;
|
|
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 `${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);
|
|
}
|