fix(player): a related video played is tracked, and its list is honest about its state

A related video played in place is the player's item now: its title and its own watched /
playlist / download controls, its progress checkpointed and resumed, marked watched at its end —
and its end does not step the queue. Back returns to the opened video, where it was left.

The related list:
- its marks announce through the shared notice (the Notification Center finds them), join the
  host's player session (the feed refetches on close), and stay per write, so an earlier answer no
  longer undoes a later mark still on its way;
- every host's state write patches the cached related lists, which are never refetched;
- kept for the session (gcTime), a retry asks for the page that failed, a list read so far with
  nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names
  itself on hover.

Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons
are left out.

Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide
moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the
top layer; the help sheet lists the list keys over a layer that holds a list.
This commit is contained in:
2026-10-25 00:32:28 +02:00
parent d53729a63d
commit b5045165c0
30 changed files with 1024 additions and 207 deletions
@@ -35,8 +35,18 @@ vi.mock("../../features/navigation/navigationContext", () => ({
useNavigationActions: () => ({ setPage: h.setPage }),
}));
vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
// The open player's mark, for a test to make as the player would.
const playerMark = vi.hoisted(() => ({
onState: null as null | ((id: string, s: string) => void),
}));
vi.mock("../../features/player/PlayerModal", () => ({
default: (p: { video: { id: string }; queue: { id: string }[]; startIndex: number }) => {
default: (p: {
video: { id: string };
queue: { id: string }[];
startIndex: number;
onState: (id: string, s: string) => void;
}) => {
playerMark.onState = p.onState;
h.player(
p.video.id,
p.queue.map((v) => v.id),
@@ -161,6 +171,31 @@ describe("WatchedChannels", () => {
await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("a", ["a", "b", "c"], 0));
});
it("puts the player's mark into its related lists, which are never refetched", async () => {
vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({
ok: true,
items: [video("b")],
gone: 0,
});
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
const qc = mount({ imported: true, pending: 0, channels: [channel(1)] });
qc.setQueryData(qk.related("SRC", "topical"), {
pages: [{ items: [{ id: "b", status: "new" }], has_more: false, next_cursor: null }],
pageParams: [null],
});
fireEvent.click(await screen.findByRole("button", { name: "29" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(await within(dialog).findByRole("button", { name: "card.play — Video b" }));
await waitFor(() => expect(playerMark.onState).not.toBeNull());
act(() => playerMark.onState!("b", "watched"));
await waitFor(() => {
const cached = qc.getQueryData(qk.related("SRC", "topical")) as {
pages: { items: { status: string }[] }[];
};
expect(cached.pages[0]!.items[0]!.status).toBe("watched");
});
});
it("asks for a channel's videos once per opening, whatever the list does meanwhile", async () => {
// The ask explores the channel and may spend quota: the list's own refetch (an Undo, a follow)
// must not repeat it under an open dialog, nor swap the queue of a video playing.
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EyeOff, Play, UserPlus } from "lucide-react";
import { qk } from "../../lib/queryKeys";
import { patchRelated } from "../../lib/progressCache";
import { api, type Video, type VideoStatus, type WatchedChannel } from "../../lib/api";
import { formatDate, formatDuration } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors";
@@ -65,6 +66,8 @@ export default function WatchedVideosDialog({
},
);
void qc.invalidateQueries({ queryKey: qk.feed() });
// The player's related lists are never refetched: the mark goes into them.
patchRelated(qc, id, { status });
})
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
});
@@ -54,10 +54,12 @@ vi.mock("../../features/player/PlayerModal", async () => {
default: function Player({
video,
onState,
onWrite,
onClose,
}: {
video: { id: string };
onState: (id: string, status: string) => void;
onState: (id: string, status: string, v?: unknown) => void;
onWrite?: (write: Promise<unknown>) => void;
onClose: () => void;
}) {
useEffect(
@@ -70,6 +72,19 @@ vi.mock("../../features/player/PlayerModal", async () => {
return (
<div>
<button onClick={() => onState(video.id, "watched")}>mark</button>
<button
onClick={() =>
onState("rel1", "watched", {
id: "rel1",
title: "Related one",
channel_id: "UCr",
channel_title: "Rel channel",
})
}
>
mark related
</button>
<button onClick={() => onWrite?.(Promise.resolve())}>related list wrote</button>
<button onClick={onClose}>close</button>
</div>
);
@@ -81,6 +96,7 @@ import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { patchCachedProgress } from "../../lib/progressCache";
import { clearAll, getNotifications } from "../../lib/notifications";
beforeAll(() => {
class Stub {
@@ -99,8 +115,24 @@ afterEach(() => {
vi.restoreAllMocks();
player.watchOnUnmount = false;
player.flush = null;
clearAll();
});
// A cached related list (the player's, kept for the session) holding these videos.
const seedRelated = (qc: QueryClient, ...ids: string[]) =>
qc.setQueryData(qk.related("SRC", "topical"), {
pages: [
{ items: ids.map((id) => ({ id, status: "new" })), has_more: false, next_cursor: null },
],
pageParams: [null],
});
const relatedStatus = (qc: QueryClient, id: string) =>
(
qc.getQueryData(qk.related("SRC", "topical")) as {
pages: { items: { id: string; status: string }[] }[];
}
).pages[0]!.items.find((v) => v.id === id)?.status;
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
@@ -227,6 +259,39 @@ describe("Feed — a video marked watched in the player", () => {
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("refetches when the player's related list wrote", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("related list wrote"));
const before = feed.mock.calls.length;
fireEvent.click(screen.getByText("close"));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("names a video that is none of its own in the notice", async () => {
mount();
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark related"));
await waitFor(() =>
expect(getNotifications().map((n) => n.meta)).toContainEqual({
kind: "video-watched",
videoId: "rel1",
title: "Related one",
channelId: "UCr",
channelName: "Rel channel",
}),
);
});
it("puts the mark into the player's related lists, which are never refetched", async () => {
const { qc } = mount();
seedRelated(qc, "v1", "other");
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
await waitFor(() => expect(relatedStatus(qc, "v1")).toBe("watched"));
expect(relatedStatus(qc, "other")).toBe("new");
});
it("does not refetch on a close with nothing changed", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
//
// An empty feed with a term typed offers to search YouTube for it — unless the instance turned live
// search off (a daily limit of 0), where that search could only be refused.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, 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() }),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters } 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: "cats",
sort: "relevance",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
function mount(searchEnabled: boolean) {
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 0, facets: {} } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, search_enabled: searchEnabled });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
}
describe("Feed — an empty result with a term typed", () => {
it("offers a live YouTube search for it", async () => {
mount(true);
await screen.findByText("feed.noMatches");
expect(screen.getByRole("button", { name: "feed.yt.searchFor" })).toBeTruthy();
});
it("offers none when the instance turned live search off", async () => {
mount(false);
// The empty state is up: what it offers is final.
await screen.findByText("feed.noMatches");
expect(screen.queryByRole("button", { name: "feed.yt.searchFor" })).toBeNull();
});
});
+27 -50
View File
@@ -25,9 +25,11 @@ import { YouTubeIcon } from "../../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors";
import { notify, resolveVideo } from "../../lib/notifications";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import { useDebounced } from "../../lib/useDebounced";
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
import { patchCachedProgress, patchRelated, progressFlushes } from "../../lib/progressCache";
import {
buildSort,
feedSortOptions,
@@ -178,6 +180,8 @@ export default function Feed({
null,
);
const qc = useQueryClient();
// Live search turned off on this instance: the empty feed offers no search that would be refused.
const searchOn = useMe().search_enabled;
// What changed state while the player was open, and the writes still in flight: closing it
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
@@ -339,18 +343,23 @@ export default function Feed({
// The toast's Undo re-enters onState, a forward reference: through this ref (set below).
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
// A write made while the player is open joins its session: the list refetches once the player
// has closed and every such write has landed (the effect above). The player's own related list
// writes through this too — its marks reach videos this list may hold.
const trackSessionWrite = useCallback((write: Promise<unknown>) => {
const session = playerSession.current;
if (!session.open) return;
session.changed = true;
session.pending.add(write);
const settled = () => void session.pending.delete(write);
write.then(settled, settled);
}, []);
const onState = useCallback(
(id: string, status: VideoStatus) => {
(id: string, status: VideoStatus, video?: NoticeVideo) => {
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = api.setState(id, status);
const session = playerSession.current;
if (session.open) {
session.changed = true;
session.pending.add(write);
const settled = () => void session.pending.delete(write);
write.then(settled, settled);
}
trackSessionWrite(write);
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
@@ -364,44 +373,10 @@ export default function Feed({
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.hiddenNamed", { title: v.title })
: i18n.t("feed.hidden"),
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
meta: {
kind: "video-hidden",
videoId: id,
title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
});
} else if (status === "watched") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"),
action: {
label: i18n.t("feed.unwatch"),
onClick: () => onStateRef.current(id, "new"),
},
meta: {
kind: "video-watched",
videoId: id,
title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
});
} else if (status === "new") {
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
// any stale hide/watch notice for this video so it doesn't linger with a dead action.
resolveVideo(id);
}
patchRelated(qc, id, { status });
// The video the player is on may be none of this list's (a related one): it names itself.
const v = video ?? loadedRef.current.find((x) => x.id === id);
announceVideoState(id, status, v, () => onStateRef.current(id, "new"));
})
.catch((e: unknown) => {
// Server rejected the change — drop the optimistic override so the card
@@ -411,7 +386,7 @@ export default function Feed({
setOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
[qc, trackSessionWrite],
);
const onResetState = useCallback(
@@ -647,6 +622,7 @@ export default function Feed({
queue={ytPlayerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
/>
</Suspense>
)}
@@ -928,7 +904,7 @@ export default function Feed({
</Button>
</div>
)}
{filters.q.trim() && (
{filters.q.trim() && searchOn && (
<Button
className="mt-4"
icon={<YouTubeIcon />}
@@ -971,6 +947,7 @@ export default function Feed({
queue={playerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
/>
</Suspense>
)}
@@ -45,7 +45,7 @@ function tipFor(source: Me["search_source"]): string | null {
disconnect() {}
},
);
me = { search_source: source, role: "user" } as Me;
me = { search_source: source, search_enabled: true, role: "user" } as Me;
render(
<PageShell header={<div />}>
<FeedHeader />
@@ -63,3 +63,22 @@ describe("the live-search tooltip", () => {
expect(tipFor("api")).toBe("header.searchYoutubeTipApi");
});
});
describe("the live-search button", () => {
it("is left out when the instance turned live search off", () => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
me = { search_source: "scrape", search_enabled: false, role: "user" } as Me;
render(
<PageShell header={<div />}>
<FeedHeader />
</PageShell>,
);
expect(screen.queryByRole("button", { name: /header\.searchYoutube$/ })).toBeNull();
});
});
+2 -1
View File
@@ -30,7 +30,8 @@ export default function FeedHeader() {
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}}
onGo={() => enterYtSearch(trimmed)}
// Turned off on this instance, the box only filters: no Go that could only be refused.
onGo={me.search_enabled ? () => enterYtSearch(trimmed) : undefined}
goLabel={t("header.searchYoutube")}
goTitle={t(
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
@@ -495,6 +495,31 @@ describe("Home", () => {
expect(screen.queryByText("Hero v1")).toBeNull();
});
it("puts a mark into the player's related lists, which are never refetched", async () => {
const { qc } = mountWithClient();
await screen.findByText("Second thing");
qc.setQueryData(qk.related("SRC", "topical"), {
pages: [
{
items: [
{ id: "v1", status: "new" },
{ id: "v2", status: "new" },
],
has_more: false,
next_cursor: null,
},
],
pageParams: [null],
});
fireEvent.click(screen.getAllByTitle("card.markWatched")[0]!);
await waitFor(() => {
const cached = qc.getQueryData(qk.related("SRC", "topical")) as {
pages: { items: { status: string }[] }[];
};
expect(cached.pages[0]!.items.some((v) => v.status === "watched")).toBe(true);
});
});
it("refreshes the payload the bookmark is read from", async () => {
mount();
await screen.findByText("Second thing");
+6 -1
View File
@@ -14,6 +14,7 @@ import {
Star,
} from "lucide-react";
import { qk } from "../../lib/queryKeys";
import { patchRelated } from "../../lib/progressCache";
import { plexLang } from "../../i18n";
import {
api,
@@ -838,7 +839,11 @@ export default function Home() {
// The feed avoids this because it would refetch ten infinite-query pages per click.
void api
.setState(id, status)
.then(() => qc.invalidateQueries({ queryKey: qk.discover() }))
.then(() => {
// The player's related lists are never refetched: the mark goes into them.
patchRelated(qc, id, { status });
return qc.invalidateQueries({ queryKey: qk.discover() });
})
// Without this the rejection escapes unhandled and the click just does nothing — the
// feed's equivalents both catch, and the reorder on this same page already does.
// A WRITE key, not `home.error`: that string is the page's LOAD failure ("could not load
@@ -363,6 +363,25 @@ describe("Playlists — the player outlives a change of selection", () => {
});
expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true);
});
it("puts the player's mark into its related lists, which are never refetched", async () => {
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
mount({ 1: [vid("V", 0)] });
qc.setQueryData(qk.related("SRC", "topical"), {
pages: [{ items: [{ id: "V", status: "new" }], has_more: false, next_cursor: null }],
pageParams: [null],
});
await play("Video V");
await waitFor(() => expect(h.player).not.toBeNull());
await act(async () => {
h.player!.onState("V", "watched");
await new Promise((r) => setTimeout(r, 0));
});
const cached = qc.getQueryData(qk.related("SRC", "topical")) as {
pages: { items: { status: string }[] }[];
};
expect(cached.pages[0]!.items[0]!.status).toBe("watched");
});
});
const rowsOnScreen = () =>
+11 -2
View File
@@ -3,6 +3,7 @@ import { useLatest, useStableCallback } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { patchRelated } from "../../lib/progressCache";
import {
DndContext,
KeyboardSensor,
@@ -21,7 +22,13 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { Check, FolderInput, History, ListPlus, Play, X } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Playlist, type PlaylistDetail, type Video } from "../../lib/api";
import {
api,
type Playlist,
type PlaylistDetail,
type Video,
type VideoStatus,
} from "../../lib/api";
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
@@ -603,7 +610,7 @@ function PlaylistDetail() {
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
}
function playerState(id: string, status: string) {
function playerState(id: string, status: VideoStatus) {
api
.setState(id, status)
.then(() => {
@@ -612,6 +619,8 @@ function PlaylistDetail() {
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
// The player's related lists are never refetched: the mark goes into them.
patchRelated(qc, id, { status });
})
// No catch used to mean an unhandled rejection and nothing said.
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));