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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user