Files
siftlode/frontend/src/modules/playlists/Playlists.tsx
T
peter b636ef38bc 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.
2026-11-02 22:15:13 +01:00

1730 lines
76 KiB
TypeScript

import {
lazy,
Suspense,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MouseEvent,
} from "react";
import { useLatest, useStableCallback } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { hasResumePoint } from "../../lib/api/feed";
import {
movePlaylistItems,
removePlaylistItems,
restorePlaylistItems,
undoPlaylistMove,
clearVideoProgress,
writeVideoState,
} from "../../features/video/videoWrites";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
DragOverlay,
KeyboardCode,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
type Modifier,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS, getEventCoordinates } from "@dnd-kit/utilities";
import { Check, Eraser, FolderInput, History, ListPlus, Play, X } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import {
api,
type Playlist,
type PlaylistDetail,
type Video,
type VideoStatus,
} from "../../lib/api";
import { msg, notify, raw } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { playlistName, playlistNameText } from "../../lib/playlistName";
import { reinsertAt } from "../../lib/reinsert";
import { useRowSelection } from "../../lib/useRowSelection";
import { useUndoable } from "../../lib/useUndoable";
import { cycleSort, type SortState } from "../../lib/columnSort";
import { useUrlMirror } from "../../lib/useUrlMirror";
import {
isStoredSort,
PLAYLIST_STORED_SORT,
playlistViewFrom,
playlistViewParamUpdates,
} from "../../lib/playlistsUrlState";
import { formatRuntime } from "../../lib/format";
import { applyView, sameOrder } from "../../lib/playlistView";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
import { Button } from "../../components/ui/Button";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { useComfortPin, pinPlaylist, pinVideo } from "../../features/comfort/comfortPin";
import { ContextMenu } from "../../components/ui/ContextMenu";
import Overlay from "../../components/ui/Overlay";
import { useContextMenu } from "../../components/ui/useContextMenu";
import { usePageMenu } from "../../components/ui/pageMenuSlot";
import { menuGroups } from "../../components/ui/menuGroups";
import UndoToolbar from "../../components/ui/UndoToolbar";
import NavGrid from "../../components/ui/NavGrid";
import { PageToolbar } from "../../components/ui/PageShell";
import { usePlaylistActions } from "./usePlaylistActions";
import { usePlaylists } from "./playlistsContext";
import { railDefaultId } from "./playlistsInit";
import { usePref } from "../../lib/usePref";
import { followRemoval, navItemAttrs } from "../../lib/listNav";
import { ColumnHead } from "../../components/ui/tableHeader";
import { alignClass } from "../../components/ui/tableAlign";
import {
columnHideClass,
filterRows,
isActive,
type Column,
type FilterMap,
} from "../../lib/tableColumns";
import { GRIP_KEY, playlistColumns, selectColumn } from "./playlistColumns";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
import { DragHandle } from "../../components/ui/DragHandle";
import { SURFACE } from "../../components/ui/surfaces";
import { RowSelectBox } from "../../components/ui/RowSelectBox";
import { useOrderItems } from "../../components/ui/useOrderItems";
import { Input } from "../../components/ui/Input";
import { CheckRow } from "../../components/ui/NativeControls";
import { useReturnFocus } from "../../components/ui/useReturnFocus";
import { MoreButton } from "../../components/ui/MoreButton";
import { HiddenFilterCue } from "../../components/ui/HiddenFilterCue";
import { PageHeading } from "../../components/ui/Headings";
import { useCopyItems } from "../../components/ui/copyItems";
import RemoteImage from "../../components/ui/RemoteImage";
import PlaylistsHeader from "./PlaylistsHeader";
import { SelectionBand } from "./SelectionBand";
import { isOverRail, railDropTarget, startRailDrag, stopRailDrag } from "./railDrop";
import clsx from "clsx";
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
//
// The distinction that shapes the whole module: **the column sort and the filters are a VIEW**,
// while the playlist's own order is stored data. At first the sort control wrote straight through
// to the server, so picking "sort by title" silently rewrote the playlist and marked a
// YouTube-linked mirror dirty. Now nothing is written until the user presses "Save this order",
// which still goes through the undoable path — the capability is intact, it just stopped being a
// side effect of looking at the list.
//
// It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns
// its own filter/sort/page state and the stored order needs a single owner.
// The neutral view ("#" ascending, the stored order) lives beside the address-bar codec of the view.
const STORED_SORT = PLAYLIST_STORED_SORT;
const idsKey = (items: Video[]) =>
items
.map((v) => v.id)
.sort()
.join(",");
const moveTargetIcon = (pl: Playlist) =>
pl.source === "youtube" || pl.yt_playlist_id ? (
<YouTubeIcon className="w-4 h-4" />
) : (
<ListPlus className="w-4 h-4" />
);
/** Under a view a key would pick the row up with nowhere to put it: only the rail is left to go to.
* No key starts it, rather than no such sensor: dnd-kit takes the sensors as an effect's dependency
* list, whose size must not change. */
const NO_KEY_STARTS = {
start: [],
cancel: [KeyboardCode.Esc],
end: [KeyboardCode.Space, KeyboardCode.Enter, KeyboardCode.Tab],
};
/** A pointer drag lands where the pointer is. dnd-kit measures from the drag image, whose small
* box has its centre elsewhere: on a tablet's taller rows a drop reached the row before. */
function pointerFirst(args: Parameters<CollisionDetection>[0]): Parameters<CollisionDetection>[0] {
const at = args.pointerCoordinates;
if (!at) return args;
const { width, height } = args.collisionRect;
const left = at.x - width / 2;
const top = at.y - height / 2;
return {
...args,
collisionRect: { width, height, left, top, right: left + width, bottom: top + height },
};
}
/** The drag image hangs below the pointer: on it, it covered the name of the list it was over. */
const beneathPointer: Modifier = ({ activatorEvent, draggingNodeRect, transform }) => {
const at = activatorEvent && getEventCoordinates(activatorEvent);
if (!at || !draggingNodeRect) return transform;
return {
...transform,
x: transform.x + at.x - draggingNodeRect.left + 12,
y: transform.y + at.y - draggingNodeRect.top + 16,
};
};
function Row({
video,
index,
columns,
selected,
carried,
dragDisabled,
listDragOnly,
dragHint,
menuProps,
}: {
video: Video;
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
index: number;
columns: Column<Video>[];
selected: boolean;
/** Going along with the row being dragged. */
carried: boolean;
dragDisabled: boolean;
/** A drag can only leave for another list: the view holds the order, and no key reaches the rail. */
listDragOnly: boolean;
dragHint: string;
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
* behaviour — a right-click just isn't a button press. */
menuProps: Record<string, unknown>;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id: video.id,
disabled: dragDisabled,
});
return (
<tr
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
// No transition on the row being dragged: dnd-kit drives `transform` from the pointer, so
// animating it makes the row trail the cursor. The others keep it for the reflow animation.
transition: isDragging ? undefined : transition,
opacity: isDragging || carried ? 0.6 : 1,
}}
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
// the same lag by another route (150ms behind the pointer, on every row).
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
className={`group border-b border-border/50 transition-colors ${selected ? "bg-accent/12 hover:bg-accent/16" : "state-layer"}`}
data-selected={selected ? "" : undefined}
{...navItemAttrs(index)}
{...menuProps}
>
{columns.map((col) => (
<td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}${columnHideClass(col)}`}
>
{col.key === GRIP_KEY ? (
// The one cell the column defs can't render: the handle needs this row's sortable
// attributes/listeners. Disabled (not hidden) with nowhere to go, so the affordance
// stays put and can explain itself instead of silently doing nothing.
<DragHandle
{...attributes}
aria-describedby={listDragOnly ? undefined : attributes["aria-describedby"]}
{...(dragDisabled ? {} : listeners)}
unavailable={dragDisabled}
label={
dragDisabled
? dragHint
: t(listDragOnly ? "playlists.dragHandleToList" : "playlists.dragHandleOf", {
title: video.title,
})
}
/>
) : (
col.render(video)
)}
</td>
))}
</tr>
);
}
// The page: its header pill, and the selected playlist's detail (whose loading and empty states
// return early, so the header sits outside it).
export default function Playlists() {
return (
<>
<PlaylistsHeader />
<PlaylistDetail />
</>
);
}
/** `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.
const { selectedId, linkedId } = usePlaylists();
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const plActions = usePlaylistActions();
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[]>([], {
onApply: (next) => {
if (selectedId == null) return;
const listId = selectedId;
api
.reorderPlaylist(
listId,
next.map((v) => v.id),
)
.then(() => {
// Write the accepted order into the cache BEFORE the refetch. The cache is what
// `restoreFromCache` rolls back to, and until this refetch landed it still held the order
// 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.
// 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.
refreshAfterEdit([]);
})
.catch((e) => {
// 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 put back the order the server
// still has — or the rejected one becomes the base for the next drag.
notifyYouTubeActionError(e, msg("playlists.reorderFailed"));
restoreFromCache(listId);
refreshAfterEdit([]);
});
},
});
const items = order.value;
// VIEW state — never written to the server on its own. The address names it, so a reload or a
// bookmark opens the playlist sorted, filtered and grouped as it was left (the view it opens on is
// the address's; a switch to another playlist starts over, below).
const [opened] = useState(() => playlistViewFrom(new URLSearchParams(window.location.search)));
const [viewSort, setViewSort] = useState<SortState>(opened.sort);
const [filters, setFilters] = useState<FilterMap>(opened.filters);
const [groupBy, setGroupBy] = useState(opened.groupBy);
// Tracks the membership (id set) currently loaded into `order`, so a refetch that only
// 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 = useLatest(selectedId);
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
// (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen.
// Only for the list on screen: the selection may have moved meanwhile.
function restoreFromCache(listId: number) {
if (listId !== selectedRef.current) return;
const cur = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
if (!cur) return;
lastSetRef.current = `${listId}:${idsKey(cur.items)}`;
order.reset(cur.items);
}
// 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. `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. So does whether that button exists at all
// (`editable`): gating it on the current selection offered Remove on a player launched from
// Watch later — which deliberately has none — as soon as another list was selected under it.
const [playing, setPlaying] = useState<{
video: Video;
queue: Video[];
index: number;
listId: number | null;
editable: boolean;
} | 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
// preference like every other remembered choice. Null until the first move of this account's life.
const [lastMoveTargetId, setLastMoveTargetId] = usePref<number | null>(
"lastMovePlaylistId",
null,
);
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
const rowMenu = useContextMenu<Video>();
// The comfort-shelf pin composes into the row's existing menu rather than mounting a
// second one on the same <tr>.
const comfortPin = useComfortPin(rowMenu.open);
const copy = useCopyItems();
// The header's "…": the list's own menu, the one its rail row opens (usePlaylistActions.items).
const headerMenu = useContextMenu<PlaylistDetail>();
const headerPin = useComfortPin(headerMenu.open);
const listQuery = useQuery({
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
refetchOnWindowFocus: true,
});
const playlists = listQuery.data ?? [];
const detailQuery = useQuery({
queryKey: qk.playlist(selectedId),
queryFn: () => api.playlist(selectedId as number),
enabled: selectedId != null,
// Refetch when returning to the tab so a change made elsewhere (another tab/device)
// shows up — including the player's live N / M count, which reads the queue length.
refetchOnWindowFocus: true,
});
const detail = detailQuery.data;
// Which kind of list an item write goes to (the Watch later list's items are the bookmark):
// the rail's list knows every one, the open detail the one on screen. Only a user list's rows are
// removed or moved from here today (Watch later offers neither), so it reads "user" — looked up
// all the same, so no write here takes the kind for granted. A move INTO Watch later passes the
// rail's own row as its target.
const listRef = (id: number) => ({
id,
kind: playlists.find((p) => p.id === id)?.kind ?? (detail?.id === id ? detail.kind : undefined),
});
// The page's background menu (DESIGN "Background menu"), live only while a list is on screen.
const pageMenu = usePageMenu(selectedId != null && !!detail);
const pagePin = useComfortPin(pageMenu.open);
// Destructured so the effect below can name what it calls: both are stable useCallbacks, while
// the object around them is rebuilt every render (lib/useUndoable).
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)}`;
if (key !== lastSetRef.current) {
lastSetRef.current = key;
resetOrder(detail.items);
} else {
// Same membership, fresher ROWS — a watch position the player just stored, a status. The
// guard above looks at the id set only, so without this the list kept showing, and handing the
// player, each row as it was when the list first loaded: watch a video to 830 s, close it,
// reopen it, and it resumed at the old spot and the next checkpoint wrote that over the 830.
// `rebase` takes the fresh rows into every snapshot and keeps the order and its undo history.
const fresh = new Map(detail.items.map((v) => [v.id, v]));
rebaseOrder((list) => list.map((v) => fresh.get(v.id) ?? v));
}
// `reset` and `rebase` are stable useCallbacks (lib/useUndoable), so naming them costs nothing;
// `selectedId` moves only together with `detail` anyway, since detail IS the selected playlist's.
}, [detail, selectedId, resetOrder, rebaseOrder]);
// Reset the view when switching playlists (the stored order itself is per-playlist) — during the
// render that switches, so the incoming playlist is never drawn once through the previous one's
// sort, filters and grouping. All three are this component's own state, which is what makes a
// render-phase write legal here.
const [viewFor, setViewFor] = useState(selectedId);
const [bandWasUp, setBandWasUp] = useState(false);
if (viewFor !== selectedId) {
setViewFor(selectedId);
setViewSort(STORED_SORT);
setFilters({});
setGroupBy(false);
setBandWasUp(false);
}
useUrlMirror(
playlistViewParamUpdates(selectedId != null ? { sort: viewSort, filters, groupBy } : null),
);
const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
// dirty mirror so it won't clobber unpushed edits); the header's "Unsynced" says when they differ.
const editable = !builtin;
const linked = editable && !!detail?.yt_playlist_id;
// LOCAL editing (reorder, remove) needs no YouTube write scope — `can_write` gates only the
// push/delete-on-YouTube paths (usePlaylistActions). Folding it in here would have taken drag and remove away from
// every account without the scope, which they have always had.
const canEdit = editable;
const canEditRef = useLatest(canEdit);
// Forward refs to `shown` and `removeItem` (defined further down) for the stable callbacks here,
// pointed at the latest values after each commit (below).
const shownRef = useRef<Video[]>([]);
const removeRef = useRef<typeof removeItem>(async () => false);
// Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
// than closing over it.
const openPlayer = useStableCallback((id: string) => {
const queue = shownRef.current;
const index = queue.findIndex((v) => v.id === id);
if (index < 0) return;
setPlaying({
video: queue[index]!,
queue,
index,
listId: selectedRef.current,
editable: canEditRef.current,
});
});
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
// Delete key use — one optimistic update, one undo toast, 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. 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 = (v: Video) => removeRef.current(v, launchListId);
const storedIndex = useMemo(() => {
const m = new Map(items.map((v, i) => [v.id, i]));
return (v: Video) => m.get(v.id) ?? 0;
}, [items]);
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not
// the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums.
const totalSeconds = useMemo(
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0),
[items],
);
// The row's "…" opens the row menu under itself. Which row's menu is open is part of the
// cell (aria-expanded), so the columns rebuild when it changes — a cheap array of closures.
const { buttonProps: rowMenuButton, open: rowMenuOpen, target: rowMenuTarget } = rowMenu;
const columns = useMemo(
() =>
playlistColumns({
t,
indexOf: storedIndex,
onPlay: (v) => openPlayer(v.id),
more: (v) => (
<MoreButton
label={t("playlists.rowMenu")}
expanded={rowMenuOpen && rowMenuTarget === v}
menuProps={rowMenuButton(v)}
className={rowMenuOpen && rowMenuTarget === v ? undefined : "reveal"}
/>
),
}),
[t, storedIndex, openPlayer, rowMenuButton, rowMenuOpen, rowMenuTarget],
);
const filterActive = Object.values(filters).some(isActive);
const shown = useMemo(
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy, lang),
[items, columns, filters, viewSort, groupBy, lang],
);
// The rows the bulk actions cover. Watch later offers no edit, so it has nothing to select them for.
const selection = useRowSelection(
items.map((v) => v.id),
shown.map((v) => v.id),
selectedId,
);
const selectAllRef = useRef<HTMLInputElement>(null);
const bandRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
// The band's own "Move to" list, the row menu's with no row behind it.
const moveMenu = useContextMenu<"selection">();
const tableColumns = canEdit
? [
selectColumn({
t,
allBox: (
<RowSelectBox
ref={selectAllRef}
label={t("playlists.selectAllShown")}
title={t("playlists.selectAllShown")}
checked={selection.headerState === "all"}
mixed={selection.headerState === "some"}
onToggle={selection.toggleAllShown}
/>
),
isSelected: selection.isSelected,
onToggle: selection.toggle,
}),
...columns,
]
: columns;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
// While anything is selected, shown or not: a tick only a filter hides still needs its way out.
const bandUp = canEdit && selection.shownSelected.length + selection.hiddenCount > 0;
if (bandUp && !bandWasUp) setBandWasUp(true);
// Out loud from a region that outlives the band: its news includes the band going.
const selectionStatus = bandUp
? [
t("playlists.selectedCount", { count: selection.shownSelected.length }),
...(selection.hiddenCount > 0
? [t("playlists.selectedHidden", { count: selection.hiddenCount })]
: []),
].join(" · ")
: bandWasUp
? t("playlists.selectionCleared")
: "";
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
// offers itself when the view is a pure reordering of the whole list.
const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);
function toggleSort(key: string) {
// Cycling off any column lands back on the stored order rather than on a nameless "unsorted".
setViewSort((s) => cycleSort(s, key) ?? STORED_SORT);
}
function applyFilter(key: string, value: string | string[]) {
setFilters((f) => ({ ...f, [key]: value }));
}
function clearView() {
setViewSort(STORED_SORT);
setFilters({});
setGroupBy(false);
}
// 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 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 } }),
// The grip was focusable and called itself "reorder", but only a pointer could move it.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
keyboardCodes: viewActive ? NO_KEY_STARTS : undefined,
}),
);
// Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y).
const dndA11y = useSortableA11y((id) => {
const rows = shownRef.current;
return {
title: rows.find((v) => v.id === id)?.title ?? "",
pos: rows.findIndex((v) => v.id === id) + 1,
total: rows.length,
};
});
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
/** The rows on the drag image. A selected row carries the others; a key moves only its own row. */
const [carried, setCarried] = useState<readonly string[] | null>(null);
const carriedRef = useRef<readonly string[]>([]);
const droppedOnRef = useRef<number | null>(null);
useEffect(
() => () => {
stopRailDrag();
},
[],
);
function onDragStart(e: DragStartEvent) {
const id = String(e.active.id);
const from = "clientX" in e.activatorEvent ? (e.activatorEvent as PointerEvent) : null;
const rows =
from && selection.isSelected(id) && selection.shownSelected.length > 1
? selection.shownSelected
: [id];
carriedRef.current = rows;
droppedOnRef.current = null;
// No drag image for a key: it leaves nothing to carry, and the row itself walks the list.
if (from) {
setCarried(rows);
startRailDrag(from.clientX, from.clientY);
}
}
function onDragCancel() {
stopRailDrag();
setCarried(null);
}
function onDragEnd(e: DragEndEvent) {
const { active: a, over } = e;
const droppedOn = stopRailDrag();
setCarried(null);
droppedOnRef.current = droppedOn;
if (droppedOn != null) {
const target = moveTargets.find((p) => p.id === droppedOn);
const row = shown.find((v) => v.id === a.id);
if (!target || !row) return;
if (e.activatorEvent.target instanceof Element) followRemoval(e.activatorEvent.target);
if (selection.isSelected(row.id)) moveSelected(target);
else void moveRows([row], target);
return;
}
// A collision reports nothing under a view or for many rows, so nothing is reordered there.
if (!over || a.id === over.id || selectedId == null) return;
const oldIndex = items.findIndex((v) => v.id === a.id);
const newIndex = items.findIndex((v) => v.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
}
/** Takes a row out at once and says so in a toast whose Undo puts it back where it was (the
* X column became the Delete key + this toast; no confirm, because nothing is lost).
* @returns whether the write went through — the player needs to know, because it must step to the
* next item (or close) only then. */
function removeItem(video: Video, listId?: number | null): Promise<boolean> {
return removeRows([video], tickOf(video), listId);
}
// The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
// a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
useLayoutEffect(() => {
shownRef.current = shown;
removeRef.current = removeItem;
});
// A reload or a bookmark of the player reopens it once this playlist is in, its view as the queue
// again (the playlist and its view are in the address under it). A video the view does not show
// (filtered out, gone from the list, the list emptied or out of reach) plays alone, and nothing
// removes it from a list. In: the order seeded from it (an empty one has none to wait for), or its
// read refused. Scoped to the playlist it was linked over: another one picked first gives it up.
// Linked over the playlist the store started with, not the selection this page mounted on: the rail
// may have moved that already. One the account's list does not hold (deleted since, another
// account's link) is out of reach as soon as that list is in: the rail then moves to the first one
// by itself, which is no pick — any other is.
const linkedOver = linkedId;
const linkedOutOfReach =
listQuery.data != null && !listQuery.data.some((p) => p.id === linkedOver);
const railMovedTo = railDefaultId(listQuery.data);
const linkedListIn =
linkedOutOfReach ||
(detail != null && (detail.items.length === 0 || items.length > 0)) ||
(selectedId != null && detailQuery.isError);
useOpenLinkedPlayer(
linkedListIn,
(id) => (linkedOutOfReach ? undefined : shown.find((v) => v.id === id)),
(video, inList) => {
if (inList) openPlayer(video.id);
else {
const listId = linkedOutOfReach ? null : selectedId;
setPlaying({ video, queue: [video], index: 0, listId, editable: false });
}
},
linkedOutOfReach && selectedId === railMovedTo ? linkedOver : selectedId,
);
/** Off the screen at once. Returns the cached list as it stood: the place an Undo goes back to, and
* what a failure puts back. */
function takeOut(listId: number, ids: readonly string[]) {
const gone = new Set(ids);
// Only the list ON SCREEN has rows to take out optimistically — the open player can be removing
// from the list it was launched from while another one is selected.
if (listId === selectedRef.current) {
// Not itself on the reorder undo stack, but it no longer THROWS AWAY that history: rebasing
// drops the rows from every snapshot, so the stack stays valid for what's left.
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 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 (
!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`.) 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,
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. 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 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 ?? "");
// What an action on this one row hands a failure to tick again.
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`). 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[]) =>
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) {
if (added >= asked) return;
notify({
level: "warning",
message: added
? msg("playlists.undoSomeBack", { count: added })
: msg("playlists.undoNoneBack", { count: asked }),
});
}
/** Move rows into another list, in ONE request (see the endpoint). `ticked`: the ones the selection
* held, which a failure ticks again. */
async function moveRows(rows: Video[], target: Playlist, ticked: readonly string[] = []) {
if (selectedId == null || !rows.length) return;
const listId = selectedId;
const ids = rows.map((v) => v.id);
const from = listRef(listId);
const before = takeOut(listId, ids);
const failTitle = msg(rows.length > 1 ? "playlists.moveManyFailed" : "playlists.moveFailed");
let done: Awaited<ReturnType<typeof movePlaylistItems>> | null = null;
try {
done = await movePlaylistItems(qc, from, ids, target);
} catch (e) {
// NOT a silent revert: a target that is gone answers 404, which `req` leaves to the caller. A
// fault `req` already put in its error dialog is not toasted again (`notifyActionError` checks).
notifyActionError(e, failTitle);
}
writeDone(listId);
if (!done) {
putBack(listId, before, ids, ticked);
refreshAfterEdit(ids);
return;
}
const { moved, already_there: alreadyThere } = done;
const left = [...moved, ...alreadyThere];
refreshAfterEdit(ids);
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a shortcut
// to failing again.
setLastMoveTargetId(target.id);
if (!left.length) {
notify({ level: "warning", message: msg("playlists.moveNoneLeft", { count: rows.length }) });
return;
}
const name = playlistNameText(target);
const storedBefore = before?.items.map((v) => v.id) ?? [];
notify({
level: "success",
message:
left.length === 1
? msg("playlists.movedToast", { title: titleOf(rows, left[0]), name })
: 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 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) => {
const place = new Map(back.keys.map((id, i) => [id, back.positions[i]!]));
const keep = (list: string[]) => list.filter((id) => place.has(id));
const undoMoved = keep(moved);
const undoHeld = keep(alreadyThere);
if (back.keys.length)
return undoPlaylistMove(qc, from, target, {
moved: undoMoved,
already_there: undoHeld,
positions: [...undoMoved, ...undoHeld].map((id) => place.get(id)!),
}).then((r) => sayWhatCameBack(back.keys.length, r.added));
})
.catch((e: unknown) =>
notifyActionError(
e,
msg(left.length > 1 ? "playlists.undoMoveManyFailed" : "playlists.undoMoveFailed"),
),
)
.finally(() => refreshAfterEdit(left)),
},
});
}
/** Remove rows in ONE request, with an Undo that puts them back at their places in one too: an
* append plus a whole-list reorder would land them at the end whenever the second call failed.
* `ticked`: the ones the selection held, which a failure ticks again.
* @returns whether the write went through. */
async function removeRows(
rows: Video[],
ticked: readonly string[],
listIdArg?: number | null,
): Promise<boolean> {
// Defaulted here, not as `listIdArg = selectedRef.current` in the signature: the React Compiler
// cannot reorder a ref read in a default parameter and skipped the component for it.
const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
if (listId == null || !rows.length) return false;
const ids = rows.map((v) => v.id);
const list = listRef(listId);
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;
} catch (e) {
// Said here, or a removal fails in silence (inside the full-screen player there is not even a row
// snapping back to show it) — unless `req` already put it in its error dialog, which
// `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) 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", { count: rows.length }),
});
return true;
}
const storedBefore = before?.items.map((v) => v.id) ?? [];
notify({
level: "success",
message:
went.length === 1 && rows.length === 1
? msg("playlists.removedToast", { title: titleOf(rows, went[0]) })
: msg("playlists.removedManyToast", { count: went.length }),
action: {
label: msg("common.undo"),
onClick: () =>
void placesNow(listId, storedBefore, went)
.then((back) => {
if (back.keys.length)
return restorePlaylistItems(qc, list, back.keys, back.positions).then((r) =>
sayWhatCameBack(back.keys.length, r.added),
);
})
.catch((e: unknown) =>
notifyActionError(
e,
msg(
went.length > 1 ? "playlists.undoRemoveManyFailed" : "playlists.undoRemoveFailed",
),
),
)
.finally(() => refreshAfterEdit(went)),
},
});
return true;
}
// The band goes when the selection does. A focus inside it would fall to <body>, and the next Tab
// start over at the top of the page: the header's box takes it (`takeOut` moves it on when the
// action leaves no row and so no box).
function leaveBand() {
if (bandRef.current?.contains(document.activeElement)) selectAllRef.current?.focus();
}
// The selection goes as the action starts: its rows are leaving, and a tick a filter hides would
// come back later on a row the reader has forgotten about. A failure ticks the rows acted on again.
function selectionRows() {
const rows = shown.filter((v) => selection.isSelected(v.id));
leaveBand();
selection.clear();
return rows;
}
function moveSelected(target: Playlist) {
const rows = selectionRows();
void moveRows(
rows,
target,
rows.map((v) => v.id),
);
}
function removeSelected() {
const rows = selectionRows();
void removeRows(
rows,
rows.map((v) => v.id),
);
}
// Keyboard/menu reordering, for the same stored order the drag handle writes — so it goes through
// `order.set` (undoable + persisted), not a direct API call. Gated by the same rule as the drag
// handle: a view (sort/group/filter) is not the stored order, so moving "up" inside one would
// write something the user never saw.
const orderItems = useOrderItems();
function moveInOrder(video: Video, to: number) {
const from = items.findIndex((v) => v.id === video.id);
if (from < 0 || to === from) return;
order.set(arrayMove(items, from, to)); // onApply persists
}
async function saveRename() {
if (selectedId == null) return;
const name = renameValue.trim();
setRenaming(false);
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
}
function playerState(id: string, status: VideoStatus) {
const write = writeVideoState(qc, id, status);
write
.then(() => {
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
// list that is no longer the selected one (see `playing`), and that list holds the row whose
// status just changed. Only lists actually on screen refetch.
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed() });
})
// No catch used to mean an unhandled rejection and nothing said.
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write;
}
// The video's resume point goes (its status stays), from the row's menu.
function clearRowProgress(id: string) {
clearVideoProgress(qc, id)
.then(() => {
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
})
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoProgress")));
}
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
const closePlayer = () => setPlaying(null);
// Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
const sortLabel =
viewSort && !isStoredSort(viewSort)
? `${columns.find((c) => c.key === viewSort.key)?.header ?? viewSort.key} ${
viewSort.dir === "asc" ? "↑" : "↓"
}`
: null;
const viewName = groupBy
? sortLabel
? t("playlists.viewGroupedAnd", { sort: sortLabel })
: t("playlists.viewGrouped")
: (sortLabel ?? "");
const viewStatus = filterActive
? t("playlists.viewFiltered", { shown: shown.length, total: items.length })
: canSaveOrder
? t("playlists.viewDiffers", { view: viewName })
: t("playlists.viewMatches", { view: viewName });
// 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");
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
const moveTargets = playlists.filter((p) => p.id !== selectedId);
// A one-click repeat of the last move — the submenu is two levels deep, and moving a run of videos
// into the same list is the shape this actually gets used in. Absent (not disabled) when there is
// nothing to repeat: before the first move, or when the remembered list IS the one you are looking
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
// `playlists` is empty until the rail's list is in, too: no targets only once it says so.
const noTargetsNote = listQuery.data
? t("playlists.moveNoTargets")
: listQuery.isError
? t("playlists.moveTargetsFailed")
: t("playlists.moveTargetsLoading");
const storedOrderEditable = canEdit && !viewActive;
// While the rows must stay put the others make no way: no row is "over" anything.
const dragCollision: CollisionDetection = (args) =>
storedOrderEditable && carriedRef.current.length <= 1 && !isOverRail()
? closestCenter(pointerFirst(args))
: [];
const listNameOf = useLatest((id: number | null) => {
const list = moveTargets.find((p) => p.id === id);
return list ? plName(list) : "";
});
const dragA11y = useMemo(() => {
const base = dndA11y.announcements;
return {
...dndA11y,
announcements: {
...base,
onDragStart: (args: Parameters<typeof base.onDragStart>[0]) =>
carriedRef.current.length > 1
? t("playlists.dragPickedMany", { count: carriedRef.current.length })
: base.onDragStart(args),
onDragCancel: (args: Parameters<typeof base.onDragCancel>[0]) =>
carriedRef.current.length > 1
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
: base.onDragCancel(args),
onDragMove: () => {
const id = railDropTarget();
return id == null
? undefined
: t("playlists.dragOverList", { name: listNameOf.current(id) });
},
onDragEnd: (args: Parameters<typeof base.onDragEnd>[0]) =>
droppedOnRef.current == null
? carriedRef.current.length > 1
? t("playlists.dragCancelledMany", { count: carriedRef.current.length })
: base.onDragEnd(args)
: t("playlists.dragDroppedOnList", { name: listNameOf.current(droppedOnRef.current) }),
},
};
}, [dndA11y, t, listNameOf]);
const ctxRow = rowMenu.target;
const ctxItems: ActionMenuItem[] = !ctxRow
? []
: [
{
id: "play",
label: t("card.play"),
icon: <Play className="w-4 h-4" />,
onSelect: () => openPlayer(ctxRow.id),
},
...comfortPin.item(pinVideo(ctxRow.id), raw(ctxRow.title ?? "")),
...(hasResumePoint(ctxRow)
? [
{
id: "clearProgress",
label: t("card.clearProgress"),
icon: <Eraser className="w-4 h-4" />,
onSelect: () => clearRowProgress(ctxRow.id),
},
]
: []),
...copy(ctxRow.title, ctxRow.watch_url).map((it, i) => ({
...it,
separatorBefore: i === 0,
})),
...(canEdit
? ([
...(recentMoveTarget
? [
{
id: "move-recent",
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
icon: <History className="w-4 h-4" />,
separatorBefore: true,
onSelect: () => void moveRows([ctxRow], recentMoveTarget, tickOf(ctxRow)),
},
]
: []),
moveTargets.length
? {
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
// Only ONE separator opens the move block, wherever it starts.
separatorBefore: !recentMoveTarget,
items: moveTargets.map((pl) => ({
id: `move-${pl.id}`,
label: plName(pl),
icon: moveTargetIcon(pl),
onSelect: () => void moveRows([ctxRow], pl, tickOf(ctxRow)),
})),
}
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
// nobody why — and its `note` says the why, under the label.
{
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
separatorBefore: !recentMoveTarget,
disabled: true,
note: noTargetsNote,
onSelect: () => {},
},
// Same gate as the drag handle; the reason goes under the first of the four rows.
...orderItems(
items.findIndex((v) => v.id === ctxRow.id),
items.length,
(to) => moveInOrder(ctxRow, to),
storedOrderEditable ? undefined : { reason: dragHint },
),
{
id: "remove",
label: t("playlists.removeItem"),
icon: <X className="w-4 h-4" />,
separatorBefore: true,
danger: true,
onSelect: () => void removeItem(ctxRow),
},
] satisfies ActionMenuItem[])
: []),
];
const moveMenuItems: ActionMenuItem[] = moveTargets.length
? moveTargets.map((pl) => ({
id: `move-${pl.id}`,
label: plName(pl),
icon: moveTargetIcon(pl),
onSelect: () => moveSelected(pl),
}))
: [
{
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
disabled: true,
note: noTargetsNote,
onSelect: () => {},
},
];
const headerPl = headerMenu.target;
const headerItems: ActionMenuItem[] = !headerPl
? []
: plActions.items(headerPl, {
onRename: () => {
setRenameValue(headerPl.name);
setRenaming(true);
},
pin: headerPin.item(pinPlaylist(headerPl.id), playlistNameText(headerPl)),
});
// The page's background menu mirrors the band: Play all, the view controls, the order's undo, and
// the list's own "…" without its destructive rows (Delete, Revert) — a stray right-click on the
// page's blank area must not be one click from those.
const pageItems: ActionMenuItem[] = !detail
? []
: menuGroups(
[
{
id: "playAll",
label: t("playlists.playAll"),
disabled: !shown.length,
onSelect: () => shown.length && openPlayer(shown[0]!.id),
},
],
items.length > 1 || viewActive
? [
{
id: "groupBy",
label: t("playlists.groupByChannel"),
check: { kind: "many", checked: groupBy },
onSelect: () => setGroupBy(!groupBy),
},
...(viewActive
? [{ id: "clearView", label: t("playlists.clearView"), onSelect: clearView }]
: []),
...(canSaveOrder
? [{ id: "saveOrder", label: t("playlists.saveOrder"), onSelect: saveOrder }]
: []),
]
: [],
canEdit && (order.canUndo || order.canRedo)
? [
...(order.canUndo
? [{ id: "undo", label: t("common.undo"), onSelect: order.undo }]
: []),
...(order.canRedo
? [{ id: "redo", label: t("common.redo"), onSelect: order.redo }]
: []),
]
: [],
plActions
.items(detail, {
onRename: () => {
setRenameValue(detail.name);
setRenaming(true);
},
pin: pagePin.item(pinPlaylist(detail.id), playlistNameText(detail)),
})
.filter((it) => !("danger" in it && it.danger)),
);
// The open player, at ONE place in the tree whichever state the page below it is in. Selecting
// another list under an open player (the command palette works over it) puts the page into
// "Loading…" until that list arrives; with the player rendered only in the loaded branch, that
// unmounted it mid-playback and remounted it at the video it was launched with.
const player = playing && (
<Suspense fallback={null}>
<PlayerModal
video={playing.video}
queue={playing.queue}
startIndex={playing.index}
startAt={null}
onClose={closePlayer}
onState={playerState}
onRemoveFromList={playing.editable ? removeFromPlayer : undefined}
/>
</Suspense>
);
if (selectedId == null) {
return (
<>
<div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>
{player}
</>
);
}
// The page used to render "Loading…" forever when the detail query failed — an error read as a
// slow network that never arrived. Only with nothing to show, though (the `isError && <no data>`
// convention, see ui/QueryState): a background refetch that failed — on focus, after a move or a
// removal — replaced a list that was on screen with this message.
if (detailQuery.isError && !detail) {
return (
<>
<StateMessage tone="error" onRetry={() => detailQuery.refetch()}>
{t("playlists.loadFailed")}
</StateMessage>
{player}
</>
);
}
if (!detail) {
return (
<>
<LoadingState shape="list" label={t("playlists.loading")} />
{player}
</>
);
}
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
return (
<>
<div className="px-4 pb-4">
<div role="status" className="sr-only">
{selectionStatus}
</div>
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
row it sheds is list height won back — the old layout stacked three rows next to the
cover plus a fourth block under it, for roughly twice this height. */}
<div className="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center">
{/* Off on a phone, and the state's word and Play all's shrink: past them the list's name had
no width left at all. */}
<div className="hidden w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0 sm:block">
{items[0]?.thumbnail_url && (
<RemoteImage
src={items[0].thumbnail_url}
alt=""
className="w-full h-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
{renaming ? (
<Input
className="flex-1 text-lg font-semibold"
size="heading"
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") {
armRenameReturn(e);
void saveRename(); // plActions.rename reports its own failure
}
if (e.key === "Escape") {
armRenameReturn(e);
setRenaming(false);
}
}}
aria-label={t("playlists.rename")}
/>
) : (
<PageHeading className="text-lg font-semibold truncate">
{plName(detail)}
</PageHeading>
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span className="sr-only xl:not-sr-only">{t("playlists.unsynced")}</span>
</span>
)}
<Button
size="sm"
onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length}
label={t("playlists.playAll")}
icon={<Play className="fill-current" />}
>
<span className="hidden sm:inline">{t("playlists.playAll")}</span>
</Button>
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE
menu, the same the rail row opens — the header keeps Play all. */}
<MoreButton
ref={renameTarget}
variant="outline"
label={t("playlists.railMenu")}
expanded={headerMenu.open}
menuProps={headerMenu.buttonProps(detail)}
/>
</div>
</div>
{/* One cell: the row a selection replaces keeps its height, invisible, so the first
tick moves no row under the second tap, however the two wrap. */}
<div className="grid">
{bandUp && (
<div className="[grid-area:1/1]">
<SelectionBand
ref={bandRef}
count={selection.shownSelected.length}
hidden={selection.hiddenCount}
recentName={recentMoveTarget ? plName(recentMoveTarget) : null}
onMoveRecent={() => recentMoveTarget && moveSelected(recentMoveTarget)}
moveMenuProps={moveMenu.buttonProps("selection")}
moveMenuOpen={moveMenu.open}
onRemove={removeSelected}
onClear={() => {
leaveBand();
selection.clear();
}}
onClearView={viewActive ? resetView : undefined}
trailing={
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
}
/>
</div>
)}
<div
aria-hidden={bandUp || undefined}
className={clsx(
"flex items-center gap-2 mt-1 flex-wrap [grid-area:1/1]",
bandUp && "invisible",
)}
>
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
{filterActive &&
` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && (
<>
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<CheckRow
checked={groupBy}
onChange={setGrouping}
className="text-xs text-muted"
>
{t("playlists.groupByChannel")}
</CheckRow>
{viewActive && (
<Button
variant="link-muted"
onClick={resetView}
className="text-xs underline underline-offset-2"
>
{t("playlists.clearView")}
</Button>
)}
{canSaveOrder && (
<Button
variant="outline-accent"
size="xs"
onClick={(e) => {
leaveView(e);
saveOrder();
}}
icon={<Check className="w-3.5 h-3.5" />}
>
{t("playlists.saveOrder")}
</Button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
(reset link + save hint + "reordering is off"), and none of them answered the
question a user actually has — "did I just change my playlist?". Worse,
sorting a list whose stored order ALREADY matches the sort produced a
warning, no Save button and no visible change, which reads as a malfunction.
Now the line says which of the three situations you are in; the disabled grip
still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12">
<div>
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
{t("playlists.empty")}
</div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={dragCollision}
// Not the list, under a drag that is over the rail: it would run away beside it.
autoScroll={{ canScroll: () => !isOverRail() }}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
// drag; now that the keyboard can, they ARE the instructions for the feature.
accessibility={dragA11y}
>
{/* Not clipped, so the header's filter popovers can overflow the table box. A container,
so a narrow column hides by the table's width, not the window's (`narrowHidden`). */}
<div className="@container max-w-5xl mx-auto">
{/* A narrow table drops Channel and its funnel with it: the cue keeps its filter named. */}
<HiddenFilterCue
columns={columns}
filters={filters}
by="width"
onFilter={applyFilter}
/>
{/* table-fixed, not auto: every column but the title has a declared width, so the
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
min-content, and a nowrap title cell's min-content is the entire title. */}
<table ref={tableRef} className="w-full table-fixed border-collapse text-sm">
<ColumnHead
columns={tableColumns}
sort={viewSort}
onToggleSort={toggleSort}
filters={filters}
onFilter={applyFilter}
/>
<SortableContext
items={shown.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
{/* The rows are a keyboard list: `j`/`k` and the arrows walk them, Enter plays. The grip
keeps Space/Enter, and during a keyboard drag the arrows too (listNav leaves a key
dnd-kit has handled). */}
<NavGrid
as="tbody"
// Delete on a walked row = the menu's Remove, with the same undo toast.
onDelete={
canEdit
? (i) => {
const v = shownRef.current[i];
if (v) void removeItem(v);
}
: undefined
}
selection={
canEdit
? {
toggle: (i) => {
const v = shownRef.current[i];
if (v) selection.toggle(v.id);
},
clear: () => {
leaveBand();
return selection.clear();
},
removeSelected: () => {
if (!bandUp) return false;
// Nothing happens to a row the reader cannot see, and neither to the
// row under the keyboard, which no one selected.
if (selection.shownSelected.length) removeSelected();
else
notify({
level: "warning",
message: msg("playlists.removeAllHidden"),
});
return true;
},
}
: undefined
}
>
{shown.map((v, i) => (
<Row
key={v.id}
index={i}
video={v}
columns={tableColumns}
selected={selection.isSelected(v.id)}
carried={!!carried && carried.includes(v.id)}
dragDisabled={!canEdit || (viewActive && !moveTargets.length)}
listDragOnly={viewActive}
dragHint={dragHint}
menuProps={rowMenu.triggerProps(v)}
/>
))}
</NavGrid>
</SortableContext>
</table>
{shown.length === 0 && (
<div className="text-muted py-8 text-center text-sm">
{t("playlists.noneMatchFilter")}
</div>
)}
</div>
{/* On <body>: the page's scroller contains anything fixed, and the rail lies outside it.
Above every layer (the tooltip's level, by its token: dnd-kit's `zIndex` takes a number
only, and its `style` comes after it), and no way back to fly: it drops or stays. Out of
the pointer's way: lying under it, it kept the rail's tab from peeking open. */}
<Overlay>
<DragOverlay
dropAnimation={null}
style={{ zIndex: "var(--z-index-tooltip)" }}
className="pointer-events-none"
modifiers={[beneathPointer]}
>
{carried && (
<div
className={`${SURFACE.popover} w-fit max-w-xs truncate px-3 py-2 text-sm font-medium`}
>
{carried.length > 1
? t("playlists.itemCount", { count: carried.length })
: shown.find((v) => v.id === carried[0])?.title}
</div>
)}
</DragOverlay>
</Overlay>
</DndContext>
)}
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
<ContextMenu menu={headerMenu} ariaLabel={t("playlists.railMenu")} items={headerItems} />
<ContextMenu
menu={moveMenu}
ariaLabel={t("playlists.moveSelected")}
items={moveMenuItems}
/>
<ContextMenu
menu={pageMenu}
ariaLabel={t("common.pageMenu")}
items={pageMenu.open ? pageItems : []}
/>
</div>
{player}
</>
);
}