import { 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 { LS } from "../lib/storage"; import { usePref } from "../lib/usePref"; 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"; import { seasonTitle } from "../lib/plexDetail"; import { cardImage } from "../lib/plexImage"; import RemoteImage from "./ui/RemoteImage"; // 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. // // 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; /** 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(); 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; collapsedSeasons: Set; 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; /** The row's index among the rows the keyboard walks (`navItemAttrs`). */ navIndex: (key: string) => number; }; function Poster({ src, className }: { src: string; className: string }) { return (
); } 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 (
{ep.episode_number} ctx.onPlay(ep.id)} data-nav-open className="group/thumb flex min-w-0 flex-1 items-center gap-2.5" >
{ep.status === "watched" && ✓} {inProgress &&
}
{ep.title}
{ctx.more(target)}
); } 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 (
ctx.onPlay(item.id)} data-nav-open className="group/thumb flex min-w-0 flex-1 items-center gap-3" >
{item.title}
{item.year}
{ctx.more(target)}
); } /** A season's name, as its label and in the toast that says it went — named as the show page names it * (season 0 is the Specials). */ function useSeasonLabel() { const { t } = useTranslation(); return (s: Season) => seasonTitle({ season_number: s.number }, t); } 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 (
{/* Group header — drag moves the whole show; the chevron collapses; its "…" holds the rest. */}
ctx.toggleShow(block.showKey)} aria-expanded={open} label={block.title} > {open ? : } {!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. ctx.toggleShow(block.showKey)} className="flex shrink-0" tabIndex={-1} aria-hidden > {block.items.slice(0, 3).map((ep, i) => ( ))} )} ctx.toggleShow(block.showKey)} data-nav-open className="min-w-0 flex-1" >
{block.title}
{t("plex.playlist.episodes", { count: block.items.length })}
{ctx.more(target)}
{open && (
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 (
{dense && ( ctx.toggleSeason(sKey)} aria-expanded={sOpen} label={seasonLabel(s)} > {sOpen ? : } )} {seasonLabel(s)} · {s.items.length} {ctx.more(sTarget)}
{sOpen && (
{s.items.map((ep) => ( ))}
)}
); })}
)}
); } 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, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); const confirm = useConfirm(); const [renaming, setRenaming] = useState(false); const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming); const [name, setName] = useState(""); const [layout, setLayout] = usePref("plexPlaylistLayout", "accordion", { localKey: LS.plexPlaylistLayout, parse: (raw) => (raw === "tree" ? "tree" : "accordion"), }); // 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>(new Set()); const [collapsedSeasons, setCollapsedSeasons] = useState>(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: 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 ------------------------------------------------------------- // Keyed: a row builds its target afresh each render, and its "…" must close the menu it opened. const rowMenu = useContextMenu({ 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: , danger: true, separatorBefore: true, disabled: busy, onSelect: () => remove(target), }); const playRow = (label: string, first: PlexCard | undefined): ActionMenuItem[] => first ? [ { id: "play", label, icon: , 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): 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, { anchor: rowMenu.anchor }), ...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(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 ( ); }, trigger: rowMenuTrigger, navIndex: (key) => navIndexOf.get(key) ?? -1, }; // --- The playlist's own menu: the header's "…" --------------------------------------------------- const headerMenu = useContextMenu(); const headerItems: ActionMenuItem[] = [ { id: "rename", label: t("plex.playlist.rename"), icon: , onSelect: () => { setName(q.data?.title ?? ""); setRenaming(true); }, }, { id: "delete", label: t("plex.playlist.delete"), icon: , danger: true, separatorBefore: true, onSelect: () => void del(), }, ]; const layoutOptions: ViewOption[] = [ { id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 }, { id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree }, ]; return (
{renaming ? ( setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { armRenameReturn(e); void rename(); // reports its own failure } // Unmounting the field fires no blur, so Escape really does cancel. if (e.key === "Escape") { armRenameReturn(e); setRenaming(false); } }} onBlur={rename} aria-label={t("plex.playlist.rename")} /> ) : ( {q.data?.title ?? " "} )} {items.length > 0 && (
{t("plex.browse.items", { count: items.length })} {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
)}
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */} {items.length > 0 && ( )} {/* Rename and delete — the playlist's own, rarer actions — sit in its "…"; a rename opened from there hands the focus back to it. */}
{q.isError && !q.data ? ( q.refetch()}> {t("common.loadError")} ) : q.isLoading ? ( ) : items.length === 0 ? (

{t("plex.playlist.empty")}

) : ( b.id)} strategy={verticalListSortingStrategy}>
{blocks.map((b) => b.kind === "movie" ? ( ) : ( ), )}
)}
{titleActions.panel}
); }