diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index 91e1ec44..09b4e057 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -878,6 +878,15 @@ export default function Feed({
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={openVideo}
+ // A channel page's view is the channel already: no row there to narrow it to one.
+ channelFilter={
+ channelScoped
+ ? undefined
+ : {
+ ids: filters.channelIds,
+ set: (channelIds) => setFilters({ ...filters, channelIds }),
+ }
+ }
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
// re-request it once a round-trip for as long as the end is in view. The row
// below says so and retries on a click.
diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx
index 52c2c63c..7a864913 100644
--- a/frontend/src/components/VirtualFeed.tsx
+++ b/frontend/src/components/VirtualFeed.tsx
@@ -3,6 +3,7 @@ import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import type { NEXT_PAGE } from "../lib/nextPage";
import VideoCard from "./VideoCard";
import { VideoMenu } from "./VideoMenu";
+import type { VideoActionHandlers } from "./useVideoActions";
import { useContextMenu } from "./ui/useContextMenu";
import VirtualGrid from "./ui/VirtualGrid";
@@ -28,6 +29,8 @@ export interface VirtualFeedProps {
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void;
+ /** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */
+ channelFilter?: VideoActionHandlers["channelFilter"];
hasNextPage: boolean;
isFetching: boolean;
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
@@ -43,6 +46,7 @@ export default function VirtualFeed({
onToggleSave,
onResetState,
onOpen,
+ channelFilter,
hasNextPage,
isFetching,
fetchNextPage,
@@ -86,7 +90,10 @@ export default function VirtualFeed({
/>
)}
/>
-
+
>
);
}
diff --git a/frontend/src/components/useVideoActions.dom.test.tsx b/frontend/src/components/useVideoActions.dom.test.tsx
index 1045a934..016038bc 100644
--- a/frontend/src/components/useVideoActions.dom.test.tsx
+++ b/frontend/src/components/useVideoActions.dom.test.tsx
@@ -55,3 +55,52 @@ describe("the video menu's download row", () => {
expect(none().note).toBeUndefined();
});
});
+
+// The feed's channel filter from a card (owner): under "Open the channel", a row that adds the card's
+// channel to the filter, or takes it out again when it is already in.
+describe("the video menu's channel-filter row", () => {
+ const menu = (ids: string[] | null) => {
+ vi.spyOn(api, "downloadIndex").mockResolvedValue({});
+ const set = vi.fn();
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ const { result } = renderHook(
+ () =>
+ useVideoActions(
+ {
+ onState: vi.fn(),
+ onToggleSave: vi.fn(),
+ channelFilter: ids ? { ids, set } : undefined,
+ },
+ true,
+ ),
+ { wrapper },
+ );
+ const items = result.current.items({ ...video, channel_id: "UCa" }, null);
+ return { items, set };
+ };
+
+ it("adds the channel to a filter that names others, right under Open the channel", () => {
+ const { items, set } = menu(["UCz"]);
+ const at = items.findIndex((i) => i.id === "channel");
+ const row = items[at + 1]!;
+ expect(row.id).toBe("channel-filter");
+ expect(row.label).toBe("card.menu.channelFilterAdd");
+ row.onSelect?.();
+ expect(set).toHaveBeenCalledWith(["UCz", "UCa"]);
+ });
+
+ it("takes it out again when it is in", () => {
+ const { items, set } = menu(["UCz", "UCa"]);
+ const row = items.find((i) => i.id === "channel-filter")!;
+ expect(row.label).toBe("card.menu.channelFilterRemove");
+ row.onSelect?.();
+ expect(set).toHaveBeenCalledWith(["UCz"]);
+ });
+
+ it("is absent where the host gives no filter (a channel page, Home, a live search)", () => {
+ expect(menu(null).items.some((i) => i.id === "channel-filter")).toBe(false);
+ });
+});
diff --git a/frontend/src/components/useVideoActions.tsx b/frontend/src/components/useVideoActions.tsx
index a213515a..e5db796e 100644
--- a/frontend/src/components/useVideoActions.tsx
+++ b/frontend/src/components/useVideoActions.tsx
@@ -8,6 +8,8 @@ import {
Download,
Eye,
EyeOff,
+ Filter,
+ FilterX,
Info,
ListPlus,
Play,
@@ -39,6 +41,9 @@ export interface VideoActionHandlers {
onResetState?: (id: string) => void;
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
onOpen?: (v: Video, startAt?: number | null) => void;
+ /** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel
+ * page (the view is that channel already), in a live YouTube search and on Home. */
+ channelFilter?: { ids: string[]; set: (ids: string[]) => void };
}
const icon = "w-4 h-4";
@@ -65,7 +70,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
anchor: Element | null,
{ pin: withPin = true }: { pin?: boolean } = {},
): ActionMenuItem[] => {
- const { onState, onToggleSave, onResetState, onOpen } = handlers;
+ const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers;
const watched = v.status === "watched";
const hidden = v.status === "hidden";
const inProgress = v.position_seconds > 0 && !watched;
@@ -112,6 +117,26 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
icon: ,
onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")),
},
+ // The channel's videos in this feed, or the channel out of the filter again: channels OR
+ // together, so adding one widens a filter that already names others (as "Show in feed" does).
+ ...(channelFilter
+ ? [
+ channelFilter.ids.includes(v.channel_id)
+ ? {
+ id: "channel-filter",
+ label: t("card.menu.channelFilterRemove"),
+ icon: ,
+ onSelect: () =>
+ channelFilter.set(channelFilter.ids.filter((id) => id !== v.channel_id)),
+ }
+ : {
+ id: "channel-filter",
+ label: t("card.menu.channelFilterAdd"),
+ icon: ,
+ onSelect: () => channelFilter.set([...channelFilter.ids, v.channel_id]),
+ },
+ ]
+ : []),
{
id: "watched",
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
diff --git a/frontend/src/i18n/locales/en/card.json b/frontend/src/i18n/locales/en/card.json
index 93b84128..2513b3c9 100644
--- a/frontend/src/i18n/locales/en/card.json
+++ b/frontend/src/i18n/locales/en/card.json
@@ -24,6 +24,8 @@
"unmarkWatched": "Mark unwatched",
"unsave": "Remove from saved",
"more": "More actions",
- "openChannel": "Open the channel"
+ "openChannel": "Open the channel",
+ "channelFilterAdd": "Filter by this channel",
+ "channelFilterRemove": "Remove the channel from the filter"
}
}
diff --git a/frontend/src/i18n/locales/hu/card.json b/frontend/src/i18n/locales/hu/card.json
index 4b819df6..f8bd0835 100644
--- a/frontend/src/i18n/locales/hu/card.json
+++ b/frontend/src/i18n/locales/hu/card.json
@@ -24,6 +24,8 @@
"unmarkWatched": "Megnézés visszavonása",
"unsave": "Eltávolítás a mentettek közül",
"more": "További műveletek",
- "openChannel": "Csatorna megnyitása"
+ "openChannel": "Csatorna megnyitása",
+ "channelFilterAdd": "Szűrés erre a csatornára",
+ "channelFilterRemove": "A csatorna ki a szűrőből"
}
}