fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs
A session's marks are weighed per field against when each page of a never-refetched list was read: a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the server's word. The live search lays them over its rows too, not only the related list. Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go through features/video/videoWrites and mark the video; the contract test covers those writes and catches a write that is named without being called on the spot. The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's menu, a notice's Undo); Restart on the related row after a description link goes back to it. The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last two open-coded reads. Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both places render from one list of controls, so the docked tooltips name the mode too. The English cap message and admin hint quote "Show more"; the hint says refunds are limited.
This commit is contained in:
@@ -41,7 +41,13 @@ import {
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
|
||||
import {
|
||||
markedRows,
|
||||
patchCachedProgress,
|
||||
progressFlushes,
|
||||
stampFetch,
|
||||
useVideoMarks,
|
||||
} from "../../lib/progressCache";
|
||||
import {
|
||||
buildSort,
|
||||
feedSortOptions,
|
||||
@@ -201,6 +207,7 @@ export default function Feed({
|
||||
null,
|
||||
);
|
||||
const qc = useQueryClient();
|
||||
const videoMarks = useVideoMarks();
|
||||
// Live search turned off on this instance: the empty feed offers no search that would be refused.
|
||||
const searchOn = useMe().search_enabled;
|
||||
|
||||
@@ -276,7 +283,7 @@ export default function Feed({
|
||||
const ytQuery = useInfiniteQuery({
|
||||
queryKey: qk.ytSearch(ytSearch, ytCount),
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
|
||||
stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
enabled: ytActive,
|
||||
@@ -462,7 +469,10 @@ export default function Feed({
|
||||
);
|
||||
|
||||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||
// A mark made anywhere else (the player's related list, the Notification Center's undo) since a
|
||||
// page was read is laid over it: a Load more on its way when the mark landed rebuilt the list
|
||||
// from the pages it started from, without the mark.
|
||||
const ytLoaded: Video[] = markedRows(ytQuery.data?.pages, videoMarks);
|
||||
// onState's refs follow each commit: itself, and what the cards show (live search results if on).
|
||||
useLayoutEffect(() => {
|
||||
onStateRef.current = onState;
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// 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).
|
||||
// cap, the feature turned off, the shared budget). And a mark made elsewhere while a Load more is on
|
||||
// its way is not undone by the page landing.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
@@ -16,10 +17,12 @@ vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({ items }: { items: { id: string; title: string }[] }) => (
|
||||
default: ({ items }: { items: { id: string; title: string; status: string }[] }) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>{v.title}</li>
|
||||
<li key={v.id}>
|
||||
{v.title} ({v.status})
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
),
|
||||
@@ -28,6 +31,7 @@ vi.mock("./VirtualFeed", () => ({
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { writeVideoState } from "../../features/video/videoWrites";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -75,7 +79,10 @@ 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 } } });
|
||||
return mount();
|
||||
}
|
||||
|
||||
function mount(qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
@@ -91,6 +98,7 @@ function setup(failure: HttpError) {
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
|
||||
const loadMore = () => screen.queryByRole("button", { name: "feed.yt.loadMoreFree" });
|
||||
@@ -120,3 +128,37 @@ describe("Feed — a live search's Load more that fails", () => {
|
||||
await waitFor(() => expect(loadMore()).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feed — a live search's Load more on its way", () => {
|
||||
// Marked in this session, then changed back elsewhere: a search read after the mark carries the
|
||||
// server's word.
|
||||
it("shows a search read after a mark as the server has it", async () => {
|
||||
vi.spyOn(api, "searchYoutube").mockResolvedValue({
|
||||
items: [hit("a")],
|
||||
next_cursor: null,
|
||||
source: "scrape",
|
||||
} as never);
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await writeVideoState(qc, "a", "watched");
|
||||
await new Promise((r) => setTimeout(r, 5)); // the search is read a moment after the mark
|
||||
mount(qc);
|
||||
expect(await screen.findByText(/hit a/)).toBeTruthy();
|
||||
expect(screen.getByRole("list", { name: "results" }).textContent).toContain("hit a (new)");
|
||||
});
|
||||
|
||||
it("keeps a mark made elsewhere meanwhile when the page lands", async () => {
|
||||
let land: (p: unknown) => void = () => {};
|
||||
vi.spyOn(api, "searchYoutube")
|
||||
.mockResolvedValueOnce({ items: [hit("a")], next_cursor: "c2", source: "scrape" } as never)
|
||||
.mockReturnValueOnce(new Promise((r) => (land = r)) as never);
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "feed.yt.loadMoreFree" }));
|
||||
// Hidden from the player's related list, say: not one of the feed's own writes.
|
||||
await act(async () => void (await writeVideoState(qc, "a", "hidden")));
|
||||
await act(async () => land({ items: [hit("b")], next_cursor: null, source: "scrape" }));
|
||||
await screen.findByText(/hit b/);
|
||||
expect(screen.getByRole("list", { name: "results" }).textContent).not.toContain("hit a (new)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import FilePreview from "./FilePreview";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
import { closeKeyboardHelp, toggleKeyboardHelp, useKeyboardHelpCovered } from "../../lib/keymap";
|
||||
|
||||
const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
@@ -170,6 +171,27 @@ describe("FilePreview · stepping", () => {
|
||||
expect(onFileChange).toHaveBeenLastCalledWith(3);
|
||||
});
|
||||
|
||||
// …so the help sheet opened over that layer leaves them out, and one over the viewer lists them.
|
||||
it("tells the help sheet its keys wait while a layer sits above it", () => {
|
||||
function Covered() {
|
||||
return <span data-testid="covered">{[...useKeyboardHelpCovered()].join(",")}</span>;
|
||||
}
|
||||
mount(list, 2);
|
||||
render(<Covered />);
|
||||
const help = () => act(() => toggleKeyboardHelp());
|
||||
const confirm = pushLayer();
|
||||
try {
|
||||
help();
|
||||
expect(screen.getByTestId("covered").textContent).toBe("fileViewer");
|
||||
act(() => closeKeyboardHelp());
|
||||
} finally {
|
||||
removeLayer(confirm);
|
||||
}
|
||||
help();
|
||||
expect(screen.getByTestId("covered").textContent).toBe("");
|
||||
act(() => closeKeyboardHelp());
|
||||
});
|
||||
|
||||
it("a text line wider than the stage scrolls sideways under ← / →, it does not page", async () => {
|
||||
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a very long line", truncated: false });
|
||||
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { dispatch, useKeyScope } from "../../lib/keymap";
|
||||
import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
@@ -85,6 +85,12 @@ export default function FilePreview({
|
||||
// child first, so it sits just ABOVE the dialog's layer: the dialog is never topmost, and Escape is
|
||||
// this layer's to take — passive, so the dialog keeps its Tab trap.
|
||||
const layer = useLayer(!!file, onClose, { passive: true });
|
||||
// …and the help sheet opened over that layer must not list them (keymap `setScopeCoveredProbe`).
|
||||
const coveredNow = useEffectEvent(() => layer.blockingAbove());
|
||||
useEffect(() => {
|
||||
setScopeCoveredProbe("fileViewer", () => coveredNow());
|
||||
return () => setScopeCoveredProbe("fileViewer", null);
|
||||
}, []);
|
||||
const onKey = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (layer.blockingAbove()) return;
|
||||
// The text, scrolled sideways by its arrows: a line wider than the stage is read that way.
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useLatest, useStableCallback } from "../../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { writeVideoState } from "../../features/video/videoWrites";
|
||||
import {
|
||||
addPlaylistItem,
|
||||
movePlaylistItem,
|
||||
removePlaylistItem,
|
||||
writeVideoState,
|
||||
} from "../../features/video/videoWrites";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -301,6 +306,12 @@ function PlaylistDetail() {
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const detail = detailQuery.data;
|
||||
// Which kind of list an item write goes to (the Watch later list's items are the bookmark):
|
||||
// the rail's list knows every one, the open detail the one on screen.
|
||||
const listRef = (id: number) => ({
|
||||
id,
|
||||
kind: playlists.find((p) => p.id === id)?.kind ?? (detail?.id === id ? detail.kind : undefined),
|
||||
});
|
||||
// The page's background menu (DESIGN "Background menu"), live only while a list is on screen.
|
||||
const pageMenu = usePageMenu(selectedId != null && !!detail);
|
||||
const pagePin = useComfortPin(pageMenu.open);
|
||||
@@ -503,7 +514,7 @@ function PlaylistDetail() {
|
||||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
||||
);
|
||||
try {
|
||||
await api.removeFromPlaylist(listId, video.id);
|
||||
await removePlaylistItem(qc, listRef(listId), video.id);
|
||||
refreshAll(listId);
|
||||
} catch (e) {
|
||||
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
|
||||
@@ -538,7 +549,8 @@ function PlaylistDetail() {
|
||||
[video.id],
|
||||
now.items.map((v) => v.id),
|
||||
);
|
||||
if (back.keys.length) return api.addToPlaylist(listId, video.id, back.positions[0]);
|
||||
if (back.keys.length)
|
||||
return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, t("playlists.undoRemoveFailed")))
|
||||
.finally(() => {
|
||||
@@ -566,7 +578,7 @@ function PlaylistDetail() {
|
||||
try {
|
||||
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
|
||||
// whenever the second call fails — see the endpoint's docstring.
|
||||
await api.moveToPlaylist(selectedId, video.id, target.id);
|
||||
await movePlaylistItem(qc, listRef(selectedId), video.id, target);
|
||||
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a
|
||||
// shortcut to failing again.
|
||||
setLastMoveTargetId(target.id);
|
||||
|
||||
Reference in New Issue
Block a user