From c7a69596ada5397908c212c7e9b175a40d03ecda Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 12 Aug 2026 00:15:41 +0200 Subject: [PATCH] refactor(plex): extract PlexPlayer leaf hooks (clock/fullscreen/chrome) S4a-2 of the PlexPlayer decomposition. Move three self-contained, byte-equivalent behaviours into lib/plexPlayerHooks.ts: useWallClock (15s top-bar clock), useFullscreen (wrapper fs toggle + reactive flag), and useControlsVisibility (auto-hiding control bar + wake). The ref mirrors the hooks read (barAutoHide/menuOpen/video-paused) stay in the component and are passed in, so the timer sees fresh state. No behaviour change; verified by self-E2E (real HLS playback: clock renders, controls auto-hide after 3s while playing, wake restores + stays while paused). --- frontend/src/components/PlexPlayer.tsx | 43 +++--------------- frontend/src/lib/plexPlayerHooks.ts | 62 ++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/plexPlayerHooks.ts diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 9907c555..deae3975 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -46,12 +46,8 @@ import { type PlexPlayerPrefs, } from "../lib/plexPlayerPrefs"; import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls"; -import { - exitFullscreen, - fullscreenElement, - onFullscreenChange, - requestFullscreen, -} from "../lib/fullscreen"; +import { useControlsVisibility, useFullscreen, useWallClock } from "../lib/plexPlayerHooks"; +import { fullscreenElement } from "../lib/fullscreen"; import { LS, useAccountPersistedObject } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; @@ -105,11 +101,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const [playing, setPlaying] = useState(false); const [abs, setAbs] = useState(0); // absolute current time const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region - const [fs, setFs] = useState(false); + const { fs, toggleFs } = useFullscreen(wrapRef); const [ready, setReady] = useState(false); const [loadError, setLoadError] = useState(null); - const [uiVisible, setUiVisible] = useState(true); - const hideTimer = useRef(null); // Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off). const [audioOrd, setAudioOrd] = useState(null); const [subOrd, setSubOrd] = useState(null); @@ -152,6 +146,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const tracksTriggerRef = useRef(null); audioRef.current = audioOrd; menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open + // Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing). + const { uiVisible, wake } = useControlsVisibility({ barAutoHideRef, menuOpenRef, videoRef }); // Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely). useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]); useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]); @@ -174,11 +170,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const [infoOpen, setInfoOpen] = useState(false); const infoOpenRef = useRef(false); infoOpenRef.current = infoOpen; - const [now, setNow] = useState(() => new Date()); - useEffect(() => { - const iv = window.setInterval(() => setNow(new Date()), 15000); - return () => window.clearInterval(iv); - }, []); + const now = useWallClock(); durationRef.current = duration; @@ -469,18 +461,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { [nudgeVolume], ); - // 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 (below) read "not fullscreen" as - // permission to close the whole player instead of just leaving fullscreen. - const toggleFs = useCallback(() => { - const el = wrapRef.current; - if (!el) return; - if (fullscreenElement()) exitFullscreen(); - else requestFullscreen(el); - }, []); - useEffect(() => onFullscreenChange(() => setFs(!!fullscreenElement())), []); - const go = useCallback((otherId: string | null | undefined) => { if (otherId) { // Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local @@ -659,17 +639,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { return () => video.removeEventListener("ended", onEnded); }, [id, nextId, go, onClose]); - // Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key). - const wake = useCallback(() => { - setUiVisible(true); - if (hideTimer.current) window.clearTimeout(hideTimer.current); - if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar - hideTimer.current = window.setTimeout(() => { - // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice. - if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false); - }, 3000); - }, []); - // "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel // off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info // overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts new file mode 100644 index 00000000..507654de --- /dev/null +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { + exitFullscreen, + fullscreenElement, + onFullscreenChange, + requestFullscreen, +} from "./fullscreen"; + +// 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