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 {
|
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
Fragment,
|
|
||||||
lazy,
|
|
||||||
Suspense,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type WheelEvent as ReactWheelEvent,
|
|
||||||
} from "react";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -46,7 +37,13 @@ import {
|
|||||||
type PlexPlayerPrefs,
|
type PlexPlayerPrefs,
|
||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls";
|
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 { fullscreenElement } from "../lib/fullscreen";
|
||||||
import { LS, useAccountPersistedObject } from "../lib/storage";
|
import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
@@ -352,51 +349,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
};
|
};
|
||||||
}, [id, patchPrefs]);
|
}, [id, patchPrefs]);
|
||||||
|
|
||||||
// Periodic + on-unmount progress checkpoint.
|
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
|
||||||
const absRef = useRef(0);
|
const absRef = useRef(0);
|
||||||
absRef.current = abs;
|
absRef.current = abs;
|
||||||
useEffect(() => {
|
usePlexProgress({ id, qc, videoRef, absRef, durationRef });
|
||||||
// `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]);
|
|
||||||
|
|
||||||
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
||||||
const seekTo = useCallback(
|
const seekTo = useCallback(
|
||||||
@@ -425,41 +381,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
else v.pause();
|
else v.pause();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applyVolume = useCallback((vol: number, mute: boolean) => {
|
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
|
||||||
const v = videoRef.current;
|
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
|
||||||
if (!v) return;
|
videoRef,
|
||||||
v.volume = clamp01(vol);
|
volume,
|
||||||
v.muted = mute;
|
muted,
|
||||||
}, []);
|
ready,
|
||||||
// Set volume/mute on the <video> AND persist (survives F5).
|
patchPrefs,
|
||||||
const setVol = useCallback(
|
infoOpenRef,
|
||||||
(vol: number, mute: boolean) => {
|
helpOpenRef,
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const go = useCallback((otherId: string | null | undefined) => {
|
const go = useCallback((otherId: string | null | undefined) => {
|
||||||
if (otherId) {
|
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 {
|
import {
|
||||||
exitFullscreen,
|
exitFullscreen,
|
||||||
fullscreenElement,
|
fullscreenElement,
|
||||||
onFullscreenChange,
|
onFullscreenChange,
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
} from "./fullscreen";
|
} 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
|
// 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).
|
// 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]);
|
}, [barAutoHideRef, menuOpenRef, videoRef]);
|
||||||
return { uiVisible, wake };
|
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