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