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 [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<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;
// 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({
<DetailHero
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
posterTitle={t("plex.play")}
posterDisabled={playable.length === 0}
showPlayOverlay={playable.length > 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({
)}
<div className="mt-4 flex flex-wrap gap-2">
{playable.length > 0 && (
{canPlay && (
<>
<ActionBtn
primary
onClick={() => onPlay(playable[0]!, playable)}
onClick={() => playAll(false)}
disabled={queueBusy}
icon={Play}
label={t("plex.collection.playAll")}
/>
<ActionBtn
onClick={() => {
const order = shuffled(playable);
onPlay(order[0]!, order);
}}
onClick={() => playAll(true)}
disabled={queueBusy}
icon={Shuffle}
label={t("plex.collection.shuffle")}
/>
+1
View File
@@ -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.",
+1
View File
@@ -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ó.",