E32 S4 — the burn-down from 29 skipped functions to 1: - try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into their own function (push, the setup wizard's next, the share page's unlock). - A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo does not see it: the value is computed before the try, or the try lives in a module-level helper (config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times). - The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through one effect event, read once when the player is built; PlexPlayer's unmount relies on the leaving-the-item cleanup for the generation bump it duplicated. - What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias, the loadSession self-reference (declared early, set after commit); PlayerModal's session-position read during render (now where the player starts), navRef/modeRef writes, and a description helper that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default among same-filter views, so the seed needs no sticky write — test seen red on the old helper). - A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old contract test claimed the call was safe inside any `use…` function, which was false — replaced by a render test that is red under the old name. - PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer stack: removed. react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid (IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
539 lines
20 KiB
TypeScript
539 lines
20 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { vocab } from "../lib/vocab";
|
|
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 { plexLang } from "../i18n";
|
|
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 { ClampedText } from "./ui/ClampedText";
|
|
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,
|
|
onRestartCard,
|
|
onInfoCard,
|
|
}: {
|
|
collectionId: string;
|
|
onBack: () => void;
|
|
onPlay: (rk: string, queue: string[]) => void;
|
|
onOpenCard: (card: PlexCard) => void;
|
|
onRestartCard: (card: PlexCard) => void;
|
|
onInfoCard: (card: PlexCard) => void;
|
|
}) {
|
|
const { t, i18n } = 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 lang = plexLang(i18n.language);
|
|
const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir, lang);
|
|
const q = useQuery({
|
|
queryKey: detailKey,
|
|
queryFn: () => api.plexCollection(collectionId, sortPref, lang),
|
|
// 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 [renaming, setRenaming] = useState(false);
|
|
const { target: renameTarget, arm: armRenameReturn } = 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}`), lang);
|
|
// 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, lang),
|
|
queryFn: () => api.plexShow(c.id, lang),
|
|
});
|
|
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"));
|
|
}
|
|
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,
|
|
onRestart: onRestartCard,
|
|
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"));
|
|
}
|
|
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"));
|
|
}
|
|
setBusy(false);
|
|
}
|
|
|
|
async function enableEdit() {
|
|
setBusy(true);
|
|
try {
|
|
await api.plexSetCollectionEditable(collectionId, true);
|
|
invalidate();
|
|
} catch (e) {
|
|
notifyActionError(e, t("plex.collection.editFailed"));
|
|
}
|
|
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 && vocab(t, "certificate", d.content_rating),
|
|
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
|
|
formatRuntime(d?.total_runtime_s, lang),
|
|
].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)}
|
|
// Sized to the text beside it (E31, the owner's pick): a title, one meta line and a short
|
|
// description sat beside a show-sized poster, two thirds of the hero empty under them.
|
|
posterSizeClass="w-20 sm:w-24"
|
|
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") {
|
|
armRenameReturn(e);
|
|
void doRename(); // reports its own failure
|
|
}
|
|
if (e.key === "Escape") {
|
|
armRenameReturn(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 && (
|
|
<ClampedText
|
|
text={d.summary}
|
|
lines={3}
|
|
moreLabel={t("plex.textMore")}
|
|
lessLabel={t("plex.textLess")}
|
|
className="mt-3"
|
|
/>
|
|
)}
|
|
|
|
<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={renameTarget}
|
|
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)}
|
|
onRestart={() => onRestartCard(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>
|
|
);
|
|
}
|