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:
2026-10-25 13:51:26 +01:00
parent 13f65b4844
commit 0bc1024287
18 changed files with 629 additions and 103 deletions
+9 -5
View File
@@ -225,9 +225,10 @@ Also in force:
tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex `Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A mode cycle (the player's `pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`, kept under the pointer
auto-advance and loop) is no two-state switch: `lit` gives it the same look when it is not off, without (no hover layer or hover colour over it). A mode cycle (the player's auto-advance and loop) is no
`aria-pressed`, and its name says the mode. A raw `<button>` (or a bare two-state switch: `lit` gives it the same look when it is not off, without `aria-pressed`, and its name
says the mode. A raw `<button>` (or a bare
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces `FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces
it. [WCAG 4.1.2] it. [WCAG 4.1.2]
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch). - **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
@@ -253,8 +254,10 @@ Also in force:
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice. - A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
[Nielsen #1] [Nielsen #1]
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the - **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
refresh arrow (`RefreshCw`); a reset or revert is the counter-clockwise arrow (`RotateCcw`). A failed refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the
download's Retry had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous / counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its
menu alike; clearing a video's progress and status is the eraser (`Eraser`). A failed download's Retry
had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous /
Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous, Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous,
`Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the `Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the
panel's glyph-only row. [Nielsen #4] panel's glyph-only row. [Nielsen #4]
@@ -820,3 +823,4 @@ of a full-width panel and nothing on the right.
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered | | 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words | | 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which | | 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
@@ -34,6 +34,7 @@ import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd"; import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./features/channel/TagManager"; import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api"; import { api, HttpError, type PlexCard } from "./lib/api";
import { qk } from "./lib/queryKeys";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -157,6 +158,52 @@ describe("the add-to-playlist popover", () => {
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
}); });
// The built-in Watch later list's items ARE the bookmark: ticked here, the video is saved wherever
// it is shown (a related list, a live search), and a user list's tick marks nothing.
it("marks the video saved when Watch later is ticked, and unsaved when it is unticked", async () => {
// The server's side: the lists the video is on, so the refetch after a write agrees with it.
const on = new Set<number>();
vi.spyOn(api, "playlists").mockImplementation(
async () =>
[
{ id: 9, name: "Watch later", kind: "watch_later", has_video: on.has(9) },
{ id: 3, name: "Mix", kind: "user", has_video: on.has(3) },
] as never,
);
vi.spyOn(api, "addToPlaylist").mockImplementation(async (id: number) => {
on.add(id);
return {} as never;
});
vi.spyOn(api, "removeFromPlaylist").mockImplementation(async (id: number) => {
on.delete(id);
return {} as never;
});
const qc = client();
const saved = () =>
qc.getQueryData<Record<string, { saved?: { value: boolean } }>>(qk.videoMarks())?.v1?.saved
?.value;
render(
<QueryClientProvider client={qc}>
<AddToPlaylist videoId="v1" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
const row = async (name: string) =>
(await screen.findAllByRole("menuitemcheckbox", { hidden: true })).find((r) =>
r.textContent?.includes(name),
)!;
fireEvent.click(await row("Mix"));
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledTimes(1));
expect(saved()).toBeUndefined();
fireEvent.click(await row("playlists.watchLater"));
await waitFor(() => expect(saved()).toBe(true));
await waitFor(async () =>
expect((await row("playlists.watchLater")).getAttribute("aria-checked")).toBe("true"),
);
fireEvent.click(await row("playlists.watchLater"));
await waitFor(() => expect(saved()).toBe(false));
});
it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => { it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => {
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down")); vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
render( render(
+10 -7
View File
@@ -57,14 +57,16 @@ const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
}; };
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid // A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too. // accent"); the layer utilities leave it alone, and its colours are marked important (`!`) because a
// variant's hover colour (`act:hover:text-fg`, the overlay's `act:hover:bg-white/15`) is the more
// specific selector — without it the accent turned grey, or white, under the pointer.
const PRESSED = const PRESSED =
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline"; "aria-pressed:bg-accent! aria-pressed:text-accent-fg! aria-pressed:border-accent! aria-pressed:no-underline!";
// A mode cycle that is not off (`lit`: auto-advance Next, loop One) looks pressed but is no toggle — // A mode cycle that is not off (`lit`: auto-advance Next, loop One) looks pressed but is no toggle —
// its name says the mode, and aria-pressed would announce a two-state switch whose name changes on // its name says the mode, and aria-pressed would announce a two-state switch whose name changes on
// every press. Keyed to an attribute like PRESSED, so it outranks the variant's own colours. // every press. The same look as PRESSED, under the pointer too: the layers stand down on `[data-lit]`.
const LIT = const LIT =
"data-lit:bg-accent data-lit:text-accent-fg data-lit:border-accent data-lit:no-underline"; "data-lit:bg-accent! data-lit:text-accent-fg! data-lit:border-accent! data-lit:no-underline!";
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and // B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so // small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
@@ -125,9 +127,10 @@ const ICON_VARIANTS: Record<IconButtonVariant, string> = {
}; };
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid // An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action. // fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action. Important
const ICON_PRESSED = "aria-pressed:text-chosen"; // for the same reason as PRESSED: the variant's hover colour would take the tint off under the pointer.
const ICON_LIT = "data-lit:text-chosen"; const ICON_PRESSED = "aria-pressed:text-chosen!";
const ICON_LIT = "data-lit:text-chosen!";
const ICON_SIZES: Record<IconButtonSize, string> = { const ICON_SIZES: Record<IconButtonSize, string> = {
sm: "size-7 [&>svg]:size-4", sm: "size-7 [&>svg]:size-4",
@@ -39,7 +39,7 @@ import KeyboardHelp from "../../components/ui/KeyboardHelp";
import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap"; import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider"; import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext"; 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 { clearAll, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { writeVideoState } from "../video/videoWrites"; import { writeVideoState } from "../video/videoWrites";
@@ -139,7 +139,13 @@ describe("PlayerModal — where each item starts", () => {
const B = vid("B", 0); const B = vid("B", 0);
clock.current = 830; clock.current = 830;
renderModal( 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 })); await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 748 }));
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!); 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!]} queue={[A!, B!, C!]}
startIndex={1} startIndex={1}
onClose={() => {}} onClose={() => {}}
onState={() => {}} onState={() => Promise.resolve()}
onRemoveFromList={async () => true} onRemoveFromList={async () => true}
/>, />,
); );
@@ -194,7 +200,7 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
queue={[A!]} queue={[A!]}
startIndex={0} startIndex={0}
onClose={onClose} onClose={onClose}
onState={() => {}} onState={() => Promise.resolve()}
onRemoveFromList={async () => true} onRemoveFromList={async () => true}
/>, />,
); );
@@ -211,7 +217,7 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
queue={[A!, B!]} queue={[A!, B!]}
startIndex={0} startIndex={0}
onClose={onClose} onClose={onClose}
onState={() => {}} onState={() => Promise.resolve()}
onRemoveFromList={async () => false} onRemoveFromList={async () => false}
/>, />,
); );
@@ -236,7 +242,7 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
queue={[A!, B!, C!, D!]} queue={[A!, B!, C!, D!]}
startIndex={0} startIndex={0}
onClose={() => {}} onClose={() => {}}
onState={() => {}} onState={() => Promise.resolve()}
onRemoveFromList={() => new Promise<boolean>((r) => (answer = r))} onRemoveFromList={() => new Promise<boolean>((r) => (answer = r))}
/>, />,
); );
@@ -270,7 +276,7 @@ describe("PlayerModal — removing the playing video from its playlist", () => {
queue={[A!, B!]} queue={[A!, B!]}
startIndex={0} startIndex={0}
onClose={() => {}} onClose={() => {}}
onState={() => {}} onState={() => Promise.resolve()}
onRemoveFromList={(v) => onRemoveFromList={(v) =>
confirm({ title: "t", message: v.title, confirmLabel: "remove", danger: true }) 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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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." })); act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
expect(screen.getByTestId("player-modal").textContent).toContain("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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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")!; const stage = document.querySelector(".player-stage")!;
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => 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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
return renderModal( 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>>); } as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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.mouseEnter(screen.getByRole("heading", { name: "Video A" }));
fireEvent.pointerEnter(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 A = vid("A", 0);
const B = vid("B", 0); const B = vid("B", 0);
renderModal( 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.next" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "player.previous" })).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 A = vid("A", 0);
const B = vid("B", 0); const B = vid("B", 0);
renderModal( 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 = [ const named = [
"player.previous", "player.previous",
@@ -537,7 +579,13 @@ describe("PlayerModal — no side strips", () => {
const onClose = vi.fn(); const onClose = vi.fn();
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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"); const close = screen.getByTestId("player-close");
expect(close.getAttribute("aria-label")).toBe("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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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"); return screen.getAllByTestId("yt-click-layer");
}; };
@@ -631,7 +685,13 @@ describe("PlayerModal — fits the window's height", () => {
const A = vid("A", 0); const A = vid("A", 0);
const B = vid("B", 0); const B = vid("B", 0);
renderModal( 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 // stage → its wrapper (with the clip panel) → the card → the row
const row = const row =
@@ -678,7 +738,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
const A = vid("A", 0); const A = vid("A", 0);
const B = vid("B", 0); const B = vid("B", 0);
renderModal( 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 stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel) 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 A = vid("A", 0);
const B = vid("B", 0); const B = vid("B", 0);
renderModal( 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 = () => const row = () =>
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement! 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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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")); await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
// Shut by the user: closing the clip leaves it shut, and its strip has no scissors. // 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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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")); await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(screen.getByRole("button", { name: "clip.open" })); 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>>); } as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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 text = await screen.findByText("A long description");
const panel = screen.getByTestId("player-info"); const panel = screen.getByTestId("player-info");
@@ -841,7 +931,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
}); });
const A = vid("A", 0); const A = vid("A", 0);
renderModal( renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />, <PlayerModal
video={A}
queue={[A]}
startIndex={0}
onClose={() => {}}
onState={() => Promise.resolve()}
/>,
); );
act(() => playerEvents.onReady?.()); act(() => playerEvents.onReady?.());
// Closed until asked: nothing is fetched for a player nobody opened the list in. // 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 // 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. // queue stays where it was and Back returns to the opened one.
const playRel2 = async ( const playRel2 = async (
onState: (id: string, status: string, v?: Video) => void, onState: Parameters<typeof PlayerModal>[0]["onState"],
queue?: Video[], queue?: Video[],
opts: { onRemoveFromList?: (v: Video) => Promise<boolean>; before?: () => void } = {}, 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 () => { 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.getByRole("heading", { name: "Video rel2" })).toBeTruthy();
expect(screen.getAllByTestId("add-rel2").length).toBeGreaterThan(0); expect(screen.getAllByTestId("add-rel2").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("download-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, position_seconds: id === "rel2" ? 500 : 77,
})); }));
clock.current = 77; clock.current = 77;
await playRel2(() => {}); await playRel2(() => Promise.resolve());
clock.current = 500; clock.current = 500;
fireEvent.click(screen.getByRole("button", { name: "player.back" })); fireEvent.click(screen.getByRole("button", { name: "player.back" }));
// Where leaving it for the related one checkpointed it, not the queue's snapshot (40). // 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, position_seconds: id === "rel2" ? 500 : 77,
})); }));
clock.current = 77; clock.current = 77;
await playRel2(() => {}); await playRel2(() => Promise.resolve());
clock.current = 500; clock.current = 500;
fireEvent.click(screen.getByRole("button", { name: "player.back" })); fireEvent.click(screen.getByRole("button", { name: "player.back" }));
await waitFor(() => expect(lastStart()?.videoId).toBe("A")); 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 // 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. // per video), so Restart there is a seek.
it("wide: Restart on the related video already playing seeks it to 0", async () => { 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 before = started.length;
const list = screen.getByTestId("player-related"); const list = screen.getByTestId("player-related");
const row = within(list) const row = within(list)
@@ -977,7 +1073,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
}); });
const A = vid("A", 40); const A = vid("A", 40);
renderModal( renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />, <PlayerModal
video={A}
queue={[A]}
startIndex={0}
onClose={() => {}}
onState={() => Promise.resolve()}
/>,
); );
act(() => playerEvents.onReady?.()); act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" })); 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)); await new Promise((r) => setTimeout(r, 5));
render( render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} /> <PlayerModal
video={A}
queue={[A]}
startIndex={0}
onClose={() => {}}
onState={() => Promise.resolve()}
/>
</QueryClientProvider>, </QueryClientProvider>,
); );
await settle(); await settle();
@@ -1053,8 +1161,83 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(screen.getAllByTitle("player.markWatched").length).toBeGreaterThan(0); 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 () => { 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(); next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B")); await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
expect(screen.getByRole("heading", { name: "Video B" })).toBeTruthy(); 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. // 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 () => { 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(); expect(screen.queryByRole("button", { name: /playlists\.removeItemOf/ })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "player.back" })); fireEvent.click(screen.getByRole("button", { name: "player.back" }));
await waitFor(() => expect(lastStart()?.videoId).toBe("A")); 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); const A = vid("A", 0);
wideWindow(true); wideWindow(true);
const wideView = renderModal( 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.queryByRole("tab", { name: "player.related.title" })).toBeNull();
expect(screen.getByTestId("player-description")).toBeTruthy(); expect(screen.getByTestId("player-description")).toBeTruthy();
wideView.unmount(); wideView.unmount();
wideWindow(false); wideWindow(false);
renderModal( 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(); 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); const A = vid("A", 0);
renderModal( renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />, <PlayerModal
video={A}
queue={[A]}
startIndex={0}
onClose={() => {}}
onState={() => Promise.resolve()}
/>,
); );
act(() => playerEvents.onReady?.()); act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" })); 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); const A = vid("A", 0);
renderModal( renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />, <PlayerModal
video={A}
queue={[A]}
startIndex={0}
onClose={() => {}}
onState={() => Promise.resolve()}
/>,
); );
act(() => playerEvents.onReady?.()); act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("button", { name: "player.related.title" })); 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 A = vid("A", 0);
const onClose = vi.fn(); const onClose = vi.fn();
renderModal( 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?.()); act(() => playerEvents.onReady?.());
expect(screen.queryByRole("tab", { name: "player.related.title" })).toBeNull(); 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); const A = vid("A", 0);
renderModal( renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />, <PlayerModal
video={A}
queue={[A]}
startIndex={0}
onClose={() => {}}
onState={() => Promise.resolve()}
/>,
); );
act(() => playerEvents.onReady?.()); act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("button", { name: "player.related.title" })); 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); qc.setQueryData(qk.me(), me);
const ui = () => ( const ui = () => (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} /> <PlayerModal
video={A}
queue={[A]}
startIndex={0}
onClose={() => {}}
onState={() => Promise.resolve()}
/>
</QueryClientProvider> </QueryClientProvider>
); );
wideWindow(false); wideWindow(false);
@@ -1273,7 +1498,13 @@ describe("PlayerModal — the side panel on a wide window", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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(" "); const row = screen.getByRole("tablist").className.split(" ");
expect(row).toEqual(expect.arrayContaining(["flex", "flex-wrap", "gap-1"])); 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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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 stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel) 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); const A = vid("A", 0);
renderModal( 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"); const panel = screen.getByTestId("player-description");
expect(await within(panel).findByText("player.descriptionFailed")).toBeTruthy(); 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")); vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom"));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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" })); fireEvent.click(screen.getByRole("button", { name: "player.description" }));
expect(await screen.findByText("player.descriptionFailed")).toBeTruthy(); 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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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!; const card = document.querySelector<HTMLElement>(".player-stage")!.parentElement!;
expect(card.className).not.toMatch(/(^|\s)flex(\s|$)/); 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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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")); fireEvent.click(screen.getByLabelText("player.fullscreen"));
return screen.getAllByLabelText("player.fullscreen").length; return screen.getAllByLabelText("player.fullscreen").length;
@@ -1399,7 +1660,13 @@ describe("PlayerModal — clip mode", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
renderModal( 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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const S = { ...vid("S", 0), is_short: true } as Video; const S = { ...vid("S", 0), is_short: true } as Video;
renderModal( 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")); await waitFor(() => expect(lastStart()?.videoId).toBe("S"));
key("c"); key("c");
@@ -1524,7 +1797,13 @@ describe("PlayerModal — clip mode", () => {
const A = vid("A", 0); const A = vid("A", 0);
const B = vid("B", 0); const B = vid("B", 0);
renderModal( 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")); await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
// Auto-advance on: an ending video steps on to B. // Auto-advance on: an ending video steps on to B.
+37 -5
View File
@@ -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. // the line wraps and the meta, or the actions after it, move down instead of squeezing.
const META_MIN = "min-w-48 flex-1"; 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({ export default function PlayerModal({
video, video,
startAt, startAt,
@@ -184,8 +202,9 @@ export default function PlayerModal({
startIndex?: number; startIndex?: number;
onClose: () => void; onClose: () => void;
/** The player's own marks (its watched toggle, the auto-mark at the end), for the item it is on — /** 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). */ * named by `video`, which may be none of the host's (a related video played in place). Returns
onState: (id: string, status: VideoStatus, video?: Video) => void; * 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 /** 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). */ * writes have landed (the feed's player session). */
onWrite?: (write: Promise<unknown>) => void; 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 // 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 // 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. // 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 [openedAt] = useState(() => Date.now());
const sessionMarks = useVideoMarks(); const sessionMarks = useVideoMarks();
const own = marks[subject.id]; const own = marks[subject.id];
@@ -566,9 +590,17 @@ export default function PlayerModal({
: (elsewhere?.value ?? subject.status); : (elsewhere?.value ?? subject.status);
const watched = status === "watched"; const watched = status === "watched";
const setWatched = (on: boolean) => { const setWatched = (on: boolean) => {
const id = subject.id;
const next = on ? "watched" : "new"; const next = on ? "watched" : "new";
setMarks((m) => ({ ...m, [subject.id]: { status: next, at: Date.now() } })); const seq = ++ownSeq.current;
onState(subject.id, next, subject); 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, // On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react"; import { Check, Info, Layers, ListPlus, Play, RotateCcw, Tv2, Undo2 } from "lucide-react";
import { type PlexCard } from "../../lib/api"; import { type PlexCard } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -98,7 +98,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
{ {
id: "restart", id: "restart",
label: t("card.restartTitle"), label: t("card.restartTitle"),
icon: <SkipBack className={icon} />, icon: <RotateCcw className={icon} />,
onSelect: () => onRestart(c), onSelect: () => onRestart(c),
}, },
] ]
@@ -7,6 +7,7 @@ import {
Check, Check,
Download, Download,
Eye, Eye,
Eraser,
EyeOff, EyeOff,
Filter, Filter,
FilterX, FilterX,
@@ -14,7 +15,6 @@ import {
ListPlus, ListPlus,
Play, Play,
RotateCcw, RotateCcw,
SkipBack,
Tv, Tv,
Undo2, Undo2,
} from "lucide-react"; } from "lucide-react";
@@ -104,7 +104,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
{ {
id: "restart", id: "restart",
label: t("card.restartTitle"), label: t("card.restartTitle"),
icon: <SkipBack className={icon} />, icon: <RotateCcw className={icon} />,
onSelect: () => onOpen(v, 0), onSelect: () => onOpen(v, 0),
}, },
] ]
@@ -193,7 +193,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
{ {
id: "reset", id: "reset",
label: t("card.resetState"), label: t("card.resetState"),
icon: <RotateCcw className={icon} />, icon: <Eraser className={icon} />,
onSelect: () => onResetState(v.id), onSelect: () => onResetState(v.id),
}, },
] ]
+16 -4
View File
@@ -99,13 +99,25 @@
@custom-variant act (&:not(:disabled, [aria-disabled="true"])); @custom-variant act (&:not(:disabled, [aria-disabled="true"]));
/* B3: a transparent control's hover / pressed layer. The layer answers only a control that can act: /* B3: a transparent control's hover / pressed layer. The layer answers only a control that can act:
not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle, whose not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle or a
solid accent is its state and must not turn grey under the pointer. */ lit mode cycle (`data-lit`), whose look is its state and must not turn grey under the pointer. */
@utility state-layer { @utility state-layer {
&:hover:not(:disabled, [aria-disabled="true"], [aria-pressed="true"], [aria-selected="true"]) { &:hover:not(
:disabled,
[aria-disabled="true"],
[aria-pressed="true"],
[aria-selected="true"],
[data-lit]
) {
background-color: var(--hover); background-color: var(--hover);
} }
&:active:not(:disabled, [aria-disabled="true"], [aria-pressed="true"], [aria-selected="true"]) { &:active:not(
:disabled,
[aria-disabled="true"],
[aria-pressed="true"],
[aria-selected="true"],
[data-lit]
) {
background-color: var(--press); background-color: var(--press);
} }
} }
@@ -0,0 +1,34 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// DESIGN "A glyph means one thing": a menu row wears the glyph its action wears everywhere else. The
// "…" menus' Play from the beginning wore the skip arrow ⏮, which beside the player is Previous, while
// the card's own Restart wore RotateCcw — and RotateCcw was the same menu's Reset, the Files menu's
// Redownload. Each row is a `label: t("…")` followed by its `icon`.
const ROW_GLYPHS: Record<string, string> = {
"card.restartTitle": "RotateCcw",
"card.resetState": "Eraser",
"files.actions.redownload": "RefreshCw",
};
const root = new URL("../", import.meta.url);
const sources = () =>
readdirSync(root, { recursive: true, encoding: "utf8" })
.map((f) => f.replace(/\\/g, "/"))
.filter((f) => /\.tsx?$/.test(f) && !f.includes(".test.") && !f.startsWith("design-lab/"))
.map((f) => ({ f, src: readFileSync(new URL(f, root), "utf8") }));
describe("a menu row's glyph", () => {
for (const [key, glyph] of Object.entries(ROW_GLYPHS))
it(`${key} wears ${glyph}`, () => {
const row = new RegExp(
`label: t\\("${key.replace(/\./g, "\\.")}"\\),\\s*icon: (?:<|icon\\()(\\w+)`,
"g",
);
const worn = sources().flatMap(({ f, src }) =>
[...src.matchAll(row)].map((m) => `${f}: ${m[1]}`),
);
expect(worn.length, key).toBeGreaterThan(0);
expect(worn.filter((w) => !w.endsWith(`: ${glyph}`))).toEqual([]);
});
});
+22 -3
View File
@@ -147,7 +147,7 @@ it("styles exactly the schemes the app offers", () => {
// advertises a click that does nothing, and a pressed toggle that greys under the pointer hides its // advertises a click that does nothing, and a pressed toggle that greys under the pointer hides its
// own state; the primitives (Button, IconButton) rely on the utilities to hold this for them. // own state; the primitives (Button, IconButton) rely on the utilities to hold this for them.
it("the state layers stand down on a disabled, busy or pressed control", () => { it("the state layers stand down on a disabled, busy or pressed control", () => {
const guard = /:not\(:disabled, \[aria-disabled="true"\]/; const guard = /:not\(\s*:disabled,\s*\[aria-disabled="true"\]/;
for (const name of ["state-layer", "state-filled", "state-danger"]) { for (const name of ["state-layer", "state-filled", "state-danger"]) {
const at = css.indexOf(`@utility ${name} {`); const at = css.indexOf(`@utility ${name} {`);
const body = css.slice(at, css.indexOf("\n}", at)); const body = css.slice(at, css.indexOf("\n}", at));
@@ -155,9 +155,28 @@ it("the state layers stand down on a disabled, busy or pressed control", () => {
expect(states.length, name).toBe(2); expect(states.length, name).toBe(2);
for (const [, state, sel] of states) expect(sel, `${name} :${state}`).toMatch(guard); for (const [, state, sel] of states) expect(sel, `${name} :${state}`).toMatch(guard);
} }
expect(css.slice(css.indexOf("@utility state-layer {"))).toMatch( // A pressed toggle and a lit mode cycle keep their look under the pointer: no layer over them.
/&:hover:not\([^)]*\[aria-pressed="true"\][^)]*\)/, const layer = css.slice(
css.indexOf("@utility state-layer {"),
css.indexOf("@utility state-filled {"),
); );
const states = [...layer.matchAll(/&:(hover|active):not\(([^)]*)\)/g)];
expect(states.map(([, state]) => state)).toEqual(["hover", "active"]);
for (const [, state, exempt] of states) {
expect(exempt, state).toContain('[aria-pressed="true"]');
expect(exempt, state).toContain("[data-lit]");
}
});
// …and their colours outrank a variant's hover colour (`act:hover:text-fg`, the overlay's
// `act:hover:bg-white/15`), the more specific selector: only an important one does.
it("a pressed or lit control's colours are marked important", () => {
const styles = readFileSync(new URL("../components/ui/buttonStyles.ts", import.meta.url), "utf8");
for (const name of ["PRESSED", "LIT", "ICON_PRESSED", "ICON_LIT"]) {
const value = new RegExp(`const ${name} =\\s*"([^"]+)"`).exec(styles)?.[1];
expect(value, name).toBeTruthy();
for (const cls of value!.split(/\s+/)) expect(cls, name).toMatch(/!$/);
}
}); });
// The 4.5:1 above only holds for text that USES --accent-fg: a fixed white or black on a solid accent // The 4.5:1 above only holds for text that USES --accent-fg: a fixed white or black on a solid accent
@@ -37,14 +37,14 @@ vi.mock("../../features/navigation/navigationContext", () => ({
vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab })); vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
// The open player's mark, for a test to make as the player would. // The open player's mark, for a test to make as the player would.
const playerMark = vi.hoisted(() => ({ const playerMark = vi.hoisted(() => ({
onState: null as null | ((id: string, s: string) => void), onState: null as null | ((id: string, s: string) => Promise<unknown>),
})); }));
vi.mock("../../features/player/PlayerModal", () => ({ vi.mock("../../features/player/PlayerModal", () => ({
default: (p: { default: (p: {
video: { id: string }; video: { id: string };
queue: { id: string }[]; queue: { id: string }[];
startIndex: number; startIndex: number;
onState: (id: string, s: string) => void; onState: (id: string, s: string) => Promise<unknown>;
}) => { }) => {
playerMark.onState = p.onState; playerMark.onState = p.onState;
h.player( h.player(
@@ -187,7 +187,7 @@ describe("WatchedChannels", () => {
const dialog = await screen.findByRole("dialog"); const dialog = await screen.findByRole("dialog");
fireEvent.click(await within(dialog).findByRole("button", { name: "card.play — Video b" })); fireEvent.click(await within(dialog).findByRole("button", { name: "card.play — Video b" }));
await waitFor(() => expect(playerMark.onState).not.toBeNull()); await waitFor(() => expect(playerMark.onState).not.toBeNull());
act(() => playerMark.onState!("b", "watched")); act(() => void playerMark.onState!("b", "watched"));
await waitFor(() => { await waitFor(() => {
const cached = qc.getQueryData(qk.related("SRC", "topical")) as { const cached = qc.getQueryData(qk.related("SRC", "topical")) as {
pages: { items: { status: string }[] }[]; pages: { items: { status: string }[] }[];
@@ -54,7 +54,8 @@ export default function WatchedVideosDialog({
// The player's watched / in-progress marks: saved, then shown on this list and the feed's. // The player's watched / in-progress marks: saved, then shown on this list and the feed's.
const onState = useStableCallback((id: string, status: VideoStatus) => { const onState = useStableCallback((id: string, status: VideoStatus) => {
writeVideoState(qc, id, status) const write = writeVideoState(qc, id, status);
write
.then(() => { .then(() => {
qc.setQueryData( qc.setQueryData(
key, key,
@@ -67,6 +68,7 @@ export default function WatchedVideosDialog({
void qc.invalidateQueries({ queryKey: qk.feed() }); void qc.invalidateQueries({ queryKey: qk.feed() });
}) })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
return write;
}); });
const closePlayer = useStableCallback(() => setPlaying(null)); const closePlayer = useStableCallback(() => setPlaying(null));
+20 -6
View File
@@ -127,10 +127,17 @@ function matchesView(status: VideoStatus, show: string): boolean {
// the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer // the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer
// than its answer, and so is one sent just before it whose answer had not come back (the server may // than its answer, and so is one sent just before it whose answer had not come back (the server may
// read the list first). Dropping either put a card marked watched back into an Unwatched view. // read the list first). Dropping either put a card marked watched back into an Unwatched view.
type Stamped<T> = { value: T; seq: number }; // `answered`: the server has taken the write — the live search then reads it from the marks
// (`markedRows`), where a newer word from elsewhere can follow it; the feed holds it until a refetch.
type Stamped<T> = { value: T; seq: number; answered?: boolean };
type Overrides<T> = Record<string, Stamped<T>>; type Overrides<T> = Record<string, Stamped<T>>;
const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> => const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> =>
Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq))); Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq)));
// The server took a write: say so on its own value only — a later write for the same video stands.
const withAnswer = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
const own = o[id];
return own?.seq === seq ? { ...o, [id]: { ...own, answered: true } } : o;
};
// A failed write takes back only its OWN value: a later write for the same video stands. // A failed write takes back only its OWN value: a later write for the same video stands.
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => { const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
if (o[id]?.seq !== seq) return o; if (o[id]?.seq !== seq) return o;
@@ -400,6 +407,7 @@ export default function Feed({
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch // every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
// storm (PERF-2). The next filter change / remount refetches fresh from the server. // storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq); answered.current.add(seq);
setOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
// The video the player is on may be none of this list's (a related one): it names itself. // 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); const v = video ?? loadedRef.current.find((x) => x.id === id);
@@ -412,6 +420,7 @@ export default function Feed({
notifyActionError(e, i18n.t("errors.writeFailed.videoState")); notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => withoutWrite(o, id, seq)); setOverrides((o) => withoutWrite(o, id, seq));
}); });
return write;
}, },
[qc, trackSessionWrite], [qc, trackSessionWrite],
); );
@@ -432,6 +441,7 @@ export default function Feed({
// Stale-mark only (see setState above): the optimistic override already reset the card. // Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => { .then(() => {
answered.current.add(seq); answered.current.add(seq);
setOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
@@ -452,6 +462,7 @@ export default function Feed({
writeWatchLater(qc, id, saved) writeWatchLater(qc, id, saved)
.then(() => { .then(() => {
answered.current.add(seq); answered.current.add(seq);
setSavedOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() }); qc.invalidateQueries({ queryKey: qk.playlist() });
}) })
@@ -471,22 +482,25 @@ export default function Feed({
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items); const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
// A mark made anywhere else (the player's related list, the Notification Center's undo) since a // A mark made anywhere else (the player's related list, the Notification Center's undo) since a
// page was read is laid over it: a Load more on its way when the mark landed rebuilt the list // page was read is laid over it: a Load more on its way when the mark landed rebuilt the list
// from the pages it started from, without the mark. // from the pages it started from, without the mark. The feed's own writes reach the marks too,
// once answered: over these rows only an unanswered one of them is laid (`withOverrides`), or an
// earlier click of the feed's hid a newer mark from elsewhere for the rest of the search.
const ytLoaded: Video[] = markedRows(ytQuery.data?.pages, videoMarks); const ytLoaded: Video[] = markedRows(ytQuery.data?.pages, videoMarks);
// onState's refs follow each commit: itself, and what the cards show (live search results if on). // onState's refs follow each commit: itself, and what the cards show (live search results if on).
useLayoutEffect(() => { useLayoutEffect(() => {
onStateRef.current = onState; onStateRef.current = onState;
loadedRef.current = ytActive ? ytLoaded : loaded; loadedRef.current = ytActive ? ytLoaded : loaded;
}); });
const withOverrides = (list: Video[]): Video[] => // `live`: the live search's rows, which carry the marks — there an answered write stands down.
const withOverrides = (list: Video[], live = false): Video[] =>
list list
.map((v) => { .map((v) => {
const status = overrides[v.id]; // read once so the narrowing sticks const status = overrides[v.id]; // read once so the narrowing sticks
return status ? { ...v, status: status.value } : v; return status && !(live && status.answered) ? { ...v, status: status.value } : v;
}) })
.map((v) => { .map((v) => {
const saved = savedOverrides[v.id]; const saved = savedOverrides[v.id];
return saved ? { ...v, saved: saved.value } : v; return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v;
}); });
const merged: Video[] = withOverrides(loaded); const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show)); const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
@@ -508,7 +522,7 @@ export default function Feed({
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota). // user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) { if (ytActive) {
const ytItems: Video[] = sortYtResults( const ytItems: Video[] = sortYtResults(
withOverrides(ytLoaded), withOverrides(ytLoaded, true),
ytSort.key as SortKey, ytSort.key as SortKey,
ytSort.dir, ytSort.dir,
lang, lang,
@@ -4,7 +4,7 @@
// failure is said under them; before, the whole pane turned into the error text. Load more is the // failure is said under them; before, the whole pane turned into the error text. Load more is the
// retry where trying again can help, and is not offered after a refusal it cannot answer (the day's // retry where trying again can help, and is not offered after a refusal it cannot answer (the day's
// cap, the feature turned off, the shared budget). And a mark made elsewhere while a Load more is on // cap, the feature turned off, the shared budget). And a mark made elsewhere while a Load more is on
// its way is not undone by the page landing. // its way is not undone by the page landing, nor by an earlier write of the feed's own.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { act, render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { act, render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -17,21 +17,38 @@ vi.mock("../../features/wizard/wizardContext", () => ({
useWizard: () => ({ openWizard: vi.fn() }), useWizard: () => ({ openWizard: vi.fn() }),
})); }));
vi.mock("./VirtualFeed", () => ({ vi.mock("./VirtualFeed", () => ({
default: ({ items }: { items: { id: string; title: string; status: string }[] }) => ( default: ({
<ol aria-label="results"> items,
onState,
onToggleSave,
}: {
items: { id: string; title: string; status: string; saved?: boolean }[];
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
}) => (
<>
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>
{v.title} ({v.status}
{v.saved ? ", saved" : ""})
</li>
))}
</ol>
{items.map((v) => ( {items.map((v) => (
<li key={v.id}> <span key={v.id}>
{v.title} ({v.status}) <button onClick={() => onState(v.id, "hidden")}>hide {v.id}</button>
</li> <button onClick={() => onToggleSave(v.id, true)}>save {v.id}</button>
</span>
))} ))}
</ol> </>
), ),
})); }));
import Feed from "./Feed"; import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api"; import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { writeVideoState } from "../../features/video/videoWrites"; import { writeVideoState, writeWatchLater } from "../../features/video/videoWrites";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -159,6 +176,50 @@ describe("Feed — a live search's Load more on its way", () => {
await act(async () => void (await writeVideoState(qc, "a", "hidden"))); await act(async () => void (await writeVideoState(qc, "a", "hidden")));
await act(async () => land({ items: [hit("b")], next_cursor: null, source: "scrape" })); await act(async () => land({ items: [hit("b")], next_cursor: null, source: "scrape" }));
await screen.findByText(/hit b/); await screen.findByText(/hit b/);
expect(screen.getByRole("list", { name: "results" }).textContent).not.toContain("hit a (new)"); expect(screen.getByRole("list", { name: "results" }).textContent).toBe(
"hit a (hidden)hit b (new)",
);
});
});
describe("Feed — a live search's own writes and a newer mark", () => {
const results = () => screen.getByRole("list", { name: "results" }).textContent;
const searched = () => {
vi.spyOn(api, "searchYoutube").mockResolvedValue({
items: [hit("a")],
next_cursor: null,
source: "scrape",
} as never);
return mount();
};
const tick = () => act(async () => void (await new Promise((r) => setTimeout(r, 5))));
// Hidden from its card, then put back from the player's related row (which writes past the feed).
it("shows a status marked elsewhere after the feed's own answered write", async () => {
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = searched();
fireEvent.click(await screen.findByRole("button", { name: "hide a" }));
await waitFor(() => expect(results()).toBe("hit a (hidden)"));
await tick();
await act(async () => void (await writeVideoState(qc, "a", "new")));
await waitFor(() => expect(results()).toBe("hit a (new)"));
});
it("shows a bookmark taken off elsewhere after the feed's own answered save", async () => {
vi.spyOn(api, "watchLaterAdd").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "watchLaterRemove").mockResolvedValue({ ok: true } as never);
const qc = searched();
fireEvent.click(await screen.findByRole("button", { name: "save a" }));
await waitFor(() => expect(results()).toBe("hit a (new, saved)"));
await tick();
await act(async () => void (await writeWatchLater(qc, "a", false)));
await waitFor(() => expect(results()).toBe("hit a (new)"));
});
it("keeps its own write over the rows until the server has answered it", async () => {
vi.spyOn(api, "setState").mockReturnValue(new Promise(() => {}) as never);
searched();
fireEvent.click(await screen.findByRole("button", { name: "hide a" }));
await waitFor(() => expect(results()).toBe("hit a (hidden)"));
}); });
}); });
@@ -11,10 +11,10 @@ import {
Pin, Pin,
PinOff, PinOff,
PlayCircle, PlayCircle,
RefreshCw,
RotateCcw, RotateCcw,
Scissors, Scissors,
Share2, Share2,
SkipBack,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../../lib/api"; import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../../lib/api";
@@ -126,7 +126,7 @@ export function useFileActions({
{ {
id: "restart", id: "restart",
label: t("card.restartTitle"), label: t("card.restartTitle"),
icon: icon(SkipBack), icon: icon(RotateCcw),
onSelect: () => onPlay(file, true), onSelect: () => onPlay(file, true),
}, },
] ]
@@ -269,7 +269,7 @@ export function useFileActions({
{ {
id: "redownload", id: "redownload",
label: t("files.actions.redownload"), label: t("files.actions.redownload"),
icon: icon(RotateCcw), icon: icon(RefreshCw),
onSelect: () => onSelect: () =>
void confirmThen( void confirmThen(
t("downloads.confirm.redownloadTitle", { name: jobTitle(file) }), t("downloads.confirm.redownloadTitle", { name: jobTitle(file) }),
+4 -3
View File
@@ -10,7 +10,6 @@ import {
PinOff, PinOff,
Play, Play,
RotateCcw, RotateCcw,
SkipBack,
Star, Star,
} from "lucide-react"; } from "lucide-react";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -195,7 +194,7 @@ function useHomeCardMenu({
{ {
id: "restart", id: "restart",
label: t("card.restartTitle"), label: t("card.restartTitle"),
icon: <SkipBack className="w-4 h-4" />, icon: <RotateCcw className="w-4 h-4" />,
onSelect: () => onPlay(target, 0), onSelect: () => onPlay(target, 0),
}, },
] ]
@@ -837,7 +836,8 @@ export default function Home() {
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking // A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
// something watched genuinely changes what the page should answer (it can retire the hero). // something watched genuinely changes what the page should answer (it can retire the hero).
// The feed avoids this because it would refetch ten infinite-query pages per click. // The feed avoids this because it would refetch ten infinite-query pages per click.
void writeVideoState(qc, id, status) const write = writeVideoState(qc, id, status);
void write
.then(() => { .then(() => {
return qc.invalidateQueries({ queryKey: qk.discover() }); return qc.invalidateQueries({ queryKey: qk.discover() });
}) })
@@ -849,6 +849,7 @@ export default function Home() {
// would describe a state the user can see is untrue, over a fully populated page whose // would describe a state the user can see is untrue, over a fully populated page whose
// invalidations never even ran (they sit inside `.then`). // invalidations never even ran (they sit inside `.then`).
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed"))); .catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
return write;
}, },
[qc, t], [qc, t],
); );
@@ -419,6 +419,19 @@ describe("Playlists — a change the server refused is taken back on screen", ()
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
}); });
// Watch later's items ARE the bookmark: a video moved into it is saved wherever it is shown.
it("marks a video moved into Watch later as saved", async () => {
vi.spyOn(api, "moveToPlaylist").mockResolvedValue(undefined as never);
mount({ 1: [vid("V", 0)], [WATCH_LATER]: [] }, { lastMovePlaylistId: WATCH_LATER });
await rowMenu("Video V", "playlists.moveToRecent");
await waitFor(() =>
expect(
qc.getQueryData<Record<string, { saved?: { value: boolean } }>>(qk.videoMarks())?.V?.saved
?.value,
).toBe(true),
);
});
it("puts back the order when saving a reorder failed", async () => { it("puts back the order when saving a reorder failed", async () => {
vi.spyOn(api, "reorderPlaylist").mockRejectedValue(new HttpError(500, "boom")); vi.spyOn(api, "reorderPlaylist").mockRejectedValue(new HttpError(500, "boom"));
mount({ 1: [vid("V", 0), vid("W", 0)] }); mount({ 1: [vid("V", 0), vid("W", 0)] });
+7 -2
View File
@@ -307,7 +307,10 @@ function PlaylistDetail() {
}); });
const detail = detailQuery.data; const detail = detailQuery.data;
// Which kind of list an item write goes to (the Watch later list's items are the bookmark): // Which kind of list an item write goes to (the Watch later list's items are the bookmark):
// the rail's list knows every one, the open detail the one on screen. // the rail's list knows every one, the open detail the one on screen. Only a user list's rows are
// removed or moved from here today (Watch later offers neither), so it reads "user" — looked up
// all the same, so no write here takes the kind for granted. A move INTO Watch later passes the
// rail's own row as its target.
const listRef = (id: number) => ({ const listRef = (id: number) => ({
id, id,
kind: playlists.find((p) => p.id === id)?.kind ?? (detail?.id === id ? detail.kind : undefined), kind: playlists.find((p) => p.id === id)?.kind ?? (detail?.id === id ? detail.kind : undefined),
@@ -623,7 +626,8 @@ function PlaylistDetail() {
} }
function playerState(id: string, status: VideoStatus) { function playerState(id: string, status: VideoStatus) {
writeVideoState(qc, id, status) const write = writeVideoState(qc, id, status);
write
.then(() => { .then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a // Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose // list that is no longer the selected one (see `playing`), and that list holds the row whose
@@ -633,6 +637,7 @@ function PlaylistDetail() {
}) })
// No catch used to mean an unhandled rejection and nothing said. // No catch used to mean an unhandled rejection and nothing said.
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
return write;
} }
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure // Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure