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.
824 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|