feat(design): a playlist's one action list (C1) — the row's "…", Delete + undo, the header's "…" (E28 S3e2)

- Playlist rows: the X column became the row's "…" (play, pin, copy title/link, move, order,
  remove). Remove runs at once, with no confirm; its toast's Undo puts the row back at its index.
  The Delete key removes a keyboard-walked row, and focus moves on to the next.
- POST /api/playlists/{id}/items takes an optional `position`, so an undo is one request that
  renumbers the list around the row.
- A new `listDelete` key scope. Only a list with `onDelete` registers it, so the help sheet does not
  offer Delete where nothing handles it.
- A playlist has one list, `usePlaylistActions.items`: rename, pin, YouTube open/push/revert,
  delete. The rail row's "…" and the page header's "…" both open it. Push and revert moved into
  the hook; the header keeps Play all.
- Phone-width playlist table: "#" and Channel are hidden below sm (`Column.phoneHidden`). Their
  fixed widths had left the title no width.
This commit is contained in:
2026-09-26 04:05:39 +02:00
parent aaf1843b09
commit 7455fff401
24 changed files with 1061 additions and 425 deletions
+7 -7
View File
@@ -308,14 +308,14 @@ export default function PlayerModal({
if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id);
};
// "Remove from this playlist", offered right where you are watching the thing you want gone. The
// parent owns the removal (confirm + API + caches); the player owns what happens NEXT, because it
// parent owns the removal (API + caches + the toast whose Undo puts it back); the player owns what happens NEXT, because it
// is the only side that knows which item is playing — its queue is frozen at mount, so the parent
// cannot tell. Step to the item that followed (clamped when the last one goes), and close when the
// list runs out.
//
// Guarded against a double press by a REF, not by disabling the button: a trigger disabled for
// the confirm's lifetime was still disabled when the dialog closed and restored focus to it — a
// no-op — so a keyboard user who cancelled was left on <body>, outside this aria-modal player.
// Guarded against a double press by a REF, not by disabling the button: a trigger disabled for the
// removal's lifetime was still disabled when focus came back to it (it once waited on a confirm) —
// a no-op — so a keyboard user was left on <body>, outside this aria-modal player.
const removingRef = useRef(false);
const removeFromList = async () => {
if (!onRemoveFromList || removingRef.current) return;
@@ -327,9 +327,9 @@ export default function PlayerModal({
} finally {
removingRef.current = false;
}
if (!done) return; // cancelled at the confirm, or the request failed — leave playback alone
if (!done) return; // the request failed — leave playback alone
// Decide from where the player is NOW, not where it was when the button was pressed: prev/next
// and auto-advance keep working under the confirm dialog, and a cursor from before the await
// and auto-advance keep working while the request runs, and a cursor from before the await
// threw the viewer back onto a video they had already moved past.
const { queue: q, index: i } = navRef.current;
if ((q?.[i] ?? video).id !== victim.id) {
@@ -967,7 +967,7 @@ export default function PlayerModal({
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the row control: a bare "Remove" among Fullscreen /
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}