diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index aabe7f83..2d6dee92 100644
--- a/frontend/src/components/Playlists.tsx
+++ b/frontend/src/components/Playlists.tsx
@@ -105,11 +105,16 @@ function Row({
// put and can explain itself instead of silently doing nothing.
@@ -156,6 +161,10 @@ export default function Playlists() {
// Was a bare .then(): a failed reorder left the UI showing an order the server had
// rejected, and produced an unhandled rejection. Say so and resync from the server.
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
+ // Clear the membership guard first: a reorder never changes the id SET, so without this
+ // the arriving detail is judged "same list" and the rejected order stays on screen — and
+ // becomes the base for the next drag.
+ lastSetRef.current = "";
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
});
},
@@ -169,8 +178,10 @@ 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("");
- // The open player, as an index into the DISPLAYED rows (the queue); null = closed.
- const [playingIndex, setPlayingIndex] = useState(null);
+ // The open player is tracked by video ID, not by a position: `shown` is derived, so a refetch or
+ // a change of view would otherwise slide a different video under the index — or unmount the player
+ // mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
+ const [playingId, setPlayingId] = useState(null);
const listQuery = useQuery({
queryKey: ["playlists"],
@@ -191,7 +202,9 @@ export default function Playlists() {
useEffect(() => {
if (!detail) return;
- const key = idsKey(detail.items);
+ // 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)}`;
if (key !== lastSetRef.current) {
lastSetRef.current = key;
order.reset(detail.items);
@@ -218,9 +231,7 @@ export default function Playlists() {
const [pushing, setPushing] = useState(false);
const [reverting, setReverting] = useState(false);
- const onPlay = useCallback((v: Video) => {
- setPlayingIndex((_) => shownRef.current.findIndex((x) => x.id === v.id));
- }, []);
+ const onPlay = useCallback((v: Video) => setPlayingId(v.id), []);
const onRemove = useCallback((v: Video) => {
removeRef.current(v);
}, []);
@@ -240,7 +251,8 @@ export default function Playlists() {
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy),
[items, columns, filters, viewSort, groupBy]
);
- // Read by the memoized onPlay callback without making it depend on the list.
+ // Read by the drag announcements, which must report the CURRENT positions without making the
+ // announcement closures a render dependency of DndContext.
const shownRef = useRef(shown);
shownRef.current = shown;
@@ -271,6 +283,22 @@ export default function Playlists() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
+ const posOf = (id: string | number) => shownRef.current.findIndex((v) => v.id === id) + 1;
+ const titleOf = (id: string | number) =>
+ shownRef.current.find((v) => v.id === id)?.title ?? "";
+ const total = () => shownRef.current.length;
+ const screenReaderInstructions = { draggable: t("playlists.dndInstructions") };
+ const announcements = {
+ onDragStart: ({ active }: { active: { id: string | number } }) =>
+ t("playlists.dndPickedUp", { title: titleOf(active.id), pos: posOf(active.id), total: total() }),
+ onDragOver: ({ active }: { active: { id: string | number } }) =>
+ t("playlists.dndMovedOver", { title: titleOf(active.id), pos: posOf(active.id), total: total() }),
+ onDragEnd: ({ active }: { active: { id: string | number } }) =>
+ t("playlists.dndDropped", { title: titleOf(active.id), pos: posOf(active.id), total: total() }),
+ onDragCancel: ({ active }: { active: { id: string | number } }) =>
+ t("playlists.dndCancelled", { title: titleOf(active.id), pos: posOf(active.id) }),
+ };
+
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
function refreshAll() {
@@ -378,7 +406,7 @@ export default function Playlists() {
// 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 = idsKey(next);
+ lastSetRef.current = `${selectedId}:${idsKey(next)}`;
try {
await api.removeFromPlaylist(selectedId, video.id);
refreshAll();
@@ -419,7 +447,7 @@ export default function Playlists() {
choices: offerYoutube
? [
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
- { id: "both", label: t("playlists.deleteOnYoutubeConfirm"), danger: true },
+ { id: "both", label: t("playlists.deleteBoth"), danger: true },
]
: [{ id: "here", label: t("playlists.delete"), danger: true }],
});
@@ -451,11 +479,15 @@ export default function Playlists() {
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
- const closePlayer = useCallback(() => setPlayingIndex(null), []);
+ const closePlayer = useCallback(() => setPlayingId(null), []);
- const dragHint = filterActive
- ? t("playlists.dragBlockedFilter")
- : t("playlists.dragBlockedSort");
+ // Order matters: a built-in list is never reorderable, whatever the view says.
+ const dragHint = !canEdit
+ ? t("playlists.dragBlockedBuiltin")
+ : filterActive
+ ? t("playlists.dragBlockedFilter")
+ : t("playlists.dragBlockedSort");
+ const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
if (selectedId == null) {
return
{t("playlists.pickOne")}
;
@@ -544,7 +576,7 @@ export default function Playlists() {
) : (
-
+
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
@@ -696,10 +740,10 @@ export default function Playlists() {
)}
- {playingIndex != null && shown[playingIndex] && (
+ {playingIndex >= 0 && (
({
const { t } = useTranslation();
const [openFilter, setOpenFilter] = useState(null);
const panelRef = useRef(null);
- useDismiss(!!openFilter, () => setOpenFilter(null), panelRef);
+ // The funnel that opened the panel. `useDismiss` must know about it too, or the trigger's own
+ // mousedown closes the panel and the click that follows immediately reopens it — clicking the
+ // funnel a second time then looks like it does nothing. Also the thing to hand focus back to.
+ const triggerRef = useRef(null);
+ const restoreFocus = useRef(false);
+ useDismiss(!!openFilter, () => setOpenFilter(null), [panelRef, triggerRef]);
+
+ // Escape (or an outside click) used to unmount the focused input and strand focus on ,
+ // so the next Tab restarted from the top of the page.
+ useEffect(() => {
+ if (openFilter) {
+ restoreFocus.current = true;
+ } else if (restoreFocus.current) {
+ restoreFocus.current = false;
+ triggerRef.current?.focus();
+ }
+ }, [openFilter]);
return (
@@ -222,25 +238,37 @@ export function ColumnHead({
}
>
-
+ {/* Only a sortable header is a control. A non-sortable one used to render a
+ focusable no-op button, which put unlabeled dead stops in the tab order —
+ two of them per playlist table, whose grip and actions headers are empty. */}
+ {col.sortable ? (
+
+ ) : (
+ {col.header}
+ )}
{col.filter && (