Files
siftlode/frontend/src/components/PlexPlaylistView.tsx
T
peter de62035889 fix(playlists): an Undo puts a row back after its old neighbour, not at a stale index
A raw index recorded at removal drifted once anything changed before the Undo.
Both lists now read the list fresh and re-insert each row after the row that
preceded it (lib/reinsert). A failed watched toggle in a Plex playlist also
puts the row's state back: its local copy was never re-seeded.
2026-09-27 14:02:15 +02:00

824 lines
31 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
ListTree,
Pencil,
Play,
Rows3,
Trash2,
} from "lucide-react";
import { api, HttpError, type PlexCard } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { navItemAttrs, useListNav } from "../lib/listNav";
import { toggleWatchState } from "../lib/plexWatch";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./confirmContext";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { useCopyItems } from "./copyItems";
import { usePlexCardActions } from "./usePlexCardActions";
import { Pressable } from "./ui/Pressable";
import { DragHandle } from "./ui/DragHandle";
import { useOrderItems } from "./ui/useOrderItems";
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
import {
sameListCollision,
sameListKeyboardCoordinates,
useSortableA11y,
useSortableItem,
} from "./ui/sortableA11y";
import { Input } from "./ui/Input";
import { useReturnFocus } from "./ui/useReturnFocus";
import { SURFACE } from "./ui/surfaces";
import { PageHeading } from "./ui/Headings";
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
// list. Reorder is drag & drop, or a row menu's Top / Up / Down / Bottom (a whole show-group moves as
// one block; episodes reorder within their show). Two layouts — Accordion (A) and Tree (C) — are a
// per-account preference. Rendered as a PlexBrowse subview; "Play all" opens the player with the
// whole ordered list as its queue.
//
// E28 C1: every row — a title, a show group, a season — has ONE menu, opened by its "…" (shown on
// hover or focus, always on touch), a right-click, a long-press or Shift+F10. Removing needs no
// confirm: the toast's Undo puts the rows back where they stood. `j`/`k` walk the visible rows and
// Delete takes out the one the keyboard is on; the playlist's own rename and delete sit in the
// header's "…".
type Layout = "accordion" | "tree";
type Season = { key: string; number: number | null; items: PlexCard[] };
type ShowBlock = Extract<Block, { kind: "show" }>;
/** What a row's menu acts on. `key` is the row's identity across renders (the objects are rebuilt
* on every one), so "is THIS row's menu the open one" — and the "…" closing its own — compare keys. */
type MenuTarget =
| { kind: "item"; key: string; item: PlexCard }
| { kind: "show"; key: string; block: ShowBlock }
| { kind: "season"; key: string; block: ShowBlock; season: Season };
const targetKey = (t: MenuTarget) => t.key;
type Block =
| { kind: "movie"; id: string; item: PlexCard }
| {
kind: "show";
id: string;
showKey: string;
title: string;
items: PlexCard[];
seasons: Season[];
};
function buildSeasons(items: PlexCard[]): Season[] {
const map = new Map<string, Season>();
for (const it of items) {
const num = it.season_number ?? null;
const key = String(num ?? "?");
let s = map.get(key);
if (!s) {
s = { key, number: num, items: [] };
map.set(key, s);
}
s.items.push(it);
}
return [...map.values()];
}
// Turn the flat ordered list into blocks: contiguous episodes of one show group together (then split
// into seasons for display); everything else (movies) is its own block.
function buildBlocks(items: PlexCard[]): Block[] {
const blocks: Block[] = [];
for (const it of items) {
if (it.type === "episode") {
const showKey = it.show_id || it.show_title || "?";
const last = blocks[blocks.length - 1];
if (last && last.kind === "show" && last.showKey === showKey) {
last.items.push(it);
} else {
blocks.push({
kind: "show",
id: `show:${showKey}`,
showKey,
title: it.show_title || it.title,
items: [it],
seasons: [],
});
}
} else {
blocks.push({ kind: "movie", id: it.id, item: it });
}
}
for (const b of blocks) if (b.kind === "show") b.seasons = buildSeasons(b.items);
return blocks;
}
const flatten = (blocks: Block[]): PlexCard[] =>
blocks.flatMap((b) => (b.kind === "movie" ? [b.item] : b.items));
// Callbacks + shared state passed down to the module-scope block/row components.
type Ctx = {
layout: Layout;
busy: boolean;
expandedShows: Set<string>;
collapsedSeasons: Set<string>;
toggleShow: (k: string) => void;
toggleSeason: (k: string) => void;
onPlay: (rk: string) => void;
/** The row's "…" and its right-click / long-press / Shift+F10 — the page's one menu, on `target`. */
more: (target: MenuTarget) => ReactNode;
trigger: (target: MenuTarget) => Record<string, unknown>;
/** The row's index among the rows the keyboard walks (`navItemAttrs`). */
navIndex: (key: string) => number;
};
function Poster({ src, className }: { src: string; className: string }) {
return (
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${className}`}
>
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
</div>
);
}
function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id: ep.id,
disabled: ctx.busy,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
const target: MenuTarget = { kind: "item", key: ep.id, item: ep };
return (
<div
ref={setNodeRef}
style={style}
{...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(ep.id), ep.title, { inset: true })}
className={`group flex items-center gap-2.5 ${dense ? "rounded-lg px-1.5 py-1" : `${SURFACE.card} p-1.5`}`}
>
<DragHandle
label={t("plex.playlist.reorderItem", { title: ep.title })}
{...attributes}
{...listeners}
/>
<span className="w-5 shrink-0 text-center text-2xs text-muted tabular-nums">
{ep.episode_number}
</span>
<Pressable
selectable
onClick={() => ctx.onPlay(ep.id)}
data-nav-open
className="group/thumb flex min-w-0 flex-1 items-center gap-2.5"
>
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
>
<img src={ep.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
{ep.status === "watched" && <span className={`${MEDIA_BADGE} right-0.5 top-0.5`}>✓</span>}
{inProgress && <div className="absolute inset-x-0 bottom-0 h-0.5 bg-accent" />}
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/thumb:opacity-100">
<Play className="h-4 w-4 text-white" fill="currentColor" />
</div>
</div>
<div className="min-w-0 truncate text-[0.8125rem]">{ep.title}</div>
</Pressable>
{ctx.more(target)}
</div>
);
}
function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id: item.id,
disabled: ctx.busy,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const dense = ctx.layout === "tree";
const target: MenuTarget = { kind: "item", key: item.id, item };
return (
<div
ref={setNodeRef}
style={style}
{...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(item.id), item.title)}
className={`group ${SURFACE.card} flex items-center gap-3 p-2`}
>
<DragHandle
label={t("plex.playlist.reorderItem", { title: item.title })}
{...attributes}
{...listeners}
/>
<Pressable
selectable
onClick={() => ctx.onPlay(item.id)}
data-nav-open
className="group/thumb flex min-w-0 flex-1 items-center gap-3"
>
<Poster src={item.thumb} className={dense ? "aspect-2/3 w-10" : "aspect-2/3 w-14"} />
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.title}</div>
<div className="text-xs text-muted">{item.year}</div>
</div>
<div className="pointer-events-none ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent/0 text-transparent transition group-hover/thumb:bg-accent/15 group-hover/thumb:text-accent">
<Play className="h-4 w-4" fill="currentColor" />
</div>
</Pressable>
{ctx.more(target)}
</div>
);
}
/** A season's name, as its label and in the toast that says it went. */
function useSeasonLabel() {
const { t } = useTranslation();
return (s: Season) =>
s.number != null
? t("plex.playlist.season", { n: s.number })
: t("plex.playlist.seasonUnknown");
}
function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
id: block.id,
disabled: ctx.busy,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const open = ctx.expandedShows.has(block.showKey);
const dense = ctx.layout === "tree";
const seasonLabel = useSeasonLabel();
const target: MenuTarget = { kind: "show", key: block.id, block };
return (
<div ref={setNodeRef} style={style} className={`${SURFACE.card} overflow-hidden`}>
{/* Group header — drag moves the whole show; the chevron collapses; its "…" holds the rest. */}
<div
{...ctx.trigger(target)}
{...navItemAttrs(ctx.navIndex(block.id), block.title, { inset: true })}
// The card's corners minus its 1 px edge, so the inside keyboard ring follows the card's
// curve instead of cutting across it; square at the bottom while the seasons hang below.
className={`group flex items-center gap-2.5 p-2 rounded-t-[calc(var(--radius-xl)-1px)] ${
open ? "" : "rounded-b-[calc(var(--radius-xl)-1px)]"
}`}
>
<DragHandle
label={t("plex.playlist.reorderItem", { title: block.title })}
{...attributes}
{...listeners}
/>
<IconButton
onClick={() => ctx.toggleShow(block.showKey)}
aria-expanded={open}
label={block.title}
>
{open ? <ChevronDown /> : <ChevronRight />}
</IconButton>
{!dense && (
// A second, pointer-only target for the same toggle (its images are decorative): out of
// the tab order, so the keyboard does not stop on a button with no name.
<Pressable
onClick={() => ctx.toggleShow(block.showKey)}
className="flex shrink-0"
tabIndex={-1}
aria-hidden
>
{block.items.slice(0, 3).map((ep, i) => (
<img
key={ep.id}
src={ep.thumb}
alt=""
loading="lazy"
className="aspect-2/3 w-8 rounded-sm border-2 border-surface object-cover"
style={{ marginLeft: i ? -14 : 0 }}
/>
))}
</Pressable>
)}
<Pressable
selectable
onClick={() => ctx.toggleShow(block.showKey)}
data-nav-open
className="min-w-0 flex-1"
>
<div className="truncate text-sm font-semibold">{block.title}</div>
<div className="text-2xs text-muted">
{t("plex.playlist.episodes", { count: block.items.length })}
</div>
</Pressable>
{ctx.more(target)}
</div>
{open && (
<div className={`border-t border-border/50 ${dense ? "px-2 pb-1.5 pt-1" : "px-2 pb-2"}`}>
<SortableContext
items={block.items.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
{block.seasons.map((s) => {
const sKey = `${block.showKey}:${s.key}`;
const sOpen = !dense || !ctx.collapsedSeasons.has(sKey);
const sTarget: MenuTarget = { kind: "season", key: sKey, block, season: s };
return (
<div key={s.key} className={dense ? "" : "mt-1.5 first:mt-0.5"}>
<div
{...ctx.trigger(sTarget)}
className="group flex items-center gap-1.5 py-1 pl-1"
>
{dense && (
<IconButton
onClick={() => ctx.toggleSeason(sKey)}
aria-expanded={sOpen}
label={seasonLabel(s)}
>
{sOpen ? <ChevronDown /> : <ChevronRight />}
</IconButton>
)}
<span className="text-2xs font-medium uppercase tracking-wide text-muted">
{seasonLabel(s)} · {s.items.length}
</span>
<span className="ml-1">{ctx.more(sTarget)}</span>
</div>
{sOpen && (
<div
className={
dense ? "ml-2 space-y-0.5 border-l border-border/50 pl-1.5" : "space-y-1"
}
>
{s.items.map((ep) => (
<EpisodeRow key={ep.id} ep={ep} ctx={ctx} dense={dense} />
))}
</div>
)}
</div>
);
})}
</SortableContext>
</div>
)}
</div>
);
}
export default function PlexPlaylistView({
playlistId,
onBack,
onPlay,
onInfoCard,
}: {
playlistId: number;
onBack: () => void;
onPlay: (itemRk: string, queue: string[], fromStart?: boolean) => void;
/** A title's media-info page, from its menu's "Details". */
onInfoCard?: (card: PlexCard) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const [renaming, setRenaming] = useState(false);
const renameFocus = useReturnFocus(renaming);
const [name, setName] = useState("");
const [layout, setLayout] = useState<Layout>(() =>
getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion",
);
useEffect(() => {
setAccountRaw(LS.plexPlaylistLayout, layout);
}, [layout]);
// Show groups start collapsed (compact headers) so a long series doesn't force endless scrolling;
// seasons start expanded once their show is opened. Both are just local view state.
const [expandedShows, setExpandedShows] = useState<Set<string>>(new Set());
const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set());
// The query, the optimistic copy and the two writes that edit it (reorder, remove) live in
// `usePlexPlaylistEdits`, where their failure paths can be driven directly: the drag that reaches
// `commit` cannot be, since dnd-kit's sensors do not activate under jsdom.
const { q, items, busy, invalidate, commit, onRemove, setStatus } =
usePlexPlaylistEdits(playlistId);
const blocks = useMemo(() => buildBlocks(items), [items]);
const order = useMemo(() => items.map((i) => i.id), [items]);
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
// an unknown duration count as 0, and the runtime is dropped from the meta line when nothing sums.
const totalSeconds = useMemo(
() => items.reduce((acc, it) => acc + (it.duration_seconds ?? 0), 0),
[items],
);
async function rename() {
const title = name.trim();
// Nothing to write (as the collection rename): close, so an armed focus hand-back fires now and
// not on some later close.
if (!title || title === q.data?.title) {
setRenaming(false);
return;
}
try {
await api.plexRenamePlaylist(playlistId, title);
} catch (e) {
notifyActionError(e, t("plex.playlist.renameFailed")); // the field stays open to try again
return;
}
setRenaming(false);
invalidate();
}
async function del() {
if (
!(await confirm({
message: t("plex.playlist.deleteConfirm", { title: q.data?.title }),
danger: true,
}))
)
return;
try {
await api.plexDeletePlaylist(playlistId);
} catch (e) {
// Gone already (deleted on another device): what was asked for is true — leave the dead page.
// Anything else is a real failure, and the page stays.
if (!(e instanceof HttpError && e.status === 404)) {
notifyActionError(e, t("plex.playlist.deleteFailed"));
return;
}
}
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onBack();
}
const toggleShow = (k: string) =>
setExpandedShows((s) => {
const n = new Set(s);
n.has(k) ? n.delete(k) : n.add(k);
return n;
});
const toggleSeason = (k: string) =>
setCollapsedSeasons((s) => {
const n = new Set(s);
n.has(k) ? n.delete(k) : n.add(k);
return n;
});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sameListKeyboardCoordinates }),
);
// A whole block (a movie, a show) sorts among the blocks; an episode only within its own show.
const dndA11y = useSortableA11y((id) => {
const at = blocks.findIndex((b) => b.id === id);
if (at >= 0) {
const b = blocks[at]!;
return {
title: b.kind === "show" ? b.title : b.item.title,
pos: at + 1,
total: blocks.length,
};
}
const show = blocks.find((b) => b.kind === "show" && b.items.some((it) => it.id === id));
const eps = show?.kind === "show" ? show.items : [];
const ep = eps.findIndex((it) => it.id === id);
return { title: eps[ep]?.title ?? "", pos: ep + 1, total: eps.length };
});
// A move, by drag or by a row menu's Top / Up / Down / Bottom: a whole block (a movie, a show)
// among the blocks, an episode only within its own show.
function moveBlock(id: string, to: number) {
const from = blocks.findIndex((b) => b.id === id);
if (from < 0 || to < 0 || to >= blocks.length || from === to) return;
commit(flatten(arrayMove(blocks, from, to)));
}
function moveEpisode(id: string, to: number) {
const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === id));
if (!b || b.kind !== "show") return;
const from = b.items.findIndex((it) => it.id === id);
if (to < 0 || to >= b.items.length || from === to) return;
commit(
flatten(
blocks.map((bl) => (bl.id === b.id ? { ...bl, items: arrayMove(b.items, from, to) } : bl)),
),
);
}
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const aId = String(active.id);
const oId = String(over.id);
// `over` is always a sibling of `active` (sameListCollision): a block over a block, an episode
// over an episode of the same show. The lookups only refuse a stale id.
const toBlock = blocks.findIndex((b) => b.id === oId);
if (blocks.some((b) => b.id === aId)) {
if (toBlock >= 0) moveBlock(aId, toBlock);
return;
}
const show = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId));
const to = show?.kind === "show" ? show.items.findIndex((it) => it.id === oId) : -1;
if (to >= 0) moveEpisode(aId, to);
}
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
// Keyed: a row builds its target afresh each render, and its "…" must close the menu it opened.
const rowMenu = useContextMenu<MenuTarget>({ keyOf: targetKey });
const copy = useCopyItems();
const seasonLabel = useSeasonLabel();
const titleActions = usePlexCardActions(
{
onOpen: (c) => onPlay(c.id, order),
onRestart: (c) => onPlay(c.id, order, true),
onInfo: onInfoCard,
onToggleWatched: (c) =>
void toggleWatchState(c, t, {
optimistic: (next) => setStatus(c.id, next),
// The flip is on the view's LOCAL copy: a failed write's resync refetches an identical
// list, react-query keeps the same `data`, and the copy is never re-seeded from it.
revert: () => setStatus(c.id, c.status ?? "new"),
resync: invalidate,
}),
},
rowMenu.open,
);
// What a row's removal takes out, and the name its toast says.
const removal = (target: MenuTarget): { keys: string[]; title: string } =>
target.kind === "item"
? { keys: [target.item.id], title: target.item.title }
: target.kind === "show"
? { keys: target.block.items.map((i) => i.id), title: target.block.title }
: {
keys: target.season.items.map((i) => i.id),
title: `${target.block.title} · ${seasonLabel(target.season)}`,
};
const remove = (target: MenuTarget) => {
const { keys, title } = removal(target);
void onRemove(keys, title);
};
const removeRow = (target: MenuTarget, label: string): ActionMenuItem => ({
id: "remove",
label,
icon: <Trash2 className="w-4 h-4" />,
danger: true,
separatorBefore: true,
disabled: busy,
onSelect: () => remove(target),
});
const playRow = (label: string, first: PlexCard | undefined): ActionMenuItem[] =>
first
? [
{
id: "play",
label,
icon: <Play className="w-4 h-4" />,
onSelect: () => onPlay(first.id, order),
},
]
: [];
// The non-drag way (WCAG 2.5.7): what the row's grip drags, its menu moves — a block among the
// blocks, an episode within its show. Off while a write is in flight, like the grip.
const orderItems = useOrderItems();
const off = busy ? {} : undefined;
const orderRows = (target: Exclude<MenuTarget, { kind: "season" }>): ActionMenuItem[] => {
const id = target.kind === "item" ? target.item.id : target.block.id;
const at = blocks.findIndex((b) => b.id === id);
if (at >= 0) return orderItems(at, blocks.length, (to) => moveBlock(id, to), off);
const show = blocks.find((b) => b.kind === "show" && b.items.some((it) => it.id === id));
if (show?.kind !== "show") return [];
const ep = show.items.findIndex((it) => it.id === id);
return orderItems(ep, show.items.length, (to) => moveEpisode(id, to), off);
};
const menuTarget = rowMenu.target;
const menuItems: ActionMenuItem[] = !menuTarget
? []
: menuTarget.kind === "item"
? [
...titleActions.items(menuTarget.item),
...orderRows(menuTarget),
removeRow(menuTarget, t("plex.playlist.removeFromPlaylist")),
]
: menuTarget.kind === "show"
? [
...playRow(t("plex.playlist.playShow"), menuTarget.block.items[0]),
...copy(menuTarget.block.title, null).map((it, i) => ({
...it,
separatorBefore: i === 0,
})),
...orderRows(menuTarget),
removeRow(menuTarget, t("plex.playlist.removeShow")),
]
: [
...playRow(t("plex.playlist.playSeason"), menuTarget.season.items[0]),
removeRow(menuTarget, t("plex.playlist.removeSeason")),
];
// --- The keyboard's rows: every row on screen, in reading order ---------------------------------
// A title, a show's header and — while the show is open — its episodes; a season label is a
// heading, not a row (its "…" holds its actions). Delete takes out the row it is on.
const navRows: MenuTarget[] = [];
for (const b of blocks) {
if (b.kind === "movie") {
navRows.push({ kind: "item", key: b.item.id, item: b.item });
continue;
}
navRows.push({ kind: "show", key: b.id, block: b });
if (!expandedShows.has(b.showKey)) continue;
for (const se of b.seasons) {
if (layout === "tree" && collapsedSeasons.has(`${b.showKey}:${se.key}`)) continue;
for (const ep of se.items) navRows.push({ kind: "item", key: ep.id, item: ep });
}
}
const navIndexOf = new Map(navRows.map((r, i) => [r.key, i]));
const listRef = useRef<HTMLDivElement>(null);
useListNav(listRef, {
enabled: items.length > 0,
onDelete: (i) => {
const row = navRows[i];
if (row) remove(row);
},
});
const { buttonProps: rowMenuButton, triggerProps: rowMenuTrigger } = rowMenu;
const ctx: Ctx = {
layout,
busy,
expandedShows,
collapsedSeasons,
toggleShow,
toggleSeason,
onPlay: (rk) => onPlay(rk, order),
more: (target) => {
const mine = rowMenu.open && rowMenu.target?.key === target.key;
return (
<MoreButton
label={t("plex.playlist.rowMenu")}
expanded={mine}
menuProps={rowMenuButton(target)}
className={mine ? undefined : "reveal"}
/>
);
},
trigger: rowMenuTrigger,
navIndex: (key) => navIndexOf.get(key) ?? -1,
};
// --- The playlist's own menu: the header's "…" ---------------------------------------------------
const headerMenu = useContextMenu<number>();
const headerItems: ActionMenuItem[] = [
{
id: "rename",
label: t("plex.playlist.rename"),
icon: <Pencil className="w-4 h-4" />,
onSelect: () => {
setName(q.data?.title ?? "");
setRenaming(true);
},
},
{
id: "delete",
label: t("plex.playlist.delete"),
icon: <Trash2 className="w-4 h-4" />,
danger: true,
separatorBefore: true,
onSelect: () => void del(),
},
];
const layoutOptions: ViewOption<Layout>[] = [
{ id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
{ id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
];
return (
<div className="mx-auto w-[90%] max-w-275 p-4">
<Button variant="glass" size="xs" className="mb-4" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
{t("plex.backToLibrary")}
</Button>
<div className={`${SURFACE.panel} p-4 sm:p-6`}>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
{renaming ? (
<Input
className="w-full text-lg font-bold"
size="heading"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
renameFocus.arm(e);
rename();
}
// Unmounting the field fires no blur, so Escape really does cancel.
if (e.key === "Escape") {
renameFocus.arm(e);
setRenaming(false);
}
}}
onBlur={rename}
aria-label={t("plex.playlist.rename")}
/>
) : (
<PageHeading className="truncate text-2xl font-bold">
{q.data?.title ?? " "}
</PageHeading>
)}
{items.length > 0 && (
<div className="mt-0.5 text-sm text-muted">
{t("plex.browse.items", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
</div>
)}
</div>
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
<ViewSwitcher
value={layout}
options={layoutOptions}
onChange={setLayout}
label={t("plex.playlist.layoutLabel")}
/>
{items.length > 0 && (
<Button
onClick={() => onPlay(order[0]!, order)}
icon={<Play className="fill-current" />}
>
{t("plex.playlist.playAll")}
</Button>
)}
{/* Rename and delete — the playlist's own, rarer actions — sit in its "…" (E28 C1); a
rename opened from there hands the focus back to it. */}
<MoreButton
ref={renameFocus.target}
variant="glass"
size="md"
label={t("plex.playlist.menu")}
expanded={headerMenu.open}
menuProps={headerMenu.buttonProps(playlistId)}
/>
</div>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState shape="list" label={t("plex.loading")} />
) : items.length === 0 ? (
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={sameListCollision}
onDragEnd={onDragEnd}
accessibility={dndA11y}
>
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
<div ref={listRef} className="space-y-1.5">
{blocks.map((b) =>
b.kind === "movie" ? (
<MovieRow key={b.id} item={b.item} ctx={ctx} />
) : (
<ShowGroup key={b.id} block={b} ctx={ctx} />
),
)}
</div>
</SortableContext>
</DndContext>
)}
</div>
<ContextMenu menu={rowMenu} ariaLabel={t("plex.playlist.rowMenu")} items={menuItems} />
<ContextMenu menu={headerMenu} ariaLabel={t("plex.playlist.menu")} items={headerItems} />
{titleActions.panel}
</div>
);
}