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