fix(plex): the player restarts a reaped session, finishes a played-through item as watched, and starts each item once

Deep-review round 4, the Plex player.

- A 404 on an HLS session that WAS playing means the server reaped it (a long pause, a deploy);
  hls.js never retries a 4xx, so playback ended with "couldn't play". A new session at the playhead
  now takes its place (the frontend half of the paused-viewer reap fix). A session that 404s before
  its manifest is still a plain failure, so it cannot restart itself in a loop.
- Leaving an item that ended, or that the credits auto-skip left, sent a final checkpoint at the
  credits (racing the watched mark, and pushing a resume point to Plex after the scrobble) and
  patched "new, at 49:55" into the cache — Prev reopened it at its last seconds. `finish` settles it
  as watched at 0, said before the pause whose own checkpoint could land first.
- An audio switch, an A/V offset change and every seek control read `abs`, which is 0 until
  playback ticks: a resume at 20:00 restarted from 0:00. They use the shared `playhead`.
- A seek while a restart is pending always restarts (the old media's `seekable` described the old
  session, and the pending answer overrode the newer seek).
- A superseded direct-play session's loadedmetadata/canplay are generation-gated and detached (they
  seeked the next item to the previous one's resume point).
- The session starts once per item: a reconnect refetch, the info panel's watched toggle or a
  language switch no longer restarts the film at the last checkpoint.
- Tests for the session generation's failure paths and the item-change bump, which had none.
This commit is contained in:
2026-09-11 23:18:33 +02:00
parent 31dc4184f5
commit 6a92585098
4 changed files with 552 additions and 54 deletions
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
//
// The Plex player's HLS path, with hls.js replaced by a stand-in the test drives (jsdom has no
// MediaSource, so the real one never runs here). Its own file because the module mock is file-wide.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, waitFor, screen, 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" } }),
};
});
// Hoisted with the mock below (vi.mock runs before the module body).
const { FakeHls, hlsInstances } = vi.hoisted(() => {
type Handler = (event: string, data: unknown) => void;
const hlsInstances: InstanceType<typeof FakeHls>[] = [];
class FakeHls {
handlers = new Map<string, Handler[]>();
audioTrack = 0;
constructor() {
hlsInstances.push(this);
}
on(event: string, fn: Handler) {
this.handlers.set(event, [...(this.handlers.get(event) ?? []), fn]);
}
emit(event: string, data: unknown = {}) {
for (const fn of this.handlers.get(event) ?? []) fn(event, data);
}
loadSource() {}
attachMedia() {}
destroy() {}
}
return { FakeHls, hlsInstances };
});
vi.mock("hls.js", async (importOriginal) => {
const actual = (await importOriginal<{ default: typeof import("hls.js").default }>()).default;
return {
default: Object.assign(FakeHls, {
isSupported: () => true,
Events: actual.Events,
ErrorTypes: actual.ErrorTypes,
}),
};
});
import Hls from "hls.js";
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
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;
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() {} });
Object.defineProperty(HTMLMediaElement.prototype, "load", { configurable: true, value() {} });
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(() => {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
hlsInstances.length = 0;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
sessionStorage.clear();
localStorage.clear();
});
const detail = {
id: "A",
title: "Title A",
type: "movie",
duration_seconds: 3000,
status: "new",
position_seconds: 600,
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
subtitle_streams: [],
markers: [],
} as unknown as PlexItemDetail;
function mount() {
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
const session = vi
.spyOn(api, "plexSession")
.mockResolvedValue({ mode: "hls", url: "/hls/A/index.m3u8", start_s: 600 } as never);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="A" onClose={() => {}} />
</QueryClientProvider>,
);
return session;
}
const lost404 = {
fatal: true,
type: Hls.ErrorTypes.NETWORK_ERROR,
details: "fragLoadError",
response: { code: 404, text: "No active playback session" },
};
describe("an HLS session the server no longer has", () => {
it("is restarted where the viewer is, instead of ending playback", async () => {
// The server reaps a session nobody fetches from (a long pause; a deploy). hls.js never retries
// a 4xx, so the next segment's 404 used to end playback with "couldn't play".
const session = mount();
await waitFor(() => expect(hlsInstances).toHaveLength(1));
act(() => hlsInstances[0]!.emit(Hls.Events.MANIFEST_PARSED));
const v = document.querySelector("video") as Media;
v._t = 700; // 600 + 700 − the HLS lag
act(() => void v.dispatchEvent(new Event("timeupdate")));
act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404));
await waitFor(() => expect(session).toHaveBeenCalledTimes(2));
expect(session.mock.calls[1]![1]).toBeCloseTo(1299, 0);
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
});
it("is a failure, not a restart, when the session never played", async () => {
// Otherwise a session that 404s from the start would restart itself in a loop.
const session = mount();
await waitFor(() => expect(hlsInstances).toHaveLength(1));
act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404));
await screen.findByText("plex.player.errGeneric");
expect(session).toHaveBeenCalledTimes(1);
});
});
@@ -18,7 +18,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { api, HttpError, type PlexItemDetail } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
@@ -276,3 +276,225 @@ it("shows a toast raised while it is open inside itself, not under its scrim", a
while (host && !host.contains(toast)) host = host.parentElement;
expect(host).not.toBe(document.body); // the player's own host, beside its <video>
});
// --- deep-review round 4 ------------------------------------------------------------------------
/** A session answer that FAILS when the test says so. */
function deferredFailure() {
let reject!: (e: unknown) => void;
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((_r, j) => (reject = j));
return { promise, reject };
}
const startsAsked = (id: string) =>
vi
.mocked(api.plexSession)
.mock.calls.filter(([i]) => i === id)
.map(([, at]) => at);
describe("leaving an item that was played through", () => {
const prefs = (p: object) =>
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
prefs({ upNext: false });
const { qc, progress } = mount({
details: { A: item("A"), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2995);
act(() => void video().dispatchEvent(new Event("ended")));
await screen.findByText("Title B");
await tick();
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
fireEvent.click(screen.getByLabelText("plex.player.prev"));
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
expect(startsAsked("A")[1]).toBe(0);
});
it("sends no checkpoint at the credits when the auto-skip leaves it", async () => {
// A final checkpoint at the credits raced the watched mark: landing second, it left the row
// watched WITH a resume point, and pushed that point to Plex right after the scrobble.
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("A"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995 }],
} as unknown as PlexItemDetail;
const { qc, progress } = mount({
details: { A: withCredits, B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
// The 'pause' event (and its checkpoint) can land before the player has re-rendered for the
// next item — here it lands at once.
video().pause = function (this: HTMLMediaElement) {
this.dispatchEvent(new Event("pause"));
};
await playingAt("/media/A", 2910);
await screen.findByText("Title B");
await tick(); // the pause() that go() queued has fired its events by now
expect(setState).toHaveBeenCalledWith("A", "watched");
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
});
});
describe("restarting the session before playback has ticked", () => {
it("an audio switch or a keyboard seek keeps the resume point", async () => {
const twoAudio = {
...item("A", 1200),
audio_streams: [
{ ord: 0, label: "English", language: "en", default: true },
{ ord: 1, label: "Magyar", language: "hu", default: false },
],
} as unknown as PlexItemDetail;
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
fireEvent.keyDown(window, { key: "a" });
fireEvent.keyDown(window, { key: "ArrowRight" });
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
});
});
it("a seek made while a restart is pending wins over it", async () => {
let calls = 0;
const pending = deferred();
mount({
details: { A: item("A") },
sessions: {
A: () => {
calls++;
if (calls === 1) return Promise.resolve(direct("A"));
return calls === 2 ? pending.promise : new Promise(() => {});
},
},
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
Object.defineProperty(video(), "seekable", { get: () => ({ length: 1, end: () => 100 }) });
await playingAt("/media/A", 95);
fireEvent.click(screen.getByLabelText("plex.player.forward10")); // 105: past the buffer → restart
fireEvent.click(screen.getByLabelText("plex.player.rewind10")); // 95: inside the OLD buffer
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
await tick();
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
});
it("a direct-play item left before its metadata does not seek the next one", async () => {
mount({
details: { A: item("A", 1200), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => Promise.resolve(direct("B")) },
queue: ["A", "B"],
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
fireEvent.click(screen.getByLabelText("plex.player.next"));
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/B"));
act(() => void video().dispatchEvent(new Event("loadedmetadata")));
expect(video().currentTime).toBe(0); // not A's resume point, 1200
});
it("a refetch of the playing item's detail does not restart it", async () => {
// react-query refetches it on reconnect, and the in-player info panel invalidates it after a
// watched toggle — each one restarted the film at the last saved checkpoint.
const details = { A: item("A", 100) };
const { qc } = mount({ details, sessions: { A: () => Promise.resolve(direct("A")) } });
await playingAt("/media/A", 1500);
details.A = item("A", 1490);
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.plexItem("A") });
});
await tick();
expect(vi.mocked(api.plexItem).mock.calls.length).toBeGreaterThan(1); // it did refetch
expect(startsAsked("A")).toEqual([100]);
});
describe("a session request that fails", () => {
it("is not reported when it has been superseded", async () => {
const slowB = deferredFailure();
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.reject(new HttpError(404)));
await tick();
expect(screen.queryByText("plex.player.errNotFound")).toBeNull();
});
it("keeps following the old stream when a seek-restart fails", async () => {
let calls = 0;
const { progress, unmount } = mount({
details: { A: item("A") },
sessions: {
A: () =>
calls++ === 0 ? Promise.resolve(direct("A")) : Promise.reject(new HttpError(500)),
},
});
await playingAt("/media/A", 1500);
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
await screen.findByText("plex.player.errGeneric");
await playingAt("/media/A", 1600); // the old stream plays on
unmount();
expect(progress.mock.calls).toContainEqual(["A", 1600, 3000, true]);
});
it("saves nothing of the previous item's position when moving to the next one fails", async () => {
const { progress, unmount } = mount({
details: { A: item("A"), B: item("B") },
sessions: {
A: () => Promise.resolve(direct("A")),
B: () => Promise.reject(new HttpError(404)),
},
queue: ["A", "B"],
});
await playingAt("/media/A", 1500);
fireEvent.click(screen.getByLabelText("plex.player.next"));
await screen.findByText("plex.player.errNotFound");
await tick();
await playingAt("/media/A", 1600); // A's media, still attached, ticks once more
unmount();
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
});
});
it("a session answer that lands while the next item's detail is still loading is dropped", 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"));
const real = vi.mocked(api.plexItem).getMockImplementation()!;
vi.mocked(api.plexItem).mockImplementation((id: string, lang?: string) =>
id === "C" ? new Promise(() => {}) : real(id, lang),
);
fireEvent.click(screen.getByLabelText("plex.player.next"));
await waitFor(() => expect(startsAsked("B")).toHaveLength(1));
fireEvent.click(screen.getByLabelText("plex.player.next"));
await tick();
await act(async () => slowB.resolve(direct("B")));
await tick();
expect(video().getAttribute("src")).not.toBe("/media/B");
});
+106 -31
View File
@@ -45,6 +45,7 @@ import { createFragMeter } from "../lib/plexStats";
import { UpNextCard } from "./UpNextCard";
import Toaster from "./ui/Toaster";
import {
playhead,
useAutoSkip,
useControlsVisibility,
useFullscreen,
@@ -115,6 +116,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// 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);
// Removes the media listeners the current session put on the <video> (its canplay / loadedmetadata
// handlers). A newer session, an item change and the unmount all call it: the generation check
// guards the `await`, and these fire LATER — a superseded direct-play item's loadedmetadata used to
// run on the next item's media and seek it to the old item's resume point (whose first checkpoint
// then saved it), or start the old media playing under the new title.
const detachSessionRef = useRef<() => void>(() => {});
// 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
@@ -263,6 +270,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
if (!video) return;
const gen = ++sessGenRef.current;
const current = () => gen === sessGenRef.current && aliveRef.current;
detachSessionRef.current();
detachSessionRef.current = () => {};
const listen = (type: string, fn: () => void, once = false) => {
const guarded = () => {
if (current()) fn();
};
video.addEventListener(type, guarded, { once });
const prev = detachSessionRef.current;
detachSessionRef.current = () => {
prev();
video.removeEventListener(type, guarded);
};
};
clockLiveRef.current = false;
loadStartRef.current = startAt;
setReady(false);
@@ -319,10 +339,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// the video paused after a seek even though the user was watching. Retry once the element can
// actually play. Idempotent — only acts if still paused.
if (resumePlay) {
const onCanPlay = () => {
if (aliveRef.current && videoRef.current?.paused) videoRef.current.play().catch(() => {});
};
video.addEventListener("canplay", onCanPlay, { once: true });
listen(
"canplay",
() => {
if (videoRef.current?.paused) videoRef.current.play().catch(() => {});
},
true,
);
}
if (hlsRef.current) {
hlsRef.current.destroy();
@@ -353,7 +376,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video);
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
// Whether this session has ever played. Only such a session can be "lost" (below): one that
// fails before its manifest is simply a failure, and must not restart itself in a loop.
let parsed = false;
hls.on(Hls.Events.MANIFEST_PARSED, () => {
parsed = true;
setReady(true);
if (aliveRef.current && resumePlay) video.play().catch(() => {});
});
@@ -374,20 +401,32 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
});
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
// otherwise leave the spinner up forever — surface it instead.
//
// Except a 404 on a session that WAS playing: the server has reaped it (a pause longer than
// its idle limit, a deploy) and the stream is simply gone, not broken. hls.js never retries a
// 4xx, so this used to end playback with "couldn't play" after the buffer ran out; a new
// session at the playhead is what closing and reopening the player did by hand.
hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setLoadError(tRef.current("plex.player.errGeneric"));
if (!data.fatal || !current()) return;
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
loadSession(playhead(absRef, loadStartRef), !video.paused);
return;
}
setLoadError(tRef.current("plex.player.errGeneric"));
});
} else {
// direct file (or Safari native HLS)
video.src = sess.url;
video.load();
const onMeta = () => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true);
if (aliveRef.current && resumePlay) video.play().catch(() => {});
video.removeEventListener("loadedmetadata", onMeta);
};
video.addEventListener("loadedmetadata", onMeta);
listen(
"loadedmetadata",
() => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true);
if (resumePlay) video.play().catch(() => {});
},
true,
);
}
},
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
@@ -430,9 +469,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
loadStartRef,
});
// Start playback when the detail arrives (resume from the saved position unless already watched).
// Start playback when the detail arrives (resume from the saved position unless already watched) —
// ONCE per item. The detail object changes under a playing item too: react-query refetches it on
// reconnect (Wi-Fi blips, the query is stale after 30 s and every checkpoint moved the position),
// the in-player info panel invalidates it after a watched toggle, a language switch fetches it
// again. Keyed on its identity, each of those restarted the film at the last saved checkpoint — or
// at 0 after marking it watched (deep-review round 4). The trade-off: a detail that was stale in
// the cache when the player opened is not corrected by the refetch that follows; the player's own
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
const startedForRef = useRef<string | null>(null);
useEffect(() => {
if (!detail) return;
if (!detail || detail.id !== id || startedForRef.current === id) return;
startedForRef.current = id;
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
// audio ref synchronously so the loadSession below picks the right track on the first request.
const aOrd = audioOrdForLang(detail.audio_streams ?? [], prefs.audioLang);
@@ -441,20 +489,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], prefs.subLang));
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
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
// double-load the session — the picker handlers already reload/toggle).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail, id, loadSession]);
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
useEffect(
() => () => {
sessGenRef.current++;
detachSessionRef.current();
detachSessionRef.current = () => {};
startedForRef.current = null;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
// double-load the session — the picker handlers already reload/toggle).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail, loadSession]);
},
[id],
);
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
// play() can't leave audio playing on a detached element after leaving the player.
@@ -548,7 +600,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}, [id]);
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
const { settle: settleProgress } = usePlexProgress({ id, qc, videoRef, absRef, durationRef });
const { settle: settleProgress, finish: finishProgress } = usePlexProgress({
id,
qc,
videoRef,
absRef,
durationRef,
});
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
@@ -564,7 +622,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
absRef.current = T;
setAbs(T);
const rel = T - sessionStartRef.current;
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
// Native only when the attached media IS the current session. While one is still being asked
// for, `sessionStartRef` and `seekable` describe the OLD one: a native seek there left the
// pending restart current, and its answer then jumped to the earlier target.
if (
clockLiveRef.current &&
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
} else {
loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
@@ -593,8 +658,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
});
const go = useCallback(
(otherId: string | null | undefined) => {
(otherId: string | null | undefined, finished = false) => {
if (otherId) {
// A played-through item settles as watched, with no checkpoint at the credits (see
// usePlexProgress's `finish`) — said BEFORE the pause below, whose own checkpoint on the
// 'pause' event can land before the player has re-rendered for the next item.
if (finished) finishProgress();
// 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
@@ -613,7 +682,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setId(otherId);
}
},
[settleProgress],
[settleProgress, finishProgress],
);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
@@ -669,6 +738,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
api.plexSetState(id, "watched").catch(() => {});
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item.
finishProgress();
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
@@ -680,7 +751,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
};
video.addEventListener("ended", onEnded);
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go, onClose]);
}, [id, nextId, go, onClose, finishProgress]);
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
@@ -738,7 +809,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
useEffect(() => {
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
e.preventDefault();
seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current));
const step = fine ? fineSeekStepRef.current : seekStepRef.current;
seekTo(playhead(absRef, loadStartRef) + dir * step);
};
const step = (e: KeyboardEvent, id: string | null | undefined) => {
e.preventDefault();
@@ -1176,7 +1248,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<div className="flex items-center gap-3 text-white">
<Ctrl
label={t("plex.player.rewind10")}
onClick={() => seekTo(absRef.current - 10)}
onClick={() => seekTo(playhead(absRef, loadStartRef) - 10)}
align="start"
>
<Skip10 dir="back" />
@@ -1184,7 +1256,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl label={t("plex.player.forward10")} onClick={() => seekTo(absRef.current + 10)}>
<Ctrl
label={t("plex.player.forward10")}
onClick={() => seekTo(playhead(absRef, loadStartRef) + 10)}
>
<Skip10 dir="forward" />
</Ctrl>
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
+59 -22
View File
@@ -84,20 +84,31 @@ export function useControlsVisibility(opts: {
// 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.
//
// `finish` says the item was played THROUGH (it ended, or the credits auto-skip left it) and the
// caller has marked it watched. From then on leaving it records no position: a checkpoint at the
// credits raced the watched mark (landing second, it left a watched row with a resume point and put
// that point back on Plex after the scrobble), and the reopen patch wrote "new, at 49:55" into the
// cache — Prev within the next 30 s reopened the episode at its last seconds (or at the credits,
// which auto-skipped straight back). The server stores a watched item at position 0, so the patch
// says exactly that. Playing on from the finished frame takes the finish back.
export function usePlexProgress(opts: {
id: string;
qc: QueryClient;
videoRef: RefObject<HTMLVideoElement | null>;
absRef: MutableRefObject<number>;
durationRef: MutableRefObject<number>;
}): { settle: () => void } {
}): { settle: () => void; finish: () => void } {
const { id, qc, videoRef, absRef, durationRef } = opts;
const settleRef = useRef<() => void>(() => {});
const finishRef = useRef<() => void>(() => {});
useEffect(() => {
let finished = false;
// `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 (finished) return;
if (absRef.current > 0 && durationRef.current > 0)
api
.plexProgress(id, Math.floor(absRef.current), durationRef.current, final)
@@ -122,43 +133,72 @@ export function usePlexProgress(opts: {
// 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)
if (!finished && absRef.current > 0 && durationRef.current > 0)
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
};
window.addEventListener("pagehide", onHide);
const onPlayAgain = () => {
finished = false;
};
video?.addEventListener("play", onPlayAgain);
// 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).
// a reload starts with an empty cache and fetches the fresh position.) Done when leaving, because
// that is when the position is final. Reading the LATEST abs/duration is the whole point of the
// checkpoint (the resume point when leaving).
const settle = () => {
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
const key = qk.plexItem(id, plexLang());
if (finished) {
qc.setQueryData(key, (old) =>
old ? { ...old, status: "watched", position_seconds: 0 } : old,
);
return;
}
flush(true);
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.
qc.setQueryData(qk.plexItem(id, plexLang()), (old) =>
old ? { ...old, position_seconds: pos } : old,
);
qc.setQueryData(key, (old) => (old ? { ...old, position_seconds: pos } : old));
}
};
settleRef.current = settle;
finishRef.current = () => {
finished = true;
};
return () => {
window.clearInterval(iv);
window.clearTimeout(seekTimer);
window.removeEventListener("pagehide", onHide);
video?.removeEventListener("pause", onPauseFlush);
video?.removeEventListener("seeked", onSeekFlush);
video?.removeEventListener("play", onPlayAgain);
settle();
settleRef.current = () => {};
finishRef.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(), []) };
return {
settle: useCallback(() => settleRef.current(), []),
finish: useCallback(() => finishRef.current(), []),
};
}
/** Where the viewer is. `abs` only moves once playback has ticked — until then (a session still
* loading, or a resumed one that has stayed paused) it reads 0 while the film is minutes in, so the
* answer is where the session was ASKED to start. Anything that restarts the session "here" — an
* audio switch, an A/V offset, a keyboard seek — must use this: reading `abs` restarted a resume at
* 20:00 from 0:00, and the next checkpoint saved that (deep-review round 4). */
export function playhead(
absRef: MutableRefObject<number>,
loadStartRef: MutableRefObject<number>,
): number {
return absRef.current > 0 ? absRef.current : loadStartRef.current;
}
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
@@ -242,7 +282,8 @@ export function useAutoSkip(opts: {
autoSkipCredits: boolean;
autoSkipDelay: number;
nextId: string | null | undefined;
go: (id: string | null | undefined) => void;
/** `finished` = the item being left was played through (see usePlexProgress's `finish`). */
go: (id: string | null | undefined, finished?: boolean) => void;
seekTo: (t: number) => void;
videoRef: RefObject<HTMLVideoElement | null>;
}): {
@@ -292,7 +333,7 @@ export function useAutoSkip(opts: {
// Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT
// (the current id, captured here — not the reactive id, which go() is about to change).
api.plexSetState(id, "watched").catch(() => {});
go(nextId);
go(nextId, true);
} else seekTo(m.end_s);
},
[id, go, nextId, seekTo],
@@ -468,10 +509,11 @@ export function usePlexTracks(opts: {
/* ignore */
}
} else {
loadSession(absRef.current); // single-audio: re-map the track server-side (session restart)
// Single audio (or the session has not answered yet): re-map the track server-side.
loadSession(playhead(absRef, loadStartRef));
}
},
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef],
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
);
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
// (applied by the effect below), NO session restart. Off = null.
@@ -520,9 +562,9 @@ export function usePlexTracks(opts: {
patchPrefs({ audioOffset: v });
aoffRef.current = v;
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = window.setTimeout(() => loadSession(absRef.current), 500);
aoffTimer.current = window.setTimeout(() => loadSession(playhead(absRef, loadStartRef)), 500);
},
[patchPrefs, loadSession, aoffRef, absRef],
[patchPrefs, loadSession, aoffRef, absRef, loadStartRef],
);
// --- is the selected track actually extractable yet? -------------------------------------------
@@ -541,12 +583,7 @@ export function usePlexTracks(opts: {
// answer is where the session was asked to start. Not `sessionK`: that only lands once the
// session request has answered, so the first poll of a fresh open read 0 from it, and after a
// move to the next item it still held the PREVIOUS item's start.
queryFn: () =>
api.plexSubtitleStatus(
id,
subOrd as number,
absRef.current > 0 ? absRef.current : loadStartRef.current,
),
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead(absRef, loadStartRef)),
enabled: subOrd != null,
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
// position, so seeking past the extracted window is what asks for the next one. A settled