fix(playlists): the player's Remove follows the list it was launched from, the player survives a change of selection, and a gone list is said in the reader's language
- D3 snapshotted the removal's TARGET, but whether the player offered Remove still followed the
selected list: launched from Watch later (no Remove, by design), selecting another list under it
made Remove appear — and delete from Watch later. The snapshot now carries the launch list's
editability too.
- Selecting another list under an open player (the command palette works over it) put the page into
"Loading…", and the player was rendered only in the loaded branch — it vanished mid-playback and
came back at its launch video. It now sits at one place in the tree whatever the page is doing.
- A removal takes the video out of the CACHED list as well as the rows: the player's progress
checkpoint rewrote that entry before the refetch landed, handing the membership guard a list that
still held the video — the row came back and the undo history went.
- The watched-state callback refreshed the selected list, not the launch list (D3's sibling).
- `_own_playlist`'s 404 is structured (`{"code": "playlist", "reason": "not_found"}`) and localised:
the bare "Unknown playlist" reached the Hungarian UI in English, which also made round 1's new
`removeFailed` string unreachable. Both Remove and Move report through `notifyActionError` now.
- The "viewer moved on under the confirm" test stepped back only once, which lands on the same video
whether or not the removed one left the queue; it now takes the second step that tells them apart.
This commit is contained in:
@@ -10,9 +10,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
const h = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
selectedId: 1 as number | null,
|
||||
mounted: false,
|
||||
player: null as null | {
|
||||
video: { id: string; position_seconds: number };
|
||||
onClose: () => void;
|
||||
onState: (id: string, status: string) => void;
|
||||
onRemoveFromList?: (v: unknown) => Promise<boolean>;
|
||||
},
|
||||
}));
|
||||
@@ -34,12 +36,21 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
vi.mock("./PlaylistsProvider", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) }));
|
||||
vi.mock("./PlayerModal", () => ({
|
||||
default: (props: NonNullable<typeof h.player>) => {
|
||||
h.player = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("./PlayerModal", async () => {
|
||||
const { useEffect } = await import("react");
|
||||
return {
|
||||
default: function PlayerStandIn(props: NonNullable<typeof h.player>) {
|
||||
h.player = props;
|
||||
useEffect(() => {
|
||||
h.mounted = true;
|
||||
return () => {
|
||||
h.mounted = false;
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import Playlists from "./Playlists";
|
||||
import { api, HttpError, type Video } from "../lib/api";
|
||||
@@ -72,10 +83,11 @@ const vid = (id: string, position_seconds: number): Video =>
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
const WATCH_LATER = 9;
|
||||
const list = (id: number, items: Video[]) => ({
|
||||
id,
|
||||
name: `List ${id}`,
|
||||
kind: "user",
|
||||
kind: id === WATCH_LATER ? "watch_later" : "user",
|
||||
yt_playlist_id: null,
|
||||
item_count: items.length,
|
||||
items,
|
||||
@@ -107,6 +119,7 @@ const play = async (title: string) =>
|
||||
beforeEach(() => {
|
||||
h.selectedId = 1;
|
||||
h.player = null;
|
||||
h.mounted = false;
|
||||
h.notify.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -171,4 +184,70 @@ describe("Playlists — removing a video from inside the player", () => {
|
||||
expect(done).toBe(false);
|
||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
|
||||
});
|
||||
|
||||
it("offers Remove by the list it was launched from, not by whichever list is selected now", async () => {
|
||||
// Watch later deliberately has no Remove. Gated on the SELECTED list, it appeared on a player
|
||||
// launched from Watch later as soon as another list was selected under it — and deleted from
|
||||
// Watch later, since the removal itself targets the launch list.
|
||||
h.selectedId = WATCH_LATER;
|
||||
const { rerender } = mount({ [WATCH_LATER]: [vid("V", 0)], 2: [vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
expect(h.player!.onRemoveFromList).toBeUndefined();
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByRole("button", { name: "card.play — Video W" });
|
||||
expect(h.player!.onRemoveFromList).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes the removed video out of the cached list, so a progress checkpoint cannot bring it back", async () => {
|
||||
// The player steps to the next item after a removal and stores the last one's position into
|
||||
// every cached list. Landing before the refetch, that write handed the page a list still holding
|
||||
// the video, and the page — seeing a different set of ids — reset: the row came back.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); // the refetch is still out
|
||||
await act(async () => {
|
||||
await h.player!.onRemoveFromList!(vid("V", 0));
|
||||
});
|
||||
await act(async () => {
|
||||
patchCachedProgress(qc, new Map([["V", 99]]));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "card.play — Video V" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "card.play — Video W" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playlists — the player outlives a change of selection", () => {
|
||||
it("stays mounted while a newly selected list is still loading", async () => {
|
||||
// The page swaps itself for "Loading…" until the new list arrives; with the player rendered only
|
||||
// in the loaded branch, that took it down mid-playback and put it back at its launch video.
|
||||
const { rerender } = mount({ 1: [vid("V", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.mounted).toBe(true));
|
||||
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}));
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByText("playlists.loading");
|
||||
expect(h.mounted).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes the list it was launched from when the viewer marks the video in it", async () => {
|
||||
// Invalidating the SELECTED list left the launch list's row showing the old status.
|
||||
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
|
||||
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByRole("button", { name: "card.play — Video W" });
|
||||
await act(async () => {
|
||||
h.player!.onState("V", "watched");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user