refactor(plex): extract PlexPlayer progress + volume hooks

S4a-3a of the PlexPlayer decomposition. Move two byte-equivalent
behaviours into lib/plexPlayerHooks.ts: usePlexProgress (the 10s
interval flush + pause/pagehide beacon + reopen cache-patch checkpoint;
absRef stays in the component since seek/skip/ended mutate it too) and
usePlayerVolume (HTML5 <video> 0..1 volume + independent mute, persisted
+ re-applied per session). usePlayerVolume is the seam the later R12
volume-consolidation sprint will rework to share playerVolume.ts's
tested pure core with the YouTube player. No behaviour change; verified
by self-E2E (volume-bar sets + persists video.volume, 'm' toggles mute
keeping the level, pause fires plexProgress 200).
This commit is contained in:
2026-08-12 00:32:44 +02:00
parent c7a69596ad
commit 68f4834695
2 changed files with 153 additions and 90 deletions
+20 -89
View File
@@ -1,13 +1,4 @@
import {
Fragment,
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
type WheelEvent as ReactWheelEvent,
} from "react";
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -46,7 +37,13 @@ import {
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls";
import { useControlsVisibility, useFullscreen, useWallClock } from "../lib/plexPlayerHooks";
import {
useControlsVisibility,
useFullscreen,
usePlayerVolume,
usePlexProgress,
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
@@ -352,51 +349,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
};
}, [id, patchPrefs]);
// Periodic + on-unmount progress checkpoint.
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
const absRef = useRef(0);
absRef.current = abs;
useEffect(() => {
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
const flush = (final = false) => {
if (absRef.current > 0 && durationRef.current > 0)
api
.plexProgress(id, Math.floor(absRef.current), durationRef.current, final)
.catch(() => {});
};
const iv = window.setInterval(flush, 10000);
// Pausing is a settled position — push it to Plex now rather than waiting for unmount/unload.
const onPauseFlush = () => flush(true);
const video = videoRef.current;
video?.addEventListener("pause", onPauseFlush);
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
// keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
const onHide = () => {
if (absRef.current > 0 && durationRef.current > 0)
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
};
window.addEventListener("pagehide", onHide);
return () => {
window.clearInterval(iv);
window.removeEventListener("pagehide", onHide);
video?.removeEventListener("pause", onPauseFlush);
flush(true);
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
// the SAME session read react-query's CACHED detail — whose position_seconds predated this
// watch — so the resume landed at the old spot (the progress GET could also race the POST).
// Patch the cached detail here so a reopen resumes from where we left off. (F5 was always fine:
// a reload starts with an empty cache and fetches the fresh position.) Safe to do only on
// unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
if (absRef.current > 0 && durationRef.current > 0) {
const pos = Math.floor(absRef.current);
qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
}
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
};
}, [id, qc]);
usePlexProgress({ id, qc, videoRef, absRef, durationRef });
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
@@ -425,41 +381,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else v.pause();
}, []);
const applyVolume = useCallback((vol: number, mute: boolean) => {
const v = videoRef.current;
if (!v) return;
v.volume = clamp01(vol);
v.muted = mute;
}, []);
// Set volume/mute on the <video> AND persist (survives F5).
const setVol = useCallback(
(vol: number, mute: boolean) => {
applyVolume(vol, mute);
patchPrefs({ volume: clamp01(vol), muted: mute });
},
[applyVolume, patchPrefs],
);
const nudgeVolume = useCallback(
(d: number) => setVol(clamp01(prefs.volume + d), clamp01(prefs.volume + d) === 0),
[prefs.volume, setVol],
);
const toggleMute = useCallback(
() => setVol(prefs.volume, !prefs.muted),
[prefs.volume, prefs.muted, setVol],
);
// Re-apply the persisted volume/mute to the <video> each time a session becomes ready (the element
// is fresh after a load/seek-restart, and starts at its default volume otherwise).
useEffect(() => {
if (ready) applyVolume(prefs.volume, prefs.muted);
}, [ready, applyVolume, prefs.volume, prefs.muted]);
// Mouse-wheel over the player adjusts volume (5 units per notch); overlays scroll normally.
const onWheel = useCallback(
(e: ReactWheelEvent) => {
if (infoOpenRef.current || helpOpenRef.current) return;
nudgeVolume(e.deltaY < 0 ? 0.05 : -0.05);
},
[nudgeVolume],
);
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
videoRef,
volume,
muted,
ready,
patchPrefs,
infoOpenRef,
helpOpenRef,
});
const go = useCallback((otherId: string | null | undefined) => {
if (otherId) {