feat(plex): expand shows to episodes so a show-collection's Play-all plays (S4)

A collection's Play-all/Shuffle queue held only playable leaves
(movies/episodes), filtering out shows — so on a show-collection the queue
was empty and Play-all was hidden/dead. Build the queue by expanding each
show member into its episodes (season then episode order), concatenated in
member order; leaves still queue as themselves. Shows are fetched lazily on
click (parallel plexShow fetches), not on page load. Gate Play-all/Shuffle
and the poster on "has any playable member" (leaves OR shows), and disable
them while the queue builds. Scope: option A — no member manual-order
migration. Adds plex.collection.playFailed (HU/EN).
This commit is contained in:
2026-08-23 23:30:21 +02:00
parent fc32188fd2
commit 06b85586e6
3 changed files with 52 additions and 11 deletions
+50 -11
View File
@@ -57,6 +57,9 @@ export default function PlexCollectionView({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [busy, setBusy] = 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 = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.plexCollections() }); qc.invalidateQueries({ queryKey: qk.plexCollections() });
@@ -64,8 +67,45 @@ export default function PlexCollectionView({
}; };
const items = d?.items ?? []; const items = d?.items ?? [];
// The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly. // Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
const playable = items.filter((c) => c.type !== "show").map((c) => c.id); // 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 detail.seasons.flatMap((s) => s.episodes.map((e) => e.id));
}),
);
return perMember.flat();
}
async function playAll(shuffle: boolean) {
setQueueBusy(true);
try {
let queue = await buildQueue();
if (shuffle) queue = shuffled(queue);
if (queue.length > 0) onPlay(queue[0]!, queue);
} catch (e) {
notifyActionError(e, t("plex.collection.playFailed"));
} finally {
setQueueBusy(false);
}
}
const canEdit = isAdmin && !!d?.can_edit; const canEdit = isAdmin && !!d?.can_edit;
// A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares // 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. // isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
@@ -171,9 +211,9 @@ export default function PlexCollectionView({
<DetailHero <DetailHero
thumb={d.thumb ?? `/api/plex/image/${d.id}`} thumb={d.thumb ?? `/api/plex/image/${d.id}`}
posterTitle={t("plex.play")} posterTitle={t("plex.play")}
posterDisabled={playable.length === 0} posterDisabled={!canPlay || queueBusy}
showPlayOverlay={playable.length > 0} showPlayOverlay={canPlay}
onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)} onPosterClick={() => canPlay && !queueBusy && playAll(false)}
customizeMenu={null} customizeMenu={null}
> >
{renaming ? ( {renaming ? (
@@ -232,19 +272,18 @@ export default function PlexCollectionView({
)} )}
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
{playable.length > 0 && ( {canPlay && (
<> <>
<ActionBtn <ActionBtn
primary primary
onClick={() => onPlay(playable[0]!, playable)} onClick={() => playAll(false)}
disabled={queueBusy}
icon={Play} icon={Play}
label={t("plex.collection.playAll")} label={t("plex.collection.playAll")}
/> />
<ActionBtn <ActionBtn
onClick={() => { onClick={() => playAll(true)}
const order = shuffled(playable); disabled={queueBusy}
onPlay(order[0]!, order);
}}
icon={Shuffle} icon={Shuffle}
label={t("plex.collection.shuffle")} label={t("plex.collection.shuffle")}
/> />
+1
View File
@@ -23,6 +23,7 @@
"added": "Added", "added": "Added",
"deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.", "deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.",
"editFailed": "Collection edit failed", "editFailed": "Collection edit failed",
"playFailed": "Couldn't build the play queue — try again.",
"newCollection": "New collection", "newCollection": "New collection",
"namePlaceholder": "Collection name", "namePlaceholder": "Collection name",
"nameTaken": "A collection with this name already exists.", "nameTaken": "A collection with this name already exists.",
+1
View File
@@ -23,6 +23,7 @@
"added": "Hozzáadva", "added": "Hozzáadva",
"deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.", "deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.",
"editFailed": "A kollekció szerkesztése sikertelen", "editFailed": "A kollekció szerkesztése sikertelen",
"playFailed": "Nem sikerült összeállítani a lejátszási sort — próbáld újra.",
"newCollection": "Új kollekció", "newCollection": "Új kollekció",
"namePlaceholder": "Kollekció neve", "namePlaceholder": "Kollekció neve",
"nameTaken": "Már létezik ilyen nevű kollekció.", "nameTaken": "Már létezik ilyen nevű kollekció.",