import { useCallback, useEffect, useRef, useState, type RefObject, type WheelEvent as ReactWheelEvent, } from "react"; import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { exitFullscreen, fullscreenElement, onFullscreenChange, requestFullscreen, } from "./fullscreen"; import { api, type PlexItemDetail, type PlexMarker } from "./api"; import { qk } from "./queryKeys"; import { useLatest } from "./useLatest"; import i18n, { plexLang } from "../i18n"; import { markerSkip } from "./plexMarkers"; import { ownTextTracks } from "./plexSubTracks"; import { type PlexPlayerPrefs } from "./plexPlayerPrefs"; import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume"; // How close to the end the backend counts as "finished" (`_FINISH_MARGIN_S` in routes/plex/playback): // a progress checkpoint at or past `duration - this` marks the item watched and clears the position. // Mirrored here because `finish`'s rollback must not accidentally drive that branch. // // Drift is NOT symmetric, which an earlier version of this comment had backwards. If THIS value // grows, the rollback is skipped where it need not be — a lost resume point, annoying. If the // BACKEND's grows (its own comment invites that tuning by contrasting it with the feed's 10 s), // this guard starts admitting a rollback the backend reads as a finish, which marks the item // watched, clears the position and scrobbles to Plex — re-performing the very write that was just // refused, i.e. exactly the defect this constant was introduced to close. So the invariant is // one-directional and it is PINNED, not described: `plexFinishMargin.contract.test.ts` reads the // Python and fails if this value ever drops below it. const FINISH_MARGIN_S = 30; // A live wall clock for the top bar, re-rendered every 15s (minute resolution is enough for the // HH:MM display, and a coarse tick keeps the whole player from re-rendering each second). export function useWallClock(): Date { const [now, setNow] = useState(() => new Date()); useEffect(() => { const iv = window.setInterval(() => setNow(new Date()), 15000); return () => window.clearInterval(iv); }, []); return now; } // Fullscreen toggle for `el` (the player wrapper) + a reactive `fs` flag. // Via lib/fullscreen, not the raw API: WebKit (Safari/iPadOS) ships only the prefixed spelling, so // reading `document.fullscreenElement` alone made this player believe it is never fullscreen — the // button did nothing, `fs` never flipped, and Escape read "not fullscreen" as permission to close the // whole player instead of just leaving fullscreen. export function useFullscreen(wrapRef: RefObject): { fs: boolean; toggleFs: () => void; } { const [fs, setFs] = useState(false); const toggleFs = useCallback(() => { const el = wrapRef.current; if (!el) return; if (fullscreenElement()) exitFullscreen(); else requestFullscreen(el); }, [wrapRef]); useEffect(() => onFullscreenChange(() => setFs(!!fullscreenElement())), []); return { fs, toggleFs }; } // Auto-hiding control bar: `uiVisible` drives the chrome opacity; `wake` reveals it and (re)arms the // 3s hide timer. Reads the caller's live refs so the timer callback sees fresh state without a // closure rebuild: barAutoHide (per-user "never auto-hide" setting), whether a menu is open (keep the // bar up so it can't vanish mid-choice), and the