feat(plex): grouped collapsible playlist view + drag-and-drop + bulk add

Playlist view now groups a run of episodes from the same show into a
collapsible season/show block so a whole series doesn't sprawl into an
endless flat list; movies stay standalone (larger poster card in the
accordion). Two per-account layouts — Accordion and Tree — persisted via
LS.plexPlaylistLayout; show groups start collapsed. Reorder is drag &
drop (@dnd-kit): a show block moves as one unit, episodes reorder within
their show, keyboard-draggable via KeyboardSensor. Remove works per item,
per season, or per whole show. The add-to-playlist dialog is generalised
to a single leaf or a whole group (tri-state none/some/all with an in/size
count); the show page gains add buttons for an episode, a season, and the
whole show. i18n en/hu/de.
This commit is contained in:
npeter83 2026-07-06 22:14:56 +02:00
parent e6b22f971a
commit 2ef22982bb
9 changed files with 603 additions and 139 deletions

View file

@ -1,10 +1,11 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react";
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react";
import { ArrowLeft, CheckCircle2, Info, ListPlus, Play } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexPerson } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@ -461,6 +462,9 @@ function PlexShowView({
const { t } = useTranslation();
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
const d = q.data;
// "Add to playlist" dialog target (single episode / whole season / whole show); null = closed.
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const allEpisodeKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
return (
<div className="p-4 max-w-[1200px] mx-auto">
@ -488,41 +492,74 @@ function PlexShowView({
{d.show.summary && (
<p className="text-sm text-muted mt-2 line-clamp-6 leading-relaxed">{d.show.summary}</p>
)}
{allEpisodeKeys.length > 0 && (
<button
onClick={() => setAddTarget({ kind: "group", ratingKeys: allEpisodeKeys, title: d.show.title })}
className="glass-card glass-hover mt-3 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
>
<ListPlus className="w-4 h-4" />
{t("plex.playlist.addShow")}
</button>
)}
</div>
</div>
{d.seasons.map((se) => (
<div key={se.id} className="mb-6">
<h2 className="text-sm font-semibold mb-2">{se.title}</h2>
<div className="mb-2 flex items-center gap-2">
<h2 className="text-sm font-semibold">{se.title}</h2>
{se.episodes.length > 0 && (
<button
onClick={() =>
setAddTarget({
kind: "group",
ratingKeys: se.episodes.map((e) => e.id),
title: `${d.show.title}${se.title}`,
})
}
title={t("plex.playlist.addSeason")}
aria-label={t("plex.playlist.addSeason")}
className="rounded p-1 text-muted hover:text-accent"
>
<ListPlus className="w-4 h-4" />
</button>
)}
</div>
<div className="flex flex-col divide-y divide-border/60">
{se.episodes.map((ep) => {
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
return (
<button
key={ep.id}
onClick={() => onPlay(ep)}
className="flex items-center gap-3 py-2 text-left group"
>
<div className="relative w-28 aspect-video rounded-lg overflow-hidden bg-card border border-border shrink-0">
<img src={ep.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
{ep.status === "watched" && (
<span className="absolute top-1 right-1 text-[9px] bg-black/70 text-white px-1 rounded">
</span>
)}
{inProgress && <div className="absolute bottom-0 inset-x-0 h-0.5 bg-accent" />}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
<span className="text-muted mr-1.5">{ep.episode_number}.</span>
{ep.title}
<div key={ep.id} className="flex items-center gap-3 py-2 group">
<button onClick={() => onPlay(ep)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
<div className="relative w-28 aspect-video rounded-lg overflow-hidden bg-card border border-border shrink-0">
<img src={ep.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
{ep.status === "watched" && (
<span className="absolute top-1 right-1 text-[9px] bg-black/70 text-white px-1 rounded">
</span>
)}
{inProgress && <div className="absolute bottom-0 inset-x-0 h-0.5 bg-accent" />}
</div>
{ep.summary && (
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
)}
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
</div>
</button>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
<span className="text-muted mr-1.5">{ep.episode_number}.</span>
{ep.title}
</div>
{ep.summary && (
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
)}
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
</div>
</button>
<button
onClick={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
title={t("plex.playlist.addEpisode")}
aria-label={t("plex.playlist.addEpisode")}
className="shrink-0 rounded p-1.5 text-muted opacity-0 transition hover:text-accent group-hover:opacity-100"
>
<ListPlus className="w-4 h-4" />
</button>
</div>
);
})}
</div>
@ -530,6 +567,8 @@ function PlexShowView({
))}
</>
)}
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
</div>
);
}