import { useEffect, useMemo, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; 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 { ArrowLeft, ChevronDown, ChevronRight, GripVertical, ListTree, Pencil, Play, Rows3, Trash2, X, } from "lucide-react"; import { api, HttpError, type PlexCard } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; 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 { FocusSafeButton } from "./ui/FocusSafeButton"; import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits"; // 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 (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. type Layout = "accordion" | "tree"; type Season = { key: string; number: number | null; items: PlexCard[] }; 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; onRemove: (keys: string[]) => void; }; function Poster({ src, className }: { src: string; className: string }) { return (
); } /** `title` names the row it moves: a screen reader heard the English word "reorder" on every * row, with nothing to say which. */ function DragHandle({ title, ...props }: { title: string } & Record) { const { t } = useTranslation(); return ( ); } function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ 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"; return (
{ep.episode_number} ctx.onRemove([ep.id])} disabled={ctx.busy} className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" aria-label={t("plex.playlist.removeItem", { title: ep.title })} >
); } function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id, disabled: ctx.busy, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1, }; const dense = ctx.layout === "tree"; return (
ctx.onRemove([item.id])} disabled={ctx.busy} className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" aria-label={t("plex.playlist.removeItem", { title: item.title })} >
); } function ShowGroup({ block, ctx }: { block: Extract; ctx: Ctx }) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ 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 = (s: Season) => s.number != null ? t("plex.playlist.season", { n: s.number }) : t("plex.playlist.seasonUnknown"); return (
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
{!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.onRemove(block.items.map((i) => i.id))} disabled={ctx.busy} title={t("plex.playlist.removeShow")} className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" >
{open && (
i.id)} strategy={verticalListSortingStrategy} > {block.seasons.map((s) => { const sKey = `${block.showKey}:${s.key}`; const sOpen = !dense || !ctx.collapsedSeasons.has(sKey); return (
{dense && ( )} {seasonLabel(s)} · {s.items.length} ctx.onRemove(s.items.map((i) => i.id))} disabled={ctx.busy} title={t("plex.playlist.removeSeason")} className="ml-1 rounded-sm p-0.5 text-muted hover:text-danger disabled:opacity-30" >
{sOpen && (
{s.items.map((ep) => ( ))}
)}
); })}
)}
); } export default function PlexPlaylistView({ playlistId, onBack, onPlay, }: { playlistId: number; onBack: () => void; onPlay: (itemRk: string, queue: string[]) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const [renaming, setRenaming] = useState(false); const [name, setName] = useState(""); const [layout, setLayout] = useState(() => 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>(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 } = 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(); if (!title) 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: sortableKeyboardCoordinates }), ); 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); const blockIds = new Set(blocks.map((b) => b.id)); if (blockIds.has(aId)) { // Moving a whole block (movie or show). Resolve the drop target to a block. let oBlockId = oId; if (!blockIds.has(oId)) { const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === oId)); if (!b) return; oBlockId = b.id; } const from = blocks.findIndex((b) => b.id === aId); const to = blocks.findIndex((b) => b.id === oBlockId); if (from < 0 || to < 0) return; commit(flatten(arrayMove(blocks, from, to))); } else { // Moving an episode — only within its own show block. const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId)); if (!b || b.kind !== "show" || !b.items.some((it) => it.id === oId)) return; const from = b.items.findIndex((it) => it.id === aId); const to = b.items.findIndex((it) => it.id === oId); commit( flatten( blocks.map((bl) => bl.id === b.id ? { ...bl, items: arrayMove(b.items, from, to) } : bl, ), ), ); } } const ctx: Ctx = { layout, busy, expandedShows, collapsedSeasons, toggleShow, toggleSeason, onPlay: (rk) => onPlay(rk, order), onRemove, }; 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) => e.key === "Enter" && rename()} onBlur={rename} className="glass-card w-full rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> ) : (

{q.data?.title ?? " "}

)} {items.length > 0 && (
{t("plex.browse.items", { count: items.length })} {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
)}
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */} {items.length > 0 && ( )}
{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" ? ( ) : ( ), )}
)}
); }