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
+24 -10
View File
@@ -120,7 +120,7 @@ export default function PlayerModal({
// useState's lazy initializer and ignore later prop changes. Items that turn out gone/
// non-embeddable still surface the existing playback-error overlay when reached.
//
// The setter exists for exactly ONE writer: removing the playing video from its playlist (below).
// The setter exists for exactly ONE writer: removing a video from its playlist (below).
// That is not the feed changing under us — it is this player's own action — and leaving the item
// in the frozen queue would let prev/next step back onto a video that no longer exists.
const [queue, setQueue] = useState(() => queueProp);
@@ -262,20 +262,33 @@ export default function PlayerModal({
// parent owns the removal (confirm + API + caches); 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 while the confirm/request is in flight.
const [removing, setRemoving] = useState(false);
// 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.
const removingRef = useRef(false);
const removeFromList = async () => {
if (!onRemoveFromList || removing) return;
if (!onRemoveFromList || removingRef.current) return;
const victim = active;
setRemoving(true);
removingRef.current = true;
let done = false;
try {
done = await onRemoveFromList(victim);
} finally {
setRemoving(false);
removingRef.current = false;
}
if (!done) return; // cancelled at the confirm, or the request failed — leave playback alone
const next = afterRemoval(queue, index, victim.id);
// 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
// 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) {
// They moved on — take the removed item out of the queue and leave playback where it is.
setQueue((cur) => cur?.filter((v) => v.id !== victim.id));
return;
}
const next = afterRemoval(q, i, victim.id);
if (!next) {
onClose(); // that was the last one
return;
@@ -905,10 +918,11 @@ export default function PlayerModal({
{!navigated && onRemoveFromList && (
<button
onClick={() => void removeFromList()}
disabled={removing}
title={t("playlists.removeItem")}
aria-label={t("playlists.removeItem")}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-danger hover:bg-surface border border-border transition disabled:opacity-50"
// Named with its object, like the row control: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
aria-label={t("playlists.removeItemOf", { title: active.title })}
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-danger hover:bg-surface border border-border transition"
>
<Trash2 className="w-4 h-4" />
</button>