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) {
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from "react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
exitFullscreen,
|
||||
fullscreenElement,
|
||||
onFullscreenChange,
|
||||
requestFullscreen,
|
||||
} from "./fullscreen";
|
||||
import { api } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||
|
||||
// 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).
|
||||
@@ -60,3 +72,123 @@ export function useControlsVisibility(opts: {
|
||||
}, [barAutoHideRef, menuOpenRef, videoRef]);
|
||||
return { uiVisible, wake };
|
||||
}
|
||||
|
||||
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
|
||||
// component, since seeking/skip/ended all mutate absRef too) so the interval and unload beacon see the
|
||||
// current spot without rebuilding the effect.
|
||||
export function usePlexProgress(opts: {
|
||||
id: string;
|
||||
qc: QueryClient;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
absRef: MutableRefObject<number>;
|
||||
durationRef: MutableRefObject<number>;
|
||||
}): void {
|
||||
const { id, qc, videoRef, absRef, durationRef } = opts;
|
||||
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.
|
||||
// Reading the LATEST abs/duration at cleanup is the whole point of the checkpoint (the resume
|
||||
// point when leaving), not a stale snapshot — the "ref value may have changed" hint is a false
|
||||
// positive here. Scoped disable so the deps array below is still checked.
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
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));
|
||||
}
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow() });
|
||||
};
|
||||
// absRef/durationRef/videoRef are stable ref objects — listed to satisfy exhaustive-deps without
|
||||
// changing when the effect re-runs (id/qc only), matching the original in-component effect.
|
||||
}, [id, qc, absRef, durationRef, videoRef]);
|
||||
}
|
||||
|
||||
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
|
||||
// the per-account prefs (survives F5) and re-applies on each fresh session (the element resets to its
|
||||
// default volume after a load/seek-restart). NOTE: this is the Plex-side model; the YouTube player has
|
||||
// its own 0-100 / no-mute-flag arithmetic in playerVolume.ts. Sharing the tested pure core across both
|
||||
// is a separate R12 consolidation sprint — this hook is the seam it will rework internally.
|
||||
export function usePlayerVolume(opts: {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
ready: boolean;
|
||||
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
|
||||
infoOpenRef: RefObject<boolean>;
|
||||
helpOpenRef: RefObject<boolean>;
|
||||
}): {
|
||||
setVol: (vol: number, mute: boolean) => void;
|
||||
nudgeVolume: (d: number) => void;
|
||||
toggleMute: () => void;
|
||||
onWheel: (e: ReactWheelEvent) => void;
|
||||
} {
|
||||
const { videoRef, volume, muted, ready, patchPrefs, infoOpenRef, helpOpenRef } = opts;
|
||||
const applyVolume = useCallback(
|
||||
(vol: number, mute: boolean) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
v.volume = clamp01(vol);
|
||||
v.muted = mute;
|
||||
},
|
||||
[videoRef],
|
||||
);
|
||||
// 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(volume + d), clamp01(volume + d) === 0),
|
||||
[volume, setVol],
|
||||
);
|
||||
const toggleMute = useCallback(() => setVol(volume, !muted), [volume, 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(volume, muted);
|
||||
}, [ready, applyVolume, volume, 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, infoOpenRef, helpOpenRef],
|
||||
);
|
||||
return { setVol, nudgeVolume, toggleMute, onWheel };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user