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:
parent
e6b22f971a
commit
2ef22982bb
9 changed files with 603 additions and 139 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue