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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A video's ONE action list. The right-click and the card's "…" open the same menu, which
|
||||
// holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the
|
||||
// everyday toggles, and each row fires the same handler the card's own buttons do.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { Video } from "../../lib/api";
|
||||
|
||||
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("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
comfortPins: () => Promise.resolve({ items: [] }),
|
||||
downloadIndex: () => Promise.resolve({}),
|
||||
playlists: () => Promise.resolve([]),
|
||||
},
|
||||
};
|
||||
});
|
||||
const copyText = vi.fn((_: string) => Promise.resolve(true));
|
||||
vi.mock("../../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) }));
|
||||
|
||||
import { VideoMenu } from "./VideoMenu";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
|
||||
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.clearAllMocks();
|
||||
});
|
||||
|
||||
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
|
||||
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
|
||||
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const video = (extra: Partial<Video>): Video =>
|
||||
({
|
||||
id: "v1",
|
||||
title: "A video",
|
||||
channel_id: "c1",
|
||||
channel_title: "A channel",
|
||||
watch_url: "https://youtu.be/v1",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 0,
|
||||
...extra,
|
||||
}) as Video;
|
||||
|
||||
function Feed({
|
||||
v,
|
||||
handlers,
|
||||
}: {
|
||||
v: Video;
|
||||
handlers: Parameters<typeof VideoMenu>[0]["handlers"];
|
||||
}) {
|
||||
const menu = useContextMenu<Video>();
|
||||
return (
|
||||
<>
|
||||
<div {...menu.triggerProps(v)}>card</div>
|
||||
<MoreButton
|
||||
label="more"
|
||||
expanded={menu.open && menu.target?.id === v.id}
|
||||
menuProps={menu.buttonProps(v)}
|
||||
/>
|
||||
<VideoMenu menu={menu} handlers={handlers} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function setup(v: Video) {
|
||||
const handlers = {
|
||||
onState: vi.fn(),
|
||||
onToggleSave: vi.fn(),
|
||||
onResetState: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
};
|
||||
const qc = new QueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<Feed v={v} handlers={handlers} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { handlers };
|
||||
}
|
||||
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
|
||||
const more = () => screen.getByRole("button", { name: "more" });
|
||||
|
||||
describe("a video's menu", () => {
|
||||
it("lists the whole action list for a fresh video, in its groups", () => {
|
||||
setup(video({}));
|
||||
rightClick();
|
||||
expect(labels()).toEqual([
|
||||
"card.play",
|
||||
"card.menu.openChannel",
|
||||
"card.markWatched",
|
||||
"card.saveForLater",
|
||||
"playlists.addToPlaylist",
|
||||
"downloads.menu.download",
|
||||
"card.info",
|
||||
"common.copy.title",
|
||||
"common.copy.link",
|
||||
"card.hide",
|
||||
"home.pin.add",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers Continue and the restart on a half-watched one, the way back and the reset", async () => {
|
||||
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
|
||||
rightClick();
|
||||
expect(labels()).toContain("card.restartTitle");
|
||||
expect(labels()).toContain("card.menu.unsave");
|
||||
expect(labels()).toContain("card.resetState");
|
||||
fireEvent.click(item("card.restartTitle"));
|
||||
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
|
||||
});
|
||||
|
||||
it("each toggle fires the card's own handler", async () => {
|
||||
const { handlers } = setup(video({ status: "watched" }));
|
||||
rightClick();
|
||||
fireEvent.click(item("card.menu.unmarkWatched"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
|
||||
rightClick();
|
||||
fireEvent.click(item("card.hide"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "hidden"));
|
||||
});
|
||||
|
||||
it("copies the link INSIDE the click, before the menu closes (Safari's gesture rule)", () => {
|
||||
setup(video({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("common.copy.link"));
|
||||
// Synchronously: a row that went through closeThenAct would run a task later.
|
||||
expect(copyText).toHaveBeenCalledWith("https://youtu.be/v1");
|
||||
expect(labels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens the playlists panel from the menu, anchored where the menu came from", async () => {
|
||||
setup(video({}));
|
||||
rightClick();
|
||||
fireEvent.click(item("playlists.addToPlaylist"));
|
||||
expect(await screen.findByText("playlists.noneYet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("the '…' opens the same menu, says so, and a second press closes it", () => {
|
||||
setup(video({}));
|
||||
expect(more().getAttribute("aria-haspopup")).toBe("menu");
|
||||
expect(more().getAttribute("aria-expanded")).toBe("false");
|
||||
fireEvent.click(more());
|
||||
expect(labels()).toContain("card.hide");
|
||||
expect(more().getAttribute("aria-expanded")).toBe("true");
|
||||
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
|
||||
// it must not open it again.
|
||||
fireEvent.pointerDown(more(), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(more());
|
||||
fireEvent.click(more(), { detail: 1 });
|
||||
expect(labels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a keyboard click on the '…' always opens, whatever an earlier press left behind", () => {
|
||||
setup(video({}));
|
||||
fireEvent.click(more(), { detail: 1 });
|
||||
// A press on the open row's "…" that never became a click (released elsewhere)…
|
||||
fireEvent.pointerDown(more(), { pointerType: "mouse" });
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// …then Enter on the button: a click with no pointer (detail 0).
|
||||
fireEvent.click(more(), { detail: 0 });
|
||||
expect(labels()).toContain("card.hide");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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 "./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
|
||||
* ONE per list, outside the cards (see `ui/ContextMenu`). */
|
||||
export function VideoMenu({
|
||||
menu,
|
||||
handlers,
|
||||
}: {
|
||||
menu: ContextMenuController<Video>;
|
||||
handlers: VideoActionHandlers;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const actions = useVideoActions(handlers, menu.open);
|
||||
const v = menu.target;
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("card.menu.label")}
|
||||
items={v ? actions.items(v, menu.anchor) : []}
|
||||
/>
|
||||
{actions.panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user