fix(playlists): "remove" inside the player targets the list it came from, reports failure, and keeps the cursor and focus honest

Deep-review R5 — five defects in the Remove button f76a2afb put inside the YT player:

- It removed from whichever playlist was selected by the time it was pressed, not the one the player
  was launched from; the selection can change under an open player (the command palette opens over
  it). The launch list is now part of the player snapshot, and the optimistic row removal only
  touches the list actually on screen.
- A 403/404 failed in total silence: the catch claimed the global dialog had reported it, but `req`
  deliberately leaves those to the caller. It now notifies, as moveItemTo already did (the silent
  catch predates this round; the full-screen consumer did not). New key playlists.removeFailed, HU+EN.
- It computed the next item from the cursor as it was when the button was pressed, but prev/next and
  auto-advance keep working under the confirm dialog: the viewer was thrown back onto a video they had
  moved past. It reads the live cursor, and if they moved on it just drops the item from the queue.
- The button was disabled for the confirm's lifetime, so the dialog's focus restore hit a disabled
  element and a keyboard user who cancelled was left on <body>, outside the aria-modal player. A ref
  guards the double press instead.
- Its accessible name was a bare "Remove"; it now names its object, like the row control.

The call-site wiring (step on, drop from the queue, close on the last item, leave everything alone
when the removal did not happen) finally has tests. Verified on localdev in the real YT player: a row
reopened at the 136 s it had just stored; Cancel returned focus to the Remove button; a confirmed
removal stepped 1/3 -> 1/2. 10 mutations, each red on its assertion. Scratch list and positions reset.
This commit is contained in:
2026-09-11 01:28:27 +02:00
parent 4869d6c108
commit 060deb89d2
6 changed files with 253 additions and 34 deletions
+43 -19
View File
@@ -216,16 +216,23 @@ export default function Playlists() {
// re-confirms our own reorder doesn't wipe the undo history — we reset history only when
// the actual set of items changes (different playlist, or an add/remove).
const lastSetRef = useRef<string>("");
// The selection as of NOW, for code that runs after an await or on behalf of the open player.
const selectedRef = useRef(selectedId);
selectedRef.current = selectedId;
// What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index
// inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns
// its own cursor from mount — so deriving the mount from `shown` every render meant anything that
// dropped the launch row yanked the player off the screen mid-playback. That was a latent edge
// (a view change) until "remove this from the playlist" moved INSIDE the player, where it is the
// normal case. Only onClose takes the player down now.
// normal case. Only onClose takes the player down now. `listId` is part of that snapshot for the
// same reason: the selection can change under an open player (the command palette opens over it),
// and "remove this from the playlist" means the list it was LAUNCHED from, not whichever is
// selected by the time the button is pressed.
const [playing, setPlaying] = useState<{
video: Video;
queue: Video[];
index: number;
listId: number | null;
} | null>(null);
// Where the last move went. `LS.playlist` is the SELECTED (source) list and `updated_at` moves on
// any edit, so neither answers "where do I keep sending things"; this does, and it is a per-account
@@ -303,7 +310,7 @@ export default function Playlists() {
const queue = shownRef.current;
const index = queue.findIndex((v) => v.id === id);
if (index < 0) return;
setPlaying({ video: queue[index]!, queue, index });
setPlaying({ video: queue[index]!, queue, index, listId: selectedRef.current });
}, []);
const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
const onRemove = useCallback((v: Video) => {
@@ -312,8 +319,13 @@ export default function Playlists() {
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
// inline ✕ use — one confirm, one optimistic update, one undo-stack rebase, no second copy to
// drift. It answers whether the removal actually happened, because the player has to decide
// afterwards whether to step to the next item or close.
const removeFromPlayer = useCallback((v: Video) => removeRef.current(v), []);
// afterwards whether to step to the next item or close. It removes from the list the player was
// launched from (see `playing.listId`), not from whatever is selected by now.
const launchListId = playing?.listId ?? null;
const removeFromPlayer = useCallback(
(v: Video) => removeRef.current(v, launchListId),
[launchListId],
);
const storedIndex = useMemo(() => {
const m = new Map(items.map((v, i) => [v.id, i]));
@@ -399,9 +411,9 @@ export default function Playlists() {
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
function refreshAll() {
function refreshAll(listId: number | null = selectedId) {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
}
async function revertToYoutube() {
@@ -517,8 +529,11 @@ export default function Playlists() {
/** @returns whether the item was actually removed — the player needs to know, because it must
* step to the next item (or close) only when the removal really happened. */
async function removeItem(video: Video): Promise<boolean> {
if (selectedId == null) return false;
async function removeItem(
video: Video,
listId: number | null = selectedRef.current,
): Promise<boolean> {
if (listId == null) return false;
const ok = await confirm({
title: t("playlists.removeItem"),
message: t("playlists.confirmRemove", { title: video.title }),
@@ -526,19 +541,28 @@ export default function Playlists() {
danger: true,
});
if (!ok) return false;
// A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
// drops the item from every snapshot, so the stack stays valid for what's left.
const next = items.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
// be removing from the list it was launched from while another one is selected. Read after the
// confirm: the selection may have changed while it was open.
if (listId === selectedRef.current) {
// A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
// drops the item from every snapshot, so the stack stays valid for what's left.
const next = order.value.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${listId}:${idsKey(next)}`;
}
try {
await api.removeFromPlaylist(selectedId, video.id);
refreshAll();
await api.removeFromPlaylist(listId, video.id);
refreshAll(listId);
return true;
} catch {
// The optimistic removal above didn't stick — resync from the server to restore the row.
// api.req already surfaced the error to the user via the global error dialog.
refreshAll();
} catch (e) {
// `req` raises the global dialog for 4xx-validation and 5xx, but leaves 403/404 to the caller
// (the list was deleted or de-owned meanwhile) — so say it here, as moveItemTo does, or a
// confirmed removal fails in silence (inside the full-screen player there is not even a row
// snapping back to show it).
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
notify({ level: "error", message: e.detail || t("playlists.removeFailed") });
refreshAll(listId); // …and resync, which restores any row the optimistic removal took out
return false;
}
}