fix(plex): the player drops a session answer that lands late, and Next neither loses nor lends a position

Deep-review round 3 of bug hunt 2026-09.

- loadSession checks, after its await, that it is still the newest call and the player is still
  mounted. A late answer used to replace what was playing (Next twice quickly: the slower transcode
  landed last and played the middle item under the last one's title, recording its progress there)
  or attach a stream to the detached <video> after Back, never destroyed.
- The <video>'s clock is trusted only once the new session has answered. go()'s pause() fires a
  timeupdate AFTER the reset to 0 (measured in Chrome), so the previous item's position was sent as
  the next item's subtitle position and saved as its progress (final, pushed to Plex).
- Next settles the item being left (final checkpoint + reopen-cache patch) before switching: the
  progress cleanup ran with the position already reset and skipped, so every Next dropped the last
  stretch watched.
- A seek-restart puts its target into the position state too; the ref alone was re-synced back to
  the pre-seek spot by the restart's own render.
- The unmount teardown captures the <video> at mount: React detaches refs before an unmount's
  cleanups, so the stop/detach never ran.
- The tracks panel exposes the selected audio/subtitle track via aria-pressed.

Verified in real Chrome on localdev (Westworld S1E6 → Next): the item left was settled at 783 s, the
next asked for subtitles at its own start (0), and no progress was written for it.
This commit is contained in:
2026-09-11 18:04:41 +02:00
parent 4f37c41021
commit 5c5cabbe5a
3 changed files with 373 additions and 35 deletions
@@ -0,0 +1,278 @@
// @vitest-environment jsdom
//
// The Plex player's session lifecycle, rendered for real (deep-review round 3 of bug hunt 2026-09).
//
// jsdom has no media pipeline, so the <video> is a small stand-in that behaves like Chrome where it
// matters here: `pause()` fires its `timeupdate` in a LATER task (measured in Chrome — which is how the
// previous item's position used to arrive after the player had already reset it for the next one).
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, waitFor, screen, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
type Media = HTMLMediaElement & { _t?: number };
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
const later = (el: HTMLMediaElement, ...types: string[]) =>
setTimeout(() => types.forEach((ty) => el.dispatchEvent(new Event(ty))), 0);
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
configurable: true,
get(this: Media) {
return this._t ?? 0;
},
set(this: Media, v: number) {
this._t = v;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
value: () => Promise.resolve(),
});
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
configurable: true,
value(this: HTMLMediaElement) {
later(this, "timeupdate", "pause"); // as Chrome does: after the call has returned
},
});
Object.defineProperty(HTMLMediaElement.prototype, "load", {
configurable: true,
value(this: Media) {
this._t = 0;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
configurable: true,
get: () => ({ length: 0, end: () => 0 }),
});
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
configurable: true,
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
});
});
beforeEach(() => {
// A signed-in account whose saved subtitle language is English (prefs are per account).
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en" }));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
sessionStorage.clear();
localStorage.clear();
});
const item = (id: string, position = 0): PlexItemDetail =>
({
id,
title: `Title ${id}`,
type: "episode",
duration_seconds: 3000,
status: "new",
position_seconds: position,
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
subtitle_streams: [{ ord: 0, label: "English", language: "en", text: true }],
markers: [],
}) as unknown as PlexItemDetail;
const direct = (id: string) =>
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
ReturnType<typeof api.plexSession>
>;
/** A session answer the test releases when it wants to. */
function deferred() {
let resolve!: (v: Awaited<ReturnType<typeof api.plexSession>>) => void;
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((r) => (resolve = r));
return { promise, resolve };
}
function mount(opts: {
details: Record<string, PlexItemDetail>;
sessions: Record<string, () => Promise<Awaited<ReturnType<typeof api.plexSession>>>>;
queue?: string[];
qc?: QueryClient;
}) {
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
vi.spyOn(api, "plexSession").mockImplementation((id: string) => opts.sessions[id]!());
const progress = vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const qc = opts.qc ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
const first = opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
const view = render(
<QueryClientProvider client={qc}>
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} />
</QueryClientProvider>,
);
return { ...view, progress, status, qc };
}
const video = () => document.querySelector("video") as Media;
async function playingAt(src: string, t: number) {
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
const v = video();
v._t = t;
act(() => void v.dispatchEvent(new Event("timeupdate")));
}
const tick = () => act(() => new Promise((r) => setTimeout(r, 20)));
describe("moving to the next item", () => {
it("settles the item being left, and gives the next one none of its position", async () => {
const { progress, status, unmount } = mount({
details: { A: item("A"), B: item("B", 300) },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
await playingAt("/media/A", 1500);
fireEvent.click(screen.getByLabelText("plex.player.next"));
await waitFor(() => expect(status.mock.calls.some(([id]) => id === "B")).toBe(true));
await tick(); // the timeupdate that pause() queued has landed by now
// B's session has not answered, so where B is is where it was asked to start — not A's 25:00.
expect(status.mock.calls.filter(([id]) => id === "B").map(([, , at]) => at)).not.toContain(
1500,
);
expect(status.mock.calls.find(([id]) => id === "B")?.[2]).toBe(300);
// A got its final checkpoint at the position it was left at…
expect(progress.mock.calls).toContainEqual(["A", 1500, 3000, true]);
unmount();
// …and leaving B while it was still loading saved nothing of A's position as B's.
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
});
});
it("a seek that restarts the session is saved at its target while the new session loads", async () => {
// seekTo wrote the target into the position ref only — and the restart re-renders at once, which
// re-syncs that ref from the state: a page-hide during the restart saved the pre-seek spot.
let calls = 0;
mount({
details: { A: item("A") },
sessions: {
A: () => (calls++ === 0 ? Promise.resolve(direct("A")) : new Promise(() => {})),
},
});
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
await playingAt("/media/A", 1500);
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
await waitFor(() => expect(api.plexSession).toHaveBeenCalledTimes(2)); // a restart, not a native seek
await tick();
act(() => void window.dispatchEvent(new Event("pagehide")));
expect(beacon).toHaveBeenLastCalledWith("A", 1510, 3000, true);
});
describe("a session answer that lands late", () => {
it("is dropped when the viewer has moved on to another item meanwhile", async () => {
const slowB = deferred();
mount({
details: { A: item("A"), B: item("B"), C: item("C") },
sessions: {
A: () => Promise.resolve(direct("A")),
B: () => slowB.promise,
C: () => Promise.resolve(direct("C")),
},
queue: ["A", "B", "C"],
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
fireEvent.click(screen.getByLabelText("plex.player.next"));
await screen.findByText("Title B");
fireEvent.click(screen.getByLabelText("plex.player.next"));
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/C"));
await act(async () => slowB.resolve(direct("B")));
await tick();
expect(video().getAttribute("src")).toBe("/media/C");
});
it("is dropped when the player has been closed meanwhile", async () => {
const slowA = deferred();
const { unmount } = mount({ details: { A: item("A") }, sessions: { A: () => slowA.promise } });
await waitFor(() => expect(api.plexSession).toHaveBeenCalled());
const v = video();
unmount();
await act(async () => slowA.resolve(direct("A")));
await tick();
expect(v.getAttribute("src")).toBeNull();
});
});
it("closing the player detaches the <video> it was playing", async () => {
const { unmount } = mount({
details: { A: item("A") },
sessions: { A: () => Promise.resolve(direct("A")) },
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
const v = video();
unmount();
expect(v.getAttribute("src")).toBeNull();
});
describe("the subtitle poll and notice", () => {
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
const { status } = mount({
details: { A: item("A", 1198) },
sessions: { A: () => new Promise(() => {}) },
});
await waitFor(() => expect(status).toHaveBeenCalled());
expect(status.mock.calls[0]).toEqual(["A", 0, 1198]);
});
it("says a failing poll even while an earlier window is still on screen", async () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.plexSubtitle("A", 0), { state: "partial", code: null, gen: 1 });
const view = mount({
details: { A: item("A") },
sessions: { A: () => Promise.resolve(direct("A")) },
qc,
});
view.status.mockRejectedValue(new Error("offline"));
await waitFor(() => expect(view.status).toHaveBeenCalled());
await screen.findByText("plex.player.subUnavailable.failed");
expect(document.querySelector("track")).not.toBeNull(); // the window stays mounted meanwhile
});
it("marks which track is selected for assistive tech, not only by colour", async () => {
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
await screen.findByText("Title A");
fireEvent.click(screen.getByLabelText("plex.player.tracks"));
const off = await screen.findByText("plex.player.subOff");
expect(off.closest("button")?.getAttribute("aria-pressed")).toBe("false");
const pressed = document.querySelectorAll('[aria-pressed="true"]');
expect([...pressed].map((b) => b.textContent)).toEqual(["English"]); // the saved subtitle
});
});
it("shows a toast raised while it is open inside itself, not under its scrim", async () => {
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
await screen.findByText("Title A");
act(() => notify({ level: "error", message: "Couldn't mark it watched." }));
const toast = await screen.findByText("Couldn't mark it watched.");
let host: Element | null = video();
while (host && !host.contains(toast)) host = host.parentElement;
expect(host).not.toBe(document.body); // the player's own host, beside its <video>
});
+69 -17
View File
@@ -109,6 +109,20 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which loadSession call is the current one. Everything after its `await` checks it: a response
// that lands after a newer call, an item change or the player's unmount is dropped — it used to
// replace whatever was playing (Next twice quickly, the slower transcode answering last, played the
// middle item under the last one's title and recorded its progress there) or attach a stream to the
// detached <video> after Back, never destroyed, keeping the server session warm.
const sessGenRef = useRef(0);
// Whether the <video>'s clock belongs to the session `sessionStartRef` describes. False from the
// moment a session is asked for until it has answered: the media still attached meanwhile is the
// OLD one, so `sessionStartRef + currentTime` was the previous item's position (go()'s pause() fires
// a timeupdate AFTER the reset to 0) — sent as the new item's subtitle position and saved as its
// progress, or, during a seek-restart, the pre-seek spot overwriting the target.
const clockLiveRef = useRef(false);
// The item whose session is attached to the <video> right now (see loadSession's failure path).
const attachedIdRef = useRef<string | null>(null);
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
@@ -247,6 +261,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
async (startAt: number, resumePlay = true) => {
const video = videoRef.current;
if (!video) return;
const gen = ++sessGenRef.current;
const current = () => gen === sessGenRef.current && aliveRef.current;
clockLiveRef.current = false;
loadStartRef.current = startAt;
setReady(false);
setBuffering(false);
@@ -262,6 +279,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const m = multiAudioRef.current;
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
} catch (e) {
if (!current()) return; // superseded or left: not this request's error to show
// A failed seek-restart leaves the old session attached and its clock still true; a failed
// move to another item leaves the previous item's media, which is not this item's clock.
clockLiveRef.current = attachedIdRef.current === id;
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
// needs full transcoding (a later phase).
@@ -275,6 +296,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
);
return;
}
if (!current()) return;
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
@@ -288,6 +310,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
sessionStartRef.current = trueStart;
setSessionK(trueStart);
// From here the <video>'s clock is this session's: the old source is replaced just below, and a
// timeupdate its teardown queues already reads the new start.
clockLiveRef.current = true;
attachedIdRef.current = id;
// Belt-and-suspenders resume: the play() fired on MANIFEST_PARSED / loadedmetadata can be
// rejected when the media isn't ready yet (slow first segments over a proxy), which would leave
// the video paused after a seek even though the user was watching. Retry once the element can
@@ -416,6 +442,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
loadSession(resume);
return () => {
// A session still in flight for this item must not land after it. The LIVE counter is the point
// (bumping it is what outdates the pending call), so the "ref may have changed" hint is moot.
// eslint-disable-next-line react-hooks/exhaustive-deps
sessGenRef.current++;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
@@ -433,15 +463,21 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// (StrictMode does exactly that in development). With the flag only ever lowered, it stayed false
// for the life of the player — every play() is gated on it, so the player never started by itself,
// and a seek that restarts the session left it paused (bug hunt 2026-09, found on localdev).
//
// The element is captured HERE, not read in the cleanup: React detaches a ref before it runs an
// unmount's effect cleanups, so `videoRef.current` was already null there and the stop/detach below
// never ran (only StrictMode's simulated unmount, which keeps refs, ever reached it).
useEffect(() => {
aliveRef.current = true;
const v = videoRef.current;
return () => {
aliveRef.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps -- the live counter, as above
sessGenRef.current++;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
const v = videoRef.current;
if (v) {
try {
v.pause();
@@ -459,6 +495,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const video = videoRef.current;
if (!video) return;
const onTime = () => {
if (!clockLiveRef.current) return; // the old session's clock (see clockLiveRef)
const a = sessionStartRef.current + video.currentTime;
setAbs(a);
try {
@@ -511,7 +548,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}, [id]);
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
usePlexProgress({ id, qc, videoRef, absRef, durationRef });
const { settle: settleProgress } = usePlexProgress({ id, qc, videoRef, absRef, durationRef });
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
@@ -521,7 +558,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const wasPlaying = !video.paused; // preserve play/pause intent across the seek
const dur = durationRef.current || target;
const T = Math.max(0, Math.min(target, dur - 1));
absRef.current = T; // reflect the target immediately so a save/beacon right after a seek is accurate
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
// too: `absRef` is re-synced from `abs` on every render, and the restart below renders at once,
// so the ref alone was back at the pre-seek spot before anything could read it.
absRef.current = T;
setAbs(T);
const rel = T - sessionStartRef.current;
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
video.currentTime = rel; // native seek within the loaded region keeps playing on its own
@@ -551,21 +592,29 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
helpOpenRef,
});
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
// file), loadSession returns without replacing the <video> source, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
try {
videoRef.current?.pause();
} catch {
/* ignore */
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
// file), loadSession returns without replacing the <video> source, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
try {
videoRef.current?.pause();
} catch {
/* ignore */
}
// Settle the item being left while the position still is its own (see usePlexProgress), and
// stop trusting the old media's clock: the timeupdate that pause() queues lands after the
// reset below and would otherwise hand the old position to the new item.
settleProgress();
clockLiveRef.current = false;
setAbs(0);
setId(otherId);
}
setAbs(0);
setId(otherId);
}
}, []);
},
[settleProgress],
);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
const qIdx = queue ? queue.indexOf(id) : -1;
@@ -1250,6 +1299,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<button
key={a.ord}
onClick={() => changeAudio(a.ord)}
aria-pressed={(audioOrd ?? 0) === a.ord}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
@@ -1264,6 +1314,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<button
onClick={() => changeSubtitle(null)}
aria-pressed={subOrd == null}
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
subOrd == null ? "text-accent" : "text-white"
}`}
@@ -1274,6 +1325,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<button
key={s.ord}
onClick={() => changeSubtitle(s.ord)}
aria-pressed={subOrd === s.ord}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
subOrd === s.ord ? "text-accent" : "text-white"
}`}
+26 -18
View File
@@ -79,14 +79,20 @@ export function useControlsVisibility(opts: {
// 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.
//
// `settle` is what leaving the item does (the final checkpoint + the reopen-cache patch). The player
// calls it itself BEFORE it moves to another item: by the time this effect's cleanup runs for the
// outgoing id, the position has already been reset for the incoming one — so the cleanup found 0 and
// skipped, and every Next dropped the last stretch watched of the episode being left.
export function usePlexProgress(opts: {
id: string;
qc: QueryClient;
videoRef: RefObject<HTMLVideoElement | null>;
absRef: MutableRefObject<number>;
durationRef: MutableRefObject<number>;
}): void {
}): { settle: () => void } {
const { id, qc, videoRef, absRef, durationRef } = opts;
const settleRef = useRef<() => void>(() => {});
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
@@ -120,23 +126,15 @@ export function usePlexProgress(opts: {
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
};
window.addEventListener("pagehide", onHide);
return () => {
window.clearInterval(iv);
window.clearTimeout(seekTimer);
window.removeEventListener("pagehide", onHide);
video?.removeEventListener("pause", onPauseFlush);
video?.removeEventListener("seeked", onSeekFlush);
// 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). So leaving
// also patches the cached detail, and 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 when leaving
// — patching while mounted would retrigger the loadSession effect and reseek mid-play. Reading the
// LATEST abs/duration is the whole point of the checkpoint (the resume point when leaving).
const settle = () => {
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);
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
@@ -144,13 +142,23 @@ export function usePlexProgress(opts: {
old ? { ...old, position_seconds: pos } : old,
);
}
/* eslint-enable react-hooks/exhaustive-deps */
};
settleRef.current = settle;
return () => {
window.clearInterval(iv);
window.clearTimeout(seekTimer);
window.removeEventListener("pagehide", onHide);
video?.removeEventListener("pause", onPauseFlush);
video?.removeEventListener("seeked", onSeekFlush);
settle();
settleRef.current = () => {};
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]);
return { settle: useCallback(() => settleRef.current(), []) };
}
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to