fix(player): marks are weighed when answered, lit and pressed hold under the pointer, and Restart is one glyph
The watched toggle weighed its own click at click time against marks stamped when the server answered, so the late answer of its previous click overruled the newer one; it now holds its word while any of its writes is on the way, is stamped at the answer, and drops a refused write. The hosts return the write. In a live search the feed's own answered writes stand down over the rows, which carry the marks, so a newer mark from the related list reaches a card the feed touched earlier. A lit mode cycle got the hover layer, and a variant's hover colour outranked the pressed and lit colours: the state layer stands down on [data-lit] and those colours are important. The menus' Play from the beginning wears RotateCcw like the card's Restart, Reset wears Eraser and Redownload the retry arrow (DESIGN decision log). Tests pin the toggle's orders, the Watch later mark at its call sites, the live-search overrides, the hover exemptions and the menu glyphs.
This commit is contained in:
@@ -39,7 +39,7 @@ import KeyboardHelp from "../../components/ui/KeyboardHelp";
|
||||
import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
|
||||
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { api, type Me, type Video } from "../../lib/api";
|
||||
import { api, type Me, type Video, type VideoStatus } from "../../lib/api";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { writeVideoState } from "../video/videoWrites";
|
||||
@@ -139,7 +139,13 @@ describe("PlayerModal — where each item starts", () => {
|
||||
const B = vid("B", 0);
|
||||
clock.current = 830;
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 748 }));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||
@@ -175,7 +181,7 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
|
||||
queue={[A!, B!, C!]}
|
||||
startIndex={1}
|
||||
onClose={() => {}}
|
||||
onState={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
onRemoveFromList={async () => true}
|
||||
/>,
|
||||
);
|
||||
@@ -194,7 +200,7 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
|
||||
queue={[A!]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
onRemoveFromList={async () => true}
|
||||
/>,
|
||||
);
|
||||
@@ -211,7 +217,7 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
|
||||
queue={[A!, B!]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
onRemoveFromList={async () => false}
|
||||
/>,
|
||||
);
|
||||
@@ -236,7 +242,7 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
|
||||
queue={[A!, B!, C!, D!]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
onRemoveFromList={() => new Promise<boolean>((r) => (answer = r))}
|
||||
/>,
|
||||
);
|
||||
@@ -270,7 +276,7 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
|
||||
queue={[A!, B!]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
onRemoveFromList={(v) =>
|
||||
confirm({ title: "t", message: v.title, confirmLabel: "remove", danger: true })
|
||||
}
|
||||
@@ -299,7 +305,13 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
|
||||
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
|
||||
@@ -311,7 +323,13 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const stage = document.querySelector(".player-stage")!;
|
||||
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
|
||||
@@ -334,7 +352,13 @@ describe("PlayerModal — the dialog contract", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
return renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -478,7 +502,13 @@ describe("PlayerModal — the description", () => {
|
||||
} as Awaited<ReturnType<typeof api.videoDetail>>);
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.mouseEnter(screen.getByRole("heading", { name: "Video A" }));
|
||||
fireEvent.pointerEnter(screen.getByRole("heading", { name: "Video A" }));
|
||||
@@ -499,7 +529,13 @@ describe("PlayerModal — no side strips", () => {
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
|
||||
@@ -516,7 +552,13 @@ describe("PlayerModal — no side strips", () => {
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const named = [
|
||||
"player.previous",
|
||||
@@ -537,7 +579,13 @@ describe("PlayerModal — no side strips", () => {
|
||||
const onClose = vi.fn();
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const close = screen.getByTestId("player-close");
|
||||
expect(close.getAttribute("aria-label")).toBe("player.close");
|
||||
@@ -556,7 +604,13 @@ describe("PlayerModal — the click layer over the video", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
return screen.getAllByTestId("yt-click-layer");
|
||||
};
|
||||
@@ -631,7 +685,13 @@ describe("PlayerModal — fits the window's height", () => {
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
// stage → its wrapper (with the clip panel) → the card → the row
|
||||
const row =
|
||||
@@ -678,7 +738,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
||||
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
|
||||
@@ -708,7 +774,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const row = () =>
|
||||
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
|
||||
@@ -745,7 +817,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
// Shut by the user: closing the clip leaves it shut, and its strip has no scissors.
|
||||
@@ -766,7 +844,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
|
||||
@@ -801,7 +885,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
} as Awaited<ReturnType<typeof api.videoDetail>>);
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const text = await screen.findByText("A long description");
|
||||
const panel = screen.getByTestId("player-info");
|
||||
@@ -841,7 +931,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
});
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
act(() => playerEvents.onReady?.());
|
||||
// Closed until asked: nothing is fetched for a player nobody opened the list in.
|
||||
@@ -860,7 +956,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
// 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,
|
||||
onState: Parameters<typeof PlayerModal>[0]["onState"],
|
||||
queue?: Video[],
|
||||
opts: { onRemoveFromList?: (v: Video) => Promise<boolean>; before?: () => void } = {},
|
||||
) => {
|
||||
@@ -890,7 +986,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
};
|
||||
|
||||
it("wide: a related video played in place shows its own title and controls", async () => {
|
||||
await playRel2(() => {});
|
||||
await playRel2(() => Promise.resolve());
|
||||
expect(screen.getByRole("heading", { name: "Video rel2" })).toBeTruthy();
|
||||
expect(screen.getAllByTestId("add-rel2").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("download-rel2").length).toBeGreaterThan(0);
|
||||
@@ -903,7 +999,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
position_seconds: id === "rel2" ? 500 : 77,
|
||||
}));
|
||||
clock.current = 77;
|
||||
await playRel2(() => {});
|
||||
await playRel2(() => Promise.resolve());
|
||||
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).
|
||||
@@ -934,7 +1030,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
position_seconds: id === "rel2" ? 500 : 77,
|
||||
}));
|
||||
clock.current = 77;
|
||||
await playRel2(() => {});
|
||||
await playRel2(() => Promise.resolve());
|
||||
clock.current = 500;
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
@@ -952,7 +1048,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
// Its row stays in the list while it plays; a new player for the same video is never built (one
|
||||
// per video), so Restart there is a seek.
|
||||
it("wide: Restart on the related video already playing seeks it to 0", async () => {
|
||||
await playRel2(() => {});
|
||||
await playRel2(() => Promise.resolve());
|
||||
const before = started.length;
|
||||
const list = screen.getByTestId("player-related");
|
||||
const row = within(list)
|
||||
@@ -977,7 +1073,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
});
|
||||
const A = vid("A", 40);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
act(() => playerEvents.onReady?.());
|
||||
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||
@@ -1029,7 +1131,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await settle();
|
||||
@@ -1053,8 +1161,83 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(screen.getAllByTitle("player.markWatched").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The toggle's own writes come back as session marks too, stamped when the server answers: each
|
||||
// side is weighed at its answer, so neither a later mark nor a late answer of its own is misread.
|
||||
const hostedToggle = () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
wideWindow(true);
|
||||
const host: { qc?: QueryClient } = {};
|
||||
// As the hosts do: the write goes out, its failure is the host's to say, the write is returned.
|
||||
const onState = (id: string, status: VideoStatus) => {
|
||||
const write = writeVideoState(host.qc!, id, status);
|
||||
write.catch(() => {});
|
||||
return write;
|
||||
};
|
||||
host.qc = renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={onState} />,
|
||||
).qc;
|
||||
return host.qc;
|
||||
};
|
||||
const answersInTurn = () => {
|
||||
const answers: Array<() => void> = [];
|
||||
vi.spyOn(api, "setState").mockImplementation(
|
||||
() => new Promise((resolve) => answers.push(() => resolve({ ok: true } as never))),
|
||||
);
|
||||
return answers;
|
||||
};
|
||||
const shows = (title: string) => expect(screen.getAllByTitle(title).length).toBeGreaterThan(0);
|
||||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
it("takes a mark written elsewhere after its own click (a notice's Undo)", async () => {
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = hostedToggle();
|
||||
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
|
||||
await settle();
|
||||
shows("player.watchedUnmark");
|
||||
await act(async () => void (await tick()));
|
||||
await act(async () => void (await writeVideoState(qc, "A", "new")));
|
||||
await waitFor(() => shows("player.markWatched"));
|
||||
});
|
||||
|
||||
it("keeps its last click while the answer to the one before it is late", async () => {
|
||||
const answers = answersInTurn();
|
||||
hostedToggle();
|
||||
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
|
||||
fireEvent.click(screen.getAllByTitle("player.watchedUnmark")[0]!);
|
||||
await act(async () => void answers[0]!());
|
||||
await settle();
|
||||
shows("player.markWatched");
|
||||
await act(async () => void (await tick()));
|
||||
await act(async () => void answers[1]!());
|
||||
await settle();
|
||||
shows("player.markWatched");
|
||||
});
|
||||
|
||||
it("keeps its last click when the answers come back the other way round", async () => {
|
||||
const answers = answersInTurn();
|
||||
hostedToggle();
|
||||
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
|
||||
fireEvent.click(screen.getAllByTitle("player.watchedUnmark")[0]!);
|
||||
await act(async () => void answers[1]!());
|
||||
await settle();
|
||||
await act(async () => void (await tick()));
|
||||
await act(async () => void answers[0]!());
|
||||
await settle();
|
||||
shows("player.markWatched");
|
||||
});
|
||||
|
||||
it("drops its mark when the server refused it", async () => {
|
||||
vi.spyOn(api, "setState").mockRejectedValue(new Error("offline"));
|
||||
hostedToggle();
|
||||
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
|
||||
await settle();
|
||||
shows("player.markWatched");
|
||||
expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wide: a queue step leaves the related video for the queue's next item", async () => {
|
||||
await playRel2(() => {}, [vid("A", 40), vid("B", 0)]);
|
||||
await playRel2(() => Promise.resolve(), [vid("A", 40), vid("B", 0)]);
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
expect(screen.getByRole("heading", { name: "Video B" })).toBeTruthy();
|
||||
@@ -1063,7 +1246,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
|
||||
// Removing from the list removes the queue's item — not the related video on screen.
|
||||
it("wide: offers no removal from the list while a related video plays", async () => {
|
||||
await playRel2(() => {}, undefined, { onRemoveFromList: async () => true });
|
||||
await playRel2(() => Promise.resolve(), undefined, { onRemoveFromList: async () => true });
|
||||
expect(screen.queryByRole("button", { name: /playlists\.removeItemOf/ })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
@@ -1076,14 +1259,26 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
const A = vid("A", 0);
|
||||
wideWindow(true);
|
||||
const wideView = renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
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={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "player.related.title" })).toBeNull();
|
||||
});
|
||||
@@ -1103,7 +1298,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
});
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
act(() => playerEvents.onReady?.());
|
||||
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||
@@ -1139,7 +1340,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
});
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
act(() => playerEvents.onReady?.());
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.related.title" }));
|
||||
@@ -1163,7 +1370,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
const A = vid("A", 0);
|
||||
const onClose = vi.fn();
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
act(() => playerEvents.onReady?.());
|
||||
expect(screen.queryByRole("tab", { name: "player.related.title" })).toBeNull();
|
||||
@@ -1188,7 +1401,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
});
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
act(() => playerEvents.onReady?.());
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.related.title" }));
|
||||
@@ -1247,7 +1466,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
qc.setQueryData(qk.me(), me);
|
||||
const ui = () => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
wideWindow(false);
|
||||
@@ -1273,7 +1498,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("tablist").className.split(" ");
|
||||
expect(row).toEqual(expect.arrayContaining(["flex", "flex-wrap", "gap-1"]));
|
||||
@@ -1284,7 +1515,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const stage = document.querySelector<HTMLElement>(".player-stage")!;
|
||||
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
|
||||
@@ -1310,7 +1547,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
>);
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const panel = screen.getByTestId("player-description");
|
||||
expect(await within(panel).findByText("player.descriptionFailed")).toBeTruthy();
|
||||
@@ -1326,7 +1569,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom"));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "player.description" }));
|
||||
expect(await screen.findByText("player.descriptionFailed")).toBeTruthy();
|
||||
@@ -1338,7 +1587,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
const card = document.querySelector<HTMLElement>(".player-stage")!.parentElement!;
|
||||
expect(card.className).not.toMatch(/(^|\s)flex(\s|$)/);
|
||||
@@ -1365,7 +1620,13 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("player.fullscreen"));
|
||||
return screen.getAllByLabelText("player.fullscreen").length;
|
||||
@@ -1399,7 +1660,13 @@ describe("PlayerModal — clip mode", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1449,7 +1716,13 @@ describe("PlayerModal — clip mode", () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const S = { ...vid("S", 0), is_short: true } as Video;
|
||||
renderModal(
|
||||
<PlayerModal video={S} queue={[S]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={S}
|
||||
queue={[S]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("S"));
|
||||
key("c");
|
||||
@@ -1524,7 +1797,13 @@ describe("PlayerModal — clip mode", () => {
|
||||
const A = vid("A", 0);
|
||||
const B = vid("B", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, B]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => Promise.resolve()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
// Auto-advance on: an ending video steps on to B.
|
||||
|
||||
@@ -163,6 +163,24 @@ const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
||||
// the line wraps and the meta, or the actions after it, move down instead of squeezing.
|
||||
const META_MIN = "min-w-48 flex-1";
|
||||
|
||||
// The watched toggle's own word on an item: what it last asked for, how many of its writes are still
|
||||
// on the way, and when the last of them was answered (Infinity until then).
|
||||
type OwnMark = { status: VideoStatus; seq: number; pending: number; at: number };
|
||||
|
||||
// One of the toggle's writes was answered: stamped once none is left on the way; the latest refused,
|
||||
// the toggle's word is dropped and the item shows what the server holds.
|
||||
function settled(marks: Record<string, OwnMark>, id: string, seq: number, ok: boolean) {
|
||||
const own = marks[id];
|
||||
if (!own) return marks;
|
||||
if (!ok && own.seq === seq) {
|
||||
const rest = { ...marks };
|
||||
delete rest[id];
|
||||
return rest;
|
||||
}
|
||||
const pending = own.pending - 1;
|
||||
return { ...marks, [id]: { ...own, pending, at: pending > 0 ? Infinity : Date.now() } };
|
||||
}
|
||||
|
||||
export default function PlayerModal({
|
||||
video,
|
||||
startAt,
|
||||
@@ -184,8 +202,9 @@ export default function PlayerModal({
|
||||
startIndex?: number;
|
||||
onClose: () => 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;
|
||||
* named by `video`, which may be none of the host's (a related video played in place). Returns
|
||||
* the write: the toggle holds its own word until the server has answered, and drops a refused one. */
|
||||
onState: (id: string, status: VideoStatus, video?: Video) => Promise<unknown>;
|
||||
/** 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;
|
||||
@@ -554,7 +573,12 @@ export default function PlayerModal({
|
||||
// The newest word wins: this toggle's own mark, or one the session confirmed elsewhere since the
|
||||
// player opened (the related row's own menu, a notice's Undo — `useVideoMarks`), else the item as
|
||||
// it was opened. The toggle alone missed those, and its first click re-sent the same status.
|
||||
const [marks, setMarks] = useState<Record<string, { status: VideoStatus; at: number }>>({});
|
||||
// Both are weighed when the server answered them: the toggle's own writes come back as session
|
||||
// marks too, stamped at their answer, so a click weighed at its own time lost to the late answer
|
||||
// of the click before it. While any of its writes is on the way the toggle's word holds; a refused
|
||||
// one is dropped, so the toggle does not keep showing what the server turned down.
|
||||
const [marks, setMarks] = useState<Record<string, OwnMark>>({});
|
||||
const ownSeq = useRef(0);
|
||||
const [openedAt] = useState(() => Date.now());
|
||||
const sessionMarks = useVideoMarks();
|
||||
const own = marks[subject.id];
|
||||
@@ -566,9 +590,17 @@ export default function PlayerModal({
|
||||
: (elsewhere?.value ?? subject.status);
|
||||
const watched = status === "watched";
|
||||
const setWatched = (on: boolean) => {
|
||||
const id = subject.id;
|
||||
const next = on ? "watched" : "new";
|
||||
setMarks((m) => ({ ...m, [subject.id]: { status: next, at: Date.now() } }));
|
||||
onState(subject.id, next, subject);
|
||||
const seq = ++ownSeq.current;
|
||||
setMarks((m) => ({
|
||||
...m,
|
||||
[id]: { status: next, seq, pending: (m[id]?.pending ?? 0) + 1, at: Infinity },
|
||||
}));
|
||||
Promise.resolve(onState(id, next, subject)).then(
|
||||
() => setMarks((m) => settled(m, id, seq, true)),
|
||||
() => setMarks((m) => settled(m, id, seq, false)),
|
||||
);
|
||||
};
|
||||
|
||||
// On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
|
||||
|
||||
Reference in New Issue
Block a user