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
+13
View File
@@ -158,6 +158,19 @@ def blocked_ids(db: Session, user: User) -> set[str]:
)
def followed_among(db: Session, user: User, channel_ids: set[str]) -> set[str]:
"""Which of these channels the user subscribes to."""
if not channel_ids:
return set()
return set(
db.scalars(
select(Subscription.channel_id).where(
owned_by(Subscription.user_id, user), Subscription.channel_id.in_(channel_ids)
)
)
)
def blocked_channels(db: Session, user: User, lang: str) -> list[Channel]:
return list(
db.execute(
+4 -1
View File
@@ -284,8 +284,11 @@ def related_videos(
# (a cursor) is the same list.
if cursor is None:
quota.log_action(user.id, quota.QuotaAction.VIDEOS_RELATED)
rows = reader_queries.feed_rows_by_ids(db, user, collected)
# A related list mixes channels the user follows with ones they never met: each row says which.
followed = queries.followed_among(db, user, {r["channel_id"] for r in rows})
return {
"items": reader_queries.feed_rows_by_ids(db, user, collected),
"items": [{**r, "followed": r["channel_id"] in followed} for r in rows],
"next_cursor": next_cursor,
"has_more": bool(next_cursor),
}
+20 -1
View File
@@ -12,7 +12,7 @@ import pytest
from sqlalchemy import select
from app import quota, sysconfig
from app.catalog.models import BlockedChannel, Channel, Video
from app.catalog.models import BlockedChannel, Channel, Subscription, Video
from app.models import QuotaEvent
from app.reader.models import TakeoutItem, TakeoutKind, VideoState, VideoUnmark
from app.routes import search as search_routes
@@ -296,3 +296,22 @@ def test_reading_further_is_the_same_list_not_another_one(db, make_user, as_user
assert client.get(f"/api/search/related/{SOURCE}").status_code == 200
assert client.get(f"/api/search/related/{SOURCE}?cursor=more").status_code == 200
assert quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_RELATED) == 1
def test_each_row_says_whether_the_user_follows_its_channel(db, make_user, as_user, pages):
user = make_user()
stranger = make_user()
_known(db, 1, channel="UCfollowed1")
_known(db, 2, channel="UCstranger1")
db.add(Subscription(user_id=user.id, channel_id="UCfollowed1"))
db.add(Subscription(user_id=stranger.id, channel_id="UCstranger1"))
db.commit()
pages["pages"] = [
_page([_item(1, "UCfollowed1"), _item(2, "UCstranger1"), _item(3, "UCnobody01")], None)
]
rows = as_user(user).get(f"/api/search/related/{SOURCE}").json()["items"]
assert {r["id"]: r["followed"] for r in rows} == {
_vid(1): True,
_vid(2): False, # another user's follow is not this one's
_vid(3): False,
}
+8
View File
@@ -564,6 +564,13 @@ of a full-width panel and nothing on the right.
open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured
live). The wheel: over the video it sets the volume; over a panel with something to scroll it scrolls
only, never the volume, even at its end; over one with nothing to scroll it sets the volume. [WCAG 1.4.13]
- **Related videos (owner's picks A, A, a, a):** on demand, never open by itself — the modal has no room to
spare. Wide, a "Related" tab beside the description in the side panel; stacked, a button beside the ⓘ
opens them in a drawer over the player. A row is the thumbnailed video row in its narrow form (a 6rem
picture, the relative time only, the "…" its one control — every toggle is a row of that menu), with
a check before the channel the user follows. A click plays it in the same player, as a description
link does (Back returns), and the list stays the opened video's. What the user watched or hid is
never in it. Over it, a two-way switch: on topic or Hungarian trending, kept on the account.
- **Clip mode (both players, owner's picks A + P1 + a):** a scissors in the control row (Plex: the C5 row,
before the ⋯; YouTube: the actions beside Download, and the docked bar) or `C` opens a clip panel IN the
player; `[` / `]` mark the start / end at the playhead and open it when shut. The video being watched is the
@@ -795,3 +802,4 @@ of a full-width panel and nothing on the right.
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
@@ -52,12 +52,15 @@ beforeAll(() => {
// --- the fake IFrame API ------------------------------------------------------------------------
const clock = { current: 0, duration: 3000 };
const started: { videoId: string; start: number | undefined }[] = [];
// What the player was asked to switch to in place (a description link, a related video).
const loaded: { videoId: string; startSeconds: number }[] = [];
// The last player's event handlers, so a test can say "the player is ready" when it wants to.
let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {};
const base = {
getCurrentTime: () => clock.current,
getDuration: () => clock.duration,
getVideoData: () => null,
loadVideoById: (o: { videoId: string; startSeconds: number }) => void loaded.push(o),
};
// Anything else the modal calls on the player (volume, rate, destroy…) is a no-op. Not `then`:
// a thenable would be swallowed by the first `await` that touched it.
@@ -96,6 +99,7 @@ afterEach(() => {
cleanup();
vi.restoreAllMocks();
started.length = 0;
loaded.length = 0;
});
const lastStart = () => started[started.length - 1];
@@ -777,6 +781,66 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(spin(text, 100)).toBe(true);
});
// A row is reached by its title (its picture is a link of the same name).
const titleOf = (links: HTMLElement[]) => links.find((a) => a.hasAttribute("data-nav-open"))!;
const related = (id: string, position_seconds = 0) => ({
...vid(id, position_seconds),
followed: false,
});
it("wide: the related videos are a tab beside the description, and one plays in place", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const asked = vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2", 120)],
has_more: false,
next_cursor: null,
});
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => playerEvents.onReady?.());
// Closed until asked: nothing is fetched for a player nobody opened the list in.
expect(screen.getByTestId("player-description")).toBeTruthy();
expect(asked).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" })));
// In the same player, from where the user left it.
expect(loaded).toEqual([{ videoId: "rel2", startSeconds: 120 }]);
// The list stays the opened video's, to try the next one; it is not asked for again.
expect(screen.getAllByRole("link", { name: "Video rel1" }).length).toBeGreaterThan(0);
expect(asked.mock.calls).toEqual([["A", "topical", null]]);
expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy();
});
it("stacked: a button opens the related videos in a drawer, and playing one closes it", async () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1")],
has_more: false,
next_cursor: null,
});
const A = vid("A", 0);
const onClose = vi.fn();
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
act(() => playerEvents.onReady?.());
expect(screen.queryByRole("tab", { name: "player.related.title" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "player.related.title" }));
const drawer = await screen.findByRole("dialog", { name: "player.related.title" });
fireEvent.click(titleOf(await within(drawer).findAllByRole("link", { name: "Video rel1" })));
expect(loaded).toEqual([{ videoId: "rel1", startSeconds: 0 }]);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(),
);
// Only the drawer: the click on its row must not reach the player's backdrop (measured live —
// rendered outside the card, the whole player closed).
expect(onClose).not.toHaveBeenCalled();
});
it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
+67 -9
View File
@@ -23,6 +23,7 @@ import {
CheckCheck,
ExternalLink,
Info,
ListVideo,
Maximize,
Minimize,
Minimize2,
@@ -83,6 +84,10 @@ import { SURFACE } from "../../components/ui/surfaces";
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
import { SectionLabel } from "../../components/ui/Headings";
import { useMediaQuery } from "../../lib/useMediaQuery";
import Modal from "../../components/ui/Modal";
import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
import { RelatedVideos } from "./RelatedVideos";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -669,6 +674,17 @@ export default function PlayerModal({
// a tablet could not read a description at all. Anchored ABOVE, so it grows upward over the player
// rather than off-screen; `flip` still rescues it when there is no room up there.
const [showDesc, setShowDesc] = useState(false);
// The related videos, on demand (the owner's pick: the modal has little room to spare). Wide, they
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
// list in place, to try the next one (the owner's pick).
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
const [relatedOpen, setRelatedOpen] = useState(false);
const panelTabs = useTabPanel();
const playRelated = (v: Video, startAt: number) => {
setRelatedOpen(false);
loadVideo(v.id, startAt);
};
const descPop = useAnchoredPopover({
open: showDesc,
onOpenChange: setShowDesc,
@@ -1245,6 +1261,29 @@ export default function PlayerModal({
<Info />
</IconButton>
)}
{!wide && (
<IconButton
variant="ghost"
onClick={() => setRelatedOpen(true)}
aria-expanded={relatedOpen}
label={t("player.related.title")}
>
<ListVideo />
</IconButton>
)}
{/* Inside the card, whose click stops at its edge: a portal's events still bubble
along the React tree, and from the backdrop a click on a row closed the player. */}
{relatedOpen && !wide && (
<Modal
title={t("player.related.title")}
onClose={() => setRelatedOpen(false)}
placement="drawer"
maxWidth="max-w-sm"
initialFocus="close"
>
<RelatedVideos videoId={active.id} onPlay={playRelated} />
</Modal>
)}
{navigated && (
<Button
variant="ghost"
@@ -1455,16 +1494,35 @@ export default function PlayerModal({
)}
</div>
{wide && (
<section className="mt-5" data-testid="player-description">
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
<PlayerDescription
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
clamp
{...descriptionProps}
<div className="mt-5">
<Tabs
tabs={[
{ id: "description", label: t("player.description") },
{ id: "related", label: t("player.related.title") },
]}
active={panelTab}
onChange={(id) => setPanelTab(id === "related" ? "related" : "description")}
idBase={panelTabs.idBase}
label={t("player.panelTabs")}
className="mb-3"
/>
</section>
<div {...panelTabs.panelProps(panelTab)}>
{panelTab === "description" ? (
<section data-testid="player-description">
<PlayerDescription
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
clamp
{...descriptionProps}
/>
</section>
) : (
// Out into the panel's padding: in 20rem, every rem is the title's.
<div className="-mx-3">
<RelatedVideos videoId={active.id} onPlay={playRelated} />
</div>
)}
</div>
</div>
)}
</div>
</div>
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
//
// A video's related videos, as the player shows them. What is held: a row says when the user follows
// its channel; the region the user picks is asked for and kept on the account; a video marked seen
// here leaves the list (and comes back when the server refuses the mark); reading further sends the
// cursor; and a failure says so rather than drawing an empty list.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
import { RelatedVideos } from "./RelatedVideos";
import { api, HttpError, type RelatedVideo, type Video } from "../../lib/api";
import { clearAll } from "../../lib/notifications";
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();
clearAll();
});
const row = (id: string, extra: Partial<RelatedVideo> = {}): RelatedVideo =>
({
id,
title: `Video ${id}`,
channel_id: `UC${id}`,
channel_title: `Channel ${id}`,
watch_url: `https://www.youtube.com/watch?v=${id}`,
status: "new",
saved: false,
position_seconds: 0,
followed: false,
...extra,
}) as RelatedVideo;
const page = (items: RelatedVideo[], next: string | null = null) => ({
items,
has_more: next != null,
next_cursor: next,
});
function setup(onPlay: (v: Video, startAt: number) => void = () => {}) {
vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] } as never);
vi.spyOn(api, "downloadIndex").mockResolvedValue({});
const saved = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<RelatedVideos videoId="SRC" onPlay={onPlay} />
</FloatingTree>
</QueryClientProvider>,
);
return { saved };
}
// A row's title (its picture is a link of the same name).
const title = async (name: string) =>
(await screen.findAllByRole("link", { name })).find((a) => a.hasAttribute("data-nav-open"))!;
const titles = () =>
screen
.queryAllByRole("link")
.filter((a) => a.hasAttribute("data-nav-open"))
.map((a) => a.textContent);
// The menu's surface is visibility:hidden in jsdom: an item is matched on its text.
const menuItem = (label: string) =>
screen.queryAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!;
describe("RelatedVideos", () => {
it("marks the rows whose channel the user follows, and only those", async () => {
vi.spyOn(api, "related").mockResolvedValue(
page([row("A", { followed: true }), row("B"), row("C", { followed: true })]),
);
setup();
await title("Video A");
const marks = screen.getAllByRole("img", { name: "player.related.followed" });
const owners = marks.map((m) => m.closest("[data-video-id]")?.getAttribute("data-video-id"));
expect(owners).toEqual(["A", "C"]);
});
it("asks for the region picked, and keeps the pick on the account", async () => {
const asked = vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
const { saved } = setup();
await title("Video A");
expect(asked.mock.calls).toEqual([["SRC", "topical", null]]);
fireEvent.click(screen.getByRole("button", { name: "player.related.hu" }));
await waitFor(() => expect(asked.mock.calls).toContainEqual(["SRC", "hu", null]));
expect(saved).toHaveBeenCalledWith({ relatedRegion: "hu" });
});
it("plays a row from where the user left it", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })]));
const onPlay = vi.fn();
setup(onPlay);
fireEvent.click(await title("Video A"));
expect(onPlay).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }), 95);
});
it("drops a video hidden here at once, and brings it back when the server refuses", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
let refuse: (e: Error) => void = () => {};
const write = vi
.spyOn(api, "setState")
.mockReturnValue(new Promise((_, reject) => (refuse = reject)) as never);
setup();
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
fireEvent.click(menuItem("card.hide"));
await waitFor(() => expect(write).toHaveBeenCalledWith("A", "hidden"));
expect(titles()).toEqual(["Video B"]);
await act(async () => refuse(new Error("offline")));
expect(titles()).toEqual(["Video A", "Video B"]);
});
it("keeps a video marked watched out of the list once the server has it", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
setup();
fireEvent.contextMenu((await title("Video B")).closest("[data-video-id]")!);
fireEvent.click(menuItem("card.markWatched"));
await waitFor(() => expect(titles()).toEqual(["Video A"]));
});
it("reads further with the cursor the last page gave", async () => {
const asked = vi
.spyOn(api, "related")
.mockResolvedValueOnce(page([row("A")], "CUR1"))
.mockResolvedValueOnce(page([row("B")]));
setup();
await title("Video A");
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
await title("Video B");
expect(asked.mock.calls[1]).toEqual(["SRC", "topical", "CUR1"]);
expect(screen.queryByRole("button", { name: "player.related.more" })).toBeNull();
});
it("says so when YouTube refuses, rather than drawing an empty list", async () => {
vi.spyOn(api, "related").mockRejectedValue(
new HttpError(422, "YouTube isn't reachable", undefined),
);
setup();
expect((await screen.findByRole("alert")).textContent).toContain("YouTube isn't reachable");
expect(screen.queryByText("player.related.empty")).toBeNull();
});
it("says when nothing is left that the user has not seen", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([]));
setup();
expect(await screen.findByText("player.related.empty")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
});
@@ -0,0 +1,230 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import { CircleCheck } from "lucide-react";
import {
api,
HttpError,
type RelatedRegion,
type RelatedResponse,
type Video,
type VideoStatus,
} from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { nextPageOnClick } from "../../lib/nextPage";
import { usePref, type UsePrefOptions } from "../../lib/usePref";
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { Button } from "../../components/ui/Button";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useContextMenu } from "../../components/ui/useContextMenu";
import NavGrid from "../../components/ui/NavGrid";
import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu";
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
* decided at the list, and the account keeps the last one (the owner's pick). */
export const RELATED_REGION_PREF = "relatedRegion";
const RELATED_REGION_OPTIONS: UsePrefOptions<RelatedRegion> = {
parse: (raw) => (raw === "hu" ? "hu" : "topical"),
};
// The list leaves out what the user has seen (the server does, as it fetches); a video marked here
// leaves it the same way, at once.
const isSeen = (v: Video) => v.status === "watched" || v.status === "hidden";
type Cached = InfiniteData<RelatedResponse, string | null>;
/** Patch one video in every related list held — both regions', and another opened video's. */
function patchRelated(
qc: ReturnType<typeof useQueryClient>,
id: string,
change: Partial<Video>,
): void {
qc.setQueriesData<Cached>({ queryKey: qk.related() }, (old) =>
old
? {
...old,
pages: old.pages.map((p) => ({
...p,
items: p.items.map((v) => (v.id === id ? { ...v, ...change } : v)),
})),
}
: old,
);
}
/** The videos YouTube shows beside `videoId`, without the ones the user has watched or hidden. A
* click plays one in this player (`onPlay`); the list stays the opened video's while it does. Its
* rows carry the feed's whole menu. */
export function RelatedVideos({
videoId,
onPlay,
}: {
videoId: string;
onPlay: (v: Video, startAt: number) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [region, setRegion] = usePref(RELATED_REGION_PREF, "topical", RELATED_REGION_OPTIONS);
// Kept for the session: a refetch is another scrape and another slot of the day's cap.
const query = useInfiniteQuery({
queryKey: qk.related(videoId, region),
queryFn: ({ pageParam }) => api.related(videoId, region, pageParam),
initialPageParam: null as string | null,
getNextPageParam: (last) => (last.has_more ? last.next_cursor : undefined),
staleTime: Infinity,
retry: false,
});
// A mark not yet confirmed: the row leaves (or its bookmark flips) at once, and comes back if the
// server refuses it.
const [pending, setPending] = useState<Record<string, Partial<Video>>>({});
const settle = (id: string) =>
setPending((p) => Object.fromEntries(Object.entries(p).filter(([k]) => k !== id)));
const onState = (id: string, status: VideoStatus) => {
const v = rows.find((x) => x.id === id);
setPending((p) => ({ ...p, [id]: { ...p[id], status } }));
api
.setState(id, status)
.then(() => {
patchRelated(qc, id, { status });
settle(id);
// Stale-mark the feed, as its own marks do: the video may be in it too.
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
if (status === "hidden" || status === "watched") {
const named = status === "hidden" ? "feed.hiddenNamed" : "feed.markedWatchedNamed";
notify({
message: v?.title
? t(named, { title: v.title })
: t(status === "hidden" ? "feed.hidden" : "feed.markedWatched"),
action: {
label: t(status === "hidden" ? "feed.undo" : "feed.unwatch"),
onClick: () => onState(id, "new"),
},
});
}
})
.catch((e: unknown) => {
notifyActionError(e, t("errors.writeFailed.videoState"));
settle(id);
});
};
const onToggleSave = (id: string, saved: boolean) => {
setPending((p) => ({ ...p, [id]: { ...p[id], saved } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => {
patchRelated(qc, id, { saved });
settle(id);
void qc.invalidateQueries({ queryKey: qk.playlists() });
void qc.invalidateQueries({ queryKey: qk.playlist() });
})
.catch((e: unknown) => {
notifyActionError(
e,
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
);
settle(id);
});
};
const open = (v: Video, startAt?: number | null) => onPlay(v, startAt ?? v.position_seconds);
const menu = useContextMenu<Video>();
const openId = menu.open ? menu.target?.id : undefined;
const rows = (query.data?.pages ?? [])
.flatMap((p) => p.items)
.map((v) => (pending[v.id] ? { ...v, ...pending[v.id] } : v))
.filter((v) => !isSeen(v));
const followed = new Set(
(query.data?.pages ?? []).flatMap((p) => p.items.filter((v) => v.followed).map((v) => v.id)),
);
const error =
query.error instanceof HttpError
? query.error.detail || t("player.related.error")
: query.isError
? t("player.related.error")
: null;
return (
<section data-testid="player-related" aria-label={t("player.related.title")}>
<SegmentedToggle
options={[
{ value: "topical", label: t("player.related.topical") },
{ value: "hu", label: t("player.related.hu") },
]}
value={region}
onChange={setRegion}
ariaLabel={t("player.related.region")}
size="sm"
fullWidth
className="mb-3"
/>
{query.isPending ? (
<LoadingState shape="list" />
) : error && !rows.length ? (
<StateMessage tone="error" compact onRetry={() => void query.refetch()}>
{error}
</StateMessage>
) : !rows.length && !query.hasNextPage ? (
<StateMessage compact>{t("player.related.empty")}</StateMessage>
) : (
<>
<NavGrid className="flex flex-col gap-1.5">
{rows.map((v, i) => (
<VideoCard
key={v.id}
navIndex={i}
video={v}
view="rows"
narrow
width={0}
onState={onState}
onToggleSave={onToggleSave}
onOpen={open}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
menuOpen={openId === v.id}
channelMark={
followed.has(v.id) && (
<CircleCheck
className="w-3.5 h-3.5 shrink-0 text-success"
aria-label={t("player.related.followed")}
role="img"
/>
)
}
/>
))}
</NavGrid>
{query.hasNextPage && (
<div className="mt-3 flex justify-center">
<Button
variant="outline"
size="sm"
disabled={query.isFetchingNextPage}
onClick={() => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))}
>
{query.isFetchingNextPage ? t("player.related.loading") : t("player.related.more")}
</Button>
</div>
)}
{error && rows.length > 0 && (
<StateMessage
tone="error"
compact
onRetry={() => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))}
>
{error}
</StateMessage>
)}
</>
)}
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onOpen: open }} />
</section>
);
}
+41 -2
View File
@@ -374,6 +374,8 @@ function VideoCard({
menuOpen = false,
navIndex,
shelf = false,
narrow = false,
channelMark,
}: {
video: Video;
view: FeedView;
@@ -397,6 +399,12 @@ function VideoCard({
navIndex?: number;
/** On a shelf (a horizontal strip): a one-line title over the channel alone (ui/mediaCardStyles). */
shelf?: boolean;
/** The thumbnailed row in a side panel's ~20rem column: a smaller picture, and the "…" alone at the
* row's end — the hover toggles leave no room for the title there, and every one of them is a row
* of the menu. */
narrow?: boolean;
/** Something the list says about the channel (a "followed" mark), set before its name. */
channelMark?: ReactNode;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -610,15 +618,46 @@ function VideoCard({
const textBlock = (
<>
{title}
<div className="mt-0.5">{channelLink}</div>
<div className="mt-0.5">
{channelMark ? (
<div className="flex items-center gap-1 min-w-0">
{channelMark}
<div className="min-w-0">{channelLink}</div>
</div>
) : (
channelLink
)}
</div>
<div className="text-xs text-muted mt-0.5">
{views}
{views && " · "}
{published}
{/* The narrow row keeps the relative time and moves the date into its tooltip, as the small
card does: in a 20rem panel the full pair wrapped to a line of its own. */}
{narrow ? <RelativeTime at={video.published_at} /> : published}
</div>
</>
);
if (spec.family === "row" && narrow) {
return (
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex gap-2 p-1.5", watched && "opacity-55")}
>
<Thumb video={video} className="aspect-video shrink-0 w-24" onOpen={onOpen} small />
<div className="min-w-0 flex-1 flex flex-col">{textBlock}</div>
{more && (
<div className="shrink-0 self-start">
<MoreButton label={t("card.menu.more")} expanded={more.open} menuProps={more.props} />
</div>
)}
</div>
);
}
if (spec.family === "row") {
return (
<div
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { type Video } from "../../lib/api";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { type ContextMenuController } from "../../components/ui/useContextMenu";
import { useVideoActions, type VideoActionHandlers } from "../../features/video/useVideoActions";
import { useVideoActions, type VideoActionHandlers } from "./useVideoActions";
/** A video list's one menu (the feed grid and a channel page): every trigger — right-click,
* long-press, Shift+F10 and each card's "…" — opens it with the video's whole action list. Render
+12
View File
@@ -26,6 +26,18 @@
"descriptionFailed": "Couldn't load the description",
"descriptionMore": "More",
"descriptionLess": "Less",
"panelTabs": "Video details",
"related": {
"title": "Related",
"region": "Which related videos",
"topical": "On topic",
"hu": "Hungarian trending",
"followed": "You follow this channel",
"empty": "No related videos you haven't seen yet.",
"error": "Couldn't load the related videos",
"more": "Show more",
"loading": "Loading…"
},
"collapsePanel": "Hide the details",
"expandPanel": "Show the details",
"channel": "Channel",
+12
View File
@@ -26,6 +26,18 @@
"descriptionFailed": "Nem sikerült betölteni a leírást",
"descriptionMore": "Több",
"descriptionLess": "Kevesebb",
"panelTabs": "A videó részletei",
"related": {
"title": "Kapcsolódók",
"region": "Milyen kapcsolódó videók",
"topical": "Témába vágó",
"hu": "Magyar trend",
"followed": "Követed ezt a csatornát",
"empty": "Nincs olyan kapcsolódó videó, amit még nem láttál.",
"error": "Nem sikerült betölteni a kapcsolódó videókat",
"more": "Mutass többet",
"loading": "Betöltés…"
},
"collapsePanel": "Részletek elrejtése",
"expandPanel": "Részletek megjelenítése",
"channel": "Csatorna",
+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,
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Video, VideoStatus } from "../../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
import type { NEXT_PAGE } from "../../lib/nextPage";
import VideoCard from "../../features/video/VideoCard";
import { VideoMenu } from "./VideoMenu";
import { VideoMenu } from "../../features/video/VideoMenu";
import type { VideoActionHandlers } from "../../features/video/useVideoActions";
import { useContextMenu } from "../../components/ui/useContextMenu";
import VirtualGrid from "../../components/ui/VirtualGrid";