feat(plex): Playlists Phase 1 — per-user ordered watch-lists (Siftlode-native)
Personal ordered lists of Plex items, kept in Siftlode's own DB (works for users without a Plex account, like watch-state) — the "your own lists" counterpart to shared collections. Plex-direction sync is a later phase (plex_rating_key reserved). Backend: migration 0048 (plex_playlists + plex_playlist_items with position), PlexPlaylist/PlexPlaylistItem models, and per-user CRUD endpoints under /api/plex/playlists (list [+?contains for the add dialog], create [seeded], detail [ordered cards], rename, delete, add/remove item, reorder). _leaf_card handles the movie/episode mix. Frontend: "Add to playlist" dialog from the movie info page (all users), a Playlists section in PlexSidebar (list + create), PlexPlaylistView (reorder up/down, remove, rename, delete, Play all), and PlexPlayer gained an optional `queue` so play-through follows the list order (prev/next + auto-advance). i18n en/hu/de. Verified end-to-end on localdev (backend CRUD + the create→add→view→play-through UI flow).
This commit is contained in:
parent
1fd3003038
commit
25197ed817
16 changed files with 796 additions and 18 deletions
184
frontend/src/components/PlexPlaylistView.tsx
Normal file
184
frontend/src/components/PlexPlaylistView.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, Pencil, Play, Trash2, X } from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
// A playlist's ordered items — reorder (up/down), remove, rename, delete, and "Play all" (which opens
|
||||
// the player with the whole ordered list as its queue). Rendered as a PlexBrowse subview.
|
||||
export default function PlexPlaylistView({
|
||||
playlistId,
|
||||
onBack,
|
||||
onPlay,
|
||||
}: {
|
||||
playlistId: number;
|
||||
onBack: () => void;
|
||||
onPlay: (itemRk: string, queue: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const q = useQuery({ queryKey: ["plex-playlist", playlistId], queryFn: () => api.plexPlaylist(playlistId) });
|
||||
const items: PlexCard[] = q.data?.items ?? [];
|
||||
const order = items.map((i) => i.id);
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||
};
|
||||
|
||||
async function move(idx: number, dir: -1 | 1) {
|
||||
const to = idx + dir;
|
||||
if (busy || to < 0 || to >= order.length) return;
|
||||
setBusy(true);
|
||||
const next = [...order];
|
||||
[next[idx], next[to]] = [next[to], next[idx]];
|
||||
try {
|
||||
await api.plexReorderPlaylist(playlistId, next);
|
||||
invalidate();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function remove(itemRk: string) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexPlaylistRemoveItem(playlistId, itemRk);
|
||||
invalidate();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function rename() {
|
||||
const title = name.trim();
|
||||
if (!title) return;
|
||||
await api.plexRenamePlaylist(playlistId, title);
|
||||
setRenaming(false);
|
||||
invalidate();
|
||||
}
|
||||
async function del() {
|
||||
if (!(await confirm({ message: t("plex.playlist.deleteConfirm", { title: q.data?.title }), danger: true })))
|
||||
return;
|
||||
await api.plexDeletePlaylist(playlistId);
|
||||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||
onBack();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[90%] max-w-[1100px] mx-auto p-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
|
||||
<div className="glass rounded-2xl p-4 sm:p-6">
|
||||
{/* Header: title (rename) + Play all + delete */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && rename()}
|
||||
onBlur={rename}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setName(q.data?.title ?? "");
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("plex.playlist.rename")}
|
||||
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-accent"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={() => onPlay(order[0], order)}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
|
||||
>
|
||||
<Play className="h-5 w-5 fill-current" />
|
||||
{t("plex.playlist.playAll")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={del}
|
||||
title={t("plex.playlist.delete")}
|
||||
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{q.isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted">{t("plex.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
|
||||
) : (
|
||||
<ol className="space-y-1.5">
|
||||
{items.map((it, idx) => (
|
||||
<li key={it.id} className="glass-card flex items-center gap-3 rounded-lg p-2">
|
||||
<span className="w-5 shrink-0 text-center text-xs text-muted">{idx + 1}</span>
|
||||
<button
|
||||
onClick={() => onPlay(it.id, order)}
|
||||
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<div className="relative h-14 w-10 shrink-0 overflow-hidden rounded border border-border bg-surface">
|
||||
<img src={it.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover:opacity-100">
|
||||
<Play className="h-5 w-5 text-white" fill="currentColor" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{it.type === "episode" && it.show_title ? `${it.show_title} — ${it.title}` : it.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted">{it.year}</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={() => move(idx, -1)}
|
||||
disabled={busy || idx === 0}
|
||||
className="rounded p-1 text-muted hover:text-fg disabled:opacity-30"
|
||||
title={t("plex.playlist.up")}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(idx, 1)}
|
||||
disabled={busy || idx === items.length - 1}
|
||||
className="rounded p-1 text-muted hover:text-fg disabled:opacity-30"
|
||||
title={t("plex.playlist.down")}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(it.id)}
|
||||
disabled={busy}
|
||||
className="rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
|
||||
title={t("plex.playlist.remove")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue