storage.ts's registry gives each key a kind (pref, device, view, cache, public); a key without one does not compile. A contract test refuses a key spelled out elsewhere, an account preference read or written straight from storage instead of through usePref, and an e2e seed of a key nobody reads. The keys the next account's wipe keeps are the public ones. The seven preferences kept only in the browser move onto usePref: start on Home, the onboarding dismissal, the release banner's last version, the channel manager's layout, the Plex playlist layout, the playlist rail's sort and channel discovery's sort. usePref reads an older bare-string copy, and a value only this browser held is saved to the server once, so the account's other devices get it. Performance mode is the device's: it is neither read from the account nor saved to it.
835 lines
31 KiB
TypeScript
835 lines
31 KiB
TypeScript
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<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}`}
|
|
>
|
|
<RemoteImage
|
|
{...cardImage(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"}`}
|
|
>
|
|
<RemoteImage
|
|
{...cardImage(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 — 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 (
|
|
<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) => (
|
|
<RemoteImage
|
|
key={ep.id}
|
|
{...cardImage(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, 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<Layout>("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<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: 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<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, { 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<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") {
|
|
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")}
|
|
/>
|
|
) : (
|
|
<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, lang)}`}
|
|
</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 "…"; a
|
|
rename opened from there hands the focus back to it. */}
|
|
<MoreButton
|
|
ref={renameTarget}
|
|
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>
|
|
);
|
|
}
|