fix(playlists): overlapping writes, and what the second review found

A read from before a write no longer brings a row back: a write calls off the
read on its way, what lands while one is out stays off the screen, and the last
write out does the refresh. A failure puts back its own rows, an accepted reorder
brings back none, and an Undo reads past the cache. A list the cache has dropped
is read before a removal, so its Undo has places.

undo-move refuses the same list twice, positions cannot repeat, and undo-move
and the Plex key bodies refuse a body without their videos. Focus goes to the
header's box when the last shown row or Save this order goes.
This commit is contained in:
2026-11-02 22:15:13 +01:00
parent 195b5f3917
commit b636ef38bc
12 changed files with 578 additions and 71 deletions
+6 -3
View File
@@ -115,11 +115,14 @@
"movedManyToast_other": "{{count}} videos moved to “{{name}}”",
"removedManyToast_one": "{{count}} video removed from the playlist",
"removedManyToast_other": "{{count}} videos removed from the playlist",
"moveNoneLeft": "Nothing moved: they aren’t in this playlist any more",
"removeNoneLeft": "Nothing removed: they aren’t in this playlist any more",
"moveNoneLeft_one": "Nothing moved: it isn’t in this playlist any more",
"moveNoneLeft_other": "Nothing moved: they aren’t in this playlist any more",
"removeNoneLeft_one": "Nothing removed: it isn’t in this playlist any more",
"removeNoneLeft_other": "Nothing removed: they aren’t in this playlist any more",
"selectionCleared": "Selection cleared",
"removeAllHidden": "The selected rows are hidden by the filter",
"undoNoneBack": "Nothing came back: those videos aren’t where they were any more",
"undoNoneBack_one": "Nothing came back: that video isn’t where it was any more",
"undoNoneBack_other": "Nothing came back: those videos aren’t where they were any more",
"undoSomeBack_one": "Only {{count}} video came back: the rest aren’t where they were any more",
"undoSomeBack_other": "Only {{count}} videos came back: the rest aren’t where they were any more",
"dragCancelledMany_one": "Move cancelled; {{count}} video stays where it was",
+1 -1
View File
@@ -36,7 +36,7 @@
"gridMove": "Mozgás a listában",
"open": "Az aktuális elem megnyitása",
"listRemove": "Eltávolítás a listából",
"listSelect": "Az aktuális elem kijelölése, vagy a kijelölés törlése",
"listSelect": "Az aktuális elem kijelölése vagy a jelölés levétele",
"listClearSelection": "A kijelölés törlése",
"cap": {
"space": "Szóköz",
+6 -3
View File
@@ -115,11 +115,14 @@
"movedManyToast_other": "{{count}} videó áthelyezve ide: „{{name}}”",
"removedManyToast_one": "{{count}} videó eltávolítva a listáról",
"removedManyToast_other": "{{count}} videó eltávolítva a listáról",
"moveNoneLeft": "Nem történt áthelyezés: ezek már nincsenek ebben a listában",
"removeNoneLeft": "Nem történt eltávolítás: ezek már nincsenek ebben a listában",
"moveNoneLeft_one": "Nem történt áthelyezés: ez már nincs ebben a listában",
"moveNoneLeft_other": "Nem történt áthelyezés: ezek már nincsenek ebben a listában",
"removeNoneLeft_one": "Nem történt eltávolítás: ez már nincs ebben a listában",
"removeNoneLeft_other": "Nem történt eltávolítás: ezek már nincsenek ebben a listában",
"selectionCleared": "Kijelölés törölve",
"removeAllHidden": "A kijelölt sorokat a szűrő elrejti",
"undoNoneBack": "Semmi nem került vissza: ezek a videók már nincsenek ott, ahol voltak",
"undoNoneBack_one": "Semmi nem került vissza: ez a videó már nincs ott, ahol volt",
"undoNoneBack_other": "Semmi nem került vissza: ezek a videók már nincsenek ott, ahol voltak",
"undoSomeBack_one": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
"undoSomeBack_other": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
"dragCancelledMany_one": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
-2
View File
@@ -68,8 +68,6 @@ export const playlistsApi = {
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
deletePlaylist: (id: number, onYoutube = false) =>
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
// `position` (an index in the stored order) puts it back where a removal took it from — the
// list page's Undo; omitted, it is appended.
addToPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items`, {
method: "POST",
@@ -795,7 +795,10 @@ describe("Playlists — removing a row, and undoing it", () => {
act(() => lastToast().action!.onClick());
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "warning", message: msg("playlists.undoNoneBack") }),
expect.objectContaining({
level: "warning",
message: msg("playlists.undoNoneBack", { count: 1 }),
}),
),
);
});
@@ -1761,7 +1764,7 @@ describe("Playlists — acting on the selected rows", () => {
await tick("Video A", "Video B");
fireEvent.click(await button("playlists.moveToRecent"));
await waitFor(() => expect(h.notify).toHaveBeenCalled());
expect(toast().message).toEqual(msg("playlists.moveNoneLeft"));
expect(toast().message).toEqual(msg("playlists.moveNoneLeft", { count: 2 }));
expect(toast().action).toBeUndefined();
});
@@ -2148,7 +2151,9 @@ describe("Playlists — acting on the selected rows", () => {
mount(abc);
await tick("Video A", "Video B");
fireEvent.click(await button("playlists.removeSelected"));
await waitFor(() => expect(toast().message).toEqual(msg("playlists.removeNoneLeft")));
await waitFor(() =>
expect(toast().message).toEqual(msg("playlists.removeNoneLeft", { count: 2 })),
);
expect(toast().action).toBeUndefined();
});
@@ -2168,7 +2173,7 @@ describe("Playlists — acting on the selected rows", () => {
});
it.each([
[0, msg("playlists.undoNoneBack")],
[0, msg("playlists.undoNoneBack", { count: 2 })],
[1, msg("playlists.undoSomeBack", { count: 1 })],
])("says what came back when a move's Undo brought %i of 2", async (added, said) => {
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue(moved(["A", "B"]) as never);
@@ -2248,3 +2253,330 @@ describe("Playlists — acting on the selected rows", () => {
expect(cell.classList.contains("@sm:table-cell")).toBe(true);
});
});
describe("Playlists — writes that overlap", () => {
const later = <T,>() => {
let ok!: (v: T) => void;
let no!: (e: unknown) => void;
const promise = new Promise<T>((a, b) => {
ok = a;
no = b;
});
return { promise, ok, no };
};
const landed = () => act(async () => void (await new Promise((r) => setTimeout(r, 20))));
const abc = () => ({ 1: [vid("A", 0), vid("B", 0), vid("C", 0)] });
const inPage = () => screen.findByRole("button", { name: "card.play — Video A" });
const reads = () => vi.mocked(api.playlist).mock.calls.length;
const undoOrder = () => screen.getByRole("button", { name: "common.undo" }) as HTMLButtonElement;
const lastUndo = () =>
h.notify.mock.calls
.map(([n]) => n as { action?: { onClick: () => void } })
.filter((n) => n.action)
.pop();
// A menu row acts after the menu has closed, so a test that goes on at once acts before it.
const removeRow = async (title: string, ...left: string[]) => {
await rowMenu(title, "playlists.removeItem");
await waitFor(() => expect(rowsOnScreen()).toEqual(left));
};
it("keeps a row out when a read sent before its removal lands after it, and its history too", async () => {
vi.spyOn(api, "reorderPlaylist").mockResolvedValue(undefined as never);
const second = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist")
.mockResolvedValueOnce(wentOut("A") as never)
.mockReturnValueOnce(second.promise as never);
mount({ 1: [...abc()[1], vid("D", 0)] });
await inPage();
await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(undoOrder().disabled).toBe(false));
await landed();
const read = later<unknown>();
vi.mocked(api.playlist).mockReturnValue(read.promise as never);
const before = reads();
await removeRow("Video A", "Video C", "Video B", "Video D");
await waitFor(() => expect(reads()).toBeGreaterThan(before));
await removeRow("Video B", "Video C", "Video D");
await act(async () => read.ok(list(1, [vid("C", 0), vid("B", 0), vid("D", 0)])));
await landed();
expect(rowsOnScreen()).toEqual(["Video C", "Video D"]);
expect(undoOrder().disabled).toBe(false);
// …and the cache too, which is what a failed write goes back to.
expect(qc.getQueryData<{ items: Video[] }>(qk.playlist(1))?.items.map((v) => v.id)).toEqual([
"C",
"D",
]);
});
it("reads the list again only when the last write is in", async () => {
const first = later<unknown>();
const second = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist")
.mockReturnValueOnce(first.promise as never)
.mockReturnValueOnce(second.promise as never);
mount(abc());
await inPage();
await landed();
await removeRow("Video A", "Video B", "Video C");
await removeRow("Video B", "Video C");
const before = reads();
await act(async () => first.ok(wentOut("A")));
await landed();
expect(reads()).toBe(before);
await act(async () => second.ok(wentOut("B")));
await waitFor(() => expect(reads()).toBeGreaterThan(before));
});
it("takes no row back from a read that lands while a write is out, and takes the next one", async () => {
const write = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist").mockReturnValue(write.promise as never);
const lists = abc();
mount(lists);
await inPage();
await removeRow("Video A", "Video B", "Video C");
await act(async () => {
await qc.refetchQueries({ queryKey: qk.playlist(1) });
});
await landed();
expect(rowsOnScreen()).toEqual(["Video B", "Video C"]);
lists[1] = [vid("B", 0), vid("C", 0), vid("X", 0)];
await act(async () => write.ok(wentOut("A")));
await waitFor(() => expect(rowsOnScreen()).toEqual(["Video B", "Video C", "Video X"]));
});
it("seeds a list picked while a write to it is out", async () => {
const write = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist").mockReturnValue(write.promise as never);
const { rerender } = mount({ 1: [vid("A", 0)], 2: [vid("B", 0), vid("C", 0)] });
await play("Video A");
await waitFor(() => expect(h.player).not.toBeNull());
h.selectedId = 2;
rerender();
await screen.findByRole("button", { name: "card.play — Video C" });
act(() => void h.player!.onRemoveFromList!(vid("A", 0)));
h.selectedId = 1;
rerender();
await landed();
expect(screen.queryByRole("button", { name: "card.play — Video C" })).toBeNull();
await act(async () => write.ok(wentOut("A")));
});
it.each([
["the first", 0, 1],
["the second", 1, 0],
])(
"puts both rows back in their places when %s of two failures comes first",
async (_n, x, y) => {
const writes = [later<unknown>(), later<unknown>()];
vi.spyOn(api, "removeManyFromPlaylist")
.mockReturnValueOnce(writes[0]!.promise as never)
.mockReturnValueOnce(writes[1]!.promise as never);
mount(abc());
await inPage();
await landed();
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
await removeRow("Video A", "Video B", "Video C");
await removeRow("Video B", "Video C");
await act(async () => writes[x]!.no(new TypeError("Failed to fetch")));
await landed();
await act(async () => writes[y]!.no(new TypeError("Failed to fetch")));
await landed();
expect(rowsOnScreen()).toEqual(["Video A", "Video B", "Video C"]);
},
);
it("puts back only the row whose write failed, not one another write took out", async () => {
const first = later<unknown>();
const second = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist")
.mockReturnValueOnce(first.promise as never)
.mockReturnValueOnce(second.promise as never);
mount(abc());
await inPage();
await landed();
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
await removeRow("Video A", "Video B", "Video C");
await removeRow("Video B", "Video C");
await act(async () => second.ok(wentOut("B")));
await act(async () => first.no(new TypeError("Failed to fetch")));
await landed();
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
});
it("brings back no row taken out while a reorder was on its way", async () => {
const put = later<unknown>();
vi.spyOn(api, "reorderPlaylist").mockReturnValue(put.promise as never);
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
mount(abc());
await inPage();
await landed();
await rowMenu("Video C", "common.order.up");
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}) as never);
await removeRow("Video B", "Video A", "Video C");
await act(async () => put.ok(undefined));
await landed();
expect(rowsOnScreen()).toEqual(["Video A", "Video C"]);
expect(undoOrder().disabled).toBe(false);
});
describe("from the player, on a list the cache has dropped", () => {
const dropped = async () => {
const lists: Record<number, Video[]> = {
1: [vid("U", 0), vid("V", 0), vid("W", 0)],
2: [vid("Y", 0)],
};
const { rerender } = mount(lists);
await play("Video U");
await waitFor(() => expect(h.player).not.toBeNull());
h.selectedId = 2;
rerender();
await screen.findByRole("button", { name: "card.play — Video Y" });
qc.removeQueries({ queryKey: qk.playlist(1), exact: true });
return lists;
};
it("offers an Undo that puts the video back at its place", async () => {
const lists = await dropped();
vi.spyOn(api, "removeManyFromPlaylist").mockImplementation((async () => {
lists[1] = lists[1]!.filter((v) => v.id !== "V");
return wentOut("V");
}) as never);
const restore = vi
.spyOn(api, "restoreToPlaylist")
.mockResolvedValue({ ok: true, added: 1 } as never);
await act(async () => {
await h.player!.onRemoveFromList!(vid("V", 0));
});
await act(async () => lastUndo()!.action!.onClick());
await waitFor(() => expect(restore).toHaveBeenCalledWith(1, ["V"], [1]));
});
it("removes nothing and says so when the list cannot be read first", async () => {
await dropped();
const remove = vi.spyOn(api, "removeManyFromPlaylist");
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
let done: boolean | undefined;
await act(async () => {
done = await h.player!.onRemoveFromList!(vid("V", 0));
});
expect(done).toBe(false);
expect(remove).not.toHaveBeenCalled();
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
});
});
it("refreshes what Add to playlist knows about every row the writes took", async () => {
const first = later<unknown>();
const second = later<unknown>();
vi.spyOn(api, "removeManyFromPlaylist")
.mockReturnValueOnce(first.promise as never)
.mockReturnValueOnce(second.promise as never);
mount(abc());
await inPage();
await landed();
qc.setQueryData(qk.playlistMembership("A"), []);
await removeRow("Video A", "Video B", "Video C");
await removeRow("Video B", "Video C");
await act(async () => first.ok(wentOut("A")));
await act(async () => second.ok(wentOut("B")));
await landed();
expect(qc.getQueryState(qk.playlistMembership("A"))?.isInvalidated).toBe(true);
});
it("places an Undo by what the server says, though a removal starts while it asks", async () => {
const restore = vi
.spyOn(api, "restoreToPlaylist")
.mockResolvedValue({ ok: true, added: 1 } as never);
const lists = abc();
const remove = vi.spyOn(api, "removeManyFromPlaylist");
remove.mockImplementationOnce((async () => {
lists[1] = lists[1].filter((v) => v.id !== "B");
return wentOut("B");
}) as never);
mount(lists);
await inPage();
await landed();
await removeRow("Video B", "Video A", "Video C");
await waitFor(() => expect(lastUndo()).toBeTruthy());
await landed();
const read = later<unknown>();
vi.mocked(api.playlist).mockReturnValue(read.promise as never);
await act(async () => lastUndo()!.action!.onClick());
remove.mockReturnValueOnce(new Promise(() => {}) as never);
await removeRow("Video C", "Video A");
// Another tab has put a row at the top meanwhile: only a fresh answer knows it.
await act(async () => read.ok(list(1, [vid("X", 0), vid("A", 0), vid("C", 0)])));
await waitFor(() => expect(restore).toHaveBeenCalledWith(1, ["B"], [2]));
});
it("speaks of one video when one row found nothing to do", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut() as never);
mount(abc());
await inPage();
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ message: msg("playlists.removeNoneLeft", { count: 1 }) }),
),
);
});
});
describe("Playlists — the focus when what holds it goes", () => {
const box = (name: string) => screen.findByRole("checkbox", { name });
const headerBox = () => screen.getByRole("checkbox", { name: "playlists.selectAllShown" });
const press = (key: string) =>
fireEvent.keyDown(document.activeElement ?? document.body, { key, bubbles: true });
it("takes the header's box when the last row a filter shows goes, the list not empty", async () => {
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue(wentOut("B") as never);
window.history.replaceState(
null,
"",
`/?page=playlists&pl=1&plf=${encodeURIComponent(JSON.stringify({ title: "Video B" }))}`,
);
mount({ 1: [vid("A", 0), vid("B", 0), vid("C", 0)] });
const row = await box("playlists.selectRow:Video B");
fireEvent.click(row);
row.focus();
await act(async () => press("Delete"));
await waitFor(() => expect(api.removeManyFromPlaylist).toHaveBeenCalled());
await waitFor(() => expect(document.activeElement).toBe(headerBox()));
});
it("takes the header's box when Save this order goes with the view", async () => {
vi.spyOn(api, "reorderPlaylist").mockResolvedValue(undefined as never);
window.history.replaceState(null, "", "/?page=playlists&pl=1&plsort=title:desc");
mount({ 1: [vid("A", 0), vid("B", 0)] });
const save = await screen.findByRole("button", { name: "playlists.saveOrder" });
save.focus();
fireEvent.click(save);
await waitFor(() =>
expect(screen.queryByRole("button", { name: "playlists.saveOrder" })).toBeNull(),
);
expect(document.activeElement).toBe(headerBox());
});
it("takes the header's box when grouping goes off on a list too short to offer it", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&plgroup=1");
mount({ 1: [vid("A", 0)] });
const group = await box("playlists.groupByChannel");
group.focus();
fireEvent.click(group);
await waitFor(() =>
expect(screen.queryByRole("checkbox", { name: "playlists.groupByChannel" })).toBeNull(),
);
expect(document.activeElement).toBe(headerBox());
});
it("leaves the focus on the grouping box when the row stays", async () => {
window.history.replaceState(null, "", "/?page=playlists&pl=1&plgroup=1");
mount({ 1: [vid("A", 0), vid("B", 0)] });
const group = await box("playlists.groupByChannel");
group.focus();
fireEvent.click(group);
await waitFor(() => expect((group as HTMLInputElement).checked).toBe(false));
expect(document.activeElement).toBe(group);
});
});
+132 -47
View File
@@ -10,7 +10,7 @@ import {
} from "react";
import { useLatest, useStableCallback } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { hasResumePoint } from "../../lib/api/feed";
import {
@@ -271,6 +271,30 @@ export default function Playlists() {
);
}
/** `old` with `ids` back at their places, from the list as it stood before they went. Only these rows:
* another write out at the same time keeps what it did. */
function withRowsBack(old: PlaylistDetail, before: PlaylistDetail, ids: readonly string[]) {
const back = reinsertAt(
before.items.map((v) => v.id),
ids,
old.items.map((v) => v.id),
);
const rows = new Map(before.items.map((v) => [v.id, v]));
const items = [...old.items];
back.keys.forEach((id, i) => items.splice(back.positions[i]!, 0, rows.get(id)!));
return { ...old, items };
}
/** Reads a list into the cache. Says what went wrong, if anything did. */
async function readFailure(qc: QueryClient, listId: number): Promise<unknown> {
try {
await qc.fetchQuery({ queryKey: qk.playlist(listId), queryFn: () => api.playlist(listId) });
return null;
} catch (e) {
return e;
}
}
function PlaylistDetail() {
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection.
@@ -282,6 +306,12 @@ function PlaylistDetail() {
const [renaming, setRenaming] = useState(false);
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
const [renameValue, setRenameValue] = useState("");
// The rows leaving each list right now, and the list as it stood when the first of them left. A read
// can answer from before a write that is still out, so while one is out the list is not read again
// and what a read brings is not taken on screen. The list as it stood is for all of those writes:
// the later ones' own view of it lacks the rows the earlier ones took, and so their neighbours.
const writesOut = useRef(new Map<number, { count: number; before?: PlaylistDetail }>());
const refreshDue = useRef(new Set<string>());
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
// is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
const order = useUndoable<Video[]>([], {
@@ -299,14 +329,16 @@ function PlaylistDetail() {
// from before this reorder — so a SECOND reorder failing in that window rolled the rows
// back to an order the server does not have, permanently (the membership guard cannot
// correct an order). One accepted write, one cache entry that matches it.
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
old ? { ...old, items: next } : old,
);
// A row taken out while this was on its way stays out: `next` is from before that.
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) => {
if (!old) return old;
const held = new Set(old.items.map((v) => v.id));
return { ...old, items: next.filter((v) => held.has(v.id)) };
});
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
// and the Reset/Unsynced indicators, show without an F5). The membership guard
// keeps the refetch from wiping the undo history on a pure reorder.
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
refreshAfterEdit([]);
})
.catch((e) => {
// Was a bare .then(): a failed reorder left the UI showing an order the server had
@@ -314,7 +346,7 @@ function PlaylistDetail() {
// still has — or the rejected one becomes the base for the next drag.
notifyYouTubeActionError(e, msg("playlists.reorderFailed"));
restoreFromCache(listId);
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
refreshAfterEdit([]);
});
},
});
@@ -414,6 +446,13 @@ function PlaylistDetail() {
const { reset: resetOrder, rebase: rebaseOrder } = order;
useEffect(() => {
if (!detail) return;
// A write out on the list these rows are from: this may be a read from before it landed, and the
// refresh after the last write brings what is true. A list only just picked has no rows yet.
if (
writesOut.current.has(selectedId as number) &&
lastSetRef.current.startsWith(`${selectedId}:`)
)
return;
// Scoped to the playlist: two lists holding the SAME videos would otherwise look identical to
// the guard, so the second would render in the first's order and inherit its undo stack.
const key = `${selectedId}:${idsKey(detail.items)}`;
@@ -591,15 +630,25 @@ function PlaylistDetail() {
}
// From a button that goes with the view: a focus on it would fall to <body>, and the next Tab start
// over at the top of the page.
const resetView = (e: MouseEvent<HTMLButtonElement>) => {
const leaveView = (e: MouseEvent<HTMLButtonElement>) => {
if (document.activeElement === e.currentTarget)
(selectAllRef.current ?? renameTarget.current)?.focus();
};
const resetView = (e: MouseEvent<HTMLButtonElement>) => {
leaveView(e);
clearView();
};
function saveOrder() {
order.set(shown); // onApply persists it
clearView();
}
// The grouping row is only there for a list long enough to group, or a view to take off: with this
// the last of the view on a short list, it goes too.
function setGrouping(on: boolean) {
if (!on && items.length <= 1 && isStoredSort(viewSort) && !filterActive)
(selectAllRef.current ?? renameTarget.current)?.focus();
setGroupBy(on);
}
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -731,36 +780,62 @@ function PlaylistDetail() {
const next = order.value.filter((v) => !gone.has(v.id));
order.rebase((list) => list.filter((v) => !gone.has(v.id)));
lastSetRef.current = `${listId}:${idsKey(next)}`;
// The table goes with its last row: a focus in it or in the band would fall to <body>, and the
// next Tab start over at the top of the page. The list's "…" takes it.
// The last row shown goes: a focus in the table or in the band would fall to <body>, and the next
// Tab start over at the top of the page. The header's box takes it, while a filter hides rows and
// so keeps the table; else the table goes too, and the list's "…" does.
const held = document.activeElement;
if (!next.length && (tableRef.current?.contains(held) || bandRef.current?.contains(held)))
renameTarget.current?.focus();
if (
!shown.some((v) => !gone.has(v.id)) &&
(tableRef.current?.contains(held) || bandRef.current?.contains(held))
)
(next.length ? selectAllRef.current : renameTarget.current)?.focus();
}
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
// before the refetch lands handed the membership guard a list that still held the video — the
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
// so the guard reset the order: the removed row came back and the undo history the rebase had
// kept went with it. (A failure puts it back: `putBack`.)
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
// kept went with it. (A failure puts it back: `putBack`.) A read on its way would do the same when
// it lands, so it is called off; the write's own refresh replaces it.
const out = writesOut.current.get(listId);
const before = out ? out.before : qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
writesOut.current.set(listId, { count: (out?.count ?? 0) + 1, before });
void qc.cancelQueries({ queryKey: qk.playlist(listId) });
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
old ? { ...old, items: old.items.filter((v) => !gone.has(v.id)) } : old,
);
return before;
}
function writeDone(listId: number) {
const out = writesOut.current.get(listId);
if (out && out.count > 1) out.count--;
else writesOut.current.delete(listId);
}
// The write failed: the rows come back now (see `restoreFromCache`), ticked again if they were, so
// the reader can try once more without picking them all over.
function putBack(listId: number, before: PlaylistDetail | undefined, ticked: readonly string[]) {
if (before) qc.setQueryData(qk.playlist(listId), before);
function putBack(
listId: number,
before: PlaylistDetail | undefined,
ids: readonly string[],
ticked: readonly string[],
) {
if (before)
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
old ? withRowsBack(old, before, ids) : before,
);
restoreFromCache(listId);
if (listId === selectedRef.current) selection.select(ticked);
}
// BOTH lists of a move changed, and so did these videos' membership. `qk.playlist(id)` is an exact
// key, so it cannot cover the target: the whole `["playlist"]` prefix, as AddToPlaylist does.
// key, so it cannot cover the target: the whole `["playlist"]` prefix, as AddToPlaylist does. The
// last write out does it for them all (see `writesOut`).
function refreshAfterEdit(ids: readonly string[]) {
for (const id of ids) refreshDue.current.add(id);
if (writesOut.current.size) return;
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
for (const id of ids) qc.invalidateQueries({ queryKey: qk.playlistMembership(id) });
for (const id of refreshDue.current)
qc.invalidateQueries({ queryKey: qk.playlistMembership(id) });
refreshDue.current.clear();
}
const titleOf = (rows: Video[], id: string | undefined) =>
raw(rows.find((v) => v.id === id)?.title ?? "");
@@ -768,21 +843,16 @@ function PlaylistDetail() {
const tickOf = (v: Video) => (selection.isSelected(v.id) ? [v.id] : []);
// Where rows go back when their Undo comes: against the list as it is NOW, fetched fresh, and after
// their neighbours in the STORED order they stood in, so neither a view sort on screen nor a change
// in between shifts them (`reinsertAt`).
// in between shifts them (`reinsertAt`). Past the cache: a removal starting meanwhile calls off the
// cache's read, which then answers with what the cache holds, not with what is fresh.
const placesNow = (listId: number, storedBefore: string[], ids: string[]) =>
qc
.fetchQuery({
queryKey: qk.playlist(listId),
queryFn: () => api.playlist(listId),
staleTime: 0,
})
.then((now) =>
reinsertAt(
storedBefore,
ids,
now.items.map((v) => v.id),
),
);
api.playlist(listId).then((now) =>
reinsertAt(
storedBefore,
ids,
now.items.map((v) => v.id),
),
);
// An Undo that could not bring everything back says so: the server leaves out a video it cannot put
// back (gone on from the target, or from the catalog), and silence would read as all of them.
function sayWhatCameBack(asked: number, added: number) {
@@ -791,7 +861,7 @@ function PlaylistDetail() {
level: "warning",
message: added
? msg("playlists.undoSomeBack", { count: added })
: msg("playlists.undoNoneBack"),
: msg("playlists.undoNoneBack", { count: asked }),
});
}
@@ -812,8 +882,9 @@ function PlaylistDetail() {
// fault `req` already put in its error dialog is not toasted again (`notifyActionError` checks).
notifyActionError(e, failTitle);
}
writeDone(listId);
if (!done) {
putBack(listId, before, ticked);
putBack(listId, before, ids, ticked);
refreshAfterEdit(ids);
return;
}
@@ -824,7 +895,7 @@ function PlaylistDetail() {
// to failing again.
setLastMoveTargetId(target.id);
if (!left.length) {
notify({ level: "warning", message: msg("playlists.moveNoneLeft") });
notify({ level: "warning", message: msg("playlists.moveNoneLeft", { count: rows.length }) });
return;
}
const name = playlistNameText(target);
@@ -837,8 +908,8 @@ function PlaylistDetail() {
: msg("playlists.movedManyToast", { count: left.length, name }),
action: {
label: msg("common.undo"),
// A video back in the source by then stays where it is, in both lists. One gone on from the
// target stays out: the server says how many came back.
// A video back in the source by then stays where it is, in both lists. One this move put in the
// target and gone on from there stays out: the server says how many came back.
onClick: () =>
void placesNow(listId, storedBefore, left)
.then((back) => {
@@ -879,10 +950,19 @@ function PlaylistDetail() {
if (listId == null || !rows.length) return false;
const ids = rows.map((v) => v.id);
const list = listRef(listId);
const before = takeOut(listId, ids);
const failTitle = msg(
rows.length > 1 ? "playlists.removeManyFailed" : "playlists.removeFailed",
);
// The open player can be on a list nothing has kept in the cache since the selection moved off it:
// its Undo needs the places, so without them nothing goes.
if (!qc.getQueryData(qk.playlist(listId))) {
const failure = await readFailure(qc, listId);
if (failure) {
notifyActionError(failure, failTitle);
return false;
}
}
const before = takeOut(listId, ids);
let removed: string[] | null = null;
try {
removed = (await removePlaylistItems(qc, list, ids)).removed;
@@ -892,17 +972,19 @@ function PlaylistDetail() {
// `notifyActionError` checks.
notifyActionError(e, failTitle);
}
writeDone(listId);
// …and the rows go back NOW, not when the resync lands: a removal that failed because the server
// was unreachable has a resync that fails too.
if (removed == null) putBack(listId, before, ids, ticked);
refreshAfterEdit(ids);
if (removed == null) {
// …and the rows go back NOW, not when the resync lands: a removal that failed because the server
// was unreachable has a resync that fails too.
putBack(listId, before, ticked);
return false;
}
if (removed == null) return false;
// A video someone else took out meanwhile is not this removal's to bring back.
const went = removed;
if (!went.length) {
notify({ level: "warning", message: msg("playlists.removeNoneLeft") });
notify({
level: "warning",
message: msg("playlists.removeNoneLeft", { count: rows.length }),
});
return true;
}
const storedBefore = before?.items.map((v) => v.id) ?? [];
@@ -1439,7 +1521,7 @@ function PlaylistDetail() {
</span>
<CheckRow
checked={groupBy}
onChange={setGroupBy}
onChange={setGrouping}
className="text-xs text-muted"
>
{t("playlists.groupByChannel")}
@@ -1457,7 +1539,10 @@ function PlaylistDetail() {
<Button
variant="outline-accent"
size="xs"
onClick={saveOrder}
onClick={(e) => {
leaveView(e);
saveOrder();
}}
icon={<Check className="w-3.5 h-3.5" />}
>
{t("playlists.saveOrder")}