Files
siftlode/frontend/src/components/Playlists.tsx
T
peter ada95d04a4 fix(playlists): one rendering for a playlist total, honest below a minute (E9 R1)
Epic-end deep review, round 1. Six findings survived verification; five of them
were one defect wearing different hats.

formatRuntime floored 1-59s to "0m". Harmless while it only ever formatted a
Plex movie's length, but 12D repurposed it for an aggregate, and a total is the
only input that can land under a minute: a playlist of Shorts reported "0m"
while the rail beside it read "0:45". Fixed in the function, not behind a
`>= 60` guard at each of the seven call sites — the invariant belongs where it
cannot be forgotten. Its contract comment claimed "Plex movie/episode lengths"
and was two epics stale.

The rail rendered the same playlist total as formatDuration (1:23:45) while the
new header rendered formatRuntime (1h 23m) — two shapes of one number, side by
side on one screen. The rail now uses formatRuntime too; formatTotalHours had
already recorded that H:MM:SS is wrong for an aggregate.

The rename input kept min-w-0 without flex-1 in the compacted row, so it was
the only shrinkable child beside a shrink-0 action cluster. The Plex half of
this same epic already had the fix; this restores the symmetry.

plex.browse.items had no plural forms, so a one-item Plex playlist read
"1 items". Pre-existing, but the new header is the call site where count=1 is
ordinary. Both languages get both forms, no key drift.

formatRuntime had no unit tests at all across seven call sites. Added a tier
table plus a sweep asserting no non-zero second ever renders as "0m".

Dropped after 3/3 adversarial refutations: the meta-line segment ordering
(the filtered count is labelled, and count-then-runtime is the convention) and
the null-duration lower bound (the server's own SUM has skipped NULLs since
long before this branch; changing only the client would split them).
2026-09-03 21:43:04 +02:00

861 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
AlertTriangle,
Check,
ExternalLink,
GripVertical,
ListPlus,
Pencil,
Play,
RefreshCw,
RotateCcw,
Trash2,
Upload,
Youtube,
} from "lucide-react";
import { api, HttpError, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
import { useUndoable } from "../lib/useUndoable";
import { cycleSort, type SortState } from "../lib/columnSort";
import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell";
import { useChoice, useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import {
ColumnHead,
alignClass,
filterRows,
isActive,
type Column,
type FilterMap,
} from "./tableHeader";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
// The playlist detail is a HYBRID table (E4 S4): 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. Before S4 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 IS the stored order, so the header can show a real arrow for it
// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored
// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel).
const STORED_SORT: SortState = { key: "pos", dir: "asc" };
const isStoredSort = (s: SortState) => s?.key === "pos" && s.dir === "asc";
const idsKey = (items: Video[]) =>
items
.map((v) => v.id)
.sort()
.join(",");
function Row({
video,
columns,
dragDisabled,
dragHint,
}: {
video: Video;
columns: Column<Video>[];
dragDisabled: boolean;
dragHint: string;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
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 ? 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).
className="border-b border-border/50 hover:bg-card/40 transition-colors"
>
{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)}`}
>
{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) under a view, so the affordance stays
// put and can explain itself instead of silently doing nothing.
<button
{...attributes}
{...(dragDisabled ? {} : listeners)}
// `disabled` would drop it from the tab order AND suppress the tooltip, so the
// reason reached nobody. aria-disabled keeps it focusable and the name carries the why.
aria-disabled={dragDisabled || undefined}
onClick={(e) => dragDisabled && e.preventDefault()}
title={dragDisabled ? dragHint : t("playlists.dragHandle")}
aria-label={
dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })
}
className={`text-muted ${dragDisabled ? "opacity-30 cursor-not-allowed" : "hover:text-fg cursor-grab active:cursor-grabbing"}`}
>
<GripVertical className="w-4 h-4" />
</button>
) : (
col.render(video)
)}
</td>
))}
</tr>
);
}
export default function Playlists() {
const me = useMe();
const canWrite = me.can_write;
// 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 } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const choose = useChoice();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
// 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;
api
.reorderPlaylist(
selectedId,
next.map((v) => v.id),
)
.then(() => {
// 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.
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
})
.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 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: qk.playlist(selectedId) });
});
},
});
const items = order.value;
// VIEW state — never written to the server on its own.
const [viewSort, setViewSort] = useState<SortState>(STORED_SORT);
const [filters, setFilters] = useState<FilterMap>({});
const [groupBy, setGroupBy] = useState(false);
// 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 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<string | null>(null);
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;
useEffect(() => {
if (!detail) 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;
order.reset(detail.items);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail]);
// Reset the view when switching playlists (the stored order itself is per-playlist).
useEffect(() => {
setViewSort(STORED_SORT);
setFilters({});
setGroupBy(false);
}, [selectedId]);
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 YT-link button marks their origin.
const editable = !builtin;
const linked = editable && !!detail?.yt_playlist_id;
// LOCAL editing (reorder, remove) needs no YouTube write scope — `canWrite` gates only the
// push/delete-on-YouTube paths. 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 [pushing, setPushing] = useState(false);
const [reverting, setReverting] = useState(false);
const onPlay = useCallback((v: Video) => setPlayingId(v.id), []);
const onRemove = useCallback((v: Video) => {
removeRef.current(v);
}, []);
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],
);
const columns = useMemo(
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
[t, storedIndex, onPlay, onRemove, canEdit],
);
const filterActive = Object.values(filters).some(isActive);
const shown = useMemo(
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy),
[items, columns, filters, viewSort, groupBy],
);
// 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;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
// 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);
}
function saveOrder() {
order.set(shown); // onApply persists it
clearView();
}
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 }),
);
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() {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
}
async function revertToYoutube() {
if (selectedId == null || !detail || reverting) return;
const ok = await confirm({
title: t("playlists.revertTitle"),
message: t("playlists.revertMsg"),
confirmLabel: t("playlists.revertConfirm"),
danger: true,
});
if (!ok) return;
setReverting(true);
try {
await api.revertPlaylist(selectedId);
refreshAll();
notify({ level: "success", message: t("playlists.revertDone", { name: plName(detail) }) });
} catch (e) {
notifyYouTubeActionError(e, t("playlists.revertFailed"));
} finally {
setReverting(false);
}
}
async function pushToYoutube() {
if (selectedId == null || !detail || pushing) return;
setPushing(true);
try {
const plan = await api.playlistPushPlan(selectedId);
const nothingToDo =
plan.action === "update" &&
!plan.add_yt &&
!plan.add_local &&
!plan.remove_yt &&
!plan.remove_local &&
!plan.to_reorder &&
!plan.rename;
if (nothingToDo) {
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
return;
}
if (!plan.affordable) {
notify({
level: "warning",
message: t("playlists.pushNoQuota", {
left: plan.remaining_today,
units: plan.units_estimate,
}),
});
return;
}
let message: string;
if (plan.action === "create") {
message = t("playlists.pushPlanCreate", {
count: plan.add_yt,
units: plan.units_estimate,
left: plan.remaining_today,
});
} else {
// One sentence per side that actually changes; the untouched side gets a short "stays
// unchanged" reassurance instead of a "+0 / −0" the reader has to decode.
const here: string[] = [];
if (plan.add_local) here.push(t("playlists.mergeAddLocal", { count: plan.add_local }));
if (plan.remove_local)
here.push(t("playlists.mergeRemoveLocal", { count: plan.remove_local }));
const yt: string[] = [];
if (plan.add_yt) yt.push(t("playlists.mergeAddYt", { count: plan.add_yt }));
if (plan.remove_yt) yt.push(t("playlists.mergeRemoveYt", { count: plan.remove_yt }));
if (plan.to_reorder) yt.push(t("playlists.mergeReorder", { count: plan.to_reorder }));
if (plan.rename) yt.push(t("playlists.mergeRename"));
const sentences = [...here, ...yt];
if (here.length && !yt.length) sentences.push(t("playlists.mergeYtUnchanged"));
if (yt.length && !here.length) sentences.push(t("playlists.mergeLocalUnchanged"));
const quota =
plan.units_estimate > 0
? t("playlists.mergeQuota", {
units: plan.units_estimate,
left: plan.remaining_today,
})
: t("playlists.mergeQuotaFree");
message = sentences.join(" ") + quota;
}
const ok = await confirm({
title: t("playlists.pushTitle"),
message,
confirmLabel: t("playlists.pushConfirm"),
});
if (!ok) return;
const r = await api.pushPlaylist(selectedId);
refreshAll();
if (r.failures.length) {
notify({
level: "warning",
message: t("playlists.pushPartial", { count: r.failures.length }),
});
} else {
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
}
} catch (e) {
notifyYouTubeActionError(e, t("playlists.pushFailed"));
} finally {
setPushing(false);
}
}
function onDragEnd(e: DragEndEvent) {
const { active: a, over } = e;
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
}
async function removeItem(video: Video) {
if (selectedId == null) return;
const ok = await confirm({
title: t("playlists.removeItem"),
message: t("playlists.confirmRemove", { title: video.title }),
confirmLabel: t("playlists.removeItem"),
danger: true,
});
if (!ok) return;
// 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)}`;
try {
await api.removeFromPlaylist(selectedId, video.id);
refreshAll();
} 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();
}
}
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
const removeRef = useRef(removeItem);
removeRef.current = removeItem;
async function saveRename() {
if (selectedId == null) return;
const name = renameValue.trim();
setRenaming(false);
if (name && name !== detail?.name) {
try {
await api.renamePlaylist(selectedId, name);
} catch (e) {
notifyYouTubeActionError(e, t("playlists.renameFailed"));
}
refreshAll();
}
}
async function deletePlaylist() {
if (selectedId == null || !detail) return;
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
const offerYoutube = linked && canWrite;
const picked = await choose({
title: t("playlists.delete"),
message: offerYoutube
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
: t("playlists.confirmDelete", { name: plName(detail) }),
choices: offerYoutube
? [
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
]
: [{ id: "here", label: t("playlists.delete"), danger: true }],
});
if (picked == null) return;
const onYoutube = picked === "both";
const deletedId = selectedId;
// Pick the next selection from what's left *now* (don't go via null, or the
// auto-select effect would re-pick the just-deleted id from the stale list).
const remaining = playlists.filter((p) => p.id !== deletedId);
setSelectedId(remaining[0]?.id ?? null);
try {
await api.deletePlaylist(deletedId, onYoutube);
} catch (err) {
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
notify({
level: "warning",
message:
(err instanceof HttpError && err.detail) ||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
}
qc.removeQueries({ queryKey: qk.playlist(deletedId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
}
function playerState(id: string, status: string) {
api.setState(id, status).then(() => {
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
qc.invalidateQueries({ queryKey: qk.feed() });
});
}
// 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(() => setPlayingId(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");
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
if (selectedId == null) {
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
}
// The page used to render "Loading…" forever when the detail query failed — an error read as a
// slow network that never arrived.
if (detailQuery.isError) {
return (
<div className="text-sm p-4 flex flex-col items-start gap-3">
<span className="inline-flex items-center gap-2 text-muted">
<AlertTriangle className="w-4 h-4 text-danger" />
{t("playlists.loadFailed")}
</span>
<button
onClick={() => detailQuery.refetch()}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
>
{t("common.retry")}
</button>
</div>
);
}
if (!detail) {
return <div className="text-muted text-sm p-4">{t("playlists.loading")}</div>;
}
return (
// 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.
<div className="px-4 pb-4">
{/* 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">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
<img 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
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") setRenaming(false);
}}
aria-label={t("playlists.rename")}
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</>
)}
<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" />
{t("playlists.unsynced")}
</span>
)}
<button
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
)}
{linked && detail.dirty && (
<button
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && (
<>
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{t("playlists.clearView")}
</button>
)}
{canSaveOrder && (
<button
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
>
<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>
</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={closestCenter}
onDragEnd={onDragEnd}
// 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={{ screenReaderInstructions, announcements }}
>
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
<div className="max-w-5xl mx-auto">
{/* 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 className="w-full table-fixed border-collapse text-sm">
<ColumnHead
columns={columns}
sort={viewSort}
onToggleSort={toggleSort}
filters={filters}
onFilter={applyFilter}
/>
<SortableContext
items={shown.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
<tbody>
{shown.map((v) => (
<Row
key={v.id}
video={v}
columns={columns}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
/>
))}
</tbody>
</SortableContext>
</table>
{shown.length === 0 && (
<div className="text-muted py-8 text-center text-sm">
{t("playlists.noneMatchFilter")}
</div>
)}
</div>
</DndContext>
)}
{playingIndex >= 0 && (
<Suspense fallback={null}>
<PlayerModal
video={shown[playingIndex]!}
queue={shown}
startIndex={playingIndex}
startAt={null}
onClose={closePlayer}
onState={playerState}
/>
</Suspense>
)}
</div>
);
}