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:
@@ -9,6 +9,7 @@ import KeyboardHelp from "./KeyboardHelp";
|
||||
import { useContextMenu } from "./useContextMenu";
|
||||
import {
|
||||
closeKeyboardHelp,
|
||||
setScopeCoveredProbe,
|
||||
useGlobalKeymap,
|
||||
useHelpConditions,
|
||||
useKeyScope,
|
||||
@@ -179,6 +180,33 @@ describe("KeyboardHelp", () => {
|
||||
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.
|
||||
it("lists the context-menu chords once however many surfaces register", () => {
|
||||
function WithMenu() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useHelpConditionValues,
|
||||
useKeyboardHelpOpen,
|
||||
useKeyboardHelpOverLayer,
|
||||
useKeyboardHelpCovered,
|
||||
useNavigablePages,
|
||||
} from "../../lib/keymap";
|
||||
|
||||
@@ -33,6 +34,7 @@ export default function KeyboardHelp() {
|
||||
const scopes = useActiveScopes();
|
||||
const conditions = useHelpConditionValues();
|
||||
const overLayer = useKeyboardHelpOverLayer();
|
||||
const covered = useKeyboardHelpCovered();
|
||||
const pages = useNavigablePages();
|
||||
|
||||
if (!open) return null;
|
||||
@@ -47,7 +49,10 @@ export default function KeyboardHelp() {
|
||||
const inLayer = (s: (typeof scopes)[number]) =>
|
||||
(!isPageLevelScope(s) && 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 words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -189,7 +189,7 @@
|
||||
"recipient_required": "A recipient email is required",
|
||||
"registration_closed": "Registration is currently closed",
|
||||
"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_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.",
|
||||
"search_term_required": "Enter something to search for",
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"recipient_required": "Adj meg egy címzett e-mail címet",
|
||||
"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_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_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",
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
// 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
|
||||
// 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.
|
||||
export { HttpError, setUnauthorizedHandler };
|
||||
export { HttpError, refusalReason, setUnauthorizedHandler };
|
||||
|
||||
import { accountApi } from "./api/account";
|
||||
export type * from "./api/account";
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
|
||||
@@ -488,17 +488,31 @@ export const setListInTopLayerProbe = (probe: () => boolean): void => {
|
||||
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 toggleKeyboardHelp = (): void => {
|
||||
const next = !helpOpen.get();
|
||||
if (next)
|
||||
if (next) {
|
||||
helpOverLayer.set(!anyBlockingLayer() ? "page" : listInTopLayer() ? "layerWithList" : "layer");
|
||||
helpCovered.set(new Set([...coveredProbes].filter(([, covered]) => covered()).map(([s]) => s)));
|
||||
}
|
||||
helpOpen.set(next);
|
||||
};
|
||||
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
||||
export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue();
|
||||
/** What the sheet was opened over (see `helpOverLayer`). */
|
||||
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
|
||||
// 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({
|
||||
activeId: VID,
|
||||
resumeAt: 0,
|
||||
exactStart: false,
|
||||
mountRef: { current: mount },
|
||||
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
|
||||
currentIdRef: { current: VID },
|
||||
|
||||
@@ -72,6 +72,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
|
||||
useYtProgress({
|
||||
activeId: VID,
|
||||
resumeAt: 748,
|
||||
exactStart: false,
|
||||
mountRef: { current: mount },
|
||||
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
|
||||
currentIdRef: { current: VID },
|
||||
|
||||
@@ -339,6 +339,7 @@ function playerTimes(p: YTPlayer | null): { cur: number; dur: number } | null {
|
||||
export function useYtProgress({
|
||||
activeId,
|
||||
resumeAt,
|
||||
exactStart,
|
||||
mountRef,
|
||||
playerRef,
|
||||
currentIdRef,
|
||||
@@ -355,6 +356,8 @@ export function useYtProgress({
|
||||
}: {
|
||||
activeId: string;
|
||||
resumeAt: number;
|
||||
// `resumeAt` is where the user asked to start, so it beats a position this session stored.
|
||||
exactStart: boolean;
|
||||
mountRef: RefObject<HTMLDivElement | null>;
|
||||
playerRef: RefObject<YTPlayer | null>;
|
||||
currentIdRef: RefObject<string>;
|
||||
@@ -378,6 +381,7 @@ export function useYtProgress({
|
||||
// the end of the effect).
|
||||
const itemSnapshot = useEffectEvent(() => ({
|
||||
resumeAt,
|
||||
exactStart,
|
||||
setWatched,
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
@@ -392,6 +396,7 @@ export function useYtProgress({
|
||||
const id = activeId;
|
||||
const {
|
||||
resumeAt,
|
||||
exactStart,
|
||||
setWatched,
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
@@ -401,7 +406,8 @@ export function useYtProgress({
|
||||
qc,
|
||||
} = itemSnapshot();
|
||||
// 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;
|
||||
setPlayerError(null);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 type { Video } from "./api";
|
||||
|
||||
@@ -40,17 +40,67 @@ export const progressFlushes = (): Promise<unknown>[] => [...flushes];
|
||||
|
||||
export function patchCachedProgress(qc: QueryClient, positions: Map<string, number>): void {
|
||||
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[] => {
|
||||
let changed = false;
|
||||
const next = items.map((v) => {
|
||||
const pos = positions.get(v.id);
|
||||
if (pos === undefined || v.position_seconds === pos) return v;
|
||||
changed = true;
|
||||
return { ...v, position_seconds: pos };
|
||||
const out = edit(v);
|
||||
if (out !== v) changed = true;
|
||||
return out;
|
||||
});
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -74,31 +124,5 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
|
||||
return old;
|
||||
};
|
||||
|
||||
// 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.)
|
||||
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;
|
||||
});
|
||||
for (const queryKey of keys) qc.setQueriesData<Cached>({ queryKey }, patch);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export const qk = {
|
||||
videoDetail: (id: string) => ["video-detail", id] as const,
|
||||
related: (id?: string, region?: string) =>
|
||||
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"] as const,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EyeOff, Play, UserPlus } from "lucide-react";
|
||||
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 { formatDate, formatDuration } from "../../lib/format";
|
||||
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.
|
||||
const onState = useStableCallback((id: string, status: VideoStatus) => {
|
||||
api
|
||||
.setState(id, status)
|
||||
writeVideoState(qc, id, status)
|
||||
.then(() => {
|
||||
qc.setQueryData(
|
||||
key,
|
||||
@@ -66,8 +65,6 @@ export default function WatchedVideosDialog({
|
||||
},
|
||||
);
|
||||
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")));
|
||||
});
|
||||
|
||||
@@ -22,14 +22,26 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
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 { notifyActionError } from "../../lib/actionErrors";
|
||||
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
|
||||
import {
|
||||
resetVideoState,
|
||||
writeVideoState,
|
||||
writeWatchLater,
|
||||
} from "../../features/video/videoWrites";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { patchCachedProgress, patchRelated, progressFlushes } from "../../lib/progressCache";
|
||||
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
|
||||
import {
|
||||
buildSort,
|
||||
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.
|
||||
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({
|
||||
filters,
|
||||
setFilters,
|
||||
@@ -358,7 +379,7 @@ export default function Feed({
|
||||
(id: string, status: VideoStatus, video?: NoticeVideo) => {
|
||||
const seq = ++writeSeq.current;
|
||||
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
|
||||
const write = api.setState(id, status);
|
||||
const write = writeVideoState(qc, id, status);
|
||||
trackSessionWrite(write);
|
||||
// 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"
|
||||
@@ -373,7 +394,6 @@ export default function Feed({
|
||||
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
|
||||
answered.current.add(seq);
|
||||
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.
|
||||
const v = video ?? loadedRef.current.find((x) => x.id === id);
|
||||
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;
|
||||
patchCachedProgress(qc, new Map([[id, 0]]));
|
||||
resets.current.set(id, seq);
|
||||
api
|
||||
.clearState(id)
|
||||
resetVideoState(qc, id)
|
||||
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
||||
.then(() => {
|
||||
answered.current.add(seq);
|
||||
@@ -423,7 +442,7 @@ export default function Feed({
|
||||
(id: string, saved: boolean) => {
|
||||
const seq = ++writeSeq.current;
|
||||
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
|
||||
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||||
writeWatchLater(qc, id, saved)
|
||||
.then(() => {
|
||||
answered.current.add(seq);
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
@@ -491,6 +510,9 @@ export default function Feed({
|
||||
: ytQuery.isError
|
||||
? t("feed.yt.error")
|
||||
: 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
|
||||
// drop the quota warning + "uses quota" wording in that mode.
|
||||
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
|
||||
@@ -577,7 +599,7 @@ export default function Feed({
|
||||
|
||||
{ytQuery.isLoading ? (
|
||||
<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>
|
||||
) : ytItems.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
|
||||
@@ -594,7 +616,7 @@ export default function Feed({
|
||||
isFetching={false}
|
||||
fetchNextPage={() => {}}
|
||||
/>
|
||||
{ytQuery.hasNextPage && (
|
||||
{ytQuery.hasNextPage && !ytFinal && (
|
||||
<div className="text-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -611,6 +633,13 @@ export default function Feed({
|
||||
</Button>
|
||||
</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());
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { patchRelated } from "../../lib/progressCache";
|
||||
import { plexLang } from "../../i18n";
|
||||
import {
|
||||
api,
|
||||
@@ -54,6 +53,7 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
|
||||
import { useVideoActions } from "../../features/video/useVideoActions";
|
||||
import { writeVideoState, writeWatchLater } from "../../features/video/videoWrites";
|
||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||
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
|
||||
// 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.
|
||||
void api
|
||||
.setState(id, status)
|
||||
void writeVideoState(qc, id, status)
|
||||
.then(() => {
|
||||
// The player's related lists are never refetched: the mark goes into them.
|
||||
patchRelated(qc, id, { status });
|
||||
return qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
})
|
||||
// Without this the rejection escapes unhandled and the click just does nothing — the
|
||||
@@ -858,7 +855,7 @@ export default function Home() {
|
||||
|
||||
const onToggleSave = useCallback(
|
||||
(id: string, saved: boolean) => {
|
||||
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||||
void writeWatchLater(qc, id, saved)
|
||||
.then(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
|
||||
@@ -48,6 +48,7 @@ import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageMenu } from "../../components/ui/PageMenu";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { writeVideoState } from "../../features/video/videoWrites";
|
||||
import { useFilesActions } from "../files";
|
||||
import { useChannelsActions } from "../channels";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
||||
@@ -170,8 +171,7 @@ export default function NotificationsPanel() {
|
||||
setPage("feed");
|
||||
}
|
||||
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) {
|
||||
api
|
||||
.setState(meta.videoId, "new")
|
||||
writeVideoState(qc, meta.videoId, "new")
|
||||
.then(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLatest, useStableCallback } from "../../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { patchRelated } from "../../lib/progressCache";
|
||||
import { writeVideoState } from "../../features/video/videoWrites";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -611,16 +611,13 @@ function PlaylistDetail() {
|
||||
}
|
||||
|
||||
function playerState(id: string, status: VideoStatus) {
|
||||
api
|
||||
.setState(id, status)
|
||||
writeVideoState(qc, id, status)
|
||||
.then(() => {
|
||||
// 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
|
||||
// status just changed. Only lists actually on screen refetch.
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
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.
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||
|
||||
Reference in New Issue
Block a user