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