feat(player): a video's related videos, on demand beside it

The player shows what YouTube lists beside the video, without what the
user has watched or hidden. Wide, a Related tab beside the description;
stacked, a button beside the info opens them in a drawer. A row plays in
the same player (Back returns) and the list stays the opened video's.
A switch picks on-topic or Hungarian-trending lists, kept on the account.

Each related row says whether the user follows its channel; the row is
the video card's narrow form with its whole menu. VideoMenu moves to
features/video, where the player can reach it.
This commit is contained in:
2026-10-24 16:57:52 +02:00
parent 8b37c470ba
commit 686c673fb2
18 changed files with 697 additions and 19 deletions
+30
View File
@@ -35,6 +35,21 @@ export function isResettable(v: Pick<Video, "status" | "position_seconds">): boo
return v.status !== "new" || v.position_seconds > 0;
}
/** A video's related videos, in the locale YouTube fills the list in: on the video's topic, or with
* what trends in Hungary (the server's `RELATED_REGIONS`). */
export type RelatedRegion = "topical" | "hu";
/** A row of a related list: a feed row that also says whether the user follows its channel. */
export interface RelatedVideo extends Video {
followed: boolean;
}
export interface RelatedResponse {
items: RelatedVideo[];
has_more: boolean;
next_cursor: string | null;
}
export interface VideoDetail {
id: string;
description: string | null;
@@ -235,6 +250,21 @@ export const feedApi = {
if (limit) p.set("limit", String(limit));
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
},
// The videos YouTube shows beside this one, without what the user has watched or hidden. `quiet`
// for the same reason as the search: the list renders its refusal (the day's cap, YouTube) inline.
related: (
videoId: string,
region: RelatedRegion,
cursor: string | null,
): Promise<RelatedResponse> => {
const p = new URLSearchParams({ region });
if (cursor) p.set("cursor", cursor);
return req(
`/api/search/related/${encodeURIComponent(videoId)}?${p.toString()}`,
{},
{ quiet: true },
);
},
// Discard a set of live-search results "as if never added" (drops your search-finds + deletes
// the now-orphaned, un-kept videos). Returns how many videos were removed.
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
@@ -186,6 +186,17 @@ describe("patchCachedProgress", () => {
expect(after.pages[0]?.items[0]?.position_seconds).toBe(830);
});
it("patches a related list too — a row played from it resumes where it was left", () => {
const qc = new QueryClient();
const key = qk.related("SRC", "topical");
qc.setQueryData(key, { pages: [{ items: [video()], next_cursor: null }], pageParams: [null] });
patchCachedProgress(qc, new Map([[VID, 830]]));
const after = qc.getQueryData(key) as { pages: { items: Video[] }[] };
expect(after.pages[0]?.items[0]?.position_seconds).toBe(830);
});
it("is a no-op when nothing was stored", () => {
const qc = new QueryClient();
seedFeed(qc, video());
+6 -4
View File
@@ -74,11 +74,13 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
return old;
};
// Prefix keys: every filter combination of the feed, every playlist detail, and every live YouTube
// search — its rows come from the same projection, carry the same per-user position, and open the
// same player, so leaving it out left the rewind in place on that surface. (Patched, never
// invalidated: a refetch of an API-sourced search page spends 100 quota units.)
// Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube
// search and every related list — their rows come from the same projection, carry the same
// per-user position, and open the same player, so leaving one out left the rewind in place on that
// surface. (Patched, never invalidated: a refetch of an API-sourced search page spends 100 quota
// units, a related list's a scrape and a slot of the day's cap.)
qc.setQueriesData<Cached>({ queryKey: qk.feed() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.related() }, patch);
}
+2
View File
@@ -32,6 +32,8 @@ export const qk = {
ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const,
related: (id?: string, region?: string) =>
id !== undefined ? (["related", id, region] as const) : (["related"] as const),
// --- channels ---
channels: () => ["channels"] as const,