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:
2026-10-25 02:50:34 +02:00
parent ed51f5be7e
commit 4b5b66343d
26 changed files with 667 additions and 93 deletions
@@ -9,6 +9,7 @@ import KeyboardHelp from "./KeyboardHelp";
import { useContextMenu } from "./useContextMenu"; import { useContextMenu } from "./useContextMenu";
import { import {
closeKeyboardHelp, closeKeyboardHelp,
setScopeCoveredProbe,
useGlobalKeymap, useGlobalKeymap,
useHelpConditions, useHelpConditions,
useKeyScope, useKeyScope,
@@ -179,6 +180,33 @@ describe("KeyboardHelp", () => {
expect(descriptions()).not.toContain("keys.contextMenu"); expect(descriptions()).not.toContain("keys.contextMenu");
}); });
// The YouTube player's keys wait while a layer sits above it (its related drawer, a dialog): a
// sheet opened there leaves them out, and one opened over the player itself lists them.
it("leaves out a surface's keys while a layer above it has them waiting", () => {
let covered = true;
setScopeCoveredProbe("ytPlayer", () => covered);
try {
render(
<Host>
<Surface scope="ytPlayer" />
</Host>,
);
const id = pushLayer();
pressH();
removeLayer(id);
expect(descriptions()).not.toContain("keys.playPause");
expect(descriptions()).toContain("keys.help");
act(() => closeKeyboardHelp());
covered = false;
pressH();
expect(descriptions()).toContain("keys.playPause");
} catch (e) {
setScopeCoveredProbe("ytPlayer", null);
throw e;
}
setScopeCoveredProbe("ytPlayer", null);
});
// Two live context menus (the Playlists page mounts one, its rail another) must not double the row. // Two live context menus (the Playlists page mounts one, its rail another) must not double the row.
it("lists the context-menu chords once however many surfaces register", () => { it("lists the context-menu chords once however many surfaces register", () => {
function WithMenu() { function WithMenu() {
+6 -1
View File
@@ -10,6 +10,7 @@ import {
useHelpConditionValues, useHelpConditionValues,
useKeyboardHelpOpen, useKeyboardHelpOpen,
useKeyboardHelpOverLayer, useKeyboardHelpOverLayer,
useKeyboardHelpCovered,
useNavigablePages, useNavigablePages,
} from "../../lib/keymap"; } from "../../lib/keymap";
@@ -33,6 +34,7 @@ export default function KeyboardHelp() {
const scopes = useActiveScopes(); const scopes = useActiveScopes();
const conditions = useHelpConditionValues(); const conditions = useHelpConditionValues();
const overLayer = useKeyboardHelpOverLayer(); const overLayer = useKeyboardHelpOverLayer();
const covered = useKeyboardHelpCovered();
const pages = useNavigablePages(); const pages = useNavigablePages();
if (!open) return null; if (!open) return null;
@@ -47,7 +49,10 @@ export default function KeyboardHelp() {
const inLayer = (s: (typeof scopes)[number]) => const inLayer = (s: (typeof scopes)[number]) =>
(!isPageLevelScope(s) && s !== "contextMenu") || (!isPageLevelScope(s) && s !== "contextMenu") ||
(overLayer === "layerWithList" && (s === "list" || s === "contextMenu")); (overLayer === "layerWithList" && (s === "list" || s === "contextMenu"));
const live = overLayer === "page" ? scopes : scopes.filter(inLayer); // A surface under a layer of its own (the player under its related drawer) waits: not listed.
const live = (overLayer === "page" ? scopes : scopes.filter(inLayer)).filter(
(s) => !covered.has(s),
);
const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]); const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]);
const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") }; const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
return ( return (
@@ -35,6 +35,8 @@ vi.mock("../downloads/DownloadButton", () => ({
})); }));
import PlayerModal from "./PlayerModal"; import PlayerModal from "./PlayerModal";
import KeyboardHelp from "../../components/ui/KeyboardHelp";
import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider"; import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Me, type Video } from "../../lib/api"; 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 ( const playRel2 = async (
onState: (id: string, status: string, v?: Video) => void, onState: (id: string, status: string, v?: Video) => void,
queue?: Video[], queue?: Video[],
opts: { onRemoveFromList?: (v: Video) => Promise<boolean>; before?: () => void } = {},
) => { ) => {
wideWindow(true); wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
@@ -853,9 +856,11 @@ describe("PlayerModal — the side panel on a wide window", () => {
startIndex={0} startIndex={0}
onClose={() => {}} onClose={() => {}}
onState={onState} onState={onState}
onRemoveFromList={opts.onRemoveFromList}
/>, />,
); );
act(() => playerEvents.onReady?.()); act(() => playerEvents.onReady?.());
opts.before?.();
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" })); fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" }))); fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" })));
await waitFor(() => expect(lastStart()?.videoId).toBe("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"); 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", () => { it("leaves the related videos out when the instance turned them off", () => {
me = { related_enabled: false } as Me; me = { related_enabled: false } as Me;
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
@@ -1037,6 +1113,23 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(seeks).toEqual([]); 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 () => { it("stacked: the window regaining focus leaves it in the drawer", async () => {
wideWindow(false); wideWindow(false);
const { drawer } = await openDrawer(); const { drawer } = await openDrawer();
+27 -10
View File
@@ -65,7 +65,7 @@ import {
relativeTime, relativeTime,
} from "../../lib/format"; } from "../../lib/format";
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen"; 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 { useBackToClose } from "../../lib/history";
import ChannelNameLink from "../video/ChannelNameLink"; import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster"; 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 // 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 // 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. // 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); const [relatedOf, setRelatedOf] = useState(active.id);
if (relatedOf !== active.id) { if (relatedOf !== active.id) {
setRelatedOf(active.id); setRelatedOf(active.id);
setRelated(null); setRelated(null);
} }
const subject = related?.video ?? active; 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 mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YTPlayer | null>(null); const playerRef = useRef<YTPlayer | null>(null);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player // 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. // 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 // 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. // onState(next, "new") for an item that was already new.
const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({ const [marks, setMarks] = useState<Record<string, VideoStatus>>({});
id: active.id, const watched = (marks[subject.id] ?? subject.status) === "watched";
value: active.status,
});
const watched = (status.id === subject.id ? status.value : subject.status) === "watched";
const setWatched = (on: boolean) => { const setWatched = (on: boolean) => {
const next = on ? "watched" : "new"; const next = on ? "watched" : "new";
setStatus({ id: subject.id, value: next }); setMarks((m) => ({ ...m, [subject.id]: next }));
onState(subject.id, next, subject); 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 // Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
// be refused. // be refused.
const relatedOn = useMe().related_enabled; const relatedOn = useMe().related_enabled;
const playRelated = (v: Video, startAt: number) => { const playRelated = (v: Video, startAt: number | null) => {
setRelatedOpen(false); 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 }); setRelated({ video: v, startAt });
}; };
const descPop = useAnchoredPopover({ const descPop = useAnchoredPopover({
@@ -762,6 +769,15 @@ export default function PlayerModal({
}, []); }, []);
useKeyScope("ytPlayer"); 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` // 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. // scope) so the help sheet lists them; only the behaviour behind each id is here.
@@ -888,6 +904,7 @@ export default function PlayerModal({
useYtProgress({ useYtProgress({
activeId: subject.id, activeId: subject.id,
resumeAt: subjectResume, resumeAt: subjectResume,
exactStart: related?.startAt != null,
mountRef, mountRef,
playerRef, playerRef,
currentIdRef, currentIdRef,
@@ -25,6 +25,8 @@ import { RelatedVideos } from "./RelatedVideos";
import { api, HttpError, type RelatedVideo, type Video } from "../../lib/api"; import { api, HttpError, type RelatedVideo, type Video } from "../../lib/api";
import { clearAll, getNotifications } from "../../lib/notifications"; import { clearAll, getNotifications } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { setHintsEnabled } from "../../lib/hints";
import { writeVideoState } from "../video/videoWrites";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -65,7 +67,7 @@ const page = (items: RelatedVideo[], next: string | null = null) => ({
}); });
function setup( function setup(
onPlay: (v: Video, startAt: number) => void = () => {}, onPlay: (v: Video, startAt: number | null) => void = () => {},
onWrite?: (write: Promise<unknown>) => void, onWrite?: (write: Promise<unknown>) => void,
) { ) {
vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] } as never); vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] } as never);
@@ -116,12 +118,17 @@ describe("RelatedVideos", () => {
expect(saved).toHaveBeenCalledWith({ relatedRegion: "hu" }); 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 () => { it("plays a row from where the user left it", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })])); vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })]));
const onPlay = vi.fn(); const onPlay = vi.fn();
setup(onPlay); setup(onPlay);
fireEvent.click(await title("Video A")); 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 () => { 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(); 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 () => { it("says when nothing is left that the user has not seen", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([])); vi.spyOn(api, "related").mockResolvedValue(page([]));
setup(); setup();
+23 -13
View File
@@ -3,11 +3,18 @@ import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { CircleCheck } from "lucide-react"; 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 { qk } from "../../lib/queryKeys";
import { nextPageOnClick } from "../../lib/nextPage"; import { nextPageOnClick } from "../../lib/nextPage";
import { usePref, type UsePrefOptions } from "../../lib/usePref"; import { usePref, type UsePrefOptions } from "../../lib/usePref";
import { patchRelated } from "../../lib/progressCache"; import { useVideoMarks } from "../../lib/progressCache";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import SegmentedToggle from "../../components/ui/SegmentedToggle"; import SegmentedToggle from "../../components/ui/SegmentedToggle";
@@ -18,6 +25,7 @@ import Tooltip from "../../components/ui/Tooltip";
import VideoCard from "../video/VideoCard"; import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu"; import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice"; 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, /** 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). */ * 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 /** 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). */ * this instance and the shared budget spent (the admin, or tomorrow). */
const FINAL_REASONS = new Set(["related_daily_limit", "related_disabled", "quota"]); const FINAL_REASONS = new Set(["related_daily_limit", "related_disabled", "quota"]);
const isFinal = (e: unknown) => const isFinal = (e: unknown) => FINAL_REASONS.has(refusalReason(e) ?? "");
e instanceof HttpError &&
FINAL_REASONS.has((e.detailData as { reason?: string } | undefined)?.reason ?? "");
// A write not yet confirmed: its change shows at once, and leaves when the server answers. // 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> }; type Pending = { seq: number; id: string; change: Partial<Video> };
@@ -49,7 +55,8 @@ export function RelatedVideos({
onWrite, onWrite,
}: { }: {
videoId: string; 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`). */ /** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
onWrite?: (write: Promise<unknown>) => void; onWrite?: (write: Promise<unknown>) => void;
}) { }) {
@@ -83,11 +90,10 @@ export function RelatedVideos({
const onState = (id: string, status: VideoStatus) => { const onState = (id: string, status: VideoStatus) => {
const v = rows.find((x) => x.id === id); const v = rows.find((x) => x.id === id);
const seq = begin(id, { status }); const seq = begin(id, { status });
const write = api.setState(id, status); const write = writeVideoState(qc, id, status);
onWrite?.(write); onWrite?.(write);
write write
.then(() => { .then(() => {
patchRelated(qc, id, { status });
settle(seq); settle(seq);
// Stale-mark the feed, as its own marks do: the video may be in it too. // Stale-mark the feed, as its own marks do: the video may be in it too.
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
@@ -101,11 +107,10 @@ export function RelatedVideos({
const onToggleSave = (id: string, saved: boolean) => { const onToggleSave = (id: string, saved: boolean) => {
const seq = begin(id, { saved }); const seq = begin(id, { saved });
const write = saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id); const write = writeWatchLater(qc, id, saved);
onWrite?.(write); onWrite?.(write);
write write
.then(() => { .then(() => {
patchRelated(qc, id, { saved });
settle(seq); settle(seq);
void qc.invalidateQueries({ queryKey: qk.playlists() }); void qc.invalidateQueries({ queryKey: qk.playlists() });
void qc.invalidateQueries({ queryKey: qk.playlist() }); 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 menu = useContextMenu<Video>();
const openId = menu.open ? menu.target?.id : undefined; 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 ?? []) const rows = (query.data?.pages ?? [])
.flatMap((p) => p.items) .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)) .map((v) => pending.reduce((acc, w) => (w.id === v.id ? { ...acc, ...w.change } : acc), v))
.filter((v) => !isSeen(v)); .filter((v) => !isSeen(v));
const followed = new Set( const followed = new Set(
@@ -139,7 +147,8 @@ export function RelatedVideos({
: null; : null;
// A refused page is the one to try again: with pages loaded, a refetch started over from the first // 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. // (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 ? undefined
: query.data?.pages.length : query.data?.pages.length
? () => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage)) ? () => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))
@@ -203,7 +212,8 @@ export function RelatedVideos({
/> />
))} ))}
</NavGrid> </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"> <div className="mt-3 flex justify-center">
<Button <Button
variant="outline" 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;
});
}
+1 -1
View File
@@ -189,7 +189,7 @@
"recipient_required": "A recipient email is required", "recipient_required": "A recipient email is required",
"registration_closed": "Registration is currently closed", "registration_closed": "Registration is currently closed",
"related_disabled": "Related videos are turned off on this instance.", "related_disabled": "Related videos are turned off on this instance.",
"related_daily_limit": "You've opened today's limit of related lists ({{limit}}). Try again tomorrow.", "related_daily_limit": "You've reached today's related videos limit ({{limit}} requests — each list and each Show more counts one). Try again tomorrow.",
"search_disabled": "Live YouTube search is turned off on this instance.", "search_disabled": "Live YouTube search is turned off on this instance.",
"search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.", "search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.",
"search_term_required": "Enter something to search for", "search_term_required": "Enter something to search for",
+1 -1
View File
@@ -189,7 +189,7 @@
"recipient_required": "Adj meg egy címzett e-mail címet", "recipient_required": "Adj meg egy címzett e-mail címet",
"registration_closed": "A regisztráció jelenleg zárva van", "registration_closed": "A regisztráció jelenleg zárva van",
"related_disabled": "A kapcsolódó videók ki vannak kapcsolva ezen a példányon.", "related_disabled": "A kapcsolódó videók ki vannak kapcsolva ezen a példányon.",
"related_daily_limit": "Elérted a mai kapcsolódó listák korlátját ({{limit}}). Próbáld holnap.", "related_daily_limit": "Elérted a kapcsolódó videók mai korlátját ({{limit}} kérés — minden lista és minden „Mutass többet” egynek számít). Próbáld holnap.",
"search_disabled": "Az élő YouTube-keresés ki van kapcsolva ezen a példányon.", "search_disabled": "Az élő YouTube-keresés ki van kapcsolva ezen a példányon.",
"search_daily_limit": "Elérted a mai YouTube-keresési korlátot ({{limit}}). Próbáld holnap.", "search_daily_limit": "Elérted a mai YouTube-keresési korlátot ({{limit}}). Próbáld holnap.",
"search_term_required": "Írj be egy keresőkifejezést", "search_term_required": "Írj be egy keresőkifejezést",
+2 -2
View File
@@ -6,10 +6,10 @@
// 3. spreads each domain's method slice into the single `api` object consumers call. // 3. spreads each domain's method slice into the single `api` object consumers call.
// A new endpoint therefore has nowhere to land except a domain module — the interleave that // A new endpoint therefore has nowhere to land except a domain module — the interleave that
// motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.) // motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.)
import { HttpError, setUnauthorizedHandler } from "./api/core"; import { HttpError, refusalReason, setUnauthorizedHandler } from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged. // Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
export { HttpError, setUnauthorizedHandler }; export { HttpError, refusalReason, setUnauthorizedHandler };
import { accountApi } from "./api/account"; import { accountApi } from "./api/account";
export type * from "./api/account"; export type * from "./api/account";
+6
View File
@@ -65,6 +65,12 @@ export class HttpError extends Error {
} }
} }
/** The structured refusal's reason (`{ code, reason }`), when the server gave one. */
export const refusalReason = (err: unknown): string | undefined =>
err instanceof HttpError
? (err.detailData as { reason?: string } | undefined)?.reason
: undefined;
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI // Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a // usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English // structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
+15 -1
View File
@@ -488,17 +488,31 @@ export const setListInTopLayerProbe = (probe: () => boolean): void => {
listInTopLayer = probe; listInTopLayer = probe;
}; };
// A surface whose keys wait while a layer sits above it (the YouTube player under its related
// drawer or a dialog: every key but Escape is the layer's then). Its rows would promise keys that do
// nothing, so the sheet leaves the scope out — asked at the same moment as `helpOverLayer`.
const coveredProbes = new Map<KeyScope, () => boolean>();
export const setScopeCoveredProbe = (scope: KeyScope, probe: (() => boolean) | null): void => {
if (probe) coveredProbes.set(scope, probe);
else coveredProbes.delete(scope);
};
const helpCovered = createStore<ReadonlySet<KeyScope>>(new Set());
export const closeKeyboardHelp = (): void => helpOpen.set(false); export const closeKeyboardHelp = (): void => helpOpen.set(false);
export const toggleKeyboardHelp = (): void => { export const toggleKeyboardHelp = (): void => {
const next = !helpOpen.get(); const next = !helpOpen.get();
if (next) if (next) {
helpOverLayer.set(!anyBlockingLayer() ? "page" : listInTopLayer() ? "layerWithList" : "layer"); helpOverLayer.set(!anyBlockingLayer() ? "page" : listInTopLayer() ? "layerWithList" : "layer");
helpCovered.set(new Set([...coveredProbes].filter(([, covered]) => covered()).map(([s]) => s)));
}
helpOpen.set(next); helpOpen.set(next);
}; };
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */ /** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue(); export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue();
/** What the sheet was opened over (see `helpOverLayer`). */ /** What the sheet was opened over (see `helpOverLayer`). */
export const useKeyboardHelpOverLayer = (): HelpOver => helpOverLayer.useValue(); export const useKeyboardHelpOverLayer = (): HelpOver => helpOverLayer.useValue();
/** The surfaces a layer covered when the sheet opened (see `setScopeCoveredProbe`). */
export const useKeyboardHelpCovered = (): ReadonlySet<KeyScope> => helpCovered.useValue();
// The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and // The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and
// by the sheet (which drops the chord of a gated module rather than promise a page that isn't there). // by the sheet (which drops the chord of a gated module rather than promise a page that isn't there).
@@ -30,6 +30,7 @@ function mountPlayer(setPlayerError: (code: number | null) => void) {
useYtProgress({ useYtProgress({
activeId: VID, activeId: VID,
resumeAt: 0, resumeAt: 0,
exactStart: false,
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 },
@@ -72,6 +72,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
useYtProgress({ useYtProgress({
activeId: VID, activeId: VID,
resumeAt: 748, resumeAt: 748,
exactStart: false,
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 },
+7 -1
View File
@@ -339,6 +339,7 @@ function playerTimes(p: YTPlayer | null): { cur: number; dur: number } | null {
export function useYtProgress({ export function useYtProgress({
activeId, activeId,
resumeAt, resumeAt,
exactStart,
mountRef, mountRef,
playerRef, playerRef,
currentIdRef, currentIdRef,
@@ -355,6 +356,8 @@ export function useYtProgress({
}: { }: {
activeId: string; activeId: string;
resumeAt: number; resumeAt: number;
// `resumeAt` is where the user asked to start, so it beats a position this session stored.
exactStart: boolean;
mountRef: RefObject<HTMLDivElement | null>; mountRef: RefObject<HTMLDivElement | null>;
playerRef: RefObject<YTPlayer | null>; playerRef: RefObject<YTPlayer | null>;
currentIdRef: RefObject<string>; currentIdRef: RefObject<string>;
@@ -378,6 +381,7 @@ export function useYtProgress({
// the end of the effect). // the end of the effect).
const itemSnapshot = useEffectEvent(() => ({ const itemSnapshot = useEffectEvent(() => ({
resumeAt, resumeAt,
exactStart,
setWatched, setWatched,
setPlayerError, setPlayerError,
setLiveData, setLiveData,
@@ -392,6 +396,7 @@ export function useYtProgress({
const id = activeId; const id = activeId;
const { const {
resumeAt, resumeAt,
exactStart,
setWatched, setWatched,
setPlayerError, setPlayerError,
setLiveData, setLiveData,
@@ -401,7 +406,8 @@ export function useYtProgress({
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.
const startAt = sessionPosRef.current.get(id) ?? resumeAt; // …unless the user asked for a start of their own (Restart on a related row).
const startAt = exactStart ? resumeAt : (sessionPosRef.current.get(id) ?? resumeAt);
autoMarkedRef.current = false; autoMarkedRef.current = false;
setPlayerError(null); setPlayerError(null);
+38
View File
@@ -0,0 +1,38 @@
// What a confirmed mark reaches. A related list and a live YouTube search are never refetched (a
// scrape and a slot of the day's cap, or 100 quota units), so the mark is written into them; and it is
// kept apart from them too, where a page landing later cannot take it back.
import { describe, expect, it } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { markVideo, patchCachedProgress } from "./progressCache";
import { qk } from "./queryKeys";
import type { Video } from "./api";
const v = (id: string): Video =>
({ id, status: "new", saved: false, position_seconds: 0 }) as Video;
const pages = (...ids: string[]) => ({ pages: [{ items: ids.map(v) }], pageParams: [null] });
const items = (qc: QueryClient, key: readonly unknown[]) =>
(qc.getQueryData(key) as { pages: { items: Video[] }[] }).pages[0]!.items;
describe("markVideo", () => {
it("writes the mark into every live search and every related list", () => {
const qc = new QueryClient();
qc.setQueryData(qk.ytSearch("cats", 20), pages("X", "Y"));
qc.setQueryData(qk.related("A", "topical"), pages("X"));
markVideo(qc, "X", { status: "watched" });
expect(items(qc, qk.ytSearch("cats", 20)).map((r) => r.status)).toEqual(["watched", "new"]);
expect(items(qc, qk.related("A", "topical"))[0]!.status).toBe("watched");
});
it("keeps the mark apart from the lists, for the session", () => {
const qc = new QueryClient();
markVideo(qc, "X", { status: "hidden" });
markVideo(qc, "X", { saved: true });
patchCachedProgress(qc, new Map([["X", 90]]));
expect(qc.getQueryData(qk.videoMarks())).toEqual({
X: { status: "hidden", saved: true, position_seconds: 90 },
});
// Nothing observes it before a related list opens: it must not be collected meanwhile.
expect(qc.getQueryCache().find({ queryKey: qk.videoMarks() })?.options.gcTime).toBe(Infinity);
});
});
+58 -34
View File
@@ -1,4 +1,4 @@
import type { QueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import type { Video } from "./api"; import type { Video } from "./api";
@@ -40,17 +40,67 @@ export const progressFlushes = (): Promise<unknown>[] => [...flushes];
export function patchCachedProgress(qc: QueryClient, positions: Map<string, number>): void { export function patchCachedProgress(qc: QueryClient, positions: Map<string, number>): void {
if (positions.size === 0) return; if (positions.size === 0) return;
// Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube
// search and every related list — their rows come from the same projection, carry the same
// per-user position, and open the same player, so leaving one out left the rewind in place on that
// surface. (Patched, never invalidated: a refetch of an API-sourced search page spends 100 quota
// units, a related list's a scrape and a slot of the day's cap.)
patchLists(qc, [qk.feed(), qk.playlist(), qk.ytSearch(), qk.related()], (v) => {
const pos = positions.get(v.id);
return pos === undefined || v.position_seconds === pos ? v : { ...v, position_seconds: pos };
});
for (const [id, pos] of positions) remember(qc, id, { position_seconds: pos });
}
/** Write one video's confirmed change (its status, its Watch-later mark) into the lists that are
* never refetched: every related list (both regions', every opened video's) and every live
* YouTube search — another scrape and a slot of the day's cap, or 100 quota units, each. A video
* marked anywhere else (a feed card, a playlist, the player, the Notification Center's undo) still
* showed there unmarked. Called by `features/video/videoWrites`, which every such write goes
* through (its contract test holds that). */
export function markVideo(qc: QueryClient, id: string, change: Partial<Video>): void {
patchLists(qc, [qk.ytSearch(), qk.related()], (v) => (v.id === id ? { ...v, ...change } : v));
remember(qc, id, change);
}
// What this session confirmed about each video, kept APART from the lists that show it. A list's
// own cache cannot hold it alone: an infinite query builds its next page's data from the pages it
// read when that fetch STARTED (react-query's infiniteQueryBehavior), so a mark confirmed while a
// related list's Show more was on its way vanished when the page landed — for the session, since
// those lists are never refetched. The related list lays these over its rows (`useVideoMarks`).
type Marks = Record<string, Partial<Video>>;
function remember(qc: QueryClient, id: string, change: Partial<Video>): void {
// Kept while the app is open, observed or not: a mark made before any related list was opened
// must still be there when one is.
qc.setQueryDefaults(qk.videoMarks(), { staleTime: Infinity, gcTime: Infinity });
qc.setQueryData<Marks>(qk.videoMarks(), (m) => ({ ...m, [id]: { ...m?.[id], ...change } }));
}
/** This session's confirmed marks, per video id (see `markVideo`). */
export function useVideoMarks(): Marks {
const qc = useQueryClient();
return useQuery({
queryKey: qk.videoMarks(),
// Nothing to fetch: the marks are what this session wrote, and the cache is where they live.
queryFn: () => qc.getQueryData<Marks>(qk.videoMarks()) ?? {},
initialData: {},
staleTime: Infinity,
gcTime: Infinity,
}).data;
}
/** Apply `edit` to every row of every cached list under `keys` (prefix keys), keeping each untouched
* page's and list's identity — so an unchanged list does not re-render every card behind the
* open player. */
function patchLists(qc: QueryClient, keys: readonly QueryKey[], edit: (v: Video) => Video): void {
const patchList = (items: Video[]): Video[] => { const patchList = (items: Video[]): Video[] => {
let changed = false; let changed = false;
const next = items.map((v) => { const next = items.map((v) => {
const pos = positions.get(v.id); const out = edit(v);
if (pos === undefined || v.position_seconds === pos) return v; if (out !== v) changed = true;
changed = true; return out;
return { ...v, position_seconds: pos };
}); });
// Keep the previous array identity when nothing moved, so an untouched list does not
// re-render every card behind the open player.
return changed ? next : items; return changed ? next : items;
}; };
@@ -74,31 +124,5 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
return old; return old;
}; };
// Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube for (const queryKey of keys) qc.setQueriesData<Cached>({ queryKey }, patch);
// search and every related list — their rows come from the same projection, carry the same
// per-user position, and open the same player, so leaving one out left the rewind in place on that
// surface. (Patched, never invalidated: a refetch of an API-sourced search page spends 100 quota
// units, a related list's a scrape and a slot of the day's cap.)
qc.setQueriesData<Cached>({ queryKey: qk.feed() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.related() }, patch);
}
/** Write one video's change (its status, its Watch-later mark) into every cached related list —
* both regions', every opened video's. Those lists are never refetched (another scrape and a slot
* of the day's cap), and they leave out what the user has seen: a video marked anywhere else — a
* feed card, a playlist, the player itself — still showed in one opened again later. So every
* state write calls it once the server has the change. */
export function patchRelated(qc: QueryClient, id: string, change: Partial<Video>): void {
qc.setQueriesData<Cached>({ queryKey: qk.related() }, (old) => {
if (!old?.pages) return old;
let changed = false;
const pages = old.pages.map((page) => {
if (!isItemList(page) || !page.items.some((v) => v.id === id)) return page;
changed = true;
return { ...page, items: page.items.map((v) => (v.id === id ? { ...v, ...change } : v)) };
});
return changed ? { ...old, pages } : old;
});
} }
+2
View File
@@ -34,6 +34,8 @@ export const qk = {
videoDetail: (id: string) => ["video-detail", id] as const, videoDetail: (id: string) => ["video-detail", id] as const,
related: (id?: string, region?: string) => related: (id?: string, region?: string) =>
id !== undefined ? (["related", id, region] as const) : (["related"] as const), id !== undefined ? (["related", id, region] as const) : (["related"] as const),
// This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it.
videoMarks: () => ["video-marks"] as const,
// --- channels --- // --- channels ---
channels: () => ["channels"] as const, channels: () => ["channels"] as const,
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EyeOff, Play, UserPlus } from "lucide-react"; import { EyeOff, Play, UserPlus } from "lucide-react";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { patchRelated } from "../../lib/progressCache"; import { writeVideoState } from "../../features/video/videoWrites";
import { api, type Video, type VideoStatus, type WatchedChannel } from "../../lib/api"; import { api, type Video, type VideoStatus, type WatchedChannel } from "../../lib/api";
import { formatDate, formatDuration } from "../../lib/format"; import { formatDate, formatDuration } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
@@ -54,8 +54,7 @@ export default function WatchedVideosDialog({
// The player's watched / in-progress marks: saved, then shown on this list and the feed's. // The player's watched / in-progress marks: saved, then shown on this list and the feed's.
const onState = useStableCallback((id: string, status: VideoStatus) => { const onState = useStableCallback((id: string, status: VideoStatus) => {
api writeVideoState(qc, id, status)
.setState(id, status)
.then(() => { .then(() => {
qc.setQueryData( qc.setQueryData(
key, key,
@@ -66,8 +65,6 @@ export default function WatchedVideosDialog({
}, },
); );
void qc.invalidateQueries({ queryKey: qk.feed() }); void qc.invalidateQueries({ queryKey: qk.feed() });
// The player's related lists are never refetched: the mark goes into them.
patchRelated(qc, id, { status });
}) })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
}); });
+38 -9
View File
@@ -22,14 +22,26 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons"; import { YouTubeIcon } from "../../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api"; import {
api,
HttpError,
refusalReason,
type FeedFilters,
type Video,
type VideoStatus,
} from "../../lib/api";
import i18n from "../../i18n"; import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice"; import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import {
resetVideoState,
writeVideoState,
writeWatchLater,
} from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications"; import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import { patchCachedProgress, patchRelated, progressFlushes } from "../../lib/progressCache"; import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
import { import {
buildSort, buildSort,
feedSortOptions, feedSortOptions,
@@ -124,6 +136,15 @@ const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size. // How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
const YT_COUNTS = [20, 40, 60, 100] as const; const YT_COUNTS = [20, 40, 60, 100] as const;
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
const SEARCH_FINAL_REASONS = new Set([
"search_daily_limit",
"search_disabled",
"quota_spent",
"quota",
]);
export default function Feed({ export default function Feed({
filters, filters,
setFilters, setFilters,
@@ -358,7 +379,7 @@ export default function Feed({
(id: string, status: VideoStatus, video?: NoticeVideo) => { (id: string, status: VideoStatus, video?: NoticeVideo) => {
const seq = ++writeSeq.current; const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } })); setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = api.setState(id, status); const write = writeVideoState(qc, id, status);
trackSessionWrite(write); trackSessionWrite(write);
// Refetch once the server has the change so other views (e.g. Hidden) are in sync. // Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden" // Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
@@ -373,7 +394,6 @@ export default function Feed({
// storm (PERF-2). The next filter change / remount refetches fresh from the server. // storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq); answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
patchRelated(qc, id, { status });
// The video the player is on may be none of this list's (a related one): it names itself. // The video the player is on may be none of this list's (a related one): it names itself.
const v = video ?? loadedRef.current.find((x) => x.id === id); const v = video ?? loadedRef.current.find((x) => x.id === id);
announceVideoState(id, status, v, () => onStateRef.current(id, "new")); announceVideoState(id, status, v, () => onStateRef.current(id, "new"));
@@ -401,8 +421,7 @@ export default function Feed({
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds; const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
patchCachedProgress(qc, new Map([[id, 0]])); patchCachedProgress(qc, new Map([[id, 0]]));
resets.current.set(id, seq); resets.current.set(id, seq);
api resetVideoState(qc, id)
.clearState(id)
// Stale-mark only (see setState above): the optimistic override already reset the card. // Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => { .then(() => {
answered.current.add(seq); answered.current.add(seq);
@@ -423,7 +442,7 @@ export default function Feed({
(id: string, saved: boolean) => { (id: string, saved: boolean) => {
const seq = ++writeSeq.current; const seq = ++writeSeq.current;
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } })); setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) writeWatchLater(qc, id, saved)
.then(() => { .then(() => {
answered.current.add(seq); answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -491,6 +510,9 @@ export default function Feed({
: ytQuery.isError : ytQuery.isError
? t("feed.yt.error") ? t("feed.yt.error")
: null; : null;
// A refusal a retry cannot answer (the day's cap, turned off, the shared budget): Load more —
// the same request — is not offered after it.
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
// The backend reports which source served the search; scrape spends no search quota, so we // The backend reports which source served the search; scrape spends no search quota, so we
// drop the quota warning + "uses quota" wording in that mode. // drop the quota warning + "uses quota" wording in that mode.
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape"; const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
@@ -577,7 +599,7 @@ export default function Feed({
{ytQuery.isLoading ? ( {ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div> <div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError ? ( ) : ytError && !ytItems.length ? (
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div> <div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
) : ytItems.length === 0 ? ( ) : ytItems.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div> <div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
@@ -594,7 +616,7 @@ export default function Feed({
isFetching={false} isFetching={false}
fetchNextPage={() => {}} fetchNextPage={() => {}}
/> />
{ytQuery.hasNextPage && ( {ytQuery.hasNextPage && !ytFinal && (
<div className="text-center pt-4"> <div className="text-center pt-4">
<Button <Button
variant="outline" variant="outline"
@@ -611,6 +633,13 @@ export default function Feed({
</Button> </Button>
</div> </div>
)} )}
{/* A page that failed after others loaded: they stay, and the failure is said under
them (Load more above is the retry, when there is one). */}
{ytError && (
<StateMessage tone="error" compact className="mt-3">
{ytError}
</StateMessage>
)}
</> </>
)} )}
@@ -0,0 +1,122 @@
// @vitest-environment jsdom
//
// A live YouTube search's Load more that fails. The results already read stay on screen and the
// failure is said under them; before, the whole pane turned into the error text. Load more is the
// retry where trying again can help, and is not offered after a refusal it cannot answer (the day's
// cap, the feature turned off, the shared budget).
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("../../features/wizard/wizardContext", () => ({
useWizard: () => ({ openWizard: vi.fn() }),
}));
vi.mock("./VirtualFeed", () => ({
default: ({ items }: { items: { id: string; title: string }[] }) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>{v.title}</li>
))}
</ol>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const hit = (id: string): Video =>
({
id,
title: `hit ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 0,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
function setup(failure: HttpError) {
vi.spyOn(api, "searchYoutube")
.mockResolvedValueOnce({ items: [hit("a")], next_cursor: "c2", source: "scrape" } as never)
.mockRejectedValueOnce(failure);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch="lemmino"
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
}
const loadMore = () => screen.queryByRole("button", { name: "feed.yt.loadMoreFree" });
describe("Feed — a live search's Load more that fails", () => {
it("keeps the results read, says why under them, and offers Load more again", async () => {
setup(
new HttpError(422, "YouTube isn't reachable", { code: "youtube", reason: "unavailable" }),
);
fireEvent.click(await screen.findByRole("button", { name: "feed.yt.loadMoreFree" }));
expect(await screen.findByText("YouTube isn't reachable")).toBeTruthy();
expect(screen.getByRole("list", { name: "results" }).textContent).toContain("hit a");
expect(loadMore()).toBeTruthy();
});
it("offers no Load more after a refusal a retry cannot answer", async () => {
setup(
new HttpError(429, "Today's limit", {
code: "refused",
reason: "search_daily_limit",
limit: 2,
}),
);
fireEvent.click(await screen.findByRole("button", { name: "feed.yt.loadMoreFree" }));
expect(await screen.findByText("Today's limit")).toBeTruthy();
expect(screen.getByRole("list", { name: "results" }).textContent).toContain("hit a");
await waitFor(() => expect(loadMore()).toBeNull());
});
});
+3 -6
View File
@@ -14,7 +14,6 @@ import {
Star, Star,
} from "lucide-react"; } from "lucide-react";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { patchRelated } from "../../lib/progressCache";
import { plexLang } from "../../i18n"; import { plexLang } from "../../i18n";
import { import {
api, api,
@@ -54,6 +53,7 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard"; import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
import { useVideoActions } from "../../features/video/useVideoActions"; import { useVideoActions } from "../../features/video/useVideoActions";
import { writeVideoState, writeWatchLater } from "../../features/video/videoWrites";
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions"; import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards"; import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
import { usePlaylistsActions } from "../playlists"; import { usePlaylistsActions } from "../playlists";
@@ -837,11 +837,8 @@ export default function Home() {
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking // A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
// something watched genuinely changes what the page should answer (it can retire the hero). // something watched genuinely changes what the page should answer (it can retire the hero).
// The feed avoids this because it would refetch ten infinite-query pages per click. // The feed avoids this because it would refetch ten infinite-query pages per click.
void api void writeVideoState(qc, id, status)
.setState(id, status)
.then(() => { .then(() => {
// The player's related lists are never refetched: the mark goes into them.
patchRelated(qc, id, { status });
return qc.invalidateQueries({ queryKey: qk.discover() }); return qc.invalidateQueries({ queryKey: qk.discover() });
}) })
// Without this the rejection escapes unhandled and the click just does nothing — the // Without this the rejection escapes unhandled and the click just does nothing — the
@@ -858,7 +855,7 @@ export default function Home() {
const onToggleSave = useCallback( const onToggleSave = useCallback(
(id: string, saved: boolean) => { (id: string, saved: boolean) => {
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) void writeWatchLater(qc, id, saved)
.then(() => { .then(() => {
qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() }); qc.invalidateQueries({ queryKey: qk.playlist() });
@@ -48,6 +48,7 @@ import { PageToolbar } from "../../components/ui/PageShell";
import { PageMenu } from "../../components/ui/PageMenu"; import { PageMenu } from "../../components/ui/PageMenu";
import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigationActions } from "../../features/navigation/navigationContext";
import { writeVideoState } from "../../features/video/videoWrites";
import { useFilesActions } from "../files"; import { useFilesActions } from "../files";
import { useChannelsActions } from "../channels"; import { useChannelsActions } from "../channels";
import { useFeedFilters, useFeedFiltersActions } from "../feed"; import { useFeedFilters, useFeedFiltersActions } from "../feed";
@@ -170,8 +171,7 @@ export default function NotificationsPanel() {
setPage("feed"); setPage("feed");
} }
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) { function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) {
api writeVideoState(qc, meta.videoId, "new")
.setState(meta.videoId, "new")
.then(() => { .then(() => {
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() }); qc.invalidateQueries({ queryKey: qk.feedSummary() });
+2 -5
View File
@@ -3,7 +3,7 @@ import { useLatest, useStableCallback } from "../../lib/useLatest";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { patchRelated } from "../../lib/progressCache"; import { writeVideoState } from "../../features/video/videoWrites";
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -611,16 +611,13 @@ function PlaylistDetail() {
} }
function playerState(id: string, status: VideoStatus) { function playerState(id: string, status: VideoStatus) {
api writeVideoState(qc, id, status)
.setState(id, status)
.then(() => { .then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a // Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose // list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch. // status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() }); qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
// The player's related lists are never refetched: the mark goes into them.
patchRelated(qc, id, { status });
}) })
// No catch used to mean an unhandled rejection and nothing said. // No catch used to mean an unhandled rejection and nothing said.
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));