fix(player): related marks survive, Restart restarts, and refused pages offer nothing to click
- Every state and Watch-later write goes through features/video/videoWrites, which marks the never-refetched lists (related lists, live searches) once the server has it; a contract test holds it. The Notification Center's undo, the feed's reset and the Watch-later toggles skipped it before. - Confirmed marks are also kept apart (qk.videoMarks) and laid over the related rows: a Show more page landing later rebuilt them from the pages it read when it started, and dropped a mark made meanwhile. - Restart on a related video starts at 0 (the session's stored position won before), and on the one already playing it seeks. - The watched toggle remembers each video's mark: Back showed the opened video's launch-time state after a related one was marked. - The help sheet leaves out the player's keys while a layer above it has them waiting. - After a refusal a retry cannot answer, neither Show more (related) nor Load more (live search) is offered; a failed live-search page keeps the results already read and says why under them. - The related cap message counts requests, as the cap does.
This commit is contained in:
@@ -35,6 +35,8 @@ vi.mock("../downloads/DownloadButton", () => ({
|
||||
}));
|
||||
|
||||
import PlayerModal from "./PlayerModal";
|
||||
import KeyboardHelp from "../../components/ui/KeyboardHelp";
|
||||
import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
|
||||
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { api, type Me, type Video } from "../../lib/api";
|
||||
@@ -837,6 +839,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
const playRel2 = async (
|
||||
onState: (id: string, status: string, v?: Video) => void,
|
||||
queue?: Video[],
|
||||
opts: { onRemoveFromList?: (v: Video) => Promise<boolean>; before?: () => void } = {},
|
||||
) => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
@@ -853,9 +856,11 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={onState}
|
||||
onRemoveFromList={opts.onRemoveFromList}
|
||||
/>,
|
||||
);
|
||||
act(() => playerEvents.onReady?.());
|
||||
opts.before?.();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||
fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" })));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
|
||||
@@ -898,6 +903,77 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(lastStart()?.videoId).toBe("rel2");
|
||||
});
|
||||
|
||||
// Restart is where the user asked to start: a position this session stored for the video (which a
|
||||
// plain pick resumes from) must not win over it.
|
||||
it("wide: Restart on a related video played this session starts it at 0", async () => {
|
||||
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||
video_id: id,
|
||||
position_seconds: id === "rel2" ? 500 : 77,
|
||||
}));
|
||||
clock.current = 77;
|
||||
await playRel2(() => {});
|
||||
clock.current = 500;
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
await settle();
|
||||
const list = screen.getByTestId("player-related");
|
||||
const row = within(list)
|
||||
.getAllByRole("link", { name: "Video rel2" })[0]!
|
||||
.closest("[data-nav-index]")!;
|
||||
fireEvent.click(within(row as HTMLElement).getAllByTitle("card.restartTitle")[0]!);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
|
||||
// A start of 0 is no `start` at all (the player starts at the top).
|
||||
expect(lastStart()?.start ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
// Its row stays in the list while it plays; a new player for the same video is never built (one
|
||||
// per video), so Restart there is a seek.
|
||||
it("wide: Restart on the related video already playing seeks it to 0", async () => {
|
||||
await playRel2(() => {});
|
||||
const before = started.length;
|
||||
const list = screen.getByTestId("player-related");
|
||||
const row = within(list)
|
||||
.getAllByRole("link", { name: "Video rel2" })[0]!
|
||||
.closest("[data-nav-index]")!;
|
||||
fireEvent.click(within(row as HTMLElement).getAllByTitle("card.restartTitle")[0]!);
|
||||
expect(seeks).toContain(0);
|
||||
expect(started.length).toBe(before);
|
||||
});
|
||||
|
||||
// The toggle remembers each video's mark: a related one's did not overwrite the opened one's.
|
||||
it("wide: Back shows the opened video's own mark, not the one it was opened with", async () => {
|
||||
const onState = vi.fn();
|
||||
await playRel2(onState, undefined, {
|
||||
before: () => fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!),
|
||||
});
|
||||
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!); // rel2's
|
||||
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toEqual([
|
||||
["A", "watched"],
|
||||
["rel2", "watched"],
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
expect(screen.getAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
|
||||
expect(screen.queryAllByTitle("player.markWatched")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wide: a queue step leaves the related video for the queue's next item", async () => {
|
||||
await playRel2(() => {}, [vid("A", 40), vid("B", 0)]);
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
expect(screen.getByRole("heading", { name: "Video B" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "player.back" })).toBeNull();
|
||||
});
|
||||
|
||||
// Removing from the list removes the queue's item — not the related video on screen.
|
||||
it("wide: offers no removal from the list while a related video plays", async () => {
|
||||
await playRel2(() => {}, undefined, { onRemoveFromList: async () => true });
|
||||
expect(screen.queryByRole("button", { name: /playlists\.removeItemOf/ })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
expect(trash("A")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves the related videos out when the instance turned them off", () => {
|
||||
me = { related_enabled: false } as Me;
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
@@ -1037,6 +1113,23 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(seeks).toEqual([]);
|
||||
});
|
||||
|
||||
// So the help sheet opened over the drawer does not promise them; over the player itself, it does.
|
||||
it("stacked: the help sheet over the drawer leaves the video's keys out", async () => {
|
||||
wideWindow(false);
|
||||
const { drawer } = await openDrawer();
|
||||
render(<KeyboardHelp />);
|
||||
act(() => toggleKeyboardHelp());
|
||||
expect(screen.queryByText("keys.playPause")).toBeNull();
|
||||
act(() => closeKeyboardHelp());
|
||||
fireEvent.click(within(drawer).getByRole("button", { name: "common.close" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(),
|
||||
);
|
||||
act(() => toggleKeyboardHelp());
|
||||
expect(screen.getByText("keys.playPause")).toBeTruthy();
|
||||
act(() => closeKeyboardHelp());
|
||||
});
|
||||
|
||||
it("stacked: the window regaining focus leaves it in the drawer", async () => {
|
||||
wideWindow(false);
|
||||
const { drawer } = await openDrawer();
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
relativeTime,
|
||||
} from "../../lib/format";
|
||||
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../../lib/keymap";
|
||||
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
import ChannelNameLink from "../video/ChannelNameLink";
|
||||
import Toaster from "../../components/ui/Toaster";
|
||||
@@ -248,14 +248,18 @@ export default function PlayerModal({
|
||||
// controls show — while the queue stays where it was, and Back returns to the opened item. It is
|
||||
// the SUBJECT of the player from here down; `active` stays the queue's item, which the related
|
||||
// list, the counter and prev/next belong to. A queue step leaves it.
|
||||
const [related, setRelated] = useState<{ video: Video; startAt: number } | null>(null);
|
||||
// `startAt` null resumes it where it was left (this session's stored position first); a number is
|
||||
// where the user asked it to start — Restart's 0 — and beats that position.
|
||||
const [related, setRelated] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||||
const [relatedOf, setRelatedOf] = useState(active.id);
|
||||
if (relatedOf !== active.id) {
|
||||
setRelatedOf(active.id);
|
||||
setRelated(null);
|
||||
}
|
||||
const subject = related?.video ?? active;
|
||||
const subjectResume = related ? related.startAt : resumeAt;
|
||||
const subjectResume = related
|
||||
? (related.startAt ?? related.video.position_seconds ?? 0)
|
||||
: resumeAt;
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const playerRef = useRef<YTPlayer | null>(null);
|
||||
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
|
||||
@@ -547,14 +551,11 @@ export default function PlayerModal({
|
||||
// item's, so the toggle falls back to `subject.status` and no ordering has to be argued about.
|
||||
// Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
|
||||
// onState(next, "new") for an item that was already new.
|
||||
const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({
|
||||
id: active.id,
|
||||
value: active.status,
|
||||
});
|
||||
const watched = (status.id === subject.id ? status.value : subject.status) === "watched";
|
||||
const [marks, setMarks] = useState<Record<string, VideoStatus>>({});
|
||||
const watched = (marks[subject.id] ?? subject.status) === "watched";
|
||||
const setWatched = (on: boolean) => {
|
||||
const next = on ? "watched" : "new";
|
||||
setStatus({ id: subject.id, value: next });
|
||||
setMarks((m) => ({ ...m, [subject.id]: next }));
|
||||
onState(subject.id, next, subject);
|
||||
};
|
||||
|
||||
@@ -722,8 +723,14 @@ export default function PlayerModal({
|
||||
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
||||
// be refused.
|
||||
const relatedOn = useMe().related_enabled;
|
||||
const playRelated = (v: Video, startAt: number) => {
|
||||
const playRelated = (v: Video, startAt: number | null) => {
|
||||
setRelatedOpen(false);
|
||||
// The video already playing (its row stays in the list): a new player for the same id would
|
||||
// never be built (one per video), so a Restart is a seek, and a plain pick just keeps playing.
|
||||
if (v.id === subject.id) {
|
||||
if (startAt != null) seekTo(startAt);
|
||||
return;
|
||||
}
|
||||
setRelated({ video: v, startAt });
|
||||
};
|
||||
const descPop = useAnchoredPopover({
|
||||
@@ -762,6 +769,15 @@ export default function PlayerModal({
|
||||
}, []);
|
||||
|
||||
useKeyScope("ytPlayer");
|
||||
// Its keys wait under a layer above it (the drawer, a dialog — see onPlayerKey), so the help sheet
|
||||
// opened there must not list them.
|
||||
useEffect(() => {
|
||||
setScopeCoveredProbe(
|
||||
"ytPlayer",
|
||||
() => layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current),
|
||||
);
|
||||
return () => setScopeCoveredProbe("ytPlayer", null);
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer`
|
||||
// scope) so the help sheet lists them; only the behaviour behind each id is here.
|
||||
@@ -888,6 +904,7 @@ export default function PlayerModal({
|
||||
useYtProgress({
|
||||
activeId: subject.id,
|
||||
resumeAt: subjectResume,
|
||||
exactStart: related?.startAt != null,
|
||||
mountRef,
|
||||
playerRef,
|
||||
currentIdRef,
|
||||
|
||||
@@ -25,6 +25,8 @@ import { RelatedVideos } from "./RelatedVideos";
|
||||
import { api, HttpError, type RelatedVideo, type Video } from "../../lib/api";
|
||||
import { clearAll, getNotifications } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { setHintsEnabled } from "../../lib/hints";
|
||||
import { writeVideoState } from "../video/videoWrites";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -65,7 +67,7 @@ const page = (items: RelatedVideo[], next: string | null = null) => ({
|
||||
});
|
||||
|
||||
function setup(
|
||||
onPlay: (v: Video, startAt: number) => void = () => {},
|
||||
onPlay: (v: Video, startAt: number | null) => void = () => {},
|
||||
onWrite?: (write: Promise<unknown>) => void,
|
||||
) {
|
||||
vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] } as never);
|
||||
@@ -116,12 +118,17 @@ describe("RelatedVideos", () => {
|
||||
expect(saved).toHaveBeenCalledWith({ relatedRegion: "hu" });
|
||||
});
|
||||
|
||||
// A plain pick resumes (null: the player knows the freshest position); the row's own position
|
||||
// travels on the video it hands over.
|
||||
it("plays a row from where the user left it", async () => {
|
||||
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })]));
|
||||
const onPlay = vi.fn();
|
||||
setup(onPlay);
|
||||
fireEvent.click(await title("Video A"));
|
||||
expect(onPlay).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }), 95);
|
||||
expect(onPlay).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "A", position_seconds: 95 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a video hidden here at once, and brings it back when the server refuses", async () => {
|
||||
@@ -308,6 +315,71 @@ describe("RelatedVideos", () => {
|
||||
expect(await screen.findByText("player.related.followed")).toBeTruthy();
|
||||
});
|
||||
|
||||
// An infinite query builds its next page from the pages it read when that fetch started: a mark
|
||||
// that landed in between was dropped with them, for the session.
|
||||
it("keeps a hide that landed while Show more was on its way", async () => {
|
||||
let land: (p: ReturnType<typeof page>) => void = () => {};
|
||||
vi.spyOn(api, "related")
|
||||
.mockResolvedValueOnce(page([row("A"), row("C")], "CUR1"))
|
||||
.mockReturnValueOnce(new Promise((r) => (land = r)) as never);
|
||||
const write = vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
setup();
|
||||
await title("Video A");
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
|
||||
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
|
||||
fireEvent.click(menuItem("card.hide"));
|
||||
await waitFor(() => expect(write).toHaveBeenCalledWith("A", "hidden"));
|
||||
await act(async () => {});
|
||||
await act(async () => land(page([row("B")])));
|
||||
await title("Video B");
|
||||
expect(titles()).toEqual(["Video C", "Video B"]);
|
||||
});
|
||||
|
||||
// The Notification Center's undo, the feed's reset, a card elsewhere: every write reaches it.
|
||||
it("follows a video's state written anywhere else", async () => {
|
||||
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const { qc } = setup();
|
||||
await title("Video A");
|
||||
await act(async () => void (await writeVideoState(qc, "A", "hidden")));
|
||||
await waitFor(() => expect(titles()).toEqual(["Video B"]));
|
||||
await act(async () => void (await writeVideoState(qc, "A", "new")));
|
||||
await waitFor(() => expect(titles()).toEqual(["Video A", "Video B"]));
|
||||
});
|
||||
|
||||
// Show more is the request Retry would make: refused for good, it is not offered either.
|
||||
it("offers no Show more once the next page was refused for good", async () => {
|
||||
vi.spyOn(api, "related")
|
||||
.mockResolvedValueOnce(page([row("A")], "CUR1"))
|
||||
.mockRejectedValueOnce(
|
||||
new HttpError(429, "Limit", { code: "refused", reason: "related_daily_limit", limit: 3 }),
|
||||
);
|
||||
setup();
|
||||
await title("Video A");
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("Limit");
|
||||
expect(screen.queryByRole("button", { name: "player.related.more" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "common.retry" })).toBeNull();
|
||||
expect(titles()).toEqual(["Video A"]);
|
||||
});
|
||||
|
||||
it("names the followed mark on hover with hints turned off too", async () => {
|
||||
setHintsEnabled(false);
|
||||
try {
|
||||
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { followed: true })]));
|
||||
setup();
|
||||
await title("Video A");
|
||||
const mark = screen.getByRole("img", { name: "player.related.followed" });
|
||||
fireEvent.mouseEnter(mark.parentElement!);
|
||||
fireEvent.pointerEnter(mark.parentElement!, { pointerType: "mouse" });
|
||||
expect(await screen.findByText("player.related.followed")).toBeTruthy();
|
||||
} catch (e) {
|
||||
setHintsEnabled(true);
|
||||
throw e;
|
||||
}
|
||||
setHintsEnabled(true);
|
||||
});
|
||||
|
||||
it("says when nothing is left that the user has not seen", async () => {
|
||||
vi.spyOn(api, "related").mockResolvedValue(page([]));
|
||||
setup();
|
||||
|
||||
@@ -3,11 +3,18 @@ import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CircleCheck } from "lucide-react";
|
||||
|
||||
import { api, HttpError, type RelatedRegion, type Video, type VideoStatus } from "../../lib/api";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
refusalReason,
|
||||
type RelatedRegion,
|
||||
type Video,
|
||||
type VideoStatus,
|
||||
} from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { nextPageOnClick } from "../../lib/nextPage";
|
||||
import { usePref, type UsePrefOptions } from "../../lib/usePref";
|
||||
import { patchRelated } from "../../lib/progressCache";
|
||||
import { useVideoMarks } from "../../lib/progressCache";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
@@ -18,6 +25,7 @@ import Tooltip from "../../components/ui/Tooltip";
|
||||
import VideoCard from "../video/VideoCard";
|
||||
import { VideoMenu } from "../video/VideoMenu";
|
||||
import { announceVideoState } from "../video/stateNotice";
|
||||
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
|
||||
|
||||
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
|
||||
* decided at the list, and the account keeps the last one (the owner's pick). */
|
||||
@@ -33,9 +41,7 @@ const isSeen = (v: Video) => v.status === "watched" || v.status === "hidden";
|
||||
/** The refusals a retry cannot answer: the day's cap (tomorrow does), the feature turned off on
|
||||
* this instance and the shared budget spent (the admin, or tomorrow). */
|
||||
const FINAL_REASONS = new Set(["related_daily_limit", "related_disabled", "quota"]);
|
||||
const isFinal = (e: unknown) =>
|
||||
e instanceof HttpError &&
|
||||
FINAL_REASONS.has((e.detailData as { reason?: string } | undefined)?.reason ?? "");
|
||||
const isFinal = (e: unknown) => FINAL_REASONS.has(refusalReason(e) ?? "");
|
||||
|
||||
// A write not yet confirmed: its change shows at once, and leaves when the server answers.
|
||||
type Pending = { seq: number; id: string; change: Partial<Video> };
|
||||
@@ -49,7 +55,8 @@ export function RelatedVideos({
|
||||
onWrite,
|
||||
}: {
|
||||
videoId: string;
|
||||
onPlay: (v: Video, startAt: number) => void;
|
||||
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
|
||||
onPlay: (v: Video, startAt: number | null) => void;
|
||||
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
|
||||
onWrite?: (write: Promise<unknown>) => void;
|
||||
}) {
|
||||
@@ -83,11 +90,10 @@ export function RelatedVideos({
|
||||
const onState = (id: string, status: VideoStatus) => {
|
||||
const v = rows.find((x) => x.id === id);
|
||||
const seq = begin(id, { status });
|
||||
const write = api.setState(id, status);
|
||||
const write = writeVideoState(qc, id, status);
|
||||
onWrite?.(write);
|
||||
write
|
||||
.then(() => {
|
||||
patchRelated(qc, id, { status });
|
||||
settle(seq);
|
||||
// Stale-mark the feed, as its own marks do: the video may be in it too.
|
||||
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
@@ -101,11 +107,10 @@ export function RelatedVideos({
|
||||
|
||||
const onToggleSave = (id: string, saved: boolean) => {
|
||||
const seq = begin(id, { saved });
|
||||
const write = saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id);
|
||||
const write = writeWatchLater(qc, id, saved);
|
||||
onWrite?.(write);
|
||||
write
|
||||
.then(() => {
|
||||
patchRelated(qc, id, { saved });
|
||||
settle(seq);
|
||||
void qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
void qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
@@ -119,12 +124,15 @@ export function RelatedVideos({
|
||||
});
|
||||
};
|
||||
|
||||
const open = (v: Video, startAt?: number | null) => onPlay(v, startAt ?? v.position_seconds);
|
||||
const open = (v: Video, startAt?: number | null) => onPlay(v, startAt ?? null);
|
||||
const menu = useContextMenu<Video>();
|
||||
const openId = menu.open ? menu.target?.id : undefined;
|
||||
|
||||
// What this session confirmed about a row (here or anywhere else), then what is on its way.
|
||||
const marks = useVideoMarks();
|
||||
const rows = (query.data?.pages ?? [])
|
||||
.flatMap((p) => p.items)
|
||||
.map((v) => (marks[v.id] ? { ...v, ...marks[v.id] } : v))
|
||||
.map((v) => pending.reduce((acc, w) => (w.id === v.id ? { ...acc, ...w.change } : acc), v))
|
||||
.filter((v) => !isSeen(v));
|
||||
const followed = new Set(
|
||||
@@ -139,7 +147,8 @@ export function RelatedVideos({
|
||||
: null;
|
||||
// A refused page is the one to try again: with pages loaded, a refetch started over from the first
|
||||
// (another scrape and another slot) instead of asking for the page that failed.
|
||||
const retry = isFinal(query.error)
|
||||
const final = isFinal(query.error);
|
||||
const retry = final
|
||||
? undefined
|
||||
: query.data?.pages.length
|
||||
? () => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))
|
||||
@@ -203,7 +212,8 @@ export function RelatedVideos({
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
{query.hasNextPage && (
|
||||
{/* Show more is the same request a Retry makes: refused for good, neither is offered. */}
|
||||
{query.hasNextPage && !final && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// The whole app source: a state write can be made from any page.
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const sources = () =>
|
||||
readdirSync(root, { recursive: true, encoding: "utf8" })
|
||||
.map((f) => f.replace(/\\/g, "/"))
|
||||
.filter((f) => /\.tsx?$/.test(f) && !f.includes(".test."))
|
||||
.map((f) => ({ f, src: readFileSync(new URL(f, root), "utf8") }));
|
||||
|
||||
// The api's own definitions of these writes, and the one module that may call them.
|
||||
const ALLOWED = new Set([
|
||||
"lib/api/feed.ts",
|
||||
"lib/api/playlists.ts",
|
||||
"features/video/videoWrites.ts",
|
||||
]);
|
||||
|
||||
// A related list and a live search are never refetched, so a state or Watch-later write that does
|
||||
// not reach them (`markVideo`) leaves them showing the old mark for the session. videoWrites makes
|
||||
// those writes and the mark together; a direct api call skips the mark.
|
||||
describe("a video's state and Watch-later writes", () => {
|
||||
it("go through features/video/videoWrites, never the api directly", () => {
|
||||
const offenders = sources()
|
||||
.filter(({ f }) => !ALLOWED.has(f))
|
||||
// Only a call on the api object counts (React's own `setState` is not ours); the dot may sit
|
||||
// on the next line of a chain.
|
||||
.filter(({ src }) =>
|
||||
/\bapi\s*\.\s*(setState|clearState|watchLaterAdd|watchLaterRemove)\(/.test(src),
|
||||
)
|
||||
.map(({ f }) => f);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Each write reaches the lists that are never refetched once the server has it — and not before:
|
||||
// a refused write leaves them as they were.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { resetVideoState, writeVideoState, writeWatchLater } from "./videoWrites";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const marks = (qc: QueryClient) => qc.getQueryData(qk.videoMarks());
|
||||
|
||||
describe("videoWrites", () => {
|
||||
it("marks a state written", async () => {
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient();
|
||||
await writeVideoState(qc, "X", "hidden");
|
||||
expect(marks(qc)).toEqual({ X: { status: "hidden" } });
|
||||
});
|
||||
|
||||
it("marks a reset as new, from the start", async () => {
|
||||
vi.spyOn(api, "clearState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient();
|
||||
await resetVideoState(qc, "X");
|
||||
expect(marks(qc)).toEqual({ X: { status: "new", position_seconds: 0 } });
|
||||
});
|
||||
|
||||
it("marks a Watch-later save and its removal", async () => {
|
||||
vi.spyOn(api, "watchLaterAdd").mockResolvedValue({ ok: true } as never);
|
||||
vi.spyOn(api, "watchLaterRemove").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient();
|
||||
await writeWatchLater(qc, "X", true);
|
||||
expect(marks(qc)).toEqual({ X: { saved: true } });
|
||||
await writeWatchLater(qc, "X", false);
|
||||
expect(marks(qc)).toEqual({ X: { saved: false } });
|
||||
});
|
||||
|
||||
it("marks nothing the server refused", async () => {
|
||||
vi.spyOn(api, "setState").mockRejectedValue(new Error("offline"));
|
||||
const qc = new QueryClient();
|
||||
await expect(writeVideoState(qc, "X", "hidden")).rejects.toThrow("offline");
|
||||
expect(marks(qc)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, type VideoStatus } from "../../lib/api";
|
||||
import { markVideo } from "../../lib/progressCache";
|
||||
|
||||
// A video's user state and its Watch-later mark, written to the server and — once it has the
|
||||
// change — into the lists that are never refetched (`markVideo`: every related list, every live
|
||||
// search). These are the ONE way to make those writes, and `videoWrites.contract.test.ts` holds it:
|
||||
// four writes that called the api directly (the Notification Center's undo, the feed's reset, the
|
||||
// feed's and Home's Watch-later toggles) left a related list hiding a video the user had un-hidden,
|
||||
// or showing it unsaved after a save. Each returns the server's answer, so a caller keeps its own
|
||||
// optimistic layer, notices and rollback.
|
||||
|
||||
export function writeVideoState(qc: QueryClient, id: string, status: VideoStatus) {
|
||||
return api.setState(id, status).then((r) => {
|
||||
markVideo(qc, id, { status });
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
/** Back to pristine: the state row and its resume position both go. */
|
||||
export function resetVideoState(qc: QueryClient, id: string) {
|
||||
return api.clearState(id).then((r) => {
|
||||
markVideo(qc, id, { status: "new", position_seconds: 0 });
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
export function writeWatchLater(qc: QueryClient, id: string, saved: boolean) {
|
||||
return (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)).then((r) => {
|
||||
markVideo(qc, id, { saved });
|
||||
return r;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user