diff --git a/backend/app/catalog/queries.py b/backend/app/catalog/queries.py index 6d98ba61..63b2caee 100644 --- a/backend/app/catalog/queries.py +++ b/backend/app/catalog/queries.py @@ -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( diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index 23c2f5bd..c5386899 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -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), } diff --git a/backend/tests/test_related_route.py b/backend/tests/test_related_route.py index b5a45398..705ffffa 100644 --- a/backend/tests/test_related_route.py +++ b/backend/tests/test_related_route.py @@ -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, + } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 05543621..10420072 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 | diff --git a/frontend/src/features/player/PlayerModal.dom.test.tsx b/frontend/src/features/player/PlayerModal.dom.test.tsx index 701cecc0..3fee4b11 100644 --- a/frontend/src/features/player/PlayerModal.dom.test.tsx +++ b/frontend/src/features/player/PlayerModal.dom.test.tsx @@ -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( + {}} 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( + {}} />, + ); + 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(() => {})); diff --git a/frontend/src/features/player/PlayerModal.tsx b/frontend/src/features/player/PlayerModal.tsx index b0afdd7c..3eeb531d 100644 --- a/frontend/src/features/player/PlayerModal.tsx +++ b/frontend/src/features/player/PlayerModal.tsx @@ -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({ )} + {!wide && ( + setRelatedOpen(true)} + aria-expanded={relatedOpen} + label={t("player.related.title")} + > + + + )} + {/* 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 && ( + setRelatedOpen(false)} + placement="drawer" + maxWidth="max-w-sm" + initialFocus="close" + > + + + )} {navigated && ( + + )} + {error && rows.length > 0 && ( + void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))} + > + {error} + + )} + + )} + + + ); +} diff --git a/frontend/src/features/video/VideoCard.tsx b/frontend/src/features/video/VideoCard.tsx index 5d235783..6a892e30 100644 --- a/frontend/src/features/video/VideoCard.tsx +++ b/frontend/src/features/video/VideoCard.tsx @@ -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} -
{channelLink}
+
+ {channelMark ? ( +
+ {channelMark} +
{channelLink}
+
+ ) : ( + channelLink + )} +
{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 ? : published}
); + if (spec.family === "row" && narrow) { + return ( +
+ +
{textBlock}
+ {more && ( +
+ +
+ )} +
+ ); + } + if (spec.family === "row") { return (
): 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 => { + 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 }> => diff --git a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx index dbc10ceb..d4043ad0 100644 --- a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx +++ b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx @@ -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()); diff --git a/frontend/src/lib/progressCache.ts b/frontend/src/lib/progressCache.ts index abbfe323..673bbf99 100644 --- a/frontend/src/lib/progressCache.ts +++ b/frontend/src/lib/progressCache.ts @@ -74,11 +74,13 @@ export function patchCachedProgress(qc: QueryClient, positions: Map({ queryKey: qk.feed() }, patch); qc.setQueriesData({ queryKey: qk.playlist() }, patch); qc.setQueriesData({ queryKey: qk.ytSearch() }, patch); + qc.setQueriesData({ queryKey: qk.related() }, patch); } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 7a138cf6..47dae62f 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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, diff --git a/frontend/src/modules/feed/VirtualFeed.tsx b/frontend/src/modules/feed/VirtualFeed.tsx index 1c5fe0c3..42a4ae38 100644 --- a/frontend/src/modules/feed/VirtualFeed.tsx +++ b/frontend/src/modules/feed/VirtualFeed.tsx @@ -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";