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:
@@ -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(() => {}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -35,6 +35,21 @@ export function isResettable(v: Pick<Video, "status" | "position_seconds">): boo
|
||||
return v.status !== "new" || v.position_seconds > 0;
|
||||
}
|
||||
|
||||
/** A video's related videos, in the locale YouTube fills the list in: on the video's topic, or with
|
||||
* what trends in Hungary (the server's `RELATED_REGIONS`). */
|
||||
export type RelatedRegion = "topical" | "hu";
|
||||
|
||||
/** A row of a related list: a feed row that also says whether the user follows its channel. */
|
||||
export interface RelatedVideo extends Video {
|
||||
followed: boolean;
|
||||
}
|
||||
|
||||
export interface RelatedResponse {
|
||||
items: RelatedVideo[];
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface VideoDetail {
|
||||
id: string;
|
||||
description: string | null;
|
||||
@@ -235,6 +250,21 @@ export const feedApi = {
|
||||
if (limit) p.set("limit", String(limit));
|
||||
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
||||
},
|
||||
// The videos YouTube shows beside this one, without what the user has watched or hidden. `quiet`
|
||||
// for the same reason as the search: the list renders its refusal (the day's cap, YouTube) inline.
|
||||
related: (
|
||||
videoId: string,
|
||||
region: RelatedRegion,
|
||||
cursor: string | null,
|
||||
): Promise<RelatedResponse> => {
|
||||
const p = new URLSearchParams({ region });
|
||||
if (cursor) p.set("cursor", cursor);
|
||||
return req(
|
||||
`/api/search/related/${encodeURIComponent(videoId)}?${p.toString()}`,
|
||||
{},
|
||||
{ quiet: true },
|
||||
);
|
||||
},
|
||||
// Discard a set of live-search results "as if never added" (drops your search-finds + deletes
|
||||
// the now-orphaned, un-kept videos). Returns how many videos were removed.
|
||||
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
||||
|
||||
@@ -186,6 +186,17 @@ describe("patchCachedProgress", () => {
|
||||
expect(after.pages[0]?.items[0]?.position_seconds).toBe(830);
|
||||
});
|
||||
|
||||
it("patches a related list too — a row played from it resumes where it was left", () => {
|
||||
const qc = new QueryClient();
|
||||
const key = qk.related("SRC", "topical");
|
||||
qc.setQueryData(key, { pages: [{ items: [video()], next_cursor: null }], pageParams: [null] });
|
||||
|
||||
patchCachedProgress(qc, new Map([[VID, 830]]));
|
||||
|
||||
const after = qc.getQueryData(key) as { pages: { items: Video[] }[] };
|
||||
expect(after.pages[0]?.items[0]?.position_seconds).toBe(830);
|
||||
});
|
||||
|
||||
it("is a no-op when nothing was stored", () => {
|
||||
const qc = new QueryClient();
|
||||
seedFeed(qc, video());
|
||||
|
||||
@@ -74,11 +74,13 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
|
||||
return old;
|
||||
};
|
||||
|
||||
// Prefix keys: every filter combination of the feed, every playlist detail, and every live YouTube
|
||||
// search — its rows come from the same projection, carry the same per-user position, and open the
|
||||
// same player, so leaving it out left the rewind in place on that surface. (Patched, never
|
||||
// invalidated: a refetch of an API-sourced search page spends 100 quota units.)
|
||||
// Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube
|
||||
// search and every related list — their rows come from the same projection, carry the same
|
||||
// per-user position, and open the same player, so leaving one out left the rewind in place on that
|
||||
// surface. (Patched, never invalidated: a refetch of an API-sourced search page spends 100 quota
|
||||
// units, a related list's a scrape and a slot of the day's cap.)
|
||||
qc.setQueriesData<Cached>({ queryKey: qk.feed() }, patch);
|
||||
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
|
||||
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, patch);
|
||||
qc.setQueriesData<Cached>({ queryKey: qk.related() }, patch);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export const qk = {
|
||||
ytSearch: (q?: string | null, count?: number) =>
|
||||
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
||||
videoDetail: (id: string) => ["video-detail", id] as const,
|
||||
related: (id?: string, region?: string) =>
|
||||
id !== undefined ? (["related", id, region] as const) : (["related"] as const),
|
||||
|
||||
// --- channels ---
|
||||
channels: () => ["channels"] as const,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user