fix(player): a related video played is tracked, and its list is honest about its state

A related video played in place is the player's item now: its title and its own watched /
playlist / download controls, its progress checkpointed and resumed, marked watched at its end —
and its end does not step the queue. Back returns to the opened video, where it was left.

The related list:
- its marks announce through the shared notice (the Notification Center finds them), join the
  host's player session (the feed refetches on close), and stay per write, so an earlier answer no
  longer undoes a later mark still on its way;
- every host's state write patches the cached related lists, which are never refetched;
- kept for the session (gcTime), a retry asks for the page that failed, a list read so far with
  nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names
  itself on hover.

Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons
are left out.

Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide
moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the
top layer; the help sheet lists the list keys over a layer that holds a list.
This commit is contained in:
2026-10-25 00:32:28 +02:00
parent d53729a63d
commit b5045165c0
30 changed files with 1024 additions and 207 deletions
@@ -27,14 +27,19 @@ vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
// Their own queries and popovers are not what is under test here.
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
vi.mock("../video/AddToPlaylist", () => ({
default: ({ videoId }: { videoId: string }) => <span data-testid={`add-${videoId}`} />,
}));
vi.mock("../downloads/DownloadButton", () => ({
default: ({ videoId }: { videoId: string }) => <span data-testid={`download-${videoId}`} />,
}));
import PlayerModal from "./PlayerModal";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Video } from "../../lib/api";
import { api, type Me, type Video } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
@@ -96,6 +101,7 @@ const vid = (id: string, position_seconds: number): Video =>
function renderModal(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), me);
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
@@ -108,6 +114,12 @@ afterEach(() => {
volumes.length = 0;
});
// The signed-in account the player reads; related videos are on unless a test turns them off.
let me = { related_enabled: true } as Me;
beforeEach(() => {
me = { related_enabled: true } as Me;
});
const lastStart = () => started[started.length - 1];
describe("PlayerModal — where each item starts", () => {
@@ -434,7 +446,7 @@ describe("PlayerModal — the watch toggle after a queue advance", () => {
await settle();
// A really is marked watched — that part is correct and must keep working.
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toContainEqual(["A", "watched"]);
// B is not, and its toggle says so. Before the fix both toggles read "watchedUnmark" here.
expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0);
expect(screen.queryAllByTitle("player.markWatched").length).toBeGreaterThan(0);
@@ -449,7 +461,7 @@ describe("PlayerModal — the watch toggle after a queue advance", () => {
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toContainEqual(["A", "watched"]);
expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
});
});
@@ -813,13 +825,95 @@ describe("PlayerModal — the side panel on a wide window", () => {
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 }]);
await waitFor(() => expect(lastStart()).toEqual({ videoId: "rel2", start: 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();
});
// A related video played in place is the item it now is: tracked like a queue item, while the
// queue stays where it was and Back returns to the opened one.
const playRel2 = async (
onState: (id: string, status: string, v?: Video) => void,
queue?: Video[],
) => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2", 120)],
has_more: false,
next_cursor: null,
});
const A = vid("A", 40);
renderModal(
<PlayerModal
video={A}
queue={queue ?? [A]}
startIndex={0}
onClose={() => {}}
onState={onState}
/>,
);
act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" })));
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
};
it("wide: a related video played in place shows its own title and controls", async () => {
await playRel2(() => {});
expect(screen.getByRole("heading", { name: "Video rel2" })).toBeTruthy();
expect(screen.getAllByTestId("add-rel2").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("download-rel2").length).toBeGreaterThan(0);
expect(screen.getAllByTitle("player.markWatched").length).toBeGreaterThan(0);
});
it("wide: a related video's progress is checkpointed, and Back resumes the opened one", async () => {
const saved = vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: id === "rel2" ? 500 : 77,
}));
clock.current = 77;
await playRel2(() => {});
clock.current = 500;
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
// Where leaving it for the related one checkpointed it, not the queue's snapshot (40).
await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 77 }));
expect(saved.mock.calls.map((c) => c[0])).toContain("rel2");
expect(screen.queryByRole("button", { name: "player.back" })).toBeNull();
});
it("wide: a related video watched to its end is marked watched, and the queue stays put", async () => {
const onState = vi.fn();
const B = vid("B", 0);
await playRel2(onState, [vid("A", 40), B]);
act(() => playerEvents.onStateChange?.({ data: 0 }));
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toContainEqual(["rel2", "watched"]);
// Named for the host's notice: it is none of the host's own rows.
expect(onState.mock.calls[0]![2]).toMatchObject({ id: "rel2", title: "Video rel2" });
await settle();
expect(lastStart()?.videoId).toBe("rel2");
});
it("leaves the related videos out when the instance turned them off", () => {
me = { related_enabled: false } as Me;
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
wideWindow(true);
const wideView = renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
expect(screen.queryByRole("tab", { name: "player.related.title" })).toBeNull();
expect(screen.getByTestId("player-description")).toBeTruthy();
wideView.unmount();
wideWindow(false);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
expect(screen.queryByRole("button", { name: "player.related.title" })).toBeNull();
});
// The list keys are the page's, silenced while a layer is up — but this list is IN the layer.
const press = (key: string) =>
act(() => void fireEvent.keyDown(document.activeElement ?? document.body, { key }));
@@ -858,7 +952,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(volumes).toEqual([]);
// Enter on the row plays it, like its title's click.
press("Enter");
expect(loaded).toEqual([{ videoId: "rel2", startSeconds: 0 }]);
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
});
it("stacked: the keyboard walks the list in the drawer too", async () => {
@@ -902,7 +996,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
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(lastStart()?.videoId).toBe("rel1"));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(),
);
@@ -911,6 +1005,89 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(onClose).not.toHaveBeenCalled();
});
const openDrawer = async () => {
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);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("button", { name: "player.related.title" }));
const drawer = await screen.findByRole("dialog", { name: "player.related.title" });
await within(drawer).findAllByRole("link", { name: "Video rel1" });
return { drawer };
};
it("stacked: the video's keys are the drawer's while it is open", async () => {
wideWindow(false);
const { drawer } = await openDrawer();
act(() => within(drawer).getByRole("button", { name: "common.close" }).focus());
press("ArrowUp");
press("ArrowDown");
press("ArrowLeft");
press("ArrowRight");
expect(volumes).toEqual([]);
expect(seeks).toEqual([]);
});
it("stacked: the window regaining focus leaves it in the drawer", async () => {
wideWindow(false);
const { drawer } = await openDrawer();
const row = titleOf(within(drawer).getAllByRole("link", { name: "Video rel1" }));
act(() => row.focus());
act(() => void window.dispatchEvent(new Event("focus")));
expect(document.activeElement).toBe(row);
});
it("going wide moves the drawer's list into the panel's tab, and narrow again reopens nothing", async () => {
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 qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), me);
const ui = () => (
<QueryClientProvider client={qc}>
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />
</QueryClientProvider>
);
wideWindow(false);
const view = render(ui());
fireEvent.click(screen.getByRole("button", { name: "player.related.title" }));
await screen.findByRole("dialog", { name: "player.related.title" });
wideWindow(true);
view.rerender(ui());
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(),
);
// The list the user was in is the panel's open tab now.
expect(
screen.getByRole("tab", { name: "player.related.title" }).getAttribute("aria-selected"),
).toBe("true");
wideWindow(false);
view.rerender(ui());
expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull();
});
it("wide: the panel's tabs keep the row's gap", () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = screen.getByRole("tablist").className.split(" ");
expect(row).toEqual(expect.arrayContaining(["flex", "flex-wrap", "gap-1"]));
});
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(() => {}));
+115 -47
View File
@@ -85,6 +85,7 @@ 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 { useMe } from "../../lib/useMe";
import Modal from "../../components/ui/Modal";
import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
@@ -168,6 +169,7 @@ export default function PlayerModal({
startIndex,
onClose,
onState,
onWrite,
onRemoveFromList,
}: {
video: Video;
@@ -180,7 +182,12 @@ export default function PlayerModal({
queue?: Video[];
startIndex?: number;
onClose: () => void;
onState: (id: string, status: VideoStatus) => void;
/** The player's own marks (its watched toggle, the auto-mark at the end), for the item it is on —
* named by `video`, which may be none of the host's (a related video played in place). */
onState: (id: string, status: VideoStatus, video?: Video) => void;
/** A write the related list made itself, for a host that refetches once the player closes and its
* writes have landed (the feed's player session). */
onWrite?: (write: Promise<unknown>) => void;
/** Remove the playing video from the list it was opened from (the Playlists module passes its own
* `removeItem`; the feed passes nothing — there is no list to remove it from). Returns whether it
* actually happened, because the CONSEQUENCE is the player's: its launch queue is frozen, so it
@@ -236,6 +243,19 @@ export default function PlayerModal({
// startAt only applies to the item we opened with; advanced items resume their own pos.
const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
// A related video played in place (the owner's pick) is the item it now is: its progress is
// checkpointed, it is marked watched at its end, and its own watched / playlist / download
// controls show — while the queue stays where it was, and Back returns to the opened item. It is
// the SUBJECT of the player from here down; `active` stays the queue's item, which the related
// list, the counter and prev/next belong to. A queue step leaves it.
const [related, setRelated] = useState<{ video: Video; startAt: number } | null>(null);
const [relatedOf, setRelatedOf] = useState(active.id);
if (relatedOf !== active.id) {
setRelatedOf(active.id);
setRelated(null);
}
const subject = related?.video ?? active;
const subjectResume = related ? related.startAt : resumeAt;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YTPlayer | null>(null);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
@@ -251,11 +271,16 @@ export default function PlayerModal({
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
// dep lists too.
const focusModal = useCallback(() => cardRef.current?.focus(), []);
// The player's place on the layer stack (registered below), for what must defer to a layer above.
const layerIdRef = useRef<number | null>(null);
// What the player does on its own (turning ready, the pointer leaving the video, the window
// regaining focus, a wheel over it, leaving fullscreen) takes the focus back only when it may have
// slipped into the iframe or off the card. Taking it off a control the viewer tabbed to restarted
// their Tab from the first button.
// their Tab from the first button. Nor while a layer that owns the keyboard is open above the
// player (the related drawer, a dialog): its focus is outside the card by design, and the window
// regaining focus pulled it onto the card behind the scrim.
const reclaimFocus = useCallback(() => {
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
const card = cardRef.current;
const active = document.activeElement;
if (
@@ -351,7 +376,7 @@ export default function PlayerModal({
// currently-playing id may differ from the active item.
const [currentVideoId, setCurrentVideoId] = useState(active.id);
const currentIdRef = useRef(active.id);
const navigated = currentVideoId !== active.id;
const navigated = currentVideoId !== subject.id;
// Clip mode: the range being marked of the video that is playing, or null. Its panel sits under
// the video, which is its preview; another video starts without one.
const [clip, setClip] = useState<ClipRange | null>(null);
@@ -371,7 +396,7 @@ export default function PlayerModal({
// The picture the crop frame is drawn on. The iframe never tells its video's size: a short is
// 9:16, anything else is taken as the player's 16:9, and the cut fits the frame to the real picture
// (downloads/clip) — so a 4:3 video's frame drawn over its bars loses only what lies on the bars.
const picture = !navigated && active.is_short ? 9 / 16 : 16 / 9;
const picture = !navigated && subject.is_short ? 9 / 16 : 16 / 9;
// The docked clip panel's height, measured (it wraps with the window and the text size): the video
// above it ends there.
const clipDockRef = useRef<HTMLDivElement>(null);
@@ -488,7 +513,7 @@ export default function PlayerModal({
// Title/author of a navigated-to video, read from the player (free, no API call).
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
// What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout.
const heading = navigated ? (liveData?.title ?? t("player.loading")) : active.title;
const heading = navigated ? (liveData?.title ?? t("player.loading")) : subject.title;
// IFrame Player API error code, when the current video can't play in the embed (e.g. an
// auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
@@ -499,9 +524,13 @@ export default function PlayerModal({
const loadVideo = useStableCallback((id: string, start: number | null) => {
const p = playerRef.current;
if (!p || typeof p.loadVideoById !== "function") return;
// Explicit start wins; otherwise resume the active item, start others at 0.
// Explicit start wins; otherwise resume the item the player is on, start others at 0.
const startSeconds =
start != null ? start : id === active.id ? (sessionPosRef.current.get(id) ?? resumeAt) : 0;
start != null
? start
: id === subject.id
? (sessionPosRef.current.get(id) ?? subjectResume)
: 0;
setPlayerError(null);
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
currentIdRef.current = id;
@@ -515,18 +544,18 @@ export default function PlayerModal({
// one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING item runs
// after the commit that already advanced `active`, and near the end of a clip it auto-marks that
// item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that stray write is simply not this
// item's, so the toggle falls back to `active.status` and no ordering has to be argued about.
// item's, so the toggle falls back to `subject.status` and no ordering has to be argued about.
// Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
// onState(next, "new") for an item that was already new.
const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({
id: active.id,
value: active.status,
});
const watched = (status.id === active.id ? status.value : active.status) === "watched";
const watched = (status.id === subject.id ? status.value : subject.status) === "watched";
const setWatched = (on: boolean) => {
const next = on ? "watched" : "new";
setStatus({ id: active.id, value: next });
onState(active.id, next);
setStatus({ id: subject.id, value: next });
onState(subject.id, next, subject);
};
// On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
@@ -537,9 +566,9 @@ export default function PlayerModal({
// The watch status is NOT synced here: it is keyed by id above, which is both stale-proof and
// immune to the late writer this block cannot see (see that comment).
const [syncedTo, setSyncedTo] = useState(active.id);
if (syncedTo !== active.id) {
setSyncedTo(active.id);
setCurrentVideoId(active.id);
if (syncedTo !== subject.id) {
setSyncedTo(subject.id);
setCurrentVideoId(subject.id);
}
// `currentIdRef` must NOT move into the block above, and the reason is EFFECT ORDERING, which the
@@ -553,8 +582,8 @@ export default function PlayerModal({
// frozen launch queue's stale position — which is the exact bug the session-position map exists
// to prevent (748 instead of 830, in the test's numbers).
useEffect(() => {
currentIdRef.current = active.id;
}, [active.id]);
currentIdRef.current = subject.id;
}, [subject.id]);
const seekTo = useStableCallback((seconds: number) => {
const p = playerRef.current;
@@ -646,6 +675,8 @@ export default function PlayerModal({
if (clipOpenRef.current) return;
const { autoMode: a, loopMode: l } = modeRef.current;
if (l === "one") return replayCurrent();
// A related video's end is not the queue's: the list beside it is where the next one is picked.
if (related) return;
const { queue: q, index: i } = navRef.current;
if (!q || q.length === 0) return;
const wrap = l === "all";
@@ -681,10 +712,19 @@ export default function PlayerModal({
// list in place, to try the next one (the owner's pick).
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
const [relatedOpen, setRelatedOpen] = useState(false);
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
// tab instead. Left open, it reopened unasked when the window narrowed again.
if (wide && relatedOpen) {
setRelatedOpen(false);
setPanelTab("related");
}
const panelTabs = useTabPanel();
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
// be refused.
const relatedOn = useMe().related_enabled;
const playRelated = (v: Video, startAt: number) => {
setRelatedOpen(false);
loadVideo(v.id, startAt);
setRelated({ video: v, startAt });
};
const descPop = useAnchoredPopover({
open: showDesc,
@@ -712,7 +752,6 @@ export default function PlayerModal({
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
// download dialog, or an anchored menu) takes Escape first and this player defers. Its root lets
// the keyboard walk a list inside it (the related videos).
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer({ root: () => dialogRef.current });
layerIdRef.current = id;
@@ -764,6 +803,15 @@ export default function PlayerModal({
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
return;
}
// A layer that owns the keyboard above the player (the related drawer, a menu, a dialog) has the
// keys: on its ✕ the arrows changed the volume and seeked the video behind it. Escape is the
// exception, and `close` decides for itself which layer it closes.
if (
e.key !== "Escape" &&
layerIdRef.current != null &&
anyBlockingLayerAbove(layerIdRef.current)
)
return;
dispatch(e, "ytPlayer", {
clip: () => {
if (clip) closeClip();
@@ -838,8 +886,8 @@ export default function PlayerModal({
// the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via
// the refs passed in. See lib/playerModalHooks.
useYtProgress({
activeId: active.id,
resumeAt,
activeId: subject.id,
resumeAt: subjectResume,
mountRef,
playerRef,
currentIdRef,
@@ -1051,9 +1099,9 @@ export default function PlayerModal({
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
{!navigated && <AddToPlaylist videoId={subject.id} variant="overlay" />}
{!navigated && (
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
<DownloadButton videoId={subject.id} title={subject.title} variant="overlay" />
)}
{/* Clip mode here: the clip panel takes this bar's place. */}
{playerError == null && (
@@ -1266,7 +1314,7 @@ export default function PlayerModal({
<Info />
</IconButton>
)}
{!wide && (
{!wide && relatedOn && (
<IconButton
variant="ghost"
onClick={() => setRelatedOpen(true)}
@@ -1286,14 +1334,16 @@ export default function PlayerModal({
maxWidth="max-w-sm"
initialFocus="close"
>
<RelatedVideos videoId={active.id} onPlay={playRelated} />
<RelatedVideos videoId={active.id} onPlay={playRelated} onWrite={onWrite} />
</Modal>
)}
{navigated && (
{(navigated || related) && (
<Button
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
// One step back: from a linked video to the one it was linked from, from a
// related video to the opened item.
onClick={() => (navigated ? loadVideo(subject.id, null) : setRelated(null))}
title={t("player.backToOriginal")}
icon={<ArrowLeft />}
>
@@ -1326,8 +1376,8 @@ export default function PlayerModal({
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
src={subject.channel_thumbnail}
fallback={subject.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
@@ -1353,19 +1403,19 @@ export default function PlayerModal({
className={`flex items-center gap-1.5 min-w-0 ${wide ? "flex-1" : "shrink-0"}`}
>
<ChannelNameLink
channelId={active.channel_id}
title={active.channel_title}
channelId={subject.channel_id}
title={subject.channel_title}
onOpenInApp={() => {
// openChannel closes this player first and pushes only once its
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// and clicking the name merely closes the player
// (lib/history.closeOverlaysThenNavigate).
openChannel(active.channel_id, active.channel_title ?? undefined);
openChannel(subject.channel_id, subject.channel_title ?? undefined);
}}
className="font-medium hover:text-accent truncate text-left"
/>
<IconLink
href={active.channel_url}
href={subject.channel_url}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
@@ -1379,27 +1429,27 @@ export default function PlayerModal({
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{active.view_count != null && (
{subject.view_count != null && (
<span>
{metaSep}
{t("player.views", {
count: active.view_count,
formattedCount: formatCompact(active.view_count, lang),
count: subject.view_count,
formattedCount: formatCompact(subject.view_count, lang),
})}
</span>
)}
<span>
{metaSep}
{relativeTime(active.published_at, lang, t)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
{relativeTime(subject.published_at, lang, t)}
{subject.published_at && ` · ${fullDate(subject.published_at)}`}
</span>
{active.duration_seconds != null && (
{subject.duration_seconds != null && (
<span>
{metaSep}
{formatDuration(active.duration_seconds)}
{formatDuration(subject.duration_seconds)}
</span>
)}
{active.live_status === "was_live" && (
{subject.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("player.stream")}
</span>
@@ -1452,10 +1502,10 @@ export default function PlayerModal({
>
<Maximize />
</IconButton>
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
<AddToPlaylist videoId={subject.id} variant="outline" size="md" />
<DownloadButton
videoId={active.id}
title={active.title}
videoId={subject.id}
title={subject.title}
variant="outline"
size="md"
/>
@@ -1472,7 +1522,7 @@ export default function PlayerModal({
<Scissors />
</IconButton>
)}
{onRemoveFromList && (
{onRemoveFromList && !related && (
<IconButton
variant="outline-danger"
size="md"
@@ -1498,8 +1548,22 @@ export default function PlayerModal({
</div>
)}
</div>
{wide && (
{wide && !relatedOn && (
<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}
/>
</section>
)}
{wide && relatedOn && (
<div className="mt-5">
{/* The class replaces Tabs' own, so it keeps the row's layout and gap with
its tighter margin. */}
<Tabs
tabs={[
{ id: "description", label: t("player.description") },
@@ -1509,7 +1573,7 @@ export default function PlayerModal({
onChange={(id) => setPanelTab(id === "related" ? "related" : "description")}
idBase={panelTabs.idBase}
label={t("player.panelTabs")}
className="mb-3"
className="flex flex-wrap gap-1 mb-3"
/>
<div {...panelTabs.panelProps(panelTab)}>
{panelTab === "description" ? (
@@ -1523,7 +1587,11 @@ export default function PlayerModal({
) : (
// Out into the panel's padding: in 20rem, every rem is the title's.
<div className="-mx-3">
<RelatedVideos videoId={active.id} onPlay={playRelated} />
<RelatedVideos
videoId={active.id}
onPlay={playRelated}
onWrite={onWrite}
/>
</div>
)}
</div>
@@ -23,7 +23,8 @@ 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";
import { clearAll, getNotifications } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
@@ -63,7 +64,10 @@ const page = (items: RelatedVideo[], next: string | null = null) => ({
next_cursor: next,
});
function setup(onPlay: (v: Video, startAt: number) => void = () => {}) {
function setup(
onPlay: (v: Video, startAt: number) => void = () => {},
onWrite?: (write: Promise<unknown>) => void,
) {
vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] } as never);
vi.spyOn(api, "downloadIndex").mockResolvedValue({});
const saved = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
@@ -71,11 +75,11 @@ function setup(onPlay: (v: Video, startAt: number) => void = () => {}) {
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<RelatedVideos videoId="SRC" onPlay={onPlay} />
<RelatedVideos videoId="SRC" onPlay={onPlay} onWrite={onWrite} />
</FloatingTree>
</QueryClientProvider>,
);
return { saved };
return { saved, qc };
}
// A row's title (its picture is a link of the same name).
@@ -166,6 +170,144 @@ describe("RelatedVideos", () => {
expect(screen.queryByText("player.related.empty")).toBeNull();
});
// A deferred answer, settled when the test says.
const deferred = () => {
let resolve: () => void = () => {};
let reject: (e: Error) => void = () => {};
const promise = new Promise<never>((res, rej) => {
resolve = res as () => void;
reject = rej;
});
return { promise, resolve, reject };
};
it("keeps a row hidden while its hide is on its way, though an earlier save landed", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
const save = deferred();
const saving = vi.spyOn(api, "watchLaterAdd").mockReturnValue(save.promise as never);
const hide = deferred();
const hiding = vi.spyOn(api, "setState").mockReturnValue(hide.promise as never);
setup();
const card = (await title("Video A")).closest("[data-video-id]")!;
fireEvent.contextMenu(card);
fireEvent.click(menuItem("card.saveForLater"));
await waitFor(() => expect(saving).toHaveBeenCalledWith("A"));
fireEvent.contextMenu(card);
fireEvent.click(menuItem("card.hide"));
await waitFor(() => expect(hiding).toHaveBeenCalledWith("A", "hidden"));
expect(titles()).toEqual(["Video B"]);
await act(async () => save.resolve());
expect(titles()).toEqual(["Video B"]);
});
it("announces a hide with the video it names, for the notification center to find", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
const writes: Promise<unknown>[] = [];
setup(undefined, (w) => void writes.push(w));
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
fireEvent.click(menuItem("card.hide"));
await waitFor(() => expect(getNotifications().length).toBeGreaterThan(0));
expect(getNotifications().map((n) => n.meta)).toContainEqual({
kind: "video-hidden",
videoId: "A",
title: "Video A",
channelId: "UCA",
channelName: "Channel A",
});
// And the write reached the host's session.
expect(writes).toHaveLength(1);
});
it("flips a row's bookmark at once, and back when the server refuses", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
const save = deferred();
vi.spyOn(api, "watchLaterAdd").mockReturnValue(save.promise as never);
setup();
const card = (await title("Video A")).closest("[data-video-id]")!;
// What the menu offers is the row's state: un-save once saved.
const offers = (label: string) => {
fireEvent.contextMenu(card);
return !!menuItem(label);
};
expect(offers("card.saveForLater")).toBe(true);
fireEvent.click(menuItem("card.saveForLater"));
await waitFor(() => expect(offers("card.menu.unsave")).toBe(true));
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
await act(async () => save.reject(new Error("offline")));
await waitFor(() => expect(offers("card.saveForLater")).toBe(true));
});
it("is kept for the session, not collected when the list closes", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
const { qc } = setup();
await title("Video A");
const [cached] = qc.getQueryCache().findAll({ queryKey: qk.related() });
expect(cached!.options.gcTime).toBe(Infinity);
});
it("says the next page failed under the rows it has, and retries that page", async () => {
const asked = vi
.spyOn(api, "related")
.mockResolvedValueOnce(page([row("A")], "CUR1"))
.mockRejectedValueOnce(new HttpError(422, "YouTube isn't reachable", undefined))
.mockResolvedValueOnce(page([row("B")]));
setup();
await title("Video A");
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
expect((await screen.findByRole("alert")).textContent).toContain("YouTube isn't reachable");
expect(titles()).toEqual(["Video A"]);
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
await title("Video B");
expect(asked.mock.calls.map((c) => c[2])).toEqual([null, "CUR1", "CUR1"]);
});
it("with nothing unseen read so far, says so above Show more, and retries the page it failed", async () => {
const asked = vi
.spyOn(api, "related")
.mockResolvedValueOnce(page([], "CUR1"))
.mockRejectedValueOnce(new HttpError(422, "YouTube isn't reachable", undefined))
.mockResolvedValueOnce(page([row("B")]));
setup();
expect(await screen.findByText("player.related.emptySoFar")).toBeTruthy();
expect(screen.queryByText("player.related.empty")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
await screen.findByRole("alert");
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
await title("Video B");
expect(asked.mock.calls.map((c) => c[2])).toEqual([null, "CUR1", "CUR1"]);
});
it.each([
["the day's cap", { code: "refused", reason: "related_daily_limit", limit: 5 }],
["turned off", { code: "refused", reason: "related_disabled" }],
["the shared budget", { code: "youtube", reason: "quota" }],
])("offers no retry a retry cannot answer: %s", async (_name, detail) => {
vi.spyOn(api, "related").mockRejectedValue(new HttpError(429, "Refused", detail));
setup();
expect((await screen.findByRole("alert")).textContent).toContain("Refused");
expect(screen.queryByRole("button", { name: "common.retry" })).toBeNull();
});
it("offers a retry where trying again can help", async () => {
vi.spyOn(api, "related").mockRejectedValue(
new HttpError(422, "YouTube isn't reachable", { code: "youtube", reason: "unavailable" }),
);
setup();
await screen.findByRole("alert");
expect(screen.getByRole("button", { name: "common.retry" })).toBeTruthy();
});
it("names the followed mark on hover", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { followed: true })]));
setup();
await title("Video A");
const mark = screen.getByRole("img", { name: "player.related.followed" });
fireEvent.mouseEnter(mark.parentElement!);
fireEvent.pointerEnter(mark.parentElement!, { pointerType: "mouse" });
expect(await screen.findByText("player.related.followed")).toBeTruthy();
});
it("says when nothing is left that the user has not seen", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([]));
setup();
+67 -69
View File
@@ -1,28 +1,23 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { CircleCheck } from "lucide-react";
import {
api,
HttpError,
type RelatedRegion,
type RelatedResponse,
type Video,
type VideoStatus,
} from "../../lib/api";
import { api, HttpError, type RelatedRegion, 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 { patchRelated } from "../../lib/progressCache";
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 Tooltip from "../../components/ui/Tooltip";
import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice";
/** 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). */
@@ -35,26 +30,15 @@ const RELATED_REGION_OPTIONS: UsePrefOptions<RelatedRegion> = {
// leaves it the same way, at once.
const isSeen = (v: Video) => v.status === "watched" || v.status === "hidden";
type Cached = InfiniteData<RelatedResponse, string | null>;
/** The refusals a retry cannot answer: the day's cap (tomorrow does), the feature turned off on
* this instance and the shared budget spent (the admin, or tomorrow). */
const FINAL_REASONS = new Set(["related_daily_limit", "related_disabled", "quota"]);
const isFinal = (e: unknown) =>
e instanceof HttpError &&
FINAL_REASONS.has((e.detailData as { reason?: string } | undefined)?.reason ?? "");
/** 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,
);
}
// A write not yet confirmed: its change shows at once, and leaves when the server answers.
type Pending = { seq: number; id: string; change: Partial<Video> };
/** 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
@@ -62,63 +46,67 @@ function patchRelated(
export function RelatedVideos({
videoId,
onPlay,
onWrite,
}: {
videoId: string;
onPlay: (v: Video, startAt: number) => void;
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
onWrite?: (write: Promise<unknown>) => 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.
// Kept for the session — never refetched, never collected while the app is open: the list
// unmounts whenever the drawer closes or the tab changes, and 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,
gcTime: 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)));
// The marks on their way, one entry per WRITE: a row leaves (or its bookmark flips) at once, and
// comes back if the server refuses that write. Kept per video, the first answer to land cleared
// the video's whole entry, and a save then a hide put the hidden row back until the hide landed.
const [pending, setPending] = useState<Pending[]>([]);
const seqRef = useRef(0);
const begin = (id: string, change: Partial<Video>) => {
const seq = ++seqRef.current;
setPending((p) => [...p, { seq, id, change }]);
return seq;
};
const settle = (seq: number) => setPending((p) => p.filter((w) => w.seq !== seq));
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)
const seq = begin(id, { status });
const write = api.setState(id, status);
onWrite?.(write);
write
.then(() => {
patchRelated(qc, id, { status });
settle(id);
settle(seq);
// 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"),
},
});
}
announceVideoState(id, status, v, () => onState(id, "new"));
})
.catch((e: unknown) => {
notifyActionError(e, t("errors.writeFailed.videoState"));
settle(id);
settle(seq);
});
};
const onToggleSave = (id: string, saved: boolean) => {
setPending((p) => ({ ...p, [id]: { ...p[id], saved } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
const seq = begin(id, { saved });
const write = saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id);
onWrite?.(write);
write
.then(() => {
patchRelated(qc, id, { saved });
settle(id);
settle(seq);
void qc.invalidateQueries({ queryKey: qk.playlists() });
void qc.invalidateQueries({ queryKey: qk.playlist() });
})
@@ -127,7 +115,7 @@ export function RelatedVideos({
e,
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
);
settle(id);
settle(seq);
});
};
@@ -137,7 +125,7 @@ export function RelatedVideos({
const rows = (query.data?.pages ?? [])
.flatMap((p) => p.items)
.map((v) => (pending[v.id] ? { ...v, ...pending[v.id] } : v))
.map((v) => pending.reduce((acc, w) => (w.id === v.id ? { ...acc, ...w.change } : acc), v))
.filter((v) => !isSeen(v));
const followed = new Set(
(query.data?.pages ?? []).flatMap((p) => p.items.filter((v) => v.followed).map((v) => v.id)),
@@ -149,6 +137,13 @@ export function RelatedVideos({
: query.isError
? t("player.related.error")
: null;
// A refused page is the one to try again: with pages loaded, a refetch started over from the first
// (another scrape and another slot) instead of asking for the page that failed.
const retry = isFinal(query.error)
? undefined
: query.data?.pages.length
? () => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))
: () => void query.refetch();
return (
<section data-testid="player-related" aria-label={t("player.related.title")}>
@@ -167,13 +162,16 @@ export function RelatedVideos({
{query.isPending ? (
<LoadingState shape="list" />
) : error && !rows.length ? (
<StateMessage tone="error" compact onRetry={() => void query.refetch()}>
<StateMessage tone="error" compact onRetry={retry}>
{error}
</StateMessage>
) : !rows.length && !query.hasNextPage ? (
<StateMessage compact>{t("player.related.empty")}</StateMessage>
) : (
<>
{/* What was read so far was all seen (or marked here since): the list goes on, it is not
empty — said, so Show more is not a lone button under nothing. */}
{!rows.length && <StateMessage compact>{t("player.related.emptySoFar")}</StateMessage>}
<NavGrid className="flex flex-col gap-1.5">
{rows.map((v, i) => (
<VideoCard
@@ -191,11 +189,15 @@ export function RelatedVideos({
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"
/>
// A bare mark means nothing to a mouse: it names itself on hover, whatever
// the hints setting, since the caption is all it has.
<Tooltip hint={t("player.related.followed")} always>
<CircleCheck
className="w-3.5 h-3.5 shrink-0 text-success"
aria-label={t("player.related.followed")}
role="img"
/>
</Tooltip>
)
}
/>
@@ -214,11 +216,7 @@ export function RelatedVideos({
</div>
)}
{error && rows.length > 0 && (
<StateMessage
tone="error"
compact
onRetry={() => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))}
>
<StateMessage tone="error" compact onRetry={retry}>
{error}
</StateMessage>
)}
@@ -0,0 +1,38 @@
import i18n from "../../i18n";
import { notify, resolveVideo } from "../../lib/notifications";
import type { Video, VideoStatus } from "../../lib/api";
/** What a notice names the video by; any of it may be unknown to the surface that marked it. */
export type NoticeVideo = Pick<Video, "title" | "channel_id" | "channel_title">;
/** Say that a video's mark landed, once the server has it: "Hidden" / "Marked watched" with the
* Undo (`undo` puts it back to new) and the meta the Notification Center keeps it under, so the
* notice still finds the video after a reload. A mark back to new quietly resolves a hide or watch
* notice still standing for it, so it does not linger with a dead action. A claim of success:
* never called before the write is confirmed. One definition, because every list that marks a
* video must announce it the same way (the feed's cards, a related list). */
export function announceVideoState(
id: string,
status: VideoStatus,
video: NoticeVideo | undefined,
undo: () => void,
): void {
if (status === "new") {
resolveVideo(id);
return;
}
const hidden = status === "hidden";
notify({
message: video?.title
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"),
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
meta: {
kind: hidden ? "video-hidden" : "video-watched",
videoId: id,
title: video?.title ?? i18n.t("feed.thisVideo"),
channelId: video?.channel_id ?? "",
channelName: video?.channel_title ?? "",
},
});
}