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:
@@ -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]:
|
def blocked_channels(db: Session, user: User, lang: str) -> list[Channel]:
|
||||||
return list(
|
return list(
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|||||||
@@ -284,8 +284,11 @@ def related_videos(
|
|||||||
# (a cursor) is the same list.
|
# (a cursor) is the same list.
|
||||||
if cursor is None:
|
if cursor is None:
|
||||||
quota.log_action(user.id, quota.QuotaAction.VIDEOS_RELATED)
|
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 {
|
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,
|
"next_cursor": next_cursor,
|
||||||
"has_more": bool(next_cursor),
|
"has_more": bool(next_cursor),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import pytest
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app import quota, sysconfig
|
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.models import QuotaEvent
|
||||||
from app.reader.models import TakeoutItem, TakeoutKind, VideoState, VideoUnmark
|
from app.reader.models import TakeoutItem, TakeoutKind, VideoState, VideoUnmark
|
||||||
from app.routes import search as search_routes
|
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}").status_code == 200
|
||||||
assert client.get(f"/api/search/related/{SOURCE}?cursor=more").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
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
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]
|
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,
|
- **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
|
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
|
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-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 | 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-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 ------------------------------------------------------------------------
|
// --- the fake IFrame API ------------------------------------------------------------------------
|
||||||
const clock = { current: 0, duration: 3000 };
|
const clock = { current: 0, duration: 3000 };
|
||||||
const started: { videoId: string; start: number | undefined }[] = [];
|
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.
|
// 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 } = {};
|
let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {};
|
||||||
const base = {
|
const base = {
|
||||||
getCurrentTime: () => clock.current,
|
getCurrentTime: () => clock.current,
|
||||||
getDuration: () => clock.duration,
|
getDuration: () => clock.duration,
|
||||||
getVideoData: () => null,
|
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`:
|
// 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.
|
// a thenable would be swallowed by the first `await` that touched it.
|
||||||
@@ -96,6 +99,7 @@ afterEach(() => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
started.length = 0;
|
started.length = 0;
|
||||||
|
loaded.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastStart = () => started[started.length - 1];
|
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);
|
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", () => {
|
it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => {
|
||||||
wideWindow(false);
|
wideWindow(false);
|
||||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
CheckCheck,
|
CheckCheck,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
|
ListVideo,
|
||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
@@ -83,6 +84,10 @@ import { SURFACE } from "../../components/ui/surfaces";
|
|||||||
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
|
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
|
||||||
import { SectionLabel } from "../../components/ui/Headings";
|
import { SectionLabel } from "../../components/ui/Headings";
|
||||||
import { useMediaQuery } from "../../lib/useMediaQuery";
|
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
|
// 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
|
// 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
|
// 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.
|
// rather than off-screen; `flip` still rescues it when there is no room up there.
|
||||||
const [showDesc, setShowDesc] = useState(false);
|
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({
|
const descPop = useAnchoredPopover({
|
||||||
open: showDesc,
|
open: showDesc,
|
||||||
onOpenChange: setShowDesc,
|
onOpenChange: setShowDesc,
|
||||||
@@ -1245,6 +1261,29 @@ export default function PlayerModal({
|
|||||||
<Info />
|
<Info />
|
||||||
</IconButton>
|
</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 && (
|
{navigated && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1455,16 +1494,35 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{wide && (
|
{wide && (
|
||||||
<section className="mt-5" data-testid="player-description">
|
<div className="mt-5">
|
||||||
<SectionLabel level={3} className="mb-2">
|
<Tabs
|
||||||
{t("player.description")}
|
tabs={[
|
||||||
</SectionLabel>
|
{ 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"
|
||||||
|
/>
|
||||||
|
<div {...panelTabs.panelProps(panelTab)}>
|
||||||
|
{panelTab === "description" ? (
|
||||||
|
<section data-testid="player-description">
|
||||||
<PlayerDescription
|
<PlayerDescription
|
||||||
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
|
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
|
||||||
clamp
|
clamp
|
||||||
{...descriptionProps}
|
{...descriptionProps}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -374,6 +374,8 @@ function VideoCard({
|
|||||||
menuOpen = false,
|
menuOpen = false,
|
||||||
navIndex,
|
navIndex,
|
||||||
shelf = false,
|
shelf = false,
|
||||||
|
narrow = false,
|
||||||
|
channelMark,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
view: FeedView;
|
view: FeedView;
|
||||||
@@ -397,6 +399,12 @@ function VideoCard({
|
|||||||
navIndex?: number;
|
navIndex?: number;
|
||||||
/** On a shelf (a horizontal strip): a one-line title over the channel alone (ui/mediaCardStyles). */
|
/** On a shelf (a horizontal strip): a one-line title over the channel alone (ui/mediaCardStyles). */
|
||||||
shelf?: boolean;
|
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 { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
@@ -610,15 +618,46 @@ function VideoCard({
|
|||||||
const textBlock = (
|
const textBlock = (
|
||||||
<>
|
<>
|
||||||
{title}
|
{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">
|
<div className="text-xs text-muted mt-0.5">
|
||||||
{views}
|
{views}
|
||||||
{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>
|
</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") {
|
if (spec.family === "row") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { type Video } from "../../lib/api";
|
import { type Video } from "../../lib/api";
|
||||||
import { ContextMenu } from "../../components/ui/ContextMenu";
|
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||||
import { type ContextMenuController } from "../../components/ui/useContextMenu";
|
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,
|
/** 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
|
* long-press, Shift+F10 and each card's "…" — opens it with the video's whole action list. Render
|
||||||
@@ -26,6 +26,18 @@
|
|||||||
"descriptionFailed": "Couldn't load the description",
|
"descriptionFailed": "Couldn't load the description",
|
||||||
"descriptionMore": "More",
|
"descriptionMore": "More",
|
||||||
"descriptionLess": "Less",
|
"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",
|
"collapsePanel": "Hide the details",
|
||||||
"expandPanel": "Show the details",
|
"expandPanel": "Show the details",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
|
|||||||
@@ -26,6 +26,18 @@
|
|||||||
"descriptionFailed": "Nem sikerült betölteni a leírást",
|
"descriptionFailed": "Nem sikerült betölteni a leírást",
|
||||||
"descriptionMore": "Több",
|
"descriptionMore": "Több",
|
||||||
"descriptionLess": "Kevesebb",
|
"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",
|
"collapsePanel": "Részletek elrejtése",
|
||||||
"expandPanel": "Részletek megjelenítése",
|
"expandPanel": "Részletek megjelenítése",
|
||||||
"channel": "Csatorna",
|
"channel": "Csatorna",
|
||||||
|
|||||||
@@ -35,6 +35,21 @@ export function isResettable(v: Pick<Video, "status" | "position_seconds">): boo
|
|||||||
return v.status !== "new" || v.position_seconds > 0;
|
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 {
|
export interface VideoDetail {
|
||||||
id: string;
|
id: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -235,6 +250,21 @@ export const feedApi = {
|
|||||||
if (limit) p.set("limit", String(limit));
|
if (limit) p.set("limit", String(limit));
|
||||||
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
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
|
// 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.
|
// the now-orphaned, un-kept videos). Returns how many videos were removed.
|
||||||
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ describe("patchCachedProgress", () => {
|
|||||||
expect(after.pages[0]?.items[0]?.position_seconds).toBe(830);
|
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", () => {
|
it("is a no-op when nothing was stored", () => {
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
seedFeed(qc, video());
|
seedFeed(qc, video());
|
||||||
|
|||||||
@@ -74,11 +74,13 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
|
|||||||
return old;
|
return old;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefix keys: every filter combination of the feed, every playlist detail, and every live YouTube
|
// Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube
|
||||||
// search — its rows come from the same projection, carry the same per-user position, and open the
|
// search and every related list — their rows come from the same projection, carry the same
|
||||||
// same player, so leaving it out left the rewind in place on that surface. (Patched, never
|
// per-user position, and open the same player, so leaving one out left the rewind in place on that
|
||||||
// invalidated: a refetch of an API-sourced search page spends 100 quota units.)
|
// 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.feed() }, patch);
|
||||||
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
|
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
|
||||||
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, 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) =>
|
ytSearch: (q?: string | null, count?: number) =>
|
||||||
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
||||||
videoDetail: (id: string) => ["video-detail", id] 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: () => ["channels"] as const,
|
channels: () => ["channels"] as const,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Video, VideoStatus } from "../../lib/api";
|
|||||||
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
|
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
|
||||||
import type { NEXT_PAGE } from "../../lib/nextPage";
|
import type { NEXT_PAGE } from "../../lib/nextPage";
|
||||||
import VideoCard from "../../features/video/VideoCard";
|
import VideoCard from "../../features/video/VideoCard";
|
||||||
import { VideoMenu } from "./VideoMenu";
|
import { VideoMenu } from "../../features/video/VideoMenu";
|
||||||
import type { VideoActionHandlers } from "../../features/video/useVideoActions";
|
import type { VideoActionHandlers } from "../../features/video/useVideoActions";
|
||||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||||
import VirtualGrid from "../../components/ui/VirtualGrid";
|
import VirtualGrid from "../../components/ui/VirtualGrid";
|
||||||
|
|||||||
Reference in New Issue
Block a user