From 06b85586e6456c1ed4e8bf10ca9f1853826a4ec4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 23 Aug 2026 23:30:21 +0200 Subject: [PATCH] feat(plex): expand shows to episodes so a show-collection's Play-all plays (S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../src/components/PlexCollectionView.tsx | 61 +++++++++++++++---- frontend/src/i18n/locales/en/plex.json | 1 + frontend/src/i18n/locales/hu/plex.json | 1 + 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 052680d3..8ae9849d 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -57,6 +57,9 @@ export default function PlexCollectionView({ 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() }); @@ -64,8 +67,45 @@ export default function PlexCollectionView({ }; const items = d?.items ?? []; - // The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly. - const playable = items.filter((c) => c.type !== "show").map((c) => c.id); + // 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 { + 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; // 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. @@ -171,9 +211,9 @@ export default function PlexCollectionView({ 0} - onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)} + posterDisabled={!canPlay || queueBusy} + showPlayOverlay={canPlay} + onPosterClick={() => canPlay && !queueBusy && playAll(false)} customizeMenu={null} > {renaming ? ( @@ -232,19 +272,18 @@ export default function PlexCollectionView({ )}
- {playable.length > 0 && ( + {canPlay && ( <> onPlay(playable[0]!, playable)} + onClick={() => playAll(false)} + disabled={queueBusy} icon={Play} label={t("plex.collection.playAll")} /> { - const order = shuffled(playable); - onPlay(order[0]!, order); - }} + onClick={() => playAll(true)} + disabled={queueBusy} icon={Shuffle} label={t("plex.collection.shuffle")} /> diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 835ad1a2..a8251d79 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -23,6 +23,7 @@ "added": "Added", "deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.", "editFailed": "Collection edit failed", + "playFailed": "Couldn't build the play queue — try again.", "newCollection": "New collection", "namePlaceholder": "Collection name", "nameTaken": "A collection with this name already exists.", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 43bb5684..587c8db0 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -23,6 +23,7 @@ "added": "Hozzáadva", "deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.", "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ó", "namePlaceholder": "Kollekció neve", "nameTaken": "Már létezik ilyen nevű kollekció.",