- Channel page: the banner and identity block fold away once the grid scrolls (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds at the very top) and the name rides in the tab row. One Back button in both states keeps its focus; folded at a tablet width the actions wrap instead of pushing the page sideways. - Plex movie and show pages: the TMDB facts over the key crew are the hero's second column (plexDetailAside), the keywords sit under the text; the show page shows them for the first time, with the same three toggles. A collection's hero is sized to its text. - YouTube player, 1280px and wider: the title, channel, actions, description and queue sit in a side panel beside the video, which takes the window's height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel while it can and turns the volume elsewhere. - DESIGN.md decision log: the keywords' place under the text.
543 lines
20 KiB
TypeScript
543 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 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}`), 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"));
|
|
} 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,
|
|
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"));
|
|
} 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 && 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") {
|
|
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 && (
|
|
<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={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)}
|
|
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>
|
|
);
|
|
}
|