import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, 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, type PlexCard } from "../lib/api"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; // 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 (
); } function DragHandle(props: Record) { return ( ); } function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) { 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}
); } function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { 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 (
); } 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 && ( )}
{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}
{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 [busy, setBusy] = useState(false); 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()); const q = useQuery({ queryKey: ["plex-playlist", playlistId], queryFn: () => api.plexPlaylist(playlistId) }); // Local optimistic copy so drag/remove are instant; re-synced whenever the query returns. const [items, setItems] = useState([]); useEffect(() => { if (q.data) setItems(q.data.items); }, [q.data]); const blocks = useMemo(() => buildBlocks(items), [items]); const order = useMemo(() => items.map((i) => i.id), [items]); const invalidate = () => { qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] }); qc.invalidateQueries({ queryKey: ["plex-playlists"] }); }; function commit(next: PlexCard[]) { setItems(next); api .plexReorderPlaylist(playlistId, next.map((i) => i.id)) .then(() => qc.invalidateQueries({ queryKey: ["plex-playlists"] })); } async function onRemove(keys: string[]) { if (busy || !keys.length) return; setBusy(true); const set = new Set(keys); setItems((prev) => prev.filter((i) => !set.has(i.id))); try { if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]); else await api.plexPlaylistRemoveBulk(playlistId, keys); invalidate(); } finally { setBusy(false); } } async function rename() { const title = name.trim(); if (!title) return; await api.plexRenamePlaylist(playlistId, title); setRenaming(false); invalidate(); } async function del() { if (!(await confirm({ message: t("plex.playlist.deleteConfirm", { title: q.data?.title }), danger: true }))) return; await api.plexDeletePlaylist(playlistId); qc.invalidateQueries({ queryKey: ["plex-playlists"] }); 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 LayoutBtn = ({ v, icon, label }: { v: Layout; icon: ReactNode; label: string }) => ( ); return (
{renaming ? ( setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && rename()} onBlur={rename} className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold outline-none focus:border-accent" /> ) : (

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

)} {/* Layout preference: Accordion (A) or Tree (C). */}
} label={t("plex.playlist.layoutAccordion")} /> } label={t("plex.playlist.layoutTree")} />
{items.length > 0 && ( )}
{q.isLoading ? (

{t("plex.loading")}

) : items.length === 0 ? (

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

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