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