fix(plex): a subtitle track's failed load is heard and retried with a pause, and the poll asks about the right item, position and failure

- The <track>'s `error` never reached `onSubTrackError`: it does not bubble, and React 18 attaches no
  listener of its own to a <track> (measured in Chrome and jsdom — `onError` 0 calls, `onErrorCapture`
  1). Round 1's remount-and-give-up was dead code behind it. The hook now hands the player
  `onErrorCapture`, and the test fires a real non-bubbling `error` at a rendered <track>.
- Once reachable, the retry burned its whole budget within milliseconds of an outage (a remount
  fetches at once) and the give-up outlived turning subtitles off and on. Retries now wait (2 s,
  doubling), running out is reported at once, and picking the track again starts a fresh budget.
- Until the first timeupdate the poll sent `sessionK` — still 0 while the session request was out,
  and the PREVIOUS item's start after a move to the next episode. It now sends where the session was
  asked to start, and the track choice belongs to the item it was made for, so the next item is not
  asked about the old ordinal at the old position.
- A failing poll over a stale `partial` answer was silent: react-query keeps the last good data, the
  track stayed "showable", and the notice only rendered for an unmounted track. The hook now decides
  what the player says (`subNotice`), independently of whether a window is mounted.
This commit is contained in:
2026-09-11 03:18:42 +02:00
parent 7360339039
commit 645d14f45d
3 changed files with 234 additions and 64 deletions
+13 -7
View File
@@ -129,6 +129,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Live mirror of `abs` for stable callbacks + the progress checkpoint (seek/skip/ended write it too).
const absRef = useRef(0);
absRef.current = abs;
// Where the current session was ASKED to start — set before the request goes out, so the subtitle
// poll has a position before `sessionK` (the session's answer) and before the first timeupdate.
const loadStartRef = useRef(0);
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
const { fs, toggleFs } = useFullscreen(wrapRef);
const [ready, setReady] = useState(false);
@@ -243,6 +246,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
async (startAt: number, resumePlay = true) => {
const video = videoRef.current;
if (!video) return;
loadStartRef.current = startAt;
setReady(false);
setBuffering(false);
setLoadError(null);
@@ -378,10 +382,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
selectedSub,
subTrackOffset,
subShowable,
subFail,
subNotice,
subGen,
subTrackKey,
onSubTrackError,
subTrackListeners,
} = usePlexTracks({
id,
detail,
@@ -396,6 +400,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
audioRef,
aoffRef,
absRef,
loadStartRef,
});
// Start playback when the detail arrives (resume from the saved position unless already watched).
@@ -882,7 +887,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
srcLang={selectedSub.language ?? undefined}
label={selectedSub.label}
onError={onSubTrackError}
{...subTrackListeners}
default
/>
)}
@@ -921,15 +926,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
(154 s for a 17 GB film, measured), so it happens in the background — around the playhead
first. Until then the <track> is deliberately NOT mounted, and this says why instead of
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
item 8). It sits one rung above the track-switch OSD so the two never overlap. */}
item 8). A failure is said even over a mounted window (see `subNotice`). It sits one rung
above the track-switch OSD so the two never overlap. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-36 z-menu flex justify-center px-4"
aria-live="polite"
>
{selectedSub && !subShowable && (
{selectedSub && subNotice && (
<div className="flex max-w-full items-center gap-2 truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
{subFail ? (
<span className="text-danger">{t(`plex.player.subUnavailable.${subFail}`)}</span>
{subNotice !== "preparing" ? (
<span className="text-danger">{t(`plex.player.subUnavailable.${subNotice}`)}</span>
) : (
<>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
@@ -10,7 +10,7 @@
// window around the playhead first, and this hook's poll is what decides when there is something to
// mount and what to say while there isn't.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRef, type ReactNode, type MutableRefObject } from "react";
import { usePlexTracks } from "./plexPlayerHooks";
@@ -32,14 +32,14 @@ const detail = (): PlexItemDetail =>
markers: [],
}) as unknown as PlexItemDetail;
function mount(qc: QueryClient, { sessionK = 0, abs = 3600 } = {}) {
function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}) {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
return renderHook(
() =>
({ id }: { id: string }) =>
usePlexTracks({
id: RK,
id,
detail: detail(),
sessionK,
subOffset: 0,
@@ -52,8 +52,9 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600 } = {}) {
audioRef: { current: 0 } as MutableRefObject<number | null>,
aoffRef: { current: 0 },
absRef: { current: abs },
loadStartRef: { current: loadStart },
}),
{ wrapper },
{ wrapper, initialProps: { id: RK } },
);
}
@@ -80,7 +81,7 @@ describe("usePlexTracks — the <track> waits for the extraction", () => {
// The selected track exists and is offered in the picker — it just cannot be rendered yet, and
// the caller must be told so rather than mounting an empty <track>.
expect(result.current.subShowable).toBe(false);
expect(result.current.subFail).toBeNull();
expect(result.current.subNotice).toBe("preparing");
});
it("sends the viewer's position, so the extraction windows around where they are", async () => {
@@ -92,18 +93,35 @@ describe("usePlexTracks — the <track> waits for the extraction", () => {
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
});
it("sends the session's start before playback has ticked, not the head of the film", async () => {
it("sends where the session was asked to start before playback has ticked, not the head of the film", async () => {
// A resumed session starts minutes in, but `abs` stays 0 until the first timeupdate — and every
// poll used to say at=0, so the first window was cut around the head of the film (measured on
// localdev: a resume at 20:00 polled at=0 for as long as it stayed paused).
// localdev: a resume at 20:00 polled at=0 for as long as it stayed paused). `sessionK` is no
// substitute: it is still 0 here, because the session request has not answered yet.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const { result } = mount(client(), { sessionK: 1198, abs: 0 });
const { result } = mount(client(), { sessionK: 0, abs: 0, loadStart: 1198 });
result.current.setSubOrd(1);
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 1198));
});
it("does not carry the track choice to the next item — that item's detail decides it", async () => {
// Carried over, the old ordinal polled the NEW item (where it may be another language) at the
// OLD item's position, queueing an extraction of the wrong stretch of the next file.
const status = vi
.spyOn(api, "plexSubtitleStatus")
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const { result, rerender } = mount(client());
result.current.setSubOrd(1);
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
rerender({ id: "NEXT" });
expect(result.current.subOrd).toBeNull();
expect(result.current.subNotice).toBeNull();
await act(async () => {});
expect(status.mock.calls.some(([id]) => id === "NEXT")).toBe(false);
});
it("becomes showable on a PARTIAL track — a window around the playhead is what is needed now", async () => {
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
state: "partial",
@@ -126,7 +144,7 @@ describe("usePlexTracks — the <track> waits for the extraction", () => {
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subFail).toBe("bitmap"));
await waitFor(() => expect(result.current.subNotice).toBe("bitmap"));
expect(result.current.subShowable).toBe(false);
});
@@ -138,7 +156,7 @@ describe("usePlexTracks — the <track> waits for the extraction", () => {
});
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subFail).toBe("missing"));
await waitFor(() => expect(result.current.subNotice).toBe("missing"));
});
it("asks nothing at all while subtitles are off", async () => {
@@ -196,7 +214,7 @@ describe("usePlexTracks — when the status poll asks again, and when it stops",
const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline"));
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subFail).toBe("failed"));
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(status).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5000);
expect(status).toHaveBeenCalledTimes(1);
@@ -215,29 +233,117 @@ describe("usePlexTracks — when the status poll asks again, and when it stops",
await expect(api.plexSubtitleStatus(RK, 0, 0)).rejects.toThrow();
expect(getError()).toBeNull();
});
});
describe("usePlexTracks — a <track> whose own VTT fetch failed", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("is replaced by a fresh element a couple of times, then reported instead of left blank", async () => {
// A failed <track> never fetches again, and while the server's answer is unchanged its key was
// unchanged too — so React kept the dead element and the subtitles stayed silently blank.
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "partial", code: null, gen: 5 });
it("says a failing poll even while an earlier window is still on screen", async () => {
// react-query keeps the last good answer through a failed refetch, so a `partial` track stayed
// "showable" and the failure had nowhere to appear: past the window, blank subtitles and nothing
// on screen saying why — with the request `quiet`, not even the global dialog.
vi.spyOn(api, "plexSubtitleStatus")
.mockResolvedValueOnce({ state: "partial", code: null, gen: 5 })
.mockRejectedValue(new Error("offline"));
const { result } = mount(client());
result.current.setSubOrd(0);
await waitFor(() => expect(result.current.subShowable).toBe(true));
for (let i = 0; i < 2; i++) {
const before = result.current.subTrackKey;
act(() => result.current.onSubTrackError());
await waitFor(() => expect(result.current.subTrackKey).not.toBe(before));
expect(result.current.subShowable).toBe(true);
expect(result.current.subNotice).toBeNull();
await vi.advanceTimersByTimeAsync(2100);
await waitFor(() => expect(result.current.subNotice).toBe("failed"));
expect(result.current.subShowable).toBe(true); // the window it has is still worth showing
});
});
// Rendered for real, not by calling the hook's callback: the failure arrives as the <track>'s own
// `error` event, which does not bubble — and React 18 attaches no listener of its own to a <track>,
// so the `onError` this used to be wired through was never called in a browser at all.
function TrackHarness() {
const tracks = usePlexTracks({
id: RK,
detail: detail(),
sessionK: 0,
subOffset: 0,
subPos: 88,
loadSession: async () => {},
patchPrefs: () => {},
videoRef: createRef<HTMLVideoElement>(),
hlsRef: createRef<Hls>(),
multiAudioRef: { current: false },
audioRef: { current: 0 } as MutableRefObject<number | null>,
aoffRef: { current: 0 },
absRef: { current: 3600 },
loadStartRef: { current: 0 },
});
harness.current = tracks;
return (
<video>
{tracks.subShowable && (
<track
key={tracks.subTrackKey}
data-key={tracks.subTrackKey}
kind="subtitles"
{...tracks.subTrackListeners}
/>
)}
</video>
);
}
const harness: { current: ReturnType<typeof usePlexTracks> | null } = { current: null };
function renderTrack(qc: QueryClient) {
const view = render(
<QueryClientProvider client={qc}>
<TrackHarness />
</QueryClientProvider>,
);
const track = () => view.container.querySelector("track");
// What the browser does when the VTT fetch fails: a plain, NON-bubbling `error` on the element.
const fail = () => act(() => void track()!.dispatchEvent(new Event("error")));
return { track, fail };
}
describe("usePlexTracks — a <track> whose own VTT fetch failed", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "ready", code: null, gen: 5 });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("is replaced by a fresh element, after a pause, a couple of times — then reported instead of left blank", async () => {
// A failed <track> never fetches again, and while the server's answer is unchanged its key was
// unchanged too — so React kept the dead element and the subtitles stayed silently blank. The
// pause matters as much: remounting at once spent the whole budget inside a one-second outage.
const { track, fail } = renderTrack(client());
act(() => harness.current!.setSubOrd(0));
await waitFor(() => expect(track()).not.toBeNull());
for (const pause of [2000, 4000]) {
const before = track()!.dataset.key;
fail();
await vi.advanceTimersByTimeAsync(pause - 100);
expect(track()!.dataset.key).toBe(before); // not yet: still waiting out the pause
await vi.advanceTimersByTimeAsync(200);
await waitFor(() => expect(track()!.dataset.key).not.toBe(before));
}
act(() => result.current.onSubTrackError());
await waitFor(() => expect(result.current.subShowable).toBe(false));
expect(result.current.subFail).toBe("failed");
fail();
await waitFor(() => expect(track()).toBeNull());
expect(harness.current!.subNotice).toBe("failed");
});
it("gets a fresh budget when the viewer picks the track again", async () => {
// The give-up belongs to one track generation, and a `ready` track is no longer polled, so no new
// generation would ever come: turning subtitles off and on used to leave "couldn't be loaded".
const { track, fail } = renderTrack(client());
act(() => harness.current!.setSubOrd(0));
await waitFor(() => expect(track()).not.toBeNull());
for (let i = 0; i < 3; i++) {
fail();
await vi.advanceTimersByTimeAsync(4100);
}
await waitFor(() => expect(track()).toBeNull());
act(() => harness.current!.changeSubtitle(null));
act(() => harness.current!.changeSubtitle(0));
await waitFor(() => expect(track()).not.toBeNull());
expect(harness.current!.subNotice).toBeNull();
});
});
+84 -26
View File
@@ -349,10 +349,12 @@ export function useAutoSkip(opts: {
* fold happens here rather than multiplying near-identical translations. */
export type SubFailReason = "bitmap" | "missing" | "failed";
// The subtitle status poll's cadence after a FAILED request (see usePlexTracks), and how many times a
// <track> whose own VTT fetch failed is remounted before the track is reported as unavailable.
// The subtitle status poll's cadence after a FAILED request (see usePlexTracks), how many times a
// <track> whose own VTT fetch failed is remounted before the track is reported as unavailable, and
// the first of the (doubling) pauses before each remount.
const SUB_POLL_ERROR_MS = 10_000;
const SUB_TRACK_RETRIES = 2;
const SUB_TRACK_RETRY_MS = 2_000;
function subFailReason(code: string | null | undefined): SubFailReason {
if (code === "bitmap") return "bitmap";
@@ -375,6 +377,9 @@ export function usePlexTracks(opts: {
audioRef: MutableRefObject<number | null>;
aoffRef: MutableRefObject<number>;
absRef: MutableRefObject<number>;
/** The position the current (or still in-flight) session was ASKED to start at — written before
* the session request goes out, unlike `sessionK`, which only lands once it has answered. */
loadStartRef: MutableRefObject<number>;
}): {
audioOrd: number | null;
setAudioOrd: (ord: number | null) => void;
@@ -394,8 +399,10 @@ export function usePlexTracks(opts: {
/** The selected track has something to render (a window around the playhead, or the whole thing).
* False means "not yet" — the caller must NOT mount a <track>, it would just sit blank. */
subShowable: boolean;
/** The selected track can never be shown, bucketed for the message the player puts on screen. */
subFail: SubFailReason | null;
/** What the player must SAY about the selected track, if anything: still being prepared, or why it
* can't be shown (bucketed for the sentence the player has). Independent of `subShowable` — a
* failing status poll is worth saying even while an earlier window is still mounted. */
subNotice: "preparing" | SubFailReason | null;
/** The cached VTT's generation — it grows as the background extraction widens the track, so it
* belongs in the <track> key/URL or the browser keeps serving the first, short window. */
subGen: number;
@@ -404,14 +411,25 @@ export function usePlexTracks(opts: {
* change), the extraction generation (a widened track replaces the first short window), and the
* failed-load count (a <track> whose fetch failed is replaced rather than left dead). */
subTrackKey: string;
onSubTrackError: () => void;
/** Spread onto the <track>. Its `error` event does not bubble, and React 18 attaches no listener of
* its own to a <track> — a plain `onError` there is never called (measured in Chrome and jsdom:
* 0 calls, while `onErrorCapture` fires). The capture phase reaches the target regardless. */
subTrackListeners: { onErrorCapture: () => void };
} {
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef, loadStartRef } = opts;
const qc = useQueryClient();
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
const [subOrd, setSubOrd] = useState<number | null>(null);
// The subtitle choice belongs to the ITEM it was made for. Moving to the next episode used to
// carry the old ordinal over until the new item's detail re-derived it, so the status poll asked
// the new item about a track number that may be a different language there, at the old item's
// position. Stored with its id, it simply reads as "off" on any other item.
const [subSel, setSubSel] = useState<{ id: string; ord: number | null }>({ id, ord: null });
const subOrd = subSel.id === id ? subSel.ord : null;
const setSubOrd = useCallback((ord: number | null) => setSubSel({ id, ord }), [id]);
// Failed-<track>-load count for one track generation (see onSubTrackError below).
const [loadFails, setLoadFails] = useState({ key: "", n: 0 });
// Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
const [subOffApplied, setSubOffApplied] = useState(subOffset);
useEffect(() => {
@@ -448,12 +466,15 @@ export function usePlexTracks(opts: {
const changeSubtitle = useCallback(
(ord: number | null) => {
setSubOrd(ord);
// Picking a track (the same one included) is the viewer asking for it NOW: a track given up on
// after failed loads gets a fresh budget instead of repeating "couldn't be loaded" unasked.
setLoadFails({ key: "", n: 0 });
patchPrefs({
subLang:
ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""),
});
},
[detail, patchPrefs],
[detail, patchPrefs, setSubOrd],
);
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
const cycleAudio = useCallback(() => {
@@ -499,10 +520,16 @@ export function usePlexTracks(opts: {
// The position is only known once playback has ticked: until the first timeupdate `abs` is 0
// even though a resumed session starts minutes in — and every poll said `at=0`, so the first
// window was cut around the head of the film instead of where the viewer is (measured on
// localdev: a resume at 20:00 polled `at=0` for as long as it stayed paused). The session's own
// start is the best answer until then.
// localdev: a resume at 20:00 polled `at=0` for as long as it stayed paused). Until then the
// 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 : sessionK),
api.plexSubtitleStatus(
id,
subOrd as number,
absRef.current > 0 ? absRef.current : loadStartRef.current,
),
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
@@ -531,27 +558,58 @@ export function usePlexTracks(opts: {
// keeps the dead element and the subtitles stay silently blank. So a failed load bumps a counter
// that is part of the key — a fresh element, a fresh fetch — and after SUB_TRACK_RETRIES the track
// is reported as unavailable instead of left blank. The count belongs to one track generation: a
// different track, item or `gen` starts from zero.
// different track, item or `gen` starts from zero, and so does picking the track again.
const loadKey = `${id}:${subOrd}:${subStatus?.gen ?? 0}`;
const [loadFails, setLoadFails] = useState({ key: "", n: 0 });
const subRetry = loadFails.key === loadKey ? loadFails.n : 0;
const subGaveUp = subRetry > SUB_TRACK_RETRIES;
// Mount the <track> for a `partial` track too: a window around the playhead is exactly what the
// viewer needs right now, and the poll widens it underneath them.
const subShowable =
(subStatus?.state === "ready" || subStatus?.state === "partial") && !subGaveUp;
const subFail: SubFailReason | null =
(subStatusQ.isError || subGaveUp) && subOrd != null
? "failed"
: subStatus?.state === "failed"
? subFailReason(subStatus.code)
: null;
// A failing poll is said even while an earlier window stays mounted: react-query keeps the last
// good `data` through a failed refetch, so a `partial` answer kept the track "showable" and the
// failure had nowhere to appear — blank subtitles past the window, no spinner, no message.
const subNotice: "preparing" | SubFailReason | null =
subOrd == null
? null
: subStatusQ.isError || subGaveUp
? "failed"
: subStatus?.state === "failed"
? subFailReason(subStatus.code)
: subShowable
? null
: "preparing";
// A remount fetches at once, so remounting straight away spent the whole budget inside a
// one-second outage (an API restart): three failures in a few milliseconds, then "unavailable"
// for a track that would have loaded a moment later. Each retry waits, doubling; running out is
// reported at once. A pending retry dies with the generation it was for.
const retryTimer = useRef<number | null>(null);
useEffect(
() => () => {
if (retryTimer.current != null) window.clearTimeout(retryTimer.current);
retryTimer.current = null;
},
[loadKey],
);
const onSubTrackError = useCallback(() => {
setLoadFails((f) => ({ key: loadKey, n: (f.key === loadKey ? f.n : 0) + 1 }));
// …and ask again: if the cached VTT was evicted between the poll and the fetch, the answer drops
// back to `preparing` and the poll takes over until it has been re-extracted.
if (subOrd != null) qc.invalidateQueries({ queryKey: qk.plexSubtitle(id, subOrd) });
}, [qc, id, subOrd, loadKey]);
const bump = () =>
setLoadFails((f) => ({ key: loadKey, n: (f.key === loadKey ? f.n : 0) + 1 }));
if (subRetry >= SUB_TRACK_RETRIES) {
bump();
return;
}
if (retryTimer.current != null) window.clearTimeout(retryTimer.current);
retryTimer.current = window.setTimeout(
() => {
retryTimer.current = null;
bump();
// …and ask again: if the cached VTT was evicted between the poll and the fetch, the answer
// drops back to `preparing` and the poll takes over until it has been re-extracted.
if (subOrd != null) qc.invalidateQueries({ queryKey: qk.plexSubtitle(id, subOrd) });
},
SUB_TRACK_RETRY_MS * 2 ** subRetry,
);
}, [qc, id, subOrd, loadKey, subRetry]);
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
@@ -620,9 +678,9 @@ export function usePlexTracks(opts: {
selectedSub,
subTrackOffset,
subShowable,
subFail,
subNotice,
subGen: subStatus?.gen ?? 0,
subTrackKey: `${id}-${subOrd}-${subTrackOffset.toFixed(2)}-${subStatus?.gen ?? 0}-${subRetry}`,
onSubTrackError,
subTrackListeners: { onErrorCapture: onSubTrackError },
};
}