Every Plex poster list (library, Discover rails, person, show, season, collection, Home's rails) now opens one menu per list from the right-click, the long-press, Shift+F10 and the card's "…": play/open, watched, add to playlist, media info, copy title, the pin (usePlexCardActions + PlexMenu, replacing the pin-only PlexPinMenu). The card keeps its state at rest and shows watched / add to playlist / "…" on its picture on hover (ui/reveal.ts, shared with the video card); on touch the toggles are gone and a 44px "…" sits beside the title, while the watched pill stays. The hover ⓘ left the card; an episode's add button moved from its caption to the hover row. Seasons get their own short menu (open, watched, add, copy), labelled "Open season" (the card's overlay said "Open show"). Related shows are ordinary poster cards now. A collection's admin "Remove" X moved into its menu, still behind the confirmation. The collection grid is two columns on a phone. The unused .reveal-idle rule is gone.
537 lines
20 KiB
TypeScript
537 lines
20 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
|
import { formatRuntime } from "../lib/format";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { notify } from "../lib/notifications";
|
|
import { showEpisodeKeys } from "../lib/plexQueue";
|
|
import { healPlexSort, plexSortOptions } from "../lib/plexSort";
|
|
import { usePref } from "../lib/usePref";
|
|
import SortControl from "./ui/SortControl";
|
|
import { type SortState } from "../lib/columnSort";
|
|
import { useConfirm } from "./confirmContext";
|
|
import { BackBtn, ActionBtn, DetailHero } from "./plexDetailUi";
|
|
import { PlexPosterCard } from "./plexCards";
|
|
import { PlexMenu } from "./PlexMenu";
|
|
import { useContextMenu } from "./ui/useContextMenu";
|
|
import { usePlexCardActions } from "./usePlexCardActions";
|
|
import PlexLibraryPicker from "./PlexLibraryPicker";
|
|
import { toggleWatchState } from "../lib/plexWatch";
|
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
|
import NavGrid from "./ui/NavGrid";
|
|
import { Button } from "./ui/Button";
|
|
import { IconButton } from "./ui/IconButton";
|
|
import { Input } from "./ui/Input";
|
|
import { useReturnFocus } from "./ui/useReturnFocus";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
import { Badge } from "./ui/Badge";
|
|
import { PageHeading } from "./ui/Headings";
|
|
|
|
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
|
|
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
|
|
// admin-enabled collection — rename / delete / add / remove, all write-through to Plex (the mutation
|
|
// endpoints round-trip to Plex, so each edit awaits and re-fetches; no optimism). Read-only ones (smart
|
|
// / imdb / tmdb / … auto-lists) are marked with a lock.
|
|
// The order this page had before it grew a Sort control, and still its default: a franchise reads in
|
|
// release order. MUST match the endpoint's `DEFAULT_MEMBER_SORT` — the two only diverge if a request
|
|
// goes out without the params, which is why the control always sends them.
|
|
const COLLECTION_SORT_DEFAULT: NonNullable<SortState> = { key: "year", dir: "asc" };
|
|
|
|
function shuffled(a: string[]): string[] {
|
|
const r = [...a];
|
|
for (let i = r.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
const tmp = r[i]!;
|
|
r[i] = r[j]!;
|
|
r[j] = tmp;
|
|
}
|
|
return r;
|
|
}
|
|
|
|
export default function PlexCollectionView({
|
|
collectionId,
|
|
onBack,
|
|
onPlay,
|
|
onOpenCard,
|
|
onInfoCard,
|
|
}: {
|
|
collectionId: string;
|
|
onBack: () => void;
|
|
onPlay: (rk: string, queue: string[]) => void;
|
|
onOpenCard: (card: PlexCard) => void;
|
|
onInfoCard: (card: PlexCard) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
// One menu for the member grid (see ui/ContextMenu).
|
|
const menu = useContextMenu<PlexCard>();
|
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
|
// The member order is a PREFERENCE (server-synced, adopted on login), not an ephemeral filter — the
|
|
// same treatment the feed's view and the player's auto-advance get. It is deliberately its OWN key
|
|
// rather than the browse grid's sort: opening a collection must not silently re-sort the library
|
|
// behind it.
|
|
const [sortPref, setSortPref] = usePref<NonNullable<SortState>>(
|
|
"plexCollectionSort",
|
|
COLLECTION_SORT_DEFAULT,
|
|
);
|
|
// ONE key for the read and every write into it: when the sort joined the key, the watched toggle's
|
|
// optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches
|
|
// exactly), so the click did nothing visible until a reload.
|
|
const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir);
|
|
const q = useQuery({
|
|
queryKey: detailKey,
|
|
queryFn: () => api.plexCollection(collectionId, sortPref),
|
|
// A re-sort keeps the current page on screen while the new order loads. Without it every sort
|
|
// change was a key with no data yet, and the whole page — the Sort control and its open panel
|
|
// with it — was swapped for a spinner, so key and direction could not be set in one visit. Only
|
|
// within the SAME collection: stepping to another one must not show this one's hero meanwhile.
|
|
placeholderData: (prev, prevQuery) =>
|
|
prevQuery?.queryKey[2] === collectionId ? prev : undefined,
|
|
});
|
|
const d = q.data;
|
|
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [renaming, setRenaming] = useState(false);
|
|
const renameFocus = useReturnFocus(renaming);
|
|
const [name, setName] = useState("");
|
|
const [adding, setAdding] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
// Building the play queue can await show → episode expansion (see buildQueue); disable Play/Shuffle
|
|
// meanwhile so a double-tap can't fire two queues.
|
|
const [queueBusy, setQueueBusy] = useState(false);
|
|
|
|
const invalidate = () => {
|
|
qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
|
};
|
|
|
|
const items = d?.items ?? [];
|
|
// A collection lives in ONE library, so its members are all movies or all shows. That decides both
|
|
// what the add-panel searches and which sort keys are on offer (only a movie has a runtime).
|
|
const scope = items.some((c) => c.type === "show") ? "show" : "movie";
|
|
const sortState = healPlexSort(sortPref, scope);
|
|
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
|
// Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
|
|
// Play-all expands it into its episodes (buildQueue). Either kind means there's something to play —
|
|
// this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's
|
|
// Play-all was empty and did nothing.
|
|
const leafKeys = items.filter((c) => c.type !== "show").map((c) => c.id);
|
|
const hasShows = items.some((c) => c.type === "show");
|
|
const canPlay = leafKeys.length > 0 || hasShows;
|
|
|
|
// Build the ordered play queue: leaf members as themselves, each show member expanded into its
|
|
// episodes (season then episode order). Shows are fetched lazily HERE (on Play-all/Shuffle click),
|
|
// in parallel, but concatenated in member order so the queue follows the grid — not on page load,
|
|
// which would fetch every show up front for a button that may never be pressed.
|
|
async function buildQueue(): Promise<string[]> {
|
|
const perMember = await Promise.all(
|
|
items.map(async (c) => {
|
|
if (c.type !== "show") return [c.id];
|
|
const detail = await qc.fetchQuery({
|
|
queryKey: qk.plexShow(c.id),
|
|
queryFn: () => api.plexShow(c.id),
|
|
});
|
|
return showEpisodeKeys(detail);
|
|
}),
|
|
);
|
|
return perMember.flat();
|
|
}
|
|
|
|
async function playAll(shuffle: boolean) {
|
|
setQueueBusy(true);
|
|
try {
|
|
let queue = await buildQueue();
|
|
if (shuffle) queue = shuffled(queue);
|
|
// canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched /
|
|
// empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly.
|
|
if (queue.length > 0) onPlay(queue[0]!, queue);
|
|
else notify({ level: "info", message: t("plex.collection.playEmpty") });
|
|
} catch (e) {
|
|
notifyActionError(e, t("plex.collection.playFailed"));
|
|
} finally {
|
|
setQueueBusy(false);
|
|
}
|
|
}
|
|
|
|
const canEdit = isAdmin && !!d?.can_edit;
|
|
// A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
|
|
// isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
|
|
const canEnableEdit = isAdmin && !d?.can_edit && !d?.editable && !!d && !isIntrinsicReadOnly(d);
|
|
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
|
|
|
|
const toggleWatched = (card: PlexCard) =>
|
|
toggleWatchState(card, t, {
|
|
optimistic: (next) => {
|
|
qc.setQueryData<PlexCollectionDetail>(detailKey, (old) =>
|
|
old
|
|
? {
|
|
...old,
|
|
items: old.items.map((c) => (c.id === card.id ? { ...c, status: next } : c)),
|
|
}
|
|
: old,
|
|
);
|
|
},
|
|
// This page's OWN key too, not just the library's: the flip above lands on the entry the page
|
|
// reads, and resync is what corrects it to the truth when the toggle fails — invalidating only
|
|
// `plex-library` left a failed toggle flipped on screen. The prefix (no sort in it) also
|
|
// refreshes the other sort orders of this collection cached behind this one.
|
|
resync: () => {
|
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
|
qc.invalidateQueries({ queryKey: qk.plexCollections("detail", collectionId) });
|
|
},
|
|
});
|
|
const actions = usePlexCardActions(
|
|
{ onOpen: onOpenCard, onInfo: onInfoCard, onToggleWatched: toggleWatched },
|
|
menu.open,
|
|
);
|
|
|
|
async function doRename() {
|
|
const title = name.trim();
|
|
if (!title || !d || title === d.title) {
|
|
setRenaming(false);
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
await api.plexRenameCollection(collectionId, title);
|
|
invalidate();
|
|
setRenaming(false);
|
|
} catch (e) {
|
|
// The backend enforces per-library name uniqueness — a taken name comes back localized.
|
|
notifyActionError(e, t("plex.collection.editFailed"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function doDelete() {
|
|
if (!d) return;
|
|
if (!(await confirm({ message: t("plex.collection.deleteConfirm", { title: d.title }) })))
|
|
return;
|
|
setBusy(true);
|
|
try {
|
|
await api.plexDeleteCollection(collectionId);
|
|
invalidate();
|
|
onBack();
|
|
} catch (e) {
|
|
notifyActionError(e, t("plex.collection.editFailed"));
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function doRemove(card: PlexCard) {
|
|
// Write-through to the shared Plex library — confirm first, matching the delete-collection guard.
|
|
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
|
|
return;
|
|
setBusy(true);
|
|
try {
|
|
await api.plexCollectionRemoveItem(collectionId, card.id);
|
|
invalidate();
|
|
} catch (e) {
|
|
notifyActionError(e, t("plex.collection.editFailed"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function enableEdit() {
|
|
setBusy(true);
|
|
try {
|
|
await api.plexSetCollectionEditable(collectionId, true);
|
|
invalidate();
|
|
} catch (e) {
|
|
notifyActionError(e, t("plex.collection.editFailed"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const meta = [
|
|
d?.year_min != null &&
|
|
(d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
|
|
d?.content_rating,
|
|
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
|
|
formatRuntime(d?.total_runtime_s),
|
|
].filter(Boolean) as string[];
|
|
|
|
return (
|
|
<div className="glass-media mx-auto max-w-300 p-4">
|
|
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
|
|
|
{q.isError && !q.data ? (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("common.loadError")}
|
|
</StateMessage>
|
|
) : q.isLoading || !d ? (
|
|
<LoadingState shape="poster" label={t("plex.loading")} />
|
|
) : (
|
|
<>
|
|
<DetailHero
|
|
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
|
|
posterTitle={t("plex.play")}
|
|
posterDisabled={!canPlay || queueBusy}
|
|
showPlayOverlay={canPlay}
|
|
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
|
|
customizeMenu={null}
|
|
>
|
|
{renaming ? (
|
|
<Input
|
|
className="w-full text-2xl font-semibold"
|
|
size="heading"
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onBlur={doRename}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
renameFocus.arm(e);
|
|
doRename();
|
|
}
|
|
if (e.key === "Escape") {
|
|
renameFocus.arm(e);
|
|
setRenaming(false);
|
|
}
|
|
}}
|
|
aria-label={t("plex.collection.rename")}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<PageHeading className="text-2xl font-semibold leading-tight">
|
|
{d.title}
|
|
</PageHeading>
|
|
{readOnly && (
|
|
<Badge title={t("plex.collection.readOnly")}>
|
|
<Lock />
|
|
{t("plex.collection.readOnly")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{meta.length > 0 && (
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted">
|
|
{meta.map((m, i) => (
|
|
<span key={i}>
|
|
{i > 0 && "· "}
|
|
{m}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{d.summary && (
|
|
<div className="mt-3">
|
|
<p
|
|
className={`text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
|
|
>
|
|
{d.summary}
|
|
</p>
|
|
<Button
|
|
variant="link"
|
|
aria-expanded={expanded}
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="mt-1 text-xs"
|
|
>
|
|
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{canPlay && (
|
|
<>
|
|
<ActionBtn
|
|
primary
|
|
onClick={() => playAll(false)}
|
|
disabled={queueBusy}
|
|
icon={Play}
|
|
label={t("plex.collection.playAll")}
|
|
/>
|
|
<ActionBtn
|
|
onClick={() => playAll(true)}
|
|
disabled={queueBusy}
|
|
icon={Shuffle}
|
|
label={t("plex.collection.shuffle")}
|
|
/>
|
|
</>
|
|
)}
|
|
{canEdit && (
|
|
<>
|
|
<ActionBtn
|
|
ref={renameFocus.target}
|
|
onClick={() => {
|
|
setName(d.title);
|
|
setRenaming(true);
|
|
}}
|
|
icon={Pencil}
|
|
label={t("plex.collection.rename")}
|
|
/>
|
|
<ActionBtn
|
|
onClick={() => setAdding((v) => !v)}
|
|
icon={Plus}
|
|
label={t("plex.collection.addItem")}
|
|
/>
|
|
<ActionBtn
|
|
onClick={doDelete}
|
|
icon={Trash2}
|
|
label={t("plex.collection.delete")}
|
|
disabled={busy}
|
|
/>
|
|
</>
|
|
)}
|
|
{canEnableEdit && (
|
|
<ActionBtn
|
|
onClick={enableEdit}
|
|
icon={Check}
|
|
label={t("plex.collection.enableEdit")}
|
|
/>
|
|
)}
|
|
</div>
|
|
</DetailHero>
|
|
|
|
{adding && canEdit && (
|
|
<AddItemPanel
|
|
collectionId={collectionId}
|
|
scope={scope}
|
|
existing={new Set(items.map((c) => c.id))}
|
|
onAdded={invalidate}
|
|
onClose={() => setAdding(false)}
|
|
/>
|
|
)}
|
|
|
|
{items.length === 0 ? (
|
|
<p className="text-muted text-sm">{t("plex.collection.empty")}</p>
|
|
) : (
|
|
<>
|
|
{/* Sort sits directly above the grid, pinned right — the same placement (and the same
|
|
control, options and labels) as the browse grid, so the two read as one idea. */}
|
|
<div className="mb-2 flex items-center">
|
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
|
<SortControl
|
|
options={sortOptions}
|
|
sort={sortState}
|
|
onChange={(next) => setSortPref(next ?? COLLECTION_SORT_DEFAULT)}
|
|
label={t("plex.filter.sort")}
|
|
ascLabel={t("plex.filter.dir.asc")}
|
|
descLabel={t("plex.filter.dir.desc")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* Two columns on a phone, like every other poster grid (≥140px a card): three left
|
|
an ~85px card whose title shared its line with the touch "…" (E28 C1). */}
|
|
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
|
{items.map((c, i) => (
|
|
<PlexPosterCard
|
|
key={c.id}
|
|
card={c}
|
|
navIndex={i}
|
|
onOpen={() => onOpenCard(c)}
|
|
onToggleWatched={toggleWatched}
|
|
onAdd={actions.add}
|
|
menuProps={menu.triggerProps}
|
|
menuButtonProps={menu.buttonProps}
|
|
menuOpen={menu.open && menu.target === c}
|
|
/>
|
|
))}
|
|
</NavGrid>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
<PlexMenu
|
|
menu={menu}
|
|
actions={actions}
|
|
extra={(c) =>
|
|
canEdit
|
|
? [
|
|
{
|
|
// A write to the shared Plex library: a menu row since E28 C1 (the card's hover
|
|
// row owns its corner now), still behind the confirmation in `doRemove`.
|
|
id: "collection-remove",
|
|
label: t("plex.collection.removeItem"),
|
|
icon: <X className="w-4 h-4" />,
|
|
separatorBefore: true,
|
|
danger: true,
|
|
disabled: busy,
|
|
onSelect: () => void doRemove(c),
|
|
},
|
|
]
|
|
: []
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// A compact search-and-add panel: search the collection's library, click a result to add it (write-
|
|
// through to Plex). Stays open so several items can be added in a row; already-present items are marked.
|
|
function AddItemPanel({
|
|
collectionId,
|
|
scope,
|
|
existing,
|
|
onAdded,
|
|
onClose,
|
|
}: {
|
|
collectionId: string;
|
|
scope: string;
|
|
existing: Set<string>;
|
|
onAdded: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [term, setTerm] = useState("");
|
|
const [added, setAdded] = useState<Set<string>>(new Set());
|
|
|
|
async function add(card: PlexCard) {
|
|
try {
|
|
await api.plexCollectionAddItem(collectionId, card.id);
|
|
setAdded((s) => new Set(s).add(card.id));
|
|
onAdded();
|
|
} catch (e) {
|
|
notifyActionError(e, t("plex.collection.editFailed"));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={`${SURFACE.panel} mb-6 p-4`}>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<Search className="h-4 w-4 text-muted" />
|
|
<Input
|
|
className="flex-1"
|
|
autoFocus
|
|
value={term}
|
|
onChange={(e) => setTerm(e.target.value)}
|
|
placeholder={t("plex.collection.addSearch")}
|
|
aria-label={t("plex.collection.addSearch")}
|
|
/>
|
|
<IconButton size="md" onClick={onClose} label={t("common.close")}>
|
|
<X />
|
|
</IconButton>
|
|
</div>
|
|
<PlexLibraryPicker
|
|
scope={scope}
|
|
term={term}
|
|
emptyHint={<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>}
|
|
renderAction={(c) => {
|
|
const inColl = existing.has(c.id) || added.has(c.id);
|
|
return (
|
|
<Button
|
|
variant="glass"
|
|
size="xs"
|
|
icon={inColl ? <Check /> : <Plus />}
|
|
onClick={() => add(c)}
|
|
disabled={inColl}
|
|
>
|
|
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
|
</Button>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|