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:
@@ -16,7 +16,8 @@ import {
|
||||
} from "../../lib/keymap";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
import { useMediaKeys } from "../../lib/mediaKeys";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { navItemAttrs, useListNav } from "../../lib/listNav";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -144,6 +145,40 @@ describe("KeyboardHelp", () => {
|
||||
expect(descriptions()).toContain("keys.help");
|
||||
});
|
||||
|
||||
// The player's related videos are a list inside the layer: their keys work there (`inTopLayer`), so
|
||||
// the sheet opened over the player lists them — and over a layer with no list, still does not.
|
||||
it("lists the list and menu keys over a layer that holds a list of its own", () => {
|
||||
function LayerList() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true });
|
||||
useContextMenu<string>();
|
||||
return (
|
||||
<div data-testid="layer">
|
||||
<div ref={ref}>
|
||||
<div {...navItemAttrs(0)}>row</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<Host>
|
||||
<LayerList />
|
||||
</Host>,
|
||||
);
|
||||
const withList = pushLayer({ root: () => screen.getByTestId("layer") });
|
||||
pressH();
|
||||
expect(descriptions()).toContain("keys.listStep");
|
||||
expect(descriptions()).toContain("keys.contextMenu");
|
||||
act(() => closeKeyboardHelp());
|
||||
removeLayer(withList);
|
||||
|
||||
const bare = pushLayer();
|
||||
pressH();
|
||||
removeLayer(bare);
|
||||
expect(descriptions()).not.toContain("keys.listStep");
|
||||
expect(descriptions()).not.toContain("keys.contextMenu");
|
||||
});
|
||||
|
||||
// Two live context menus (the Playlists page mounts one, its rail another) must not double the row.
|
||||
it("lists the context-menu chords once however many surfaces register", () => {
|
||||
function WithMenu() {
|
||||
|
||||
@@ -41,11 +41,13 @@ export default function KeyboardHelp() {
|
||||
// Opened over a player or a dialog, the page-level keys (`s`, `g …`, `j`/`k`) are quiet — listing
|
||||
// them there would promise keys that do nothing until the overlay closes. And a `g` chord is listed
|
||||
// only for a module this account can actually reach.
|
||||
// The context-menu key goes too: every `useContextMenu` is a page list, and with an overlay up the
|
||||
// focus Shift+F10 needs is inside the overlay (found in UAT).
|
||||
const live = overLayer
|
||||
? scopes.filter((s) => !isPageLevelScope(s) && s !== "contextMenu")
|
||||
: scopes;
|
||||
// The context-menu key goes too, with the page's lists. Over a layer that holds a list of its own
|
||||
// (the player's related videos) the list keys and the menu key work on THAT list, so they stay —
|
||||
// but not Delete: no list inside a layer takes it, and a page's remover behind does not reach.
|
||||
const inLayer = (s: (typeof scopes)[number]) =>
|
||||
(!isPageLevelScope(s) && s !== "contextMenu") ||
|
||||
(overLayer === "layerWithList" && (s === "list" || s === "contextMenu"));
|
||||
const live = overLayer === "page" ? scopes : scopes.filter(inLayer);
|
||||
const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]);
|
||||
const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
|
||||
return (
|
||||
|
||||
@@ -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(() => {}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
"hu": "Hungarian trending",
|
||||
"followed": "You follow this channel",
|
||||
"empty": "No related videos you haven't seen yet.",
|
||||
"emptySoFar": "Nothing you haven't seen among these yet — there are more.",
|
||||
"error": "Couldn't load the related videos",
|
||||
"more": "Show more",
|
||||
"loading": "Loading…"
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"hu": "Magyar trend",
|
||||
"followed": "Követed ezt a csatornát",
|
||||
"empty": "Nincs olyan kapcsolódó videó, amit még nem láttál.",
|
||||
"emptySoFar": "Ezek közt még nincs olyan, amit nem láttál — van több is.",
|
||||
"error": "Nem sikerült betölteni a kapcsolódó videókat",
|
||||
"more": "Mutass többet",
|
||||
"loading": "Betöltés…"
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface Me {
|
||||
google_enabled: boolean;
|
||||
plex_enabled: boolean;
|
||||
search_source: "scrape" | "api";
|
||||
/** Whether live YouTube search / related videos are on: an admin turns either off with a daily
|
||||
* limit of 0, and what would only be refused is then left out. */
|
||||
search_enabled: boolean;
|
||||
related_enabled: boolean;
|
||||
can_read: boolean;
|
||||
can_write: boolean;
|
||||
pending_invites: number;
|
||||
|
||||
@@ -167,4 +167,17 @@ describe("watch state and progress", () => {
|
||||
});
|
||||
expect(opts).toEqual({ quiet: true });
|
||||
});
|
||||
|
||||
it("a related list is quiet too, and sends its region and cursor", () => {
|
||||
void feedApi.related("a/b", "hu", "CUR1");
|
||||
const [url, , opts] = vi.mocked(req).mock.lastCall!;
|
||||
const u = new URL(url as string, "http://x");
|
||||
expect(u.pathname).toBe("/api/search/related/a%2Fb");
|
||||
expect(Object.fromEntries(u.searchParams)).toEqual({ region: "hu", cursor: "CUR1" });
|
||||
expect(opts).toEqual({ quiet: true });
|
||||
|
||||
void feedApi.related("abc", "topical", null);
|
||||
const first = new URL(vi.mocked(req).mock.lastCall![0] as string, "http://x");
|
||||
expect(Object.fromEntries(first.searchParams)).toEqual({ region: "topical" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -472,22 +472,33 @@ export function useActiveScopes(): KeyScope[] {
|
||||
// One sheet for the whole app, opened by the global `h` (or a surface's own keyboard button), so
|
||||
// there is exactly one answer to "what can I press here" instead of one per player.
|
||||
const helpOpen = createStore(false);
|
||||
// Whether the sheet was opened OVER a layer that owns the keyboard (a player, a dialog). The page-level
|
||||
// keys are quiet there, so the sheet must not list them — and it cannot ask the layer stack itself
|
||||
// once it is showing, because its own Modal is a layer too. So the answer is taken at the moment of
|
||||
// opening, before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
|
||||
const helpOverLayer = createStore(false);
|
||||
// What the sheet was opened OVER: the page, a layer that owns the keyboard (a player, a dialog), or
|
||||
// such a layer with a list inside it (the player's related videos). The page-level keys are quiet over
|
||||
// a layer, so the sheet must not list them — except the list keys, which walk a list inside the
|
||||
// topmost layer (see `dispatch`'s `inTopLayer`). The sheet cannot ask the layer stack itself once it
|
||||
// is showing, because its own Modal is a layer too, so the answer is taken at the moment of opening,
|
||||
// before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
|
||||
export type HelpOver = "page" | "layer" | "layerWithList";
|
||||
const helpOverLayer = createStore<HelpOver>("page");
|
||||
|
||||
// Whether the topmost layer holds a list the keyboard walks. The lists are listNav's, which imports
|
||||
// this module and so cannot be imported back: it hands the question in when it loads.
|
||||
let listInTopLayer: () => boolean = () => false;
|
||||
export const setListInTopLayerProbe = (probe: () => boolean): void => {
|
||||
listInTopLayer = probe;
|
||||
};
|
||||
|
||||
export const closeKeyboardHelp = (): void => helpOpen.set(false);
|
||||
export const toggleKeyboardHelp = (): void => {
|
||||
const next = !helpOpen.get();
|
||||
if (next) helpOverLayer.set(anyBlockingLayer());
|
||||
if (next)
|
||||
helpOverLayer.set(!anyBlockingLayer() ? "page" : listInTopLayer() ? "layerWithList" : "layer");
|
||||
helpOpen.set(next);
|
||||
};
|
||||
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
||||
export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue();
|
||||
/** Whether the sheet was opened over a keyboard-owning layer (see `helpOverLayer`). */
|
||||
export const useKeyboardHelpOverLayer = (): boolean => helpOverLayer.useValue();
|
||||
/** What the sheet was opened over (see `helpOverLayer`). */
|
||||
export const useKeyboardHelpOverLayer = (): HelpOver => helpOverLayer.useValue();
|
||||
|
||||
// The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and
|
||||
// by the sheet (which drops the chord of a gated module rather than promise a page that isn't there).
|
||||
|
||||
@@ -61,7 +61,8 @@ export function topBlockingLayerRoot(): HTMLElement | null {
|
||||
|
||||
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
|
||||
* page-level keys (`s`, the `g` chords, list navigation) stay quiet then: they act on the page
|
||||
* underneath, which the user cannot see or reach while such a layer is up. */
|
||||
* underneath, which the user cannot see or reach while such a layer is up. A list INSIDE the
|
||||
* topmost layer is the exception: it is walked (`topBlockingLayerRoot`, listNav). */
|
||||
export function anyBlockingLayer(): boolean {
|
||||
return stack.some((id) => !passive.has(id));
|
||||
}
|
||||
|
||||
@@ -457,6 +457,51 @@ describe("useListNav · the roving tabindex and a removed item", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The same inside a layer (the player's related videos): a row marked from its menu leaves and the
|
||||
// focus falls to <body>, where the layer's quiet rule left `j` doing nothing.
|
||||
it("goes on from the slot of a removed item inside the topmost layer too", () => {
|
||||
function Removable() {
|
||||
const [ids, setIds] = useState([0, 1, 2, 3]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: true });
|
||||
return (
|
||||
<div data-testid="layer">
|
||||
<div ref={ref}>
|
||||
{ids.map((id, i) => (
|
||||
<div key={id} {...navItemAttrs(i)} data-testid={`r${id}`}>
|
||||
<button
|
||||
data-testid={`r${id}-hide`}
|
||||
onClick={() => setIds((xs) => xs.filter((x) => x !== id))}
|
||||
>
|
||||
hide
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
withLayout(
|
||||
(_, n) => ({ left: 0, top: n * 100 }),
|
||||
() => {
|
||||
render(<Removable />);
|
||||
const layer = pushLayer({ root: () => screen.getByTestId("layer") });
|
||||
try {
|
||||
act(() => screen.getByTestId("r1-hide").focus());
|
||||
press("j");
|
||||
expect(focused()).toBe("r2");
|
||||
act(() => screen.getByTestId("r2-hide").focus());
|
||||
fireEvent.click(screen.getByTestId("r2-hide"));
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
press("j");
|
||||
expect(focused()).toBe("r3");
|
||||
} finally {
|
||||
removeLayer(layer);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useListNav · leaving the list, and naming the item", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useEffectEvent, useState, type RefObject } from "react";
|
||||
import { dispatch, useKeyScope } from "./keymap";
|
||||
import { dispatch, setListInTopLayerProbe, useKeyScope } from "./keymap";
|
||||
import { anyBlockingLayer, topBlockingLayerRoot } from "./layerStack";
|
||||
import { scrollParent } from "./listPaging";
|
||||
import { focusIsUnclaimed } from "./useFocusKeeper";
|
||||
@@ -101,6 +101,12 @@ function current(): { list: NavList; index: number; item: HTMLElement } | null {
|
||||
* move between the items, so a surface that also binds them (the player's seek) leaves them alone. */
|
||||
export const focusInNavList = (): boolean => current() !== null;
|
||||
|
||||
// The help sheet lists the list keys over a layer only when this says there is a list to walk in it.
|
||||
setListInTopLayerProbe(() => {
|
||||
const layer = topBlockingLayerRoot();
|
||||
return !!layer && [...lists.keys()].some((el) => layer.contains(el));
|
||||
});
|
||||
|
||||
/** The mounted items that belong to `el` itself (not to a list nested inside it), in index order. */
|
||||
function ownItems(el: HTMLElement): HTMLElement[] {
|
||||
return [...el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
|
||||
@@ -335,10 +341,16 @@ function onKey(e: KeyboardEvent): void {
|
||||
if (move(dx, dy)) ev.preventDefault();
|
||||
};
|
||||
// The list keys act on the page, so an open layer silences them — unless the focus is in a list
|
||||
// INSIDE the topmost layer: that list is what the user is in (the player's related videos).
|
||||
// INSIDE the topmost layer: that list is what the user is in (the player's related videos). Or was,
|
||||
// a moment ago: a row marked from its menu leaves the list and the focus falls to <body>, and `j`
|
||||
// goes on from its slot (`slotOfRemoved`) as it does on the page.
|
||||
const layer = topBlockingLayerRoot();
|
||||
const cur = current();
|
||||
const inTopLayer = !!layer && !!cur && layer.contains(cur.item);
|
||||
const inTopLayer =
|
||||
!!layer &&
|
||||
(cur
|
||||
? layer.contains(cur.item)
|
||||
: focusIsUnclaimed(document.activeElement) && !!last && layer.contains(last.list.el));
|
||||
dispatch(
|
||||
e,
|
||||
"list",
|
||||
|
||||
@@ -186,7 +186,7 @@ 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", () => {
|
||||
it("patches a related list too — its rows open the same player", () => {
|
||||
const qc = new QueryClient();
|
||||
const key = qk.related("SRC", "topical");
|
||||
qc.setQueryData(key, { pages: [{ items: [video()], next_cursor: null }], pageParams: [null] });
|
||||
|
||||
@@ -84,3 +84,21 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
|
||||
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, patch);
|
||||
qc.setQueriesData<Cached>({ queryKey: qk.related() }, patch);
|
||||
}
|
||||
|
||||
/** Write one video's change (its status, its Watch-later mark) into every cached related list —
|
||||
* both regions', every opened video's. Those lists are never refetched (another scrape and a slot
|
||||
* of the day's cap), and they leave out what the user has seen: a video marked anywhere else — a
|
||||
* feed card, a playlist, the player itself — still showed in one opened again later. So every
|
||||
* state write calls it once the server has the change. */
|
||||
export function patchRelated(qc: QueryClient, id: string, change: Partial<Video>): void {
|
||||
qc.setQueriesData<Cached>({ queryKey: qk.related() }, (old) => {
|
||||
if (!old?.pages) return old;
|
||||
let changed = false;
|
||||
const pages = old.pages.map((page) => {
|
||||
if (!isItemList(page) || !page.items.some((v) => v.id === id)) return page;
|
||||
changed = true;
|
||||
return { ...page, items: page.items.map((v) => (v.id === id ? { ...v, ...change } : v)) };
|
||||
});
|
||||
return changed ? { ...old, pages } : old;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,8 +35,18 @@ vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ setPage: h.setPage }),
|
||||
}));
|
||||
vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
|
||||
// The open player's mark, for a test to make as the player would.
|
||||
const playerMark = vi.hoisted(() => ({
|
||||
onState: null as null | ((id: string, s: string) => void),
|
||||
}));
|
||||
vi.mock("../../features/player/PlayerModal", () => ({
|
||||
default: (p: { video: { id: string }; queue: { id: string }[]; startIndex: number }) => {
|
||||
default: (p: {
|
||||
video: { id: string };
|
||||
queue: { id: string }[];
|
||||
startIndex: number;
|
||||
onState: (id: string, s: string) => void;
|
||||
}) => {
|
||||
playerMark.onState = p.onState;
|
||||
h.player(
|
||||
p.video.id,
|
||||
p.queue.map((v) => v.id),
|
||||
@@ -161,6 +171,31 @@ describe("WatchedChannels", () => {
|
||||
await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("a", ["a", "b", "c"], 0));
|
||||
});
|
||||
|
||||
it("puts the player's mark into its related lists, which are never refetched", async () => {
|
||||
vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({
|
||||
ok: true,
|
||||
items: [video("b")],
|
||||
gone: 0,
|
||||
});
|
||||
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
|
||||
const qc = mount({ imported: true, pending: 0, channels: [channel(1)] });
|
||||
qc.setQueryData(qk.related("SRC", "topical"), {
|
||||
pages: [{ items: [{ id: "b", status: "new" }], has_more: false, next_cursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "29" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(await within(dialog).findByRole("button", { name: "card.play — Video b" }));
|
||||
await waitFor(() => expect(playerMark.onState).not.toBeNull());
|
||||
act(() => playerMark.onState!("b", "watched"));
|
||||
await waitFor(() => {
|
||||
const cached = qc.getQueryData(qk.related("SRC", "topical")) as {
|
||||
pages: { items: { status: string }[] }[];
|
||||
};
|
||||
expect(cached.pages[0]!.items[0]!.status).toBe("watched");
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for a channel's videos once per opening, whatever the list does meanwhile", async () => {
|
||||
// The ask explores the channel and may spend quota: the list's own refetch (an Undo, a follow)
|
||||
// must not repeat it under an open dialog, nor swap the queue of a video playing.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EyeOff, Play, UserPlus } from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { patchRelated } from "../../lib/progressCache";
|
||||
import { api, type Video, type VideoStatus, type WatchedChannel } from "../../lib/api";
|
||||
import { formatDate, formatDuration } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
@@ -65,6 +66,8 @@ export default function WatchedVideosDialog({
|
||||
},
|
||||
);
|
||||
void qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
// The player's related lists are never refetched: the mark goes into them.
|
||||
patchRelated(qc, id, { status });
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||
});
|
||||
|
||||
@@ -54,10 +54,12 @@ vi.mock("../../features/player/PlayerModal", async () => {
|
||||
default: function Player({
|
||||
video,
|
||||
onState,
|
||||
onWrite,
|
||||
onClose,
|
||||
}: {
|
||||
video: { id: string };
|
||||
onState: (id: string, status: string) => void;
|
||||
onState: (id: string, status: string, v?: unknown) => void;
|
||||
onWrite?: (write: Promise<unknown>) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(
|
||||
@@ -70,6 +72,19 @@ vi.mock("../../features/player/PlayerModal", async () => {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onState(video.id, "watched")}>mark</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onState("rel1", "watched", {
|
||||
id: "rel1",
|
||||
title: "Related one",
|
||||
channel_id: "UCr",
|
||||
channel_title: "Rel channel",
|
||||
})
|
||||
}
|
||||
>
|
||||
mark related
|
||||
</button>
|
||||
<button onClick={() => onWrite?.(Promise.resolve())}>related list wrote</button>
|
||||
<button onClick={onClose}>close</button>
|
||||
</div>
|
||||
);
|
||||
@@ -81,6 +96,7 @@ import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { patchCachedProgress } from "../../lib/progressCache";
|
||||
import { clearAll, getNotifications } from "../../lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -99,8 +115,24 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
player.watchOnUnmount = false;
|
||||
player.flush = null;
|
||||
clearAll();
|
||||
});
|
||||
|
||||
// A cached related list (the player's, kept for the session) holding these videos.
|
||||
const seedRelated = (qc: QueryClient, ...ids: string[]) =>
|
||||
qc.setQueryData(qk.related("SRC", "topical"), {
|
||||
pages: [
|
||||
{ items: ids.map((id) => ({ id, status: "new" })), has_more: false, next_cursor: null },
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
const relatedStatus = (qc: QueryClient, id: string) =>
|
||||
(
|
||||
qc.getQueryData(qk.related("SRC", "topical")) as {
|
||||
pages: { items: { id: string; status: string }[] }[];
|
||||
}
|
||||
).pages[0]!.items.find((v) => v.id === id)?.status;
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
@@ -227,6 +259,39 @@ describe("Feed — a video marked watched in the player", () => {
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
|
||||
it("refetches when the player's related list wrote", async () => {
|
||||
const { feed } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("related list wrote"));
|
||||
const before = feed.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("close"));
|
||||
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
|
||||
});
|
||||
|
||||
it("names a video that is none of its own in the notice", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark related"));
|
||||
await waitFor(() =>
|
||||
expect(getNotifications().map((n) => n.meta)).toContainEqual({
|
||||
kind: "video-watched",
|
||||
videoId: "rel1",
|
||||
title: "Related one",
|
||||
channelId: "UCr",
|
||||
channelName: "Rel channel",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("puts the mark into the player's related lists, which are never refetched", async () => {
|
||||
const { qc } = mount();
|
||||
seedRelated(qc, "v1", "other");
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
fireEvent.click(await screen.findByText("mark"));
|
||||
await waitFor(() => expect(relatedStatus(qc, "v1")).toBe("watched"));
|
||||
expect(relatedStatus(qc, "other")).toBe("new");
|
||||
});
|
||||
|
||||
it("does not refetch on a close with nothing changed", async () => {
|
||||
const { feed } = mount();
|
||||
fireEvent.click(await screen.findByText("open v1"));
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// An empty feed with a term typed offers to search YouTube for it — unless the instance turned live
|
||||
// search off (a daily limit of 0), where that search could only be refused.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "cats",
|
||||
sort: "relevance",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
function mount(searchEnabled: boolean) {
|
||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
|
||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 0, facets: {} } as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, search_enabled: searchEnabled });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Feed — an empty result with a term typed", () => {
|
||||
it("offers a live YouTube search for it", async () => {
|
||||
mount(true);
|
||||
await screen.findByText("feed.noMatches");
|
||||
expect(screen.getByRole("button", { name: "feed.yt.searchFor" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers none when the instance turned live search off", async () => {
|
||||
mount(false);
|
||||
// The empty state is up: what it offers is final.
|
||||
await screen.findByText("feed.noMatches");
|
||||
expect(screen.queryByRole("button", { name: "feed.yt.searchFor" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,11 @@ import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api";
|
||||
import i18n from "../../i18n";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify, resolveVideo } from "../../lib/notifications";
|
||||
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
|
||||
import { patchCachedProgress, patchRelated, progressFlushes } from "../../lib/progressCache";
|
||||
import {
|
||||
buildSort,
|
||||
feedSortOptions,
|
||||
@@ -178,6 +180,8 @@ export default function Feed({
|
||||
null,
|
||||
);
|
||||
const qc = useQueryClient();
|
||||
// Live search turned off on this instance: the empty feed offers no search that would be refused.
|
||||
const searchOn = useMe().search_enabled;
|
||||
|
||||
// What changed state while the player was open, and the writes still in flight: closing it
|
||||
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
|
||||
@@ -339,18 +343,23 @@ export default function Feed({
|
||||
|
||||
// The toast's Undo re-enters onState, a forward reference: through this ref (set below).
|
||||
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
|
||||
// A write made while the player is open joins its session: the list refetches once the player
|
||||
// has closed and every such write has landed (the effect above). The player's own related list
|
||||
// writes through this too — its marks reach videos this list may hold.
|
||||
const trackSessionWrite = useCallback((write: Promise<unknown>) => {
|
||||
const session = playerSession.current;
|
||||
if (!session.open) return;
|
||||
session.changed = true;
|
||||
session.pending.add(write);
|
||||
const settled = () => void session.pending.delete(write);
|
||||
write.then(settled, settled);
|
||||
}, []);
|
||||
const onState = useCallback(
|
||||
(id: string, status: VideoStatus) => {
|
||||
(id: string, status: VideoStatus, video?: NoticeVideo) => {
|
||||
const seq = ++writeSeq.current;
|
||||
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
|
||||
const write = api.setState(id, status);
|
||||
const session = playerSession.current;
|
||||
if (session.open) {
|
||||
session.changed = true;
|
||||
session.pending.add(write);
|
||||
const settled = () => void session.pending.delete(write);
|
||||
write.then(settled, settled);
|
||||
}
|
||||
trackSessionWrite(write);
|
||||
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
||||
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
|
||||
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
|
||||
@@ -364,44 +373,10 @@ export default function Feed({
|
||||
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
|
||||
answered.current.add(seq);
|
||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
if (status === "hidden") {
|
||||
const v = loadedRef.current.find((x) => x.id === id);
|
||||
notify({
|
||||
message: v?.title
|
||||
? i18n.t("feed.hiddenNamed", { title: v.title })
|
||||
: i18n.t("feed.hidden"),
|
||||
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
|
||||
meta: {
|
||||
kind: "video-hidden",
|
||||
videoId: id,
|
||||
title: v?.title ?? i18n.t("feed.thisVideo"),
|
||||
channelId: v?.channel_id ?? "",
|
||||
channelName: v?.channel_title ?? "",
|
||||
},
|
||||
});
|
||||
} else if (status === "watched") {
|
||||
const v = loadedRef.current.find((x) => x.id === id);
|
||||
notify({
|
||||
message: v?.title
|
||||
? i18n.t("feed.markedWatchedNamed", { title: v.title })
|
||||
: i18n.t("feed.markedWatched"),
|
||||
action: {
|
||||
label: i18n.t("feed.unwatch"),
|
||||
onClick: () => onStateRef.current(id, "new"),
|
||||
},
|
||||
meta: {
|
||||
kind: "video-watched",
|
||||
videoId: id,
|
||||
title: v?.title ?? i18n.t("feed.thisVideo"),
|
||||
channelId: v?.channel_id ?? "",
|
||||
channelName: v?.channel_title ?? "",
|
||||
},
|
||||
});
|
||||
} else if (status === "new") {
|
||||
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
|
||||
// any stale hide/watch notice for this video so it doesn't linger with a dead action.
|
||||
resolveVideo(id);
|
||||
}
|
||||
patchRelated(qc, id, { status });
|
||||
// The video the player is on may be none of this list's (a related one): it names itself.
|
||||
const v = video ?? loadedRef.current.find((x) => x.id === id);
|
||||
announceVideoState(id, status, v, () => onStateRef.current(id, "new"));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// Server rejected the change — drop the optimistic override so the card
|
||||
@@ -411,7 +386,7 @@ export default function Feed({
|
||||
setOverrides((o) => withoutWrite(o, id, seq));
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
[qc, trackSessionWrite],
|
||||
);
|
||||
|
||||
const onResetState = useCallback(
|
||||
@@ -647,6 +622,7 @@ export default function Feed({
|
||||
queue={ytPlayerQueue}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
onWrite={trackSessionWrite}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@@ -928,7 +904,7 @@ export default function Feed({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{filters.q.trim() && (
|
||||
{filters.q.trim() && searchOn && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
icon={<YouTubeIcon />}
|
||||
@@ -971,6 +947,7 @@ export default function Feed({
|
||||
queue={playerQueue}
|
||||
onClose={closePlayer}
|
||||
onState={onState}
|
||||
onWrite={trackSessionWrite}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,7 @@ function tipFor(source: Me["search_source"]): string | null {
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
me = { search_source: source, role: "user" } as Me;
|
||||
me = { search_source: source, search_enabled: true, role: "user" } as Me;
|
||||
render(
|
||||
<PageShell header={<div />}>
|
||||
<FeedHeader />
|
||||
@@ -63,3 +63,22 @@ describe("the live-search tooltip", () => {
|
||||
expect(tipFor("api")).toBe("header.searchYoutubeTipApi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the live-search button", () => {
|
||||
it("is left out when the instance turned live search off", () => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
me = { search_source: "scrape", search_enabled: false, role: "user" } as Me;
|
||||
render(
|
||||
<PageShell header={<div />}>
|
||||
<FeedHeader />
|
||||
</PageShell>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /header\.searchYoutube$/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,8 @@ export default function FeedHeader() {
|
||||
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
||||
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
||||
}}
|
||||
onGo={() => enterYtSearch(trimmed)}
|
||||
// Turned off on this instance, the box only filters: no Go that could only be refused.
|
||||
onGo={me.search_enabled ? () => enterYtSearch(trimmed) : undefined}
|
||||
goLabel={t("header.searchYoutube")}
|
||||
goTitle={t(
|
||||
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
|
||||
|
||||
@@ -495,6 +495,31 @@ describe("Home", () => {
|
||||
expect(screen.queryByText("Hero v1")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts a mark into the player's related lists, which are never refetched", async () => {
|
||||
const { qc } = mountWithClient();
|
||||
await screen.findByText("Second thing");
|
||||
qc.setQueryData(qk.related("SRC", "topical"), {
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
{ id: "v1", status: "new" },
|
||||
{ id: "v2", status: "new" },
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
fireEvent.click(screen.getAllByTitle("card.markWatched")[0]!);
|
||||
await waitFor(() => {
|
||||
const cached = qc.getQueryData(qk.related("SRC", "topical")) as {
|
||||
pages: { items: { status: string }[] }[];
|
||||
};
|
||||
expect(cached.pages[0]!.items.some((v) => v.status === "watched")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes the payload the bookmark is read from", async () => {
|
||||
mount();
|
||||
await screen.findByText("Second thing");
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { patchRelated } from "../../lib/progressCache";
|
||||
import { plexLang } from "../../i18n";
|
||||
import {
|
||||
api,
|
||||
@@ -838,7 +839,11 @@ export default function Home() {
|
||||
// The feed avoids this because it would refetch ten infinite-query pages per click.
|
||||
void api
|
||||
.setState(id, status)
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.discover() }))
|
||||
.then(() => {
|
||||
// The player's related lists are never refetched: the mark goes into them.
|
||||
patchRelated(qc, id, { status });
|
||||
return qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
})
|
||||
// Without this the rejection escapes unhandled and the click just does nothing — the
|
||||
// feed's equivalents both catch, and the reorder on this same page already does.
|
||||
// A WRITE key, not `home.error`: that string is the page's LOAD failure ("could not load
|
||||
|
||||
@@ -363,6 +363,25 @@ describe("Playlists — the player outlives a change of selection", () => {
|
||||
});
|
||||
expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("puts the player's mark into its related lists, which are never refetched", async () => {
|
||||
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
|
||||
mount({ 1: [vid("V", 0)] });
|
||||
qc.setQueryData(qk.related("SRC", "topical"), {
|
||||
pages: [{ items: [{ id: "V", status: "new" }], has_more: false, next_cursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
await act(async () => {
|
||||
h.player!.onState("V", "watched");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
const cached = qc.getQueryData(qk.related("SRC", "topical")) as {
|
||||
pages: { items: { status: string }[] }[];
|
||||
};
|
||||
expect(cached.pages[0]!.items[0]!.status).toBe("watched");
|
||||
});
|
||||
});
|
||||
|
||||
const rowsOnScreen = () =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLatest, useStableCallback } from "../../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { patchRelated } from "../../lib/progressCache";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -21,7 +22,13 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Check, FolderInput, History, ListPlus, Play, X } from "lucide-react";
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { api, type Playlist, type PlaylistDetail, type Video } from "../../lib/api";
|
||||
import {
|
||||
api,
|
||||
type Playlist,
|
||||
type PlaylistDetail,
|
||||
type Video,
|
||||
type VideoStatus,
|
||||
} from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
@@ -603,7 +610,7 @@ function PlaylistDetail() {
|
||||
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
|
||||
}
|
||||
|
||||
function playerState(id: string, status: string) {
|
||||
function playerState(id: string, status: VideoStatus) {
|
||||
api
|
||||
.setState(id, status)
|
||||
.then(() => {
|
||||
@@ -612,6 +619,8 @@ function PlaylistDetail() {
|
||||
// status just changed. Only lists actually on screen refetch.
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
// The player's related lists are never refetched: the mark goes into them.
|
||||
patchRelated(qc, id, { status });
|
||||
})
|
||||
// No catch used to mean an unhandled rejection and nothing said.
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||
|
||||
@@ -41,6 +41,8 @@ vi.mock("../lib/keymap", () => ({
|
||||
usePaletteOpen: () => true,
|
||||
closePalette: vi.fn(),
|
||||
toggleKeyboardHelp: vi.fn(),
|
||||
// listNav, loaded under the palette, hands the sheet its list probe as it loads.
|
||||
setListInTopLayerProbe: vi.fn(),
|
||||
}));
|
||||
|
||||
// Each module's saved views, and the page the palette opens over.
|
||||
|
||||
Reference in New Issue
Block a user