import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode, type WheelEvent as ReactWheelEvent, } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Hls from "hls.js"; import { ArrowLeft, Clock, Download, Info, Keyboard, Languages, Maximize, Minimize, Pause, Play, RotateCcw, Settings, SkipBack, SkipForward, Square, Volume2, VolumeX, X, } from "lucide-react"; import { api, type PlexMarker } from "../lib/api"; import { useAccountPersistedObject } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { useBackToClose } from "../lib/history"; // 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; } 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; wasPlaying: boolean; // resume-playing intent across reloads (best-effort; browsers may block autoplay) 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, wasPlaying: true, 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