fix(player): a step onto an unplayable video carries on the way it went

Previous onto an embed-blocked or removed video skipped forward again (the
auto-advance direction), straight back to the item just left. The player now
remembers the direction of the step that reached the item and the videos
YouTube refused this session: an error carries on that way, Previous / Next
pass over known-dead items without loading them, and go dark when only those
are left. A step of your own skips with auto-advance off too.
This commit is contained in:
2026-11-01 01:14:50 +01:00
parent c430558ab3
commit 15a3603513
9 changed files with 225 additions and 59 deletions
@@ -34,14 +34,13 @@ function mountPlayer(setPlayerError: (code: number | null) => void) {
mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } },
setWatched: () => {},
setPlayerError,
setLiveData: () => {},
reapplyStored: () => {},
reclaimFocus: () => {},
advanceOnEnd: () => {},
skipUnplayable: () => {},
sessionPosRef: { current: new Map() },
qc: new QueryClient(),
}),
@@ -76,14 +76,13 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } },
setWatched: () => {},
setPlayerError: () => {},
setLiveData: () => {},
reapplyStored: () => {},
reclaimFocus: () => {},
advanceOnEnd: () => {},
skipUnplayable: () => {},
sessionPosRef: { current: sessionPos },
qc,
}),
+13 -17
View File
@@ -17,12 +17,11 @@ import {
requestFullscreen,
} from "./fullscreen";
import { useBackToExit } from "./history";
import { api, type Video } from "./api";
import { api } from "./api";
import { qk } from "./queryKeys";
import { patchCachedProgress, trackProgressFlush } from "./progressCache";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
import type { AutoMode, LoopMode } from "./playerModes";
// How close to the end (seconds) counts as "finished" → auto-mark watched.
const FINISH_MARGIN = 10;
@@ -334,7 +333,7 @@ function playerTimes(p: YTPlayer | null): { cur: number; dur: number } | null {
/** Create the YouTube IFrame player for the active item, resume from the saved position, checkpoint
* progress every 5 s (+ a pagehide beacon), and auto-mark watched once playback reaches the end. Keyed
* on activeId (a queue advance recreates the player to reuse this single-video logic). The once-bound
* event closures read fresh queue/mode/volume via the refs passed in. Mirrors Plex's usePlexProgress —
* event closures read what must stay live via the refs passed in. Mirrors Plex's usePlexProgress —
* side-effect only, returns nothing. */
export function useYtProgress({
activeId,
@@ -343,14 +342,13 @@ export function useYtProgress({
mountRef,
playerRef,
currentIdRef,
navRef,
modeRef,
setWatched,
setPlayerError,
setLiveData,
reapplyStored,
reclaimFocus,
advanceOnEnd,
skipUnplayable,
sessionPosRef,
qc,
}: {
@@ -361,14 +359,14 @@ export function useYtProgress({
mountRef: RefObject<HTMLDivElement | null>;
playerRef: RefObject<YTPlayer | null>;
currentIdRef: RefObject<string>;
navRef: RefObject<{ queue: Video[] | undefined; index: number }>;
modeRef: RefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
setWatched: (on: boolean) => void;
setPlayerError: (code: number | null) => void;
setLiveData: (d: { title?: string; author?: string } | null) => void;
reapplyStored: () => void;
reclaimFocus: () => void;
advanceOnEnd: () => void;
/** The item turned out unplayable; `go` false once a run of them hit the cap (stay on the error). */
skipUnplayable: (go: boolean) => void;
// Positions this player session has actually STORED, per video id. Written from the server's
// answer, read back by the modal's resume so stepping next→prev inside one session does not
// rewind to the value the frozen launch queue was snapshotted with.
@@ -388,6 +386,7 @@ export function useYtProgress({
reapplyStored,
reclaimFocus,
advanceOnEnd,
skipUnplayable,
qc,
}));
@@ -403,6 +402,7 @@ export function useYtProgress({
reapplyStored,
reclaimFocus,
advanceOnEnd,
skipUnplayable,
qc,
} = itemSnapshot();
// A position this session already stored for the item beats the queue's snapshot of it.
@@ -492,16 +492,12 @@ export function useYtProgress({
onError: (e: YTPlayerEvent) => {
logYouTubeError(typeof e?.data === "number" ? e.data : -1);
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
// items can't spin. We do NOT mark the broken video watched.
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and the
// queue would freeze on it — the modal skips it the way the step that reached it went,
// bounded so a run of dead items can't spin. We do NOT mark the broken video watched.
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
const { autoMode: a } = modeRef.current;
const { queue: q } = navRef.current;
if (a === "off" || !q || q.length <= 1) return;
errorStreakRef.current += 1;
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
advanceOnEnd();
skipUnplayable(errorStreakRef.current < MAX_CONSECUTIVE_ERRORS);
},
},
});
@@ -565,9 +561,9 @@ export function useYtProgress({
// would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets
// this cleanup's late auto-mark land on the item being LEFT, which the modal records for that
// item and never paints on the next one. What genuinely has to stay live mid-session is behind
// refs (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef)
// refs (currentIdRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef)
// — and THAT split is the enforcement: a value that is not a snapshot does not belong in this
// closure. (This replaced an exhaustive-deps suppression, which made the React Compiler skip
// the whole hook.)
}, [activeId, mountRef, playerRef, currentIdRef, navRef, modeRef, sessionPosRef]);
}, [activeId, mountRef, playerRef, currentIdRef, sessionPosRef]);
}
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { afterRemoval } from "./playerQueue";
import { afterRemoval, stepIndex } from "./playerQueue";
import type { Video } from "./api";
// "Delete" from inside the player (the owner asked for it: today the only way to drop a video from a
@@ -51,3 +51,25 @@ describe("afterRemoval", () => {
expect(next!.playingId).toBe("a");
});
});
describe("stepIndex", () => {
const q = [v("a"), v("b"), v("c"), v("d")];
const none = new Set<string>();
it("steps to the neighbour that way", () => {
expect(stepIndex(q, 1, 1, none)).toBe(2);
expect(stepIndex(q, 1, -1, none)).toBe(0);
});
it("passes over a video known dead", () => {
expect(stepIndex(q, 3, -1, new Set(["c", "b"]))).toBe(0);
expect(stepIndex(q, 0, 1, new Set(["b"]))).toBe(2);
});
it("is -1 at the end, or when only dead videos are left that way", () => {
expect(stepIndex(q, 3, 1, none)).toBe(-1);
expect(stepIndex(q, 2, -1, new Set(["a", "b"]))).toBe(-1);
});
it("wraps when asked, and never lands on where it started", () => {
expect(stepIndex(q, 3, 1, none, true)).toBe(0);
expect(stepIndex(q, 0, -1, new Set(["d"]), true)).toBe(2);
expect(stepIndex(q, 1, 1, new Set(["a", "c", "d"]), true)).toBe(-1);
});
});
+22
View File
@@ -28,3 +28,25 @@ export function afterRemoval(
const next = rest[Math.min(Math.max(index, 0), rest.length - 1)]!;
return { queue: rest, playingId: next.id };
}
/** The nearest item from `index` going `dir` that is not known to be unplayable, or -1 when there is
* none that way. `wrap` goes round the ends (Loop "All"), never back onto `index` itself.
*
* A video YouTube refused to play (embed-blocked, removed) is remembered for the player session, so
* Previous / Next pass over it instead of loading it again only to be bounced off it. */
export function stepIndex(
queue: readonly Video[],
index: number,
dir: 1 | -1,
dead: ReadonlySet<string>,
wrap = false,
): number {
const n = queue.length;
for (let k = 1; k < n; k++) {
const raw = index + dir * k;
if (!wrap && (raw < 0 || raw >= n)) return -1;
const j = ((raw % n) + n) % n;
if (!dead.has(queue[j]!.id)) return j;
}
return -1;
}