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"; // 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) }; 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, }; const clamp01 = (n: number) => Math.max(0, Math.min(1, n)); // 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