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
@@ -63,7 +63,11 @@ const started: { videoId: string; start: number | undefined }[] = [];
// What the player was asked to switch to in place (a description link, a related video). // What the player was asked to switch to in place (a description link, a related video).
const loaded: { videoId: string; startSeconds: number }[] = []; const loaded: { videoId: string; startSeconds: number }[] = [];
// The last player's event handlers, so a test can say "the player is ready" when it wants to. // The last player's event handlers, so a test can say "the player is ready" when it wants to.
let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {}; let playerEvents: {
onReady?: () => void;
onStateChange?: (e: { data: number }) => void;
onError?: (e: { data: number }) => void;
} = {};
const base = { const base = {
getCurrentTime: () => clock.current, getCurrentTime: () => clock.current,
getDuration: () => clock.duration, getDuration: () => clock.duration,
@@ -1816,3 +1820,81 @@ describe("PlayerModal — clip mode", () => {
expect(screen.getByTestId("clip-panel")).toBeTruthy(); expect(screen.getByTestId("clip-panel")).toBeTruthy();
}); });
}); });
// An embed-blocked or deleted video answers with onError, never 'ended'. Stepping BACK onto one used
// to skip forward again (the auto-advance direction), straight to the item you had just left.
describe("PlayerModal — stepping over a video that cannot play", () => {
const A = vid("A", 0);
const B = vid("B", 0);
const C = vid("C", 0);
const fail = () => act(() => playerEvents.onError?.({ data: 150 }));
const opened = (id: string) => started.filter((s) => s.videoId === id).length;
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: 0,
}));
});
const open = async (startIndex: number) => {
renderModal(
<PlayerModal
video={[A, B, C][startIndex]!}
queue={[A, B, C]}
startIndex={startIndex}
onClose={() => {}}
onState={vi.fn()}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe([A, B, C][startIndex]!.id));
};
it("Previous onto a dead video goes on back, not forward to where you came from", async () => {
me = { related_enabled: true, preferences: { playerAutoAdvance: "next" } } as unknown as Me;
await open(2);
prev();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
fail();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
});
it("a step of your own skips a dead video with auto-advance off too", async () => {
await open(2);
prev();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
fail();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
});
it("a video found dead is stepped over without loading it again", async () => {
await open(2);
prev();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
fail();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
expect(opened("B")).toBe(1);
});
it("Previous is off when only dead videos are before you", async () => {
await open(0);
fail(); // opened on it, auto-advance off: it stays, and it is known dead now
await settle();
expect(lastStart()?.videoId).toBe("A");
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
for (const b of screen.getAllByRole("button", { name: "player.previous" }))
expect((b as HTMLButtonElement).disabled).toBe(true);
});
it("a video opened dead with auto-advance off stays on its error", async () => {
await open(1);
fail();
await settle();
expect(lastStart()?.videoId).toBe("B");
expect(started).toHaveLength(1);
});
});
+73 -34
View File
@@ -72,7 +72,7 @@ import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib
import ChannelNameLink from "../video/ChannelNameLink"; import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster"; import Toaster from "../../components/ui/Toaster";
import { useOverlayOrder } from "../../components/ui/overlayOrder"; import { useOverlayOrder } from "../../components/ui/overlayOrder";
import { afterRemoval } from "../../lib/playerQueue"; import { afterRemoval, stepIndex } from "../../lib/playerQueue";
import { import {
useNativeControlsYield, useNativeControlsYield,
useYtBigView, useYtBigView,
@@ -118,6 +118,11 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
// a rem width, so it grows with the text size; the video takes what is left. // a rem width, so it grows with the text size; the video takes what is left.
const WIDE_PLAYER_PX = 1280; const WIDE_PLAYER_PX = 1280;
const SIDE_PANEL = "20rem"; const SIDE_PANEL = "20rem";
// The way a queue step goes: back, on, or to a random other item.
type Way = 1 | -1 | "random";
const autoWay = (a: Exclude<AutoMode, "off">): Way =>
a === "random" ? "random" : a === "next" ? 1 : -1;
// The side panel shut to a strip: one icon button wide, close / open / previous / next in a column. // The side panel shut to a strip: one icon button wide, close / open / previous / next in a column.
const PANEL_STRIP = "2.75rem"; const PANEL_STRIP = "2.75rem";
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth). // The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
@@ -657,15 +662,46 @@ export default function PlayerModal({
// Prev/next stepping through the queue (the feed's order, or a playlist). Read the live // Prev/next stepping through the queue (the feed's order, or a playlist). Read the live
// queue+index from a ref so the window keydown handler (bound once) always steps from the // queue+index from a ref so the window keydown handler (bound once) always steps from the
// current position, not a stale closure. // current position, not a stale closure.
const navRef = useLatest({ queue, index }); //
const goPrev = () => { // The videos YouTube refused to play this session (embed-blocked, removed): a step passes over
const { queue: q, index: i } = navRef.current; // them, and Previous / Next go dark when only those are left that way. State, not a ref, because
if (q && i > 0) setPlayingId(q[i - 1]!.id); // the buttons read it.
const [dead, setDead] = useState<ReadonlySet<string>>(() => new Set());
const navRef = useLatest({ queue, index, dead });
// Which way the step that reached the playing item went — what an item that turns out unplayable
// carries on along. null: no step yet (the item the player opened on).
const stepRef = useRef<{ way: Way } | null>(null);
const stepTo = (id: string, way: Way) => {
stepRef.current = { way };
setPlayingId(id);
}; };
const goNext = () => { // Step from `i` the given way over the videos known dead; whether there was anywhere to go.
const { queue: q, index: i } = navRef.current; const moveOn = (
if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id); q: Video[],
i: number,
way: Way,
d: ReadonlySet<string>,
wrap: boolean,
): boolean => {
if (way === "random") {
const rest = q.filter((v, k) => k !== i && !d.has(v.id));
if (!rest.length) return false;
stepTo(rest[Math.floor(Math.random() * rest.length)]!.id, "random");
return true;
}
const j = stepIndex(q, i, way, d, wrap);
if (j < 0) return false;
stepTo(q[j]!.id, way);
return true;
}; };
const step = (dir: 1 | -1) => {
const { queue: q, index: i, dead: d } = navRef.current;
if (q) moveOn(q, i, dir, d, false);
};
const goPrev = () => step(-1);
const goNext = () => step(1);
const canPrev = !!queue && stepIndex(queue, index, -1, dead) >= 0;
const canNext = !!queue && stepIndex(queue, index, 1, dead) >= 0;
// "Remove from this playlist", offered right where you are watching the thing you want gone. The // "Remove from this playlist", offered right where you are watching the thing you want gone. The
// parent owns the removal (API + caches + the toast whose Undo puts it back); the player owns what happens NEXT, because it // parent owns the removal (API + caches + the toast whose Undo puts it back); the player owns what happens NEXT, because it
// is the only side that knows which item is playing — its queue is frozen at mount, so the parent // is the only side that knows which item is playing — its queue is frozen at mount, so the parent
@@ -699,7 +735,7 @@ export default function PlayerModal({
return; return;
} }
setQueue(next.queue); setQueue(next.queue);
setPlayingId(next.playingId); stepTo(next.playingId, 1); // the item that followed: a dead one there carries on forward
}; };
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s. // Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
@@ -738,28 +774,28 @@ export default function PlayerModal({
if (l === "one") return replayCurrent(); if (l === "one") return replayCurrent();
// A related video's end is not the queue's: the list beside it is where the next one is picked. // A related video's end is not the queue's: the list beside it is where the next one is picked.
if (related) return; if (related) return;
const { queue: q, index: i } = navRef.current; const { queue: q, index: i, dead: d } = navRef.current;
if (!q || q.length === 0) return; if (!q || q.length === 0 || a === "off") return;
const wrap = l === "all"; const wrap = l === "all";
if (a === "next") { // Nowhere else to go (a one-item list, or every other item dead): Loop "All" repeats this one.
if (i < q.length - 1) setPlayingId(q[i + 1]!.id); if (!moveOn(q, i, autoWay(a), d, wrap) && wrap) replayCurrent();
else if (wrap) { };
if (q.length === 1) replayCurrent(); // The playing queue item turned out unplayable (YouTube's onError — it never reaches 'ended').
else setPlayingId(q[0]!.id); // Remember it, then carry on the way the step that reached it went: Previous onto a dead video
} // goes on back, not forward to the one you just left. The item the player opened on was reached
} else if (a === "prev") { // by no step, so it moves on only with auto-advance on, in its direction. `go` false: the run of
if (i > 0) setPlayingId(q[i - 1]!.id); // errors hit the player's cap — remembered, but playback stays on the error.
else if (wrap) { const skipUnplayable = (go: boolean) => {
if (q.length === 1) replayCurrent(); if (related || clipOpenRef.current) return;
else setPlayingId(q[q.length - 1]!.id); const { queue: q, index: i, dead: d } = navRef.current;
} const gone = q?.[i]?.id;
} else if (a === "random" && q.length > 1) { if (!q || !gone) return;
let r = i; const now = new Set(d).add(gone);
while (r === i) r = Math.floor(Math.random() * q.length); setDead(now);
setPlayingId(q[r]!.id); if (!go || q.length <= 1) return;
} else if (a === "random" && wrap) { const { autoMode: a, loopMode: l } = modeRef.current;
replayCurrent(); const way = stepRef.current?.way ?? (a === "off" ? null : autoWay(a));
} if (way != null) moveOn(q, i, way, now, l === "all");
}; };
// Lazy description (fetched only when it is opened). It opens from an ⓘ beside the title, by click // Lazy description (fetched only when it is opened). It opens from an ⓘ beside the title, by click
@@ -982,14 +1018,13 @@ export default function PlayerModal({
mountRef, mountRef,
playerRef, playerRef,
currentIdRef, currentIdRef,
navRef,
modeRef,
setWatched, setWatched,
setPlayerError, setPlayerError,
setLiveData, setLiveData,
reapplyStored, reapplyStored,
reclaimFocus, reclaimFocus,
advanceOnEnd, advanceOnEnd,
skipUnplayable,
sessionPosRef, sessionPosRef,
qc, qc,
}); });
@@ -1177,6 +1212,8 @@ export default function PlayerModal({
variant="docked" variant="docked"
index={index} index={index}
queueLength={queue!.length} queueLength={queue!.length}
canPrev={canPrev}
canNext={canNext}
autoMode={autoMode} autoMode={autoMode}
loopMode={loopMode} loopMode={loopMode}
onPrev={goPrev} onPrev={goPrev}
@@ -1332,7 +1369,7 @@ export default function PlayerModal({
<IconButton <IconButton
variant="ghost" variant="ghost"
onClick={goPrev} onClick={goPrev}
disabled={index === 0} disabled={!canPrev}
label={t("player.previous")} label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`} title={`${t("player.previous")} · Shift+←`}
> >
@@ -1341,7 +1378,7 @@ export default function PlayerModal({
<IconButton <IconButton
variant="ghost" variant="ghost"
onClick={goNext} onClick={goNext}
disabled={index >= queue!.length - 1} disabled={!canNext}
label={t("player.next")} label={t("player.next")}
title={`${t("player.next")} · Shift+→`} title={`${t("player.next")} · Shift+→`}
> >
@@ -1377,6 +1414,8 @@ export default function PlayerModal({
variant="modal" variant="modal"
index={index} index={index}
queueLength={queue!.length} queueLength={queue!.length}
canPrev={canPrev}
canNext={canNext}
autoMode={autoMode} autoMode={autoMode}
loopMode={loopMode} loopMode={loopMode}
onPrev={goPrev} onPrev={goPrev}
@@ -24,6 +24,8 @@ function mount(variant: "modal" | "docked", autoMode: AutoMode, loopMode: LoopMo
variant={variant} variant={variant}
index={1} index={1}
queueLength={3} queueLength={3}
canPrev
canNext
autoMode={autoMode} autoMode={autoMode}
loopMode={loopMode} loopMode={loopMode}
onPrev={() => {}} onPrev={() => {}}
@@ -27,6 +27,8 @@ export function PlayerTransportControls({
variant, variant,
index, index,
queueLength, queueLength,
canPrev,
canNext,
autoMode, autoMode,
loopMode, loopMode,
onPrev, onPrev,
@@ -37,6 +39,9 @@ export function PlayerTransportControls({
variant: "docked" | "modal"; variant: "docked" | "modal";
index: number; index: number;
queueLength: number; queueLength: number;
/** Whether there is a playable item that way (one YouTube refused is passed over). */
canPrev: boolean;
canNext: boolean;
autoMode: AutoMode; autoMode: AutoMode;
loopMode: LoopMode; loopMode: LoopMode;
onPrev: () => void; onPrev: () => void;
@@ -70,7 +75,7 @@ export function PlayerTransportControls({
name: t("player.previous"), name: t("player.previous"),
title: `${t("player.previous")} · Shift+←`, title: `${t("player.previous")} · Shift+←`,
onClick: onPrev, onClick: onPrev,
disabled: index === 0, disabled: !canPrev,
labelFrom: "sm", labelFrom: "sm",
}, },
{ {
@@ -79,7 +84,7 @@ export function PlayerTransportControls({
name: t("player.next"), name: t("player.next"),
title: `${t("player.next")} · Shift+→`, title: `${t("player.next")} · Shift+→`,
onClick: onNext, onClick: onNext,
disabled: index === queueLength - 1, disabled: !canNext,
labelFrom: "sm", labelFrom: "sm",
trailing: true, trailing: true,
}, },
@@ -34,14 +34,13 @@ function mountPlayer(setPlayerError: (code: number | null) => void) {
mountRef: { current: mount }, mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>, playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID }, currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } },
setWatched: () => {}, setWatched: () => {},
setPlayerError, setPlayerError,
setLiveData: () => {}, setLiveData: () => {},
reapplyStored: () => {}, reapplyStored: () => {},
reclaimFocus: () => {}, reclaimFocus: () => {},
advanceOnEnd: () => {}, advanceOnEnd: () => {},
skipUnplayable: () => {},
sessionPosRef: { current: new Map() }, sessionPosRef: { current: new Map() },
qc: new QueryClient(), qc: new QueryClient(),
}), }),
@@ -76,14 +76,13 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
mountRef: { current: mount }, mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>, playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID }, currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } },
setWatched: () => {}, setWatched: () => {},
setPlayerError: () => {}, setPlayerError: () => {},
setLiveData: () => {}, setLiveData: () => {},
reapplyStored: () => {}, reapplyStored: () => {},
reclaimFocus: () => {}, reclaimFocus: () => {},
advanceOnEnd: () => {}, advanceOnEnd: () => {},
skipUnplayable: () => {},
sessionPosRef: { current: sessionPos }, sessionPosRef: { current: sessionPos },
qc, qc,
}), }),
+13 -17
View File
@@ -17,12 +17,11 @@ import {
requestFullscreen, requestFullscreen,
} from "./fullscreen"; } from "./fullscreen";
import { useBackToExit } from "./history"; import { useBackToExit } from "./history";
import { api, type Video } from "./api"; import { api } from "./api";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { patchCachedProgress, trackProgressFlush } from "./progressCache"; import { patchCachedProgress, trackProgressFlush } from "./progressCache";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube"; import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog"; import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
import type { AutoMode, LoopMode } from "./playerModes";
// How close to the end (seconds) counts as "finished" → auto-mark watched. // How close to the end (seconds) counts as "finished" → auto-mark watched.
const FINISH_MARGIN = 10; 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 /** 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 * 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 * 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. */ * side-effect only, returns nothing. */
export function useYtProgress({ export function useYtProgress({
activeId, activeId,
@@ -343,14 +342,13 @@ export function useYtProgress({
mountRef, mountRef,
playerRef, playerRef,
currentIdRef, currentIdRef,
navRef,
modeRef,
setWatched, setWatched,
setPlayerError, setPlayerError,
setLiveData, setLiveData,
reapplyStored, reapplyStored,
reclaimFocus, reclaimFocus,
advanceOnEnd, advanceOnEnd,
skipUnplayable,
sessionPosRef, sessionPosRef,
qc, qc,
}: { }: {
@@ -361,14 +359,14 @@ export function useYtProgress({
mountRef: RefObject<HTMLDivElement | null>; mountRef: RefObject<HTMLDivElement | null>;
playerRef: RefObject<YTPlayer | null>; playerRef: RefObject<YTPlayer | null>;
currentIdRef: RefObject<string>; currentIdRef: RefObject<string>;
navRef: RefObject<{ queue: Video[] | undefined; index: number }>;
modeRef: RefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
setWatched: (on: boolean) => void; setWatched: (on: boolean) => void;
setPlayerError: (code: number | null) => void; setPlayerError: (code: number | null) => void;
setLiveData: (d: { title?: string; author?: string } | null) => void; setLiveData: (d: { title?: string; author?: string } | null) => void;
reapplyStored: () => void; reapplyStored: () => void;
reclaimFocus: () => void; reclaimFocus: () => void;
advanceOnEnd: () => 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 // 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 // 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. // rewind to the value the frozen launch queue was snapshotted with.
@@ -388,6 +386,7 @@ export function useYtProgress({
reapplyStored, reapplyStored,
reclaimFocus, reclaimFocus,
advanceOnEnd, advanceOnEnd,
skipUnplayable,
qc, qc,
})); }));
@@ -403,6 +402,7 @@ export function useYtProgress({
reapplyStored, reapplyStored,
reclaimFocus, reclaimFocus,
advanceOnEnd, advanceOnEnd,
skipUnplayable,
qc, qc,
} = itemSnapshot(); } = itemSnapshot();
// A position this session already stored for the item beats the queue's snapshot of it. // 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) => { onError: (e: YTPlayerEvent) => {
logYouTubeError(typeof e?.data === "number" ? e.data : -1); logYouTubeError(typeof e?.data === "number" ? e.data : -1);
setPlayerError(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 // onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and the
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead // queue would freeze on it — the modal skips it the way the step that reached it went,
// items can't spin. We do NOT mark the broken video watched. // 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 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; errorStreakRef.current += 1;
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay skipUnplayable(errorStreakRef.current < MAX_CONSECUTIVE_ERRORS);
advanceOnEnd();
}, },
}, },
}); });
@@ -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 // 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 // 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 // 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 // — 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 // closure. (This replaced an exhaustive-deps suppression, which made the React Compiler skip
// the whole hook.) // 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 { describe, it, expect } from "vitest";
import { afterRemoval } from "./playerQueue"; import { afterRemoval, stepIndex } from "./playerQueue";
import type { Video } from "./api"; 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 // "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"); 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)]!; const next = rest[Math.min(Math.max(index, 0), rest.length - 1)]!;
return { queue: rest, playingId: next.id }; 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;
}