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:
@@ -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>
|
||||
});
|
||||
@@ -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"
|
||||
}`}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user