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
|
|
@ -249,6 +249,7 @@ export default function App() {
|
|||
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
|
||||
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
||||
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
||||
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
|
||||
const plexFilters = useMemo<PlexFilters>(() => {
|
||||
|
|
@ -741,6 +742,7 @@ export default function App() {
|
|||
setSort={setPlexSort}
|
||||
filters={plexFilters}
|
||||
setFilters={setPlexFilters}
|
||||
onOpenPlaylist={setPlexPlaylistOpen}
|
||||
collapsed={filterCollapsed}
|
||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||
/>
|
||||
|
|
@ -824,6 +826,8 @@ export default function App() {
|
|||
sort={plexSort}
|
||||
filters={plexFilters}
|
||||
setFilters={setPlexFilters}
|
||||
openPlaylist={plexPlaylistOpen}
|
||||
onPlaylistOpened={() => setPlexPlaylistOpen(null)}
|
||||
/>
|
||||
) : (
|
||||
<Feed
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ import { useHistorySubview } from "../lib/history";
|
|||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
||||
|
||||
type Sub =
|
||||
| { kind: "grid" }
|
||||
| { kind: "show"; id: string }
|
||||
| { kind: "player"; id: string }
|
||||
| { kind: "info"; id: string };
|
||||
| { kind: "player"; id: string; queue?: string[] }
|
||||
| { kind: "info"; id: string }
|
||||
| { kind: "playlist"; id: number };
|
||||
|
||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||
|
|
@ -30,6 +32,9 @@ type Props = {
|
|||
sort: string;
|
||||
filters: PlexFilters;
|
||||
setFilters: (f: PlexFilters) => void;
|
||||
// The sidebar opens a playlist by setting this; PlexBrowse turns it into a history subview + clears it.
|
||||
openPlaylist?: number | null;
|
||||
onPlaylistOpened?: () => void;
|
||||
};
|
||||
|
||||
const PAGE = 40;
|
||||
|
|
@ -41,12 +46,32 @@ function dur(n?: number | null): string {
|
|||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
export default function PlexBrowse({ q, onClearSearch, library, show, sort, filters, setFilters }: Props) {
|
||||
export default function PlexBrowse({
|
||||
q,
|
||||
onClearSearch,
|
||||
library,
|
||||
show,
|
||||
sort,
|
||||
filters,
|
||||
setFilters,
|
||||
openPlaylist,
|
||||
onPlaylistOpened,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||
|
||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||
// clear the signal so it doesn't re-open.
|
||||
useEffect(() => {
|
||||
if (openPlaylist != null) {
|
||||
sub.open({ kind: "playlist", id: openPlaylist });
|
||||
onPlaylistOpened?.();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openPlaylist]);
|
||||
|
||||
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
||||
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
||||
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||
|
|
@ -137,7 +162,19 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
|||
if (sub.view.kind === "player") {
|
||||
return (
|
||||
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
|
||||
<PlexPlayer itemId={sub.view.id} onClose={sub.back} />
|
||||
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "playlist") {
|
||||
const pid = sub.view.id;
|
||||
return (
|
||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||
<PlexPlaylistView
|
||||
playlistId={pid}
|
||||
onBack={sub.back}
|
||||
onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Check,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
ListPlus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
|
|
@ -95,6 +97,7 @@ export default function PlexInfo({
|
|||
};
|
||||
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false);
|
||||
const [customizing, setCustomizing] = useState(false);
|
||||
const custBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const custMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -367,6 +370,15 @@ export default function PlexInfo({
|
|||
{t("plex.info.clearResume")}
|
||||
</button>
|
||||
)}
|
||||
{detail.kind === "movie" && (
|
||||
<button
|
||||
onClick={() => setPlaylistOpen(true)}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
{t("plex.playlistAdd.manage")}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && detail.kind === "movie" && library && (
|
||||
<button
|
||||
onClick={() => setEditorOpen(true)}
|
||||
|
|
@ -513,6 +525,9 @@ export default function PlexInfo({
|
|||
onChanged={() => onStateChange?.()}
|
||||
/>
|
||||
)}
|
||||
{playlistOpen && (
|
||||
<PlexPlaylistAdd item={{ id: detail.id, title: detail.title }} onClose={() => setPlaylistOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ const PlexInfo = lazy(() => import("./PlexInfo"));
|
|||
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
|
||||
// restart the session at the target. Watch state / resume persist to plex_states.
|
||||
|
||||
type Props = { itemId: string; onClose: () => void };
|
||||
// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
|
||||
// the queue instead of the item's own episode neighbours.
|
||||
type Props = { itemId: string; onClose: () => void; queue?: string[] };
|
||||
|
||||
function fmt(t: number): string {
|
||||
if (!isFinite(t) || t < 0) t = 0;
|
||||
|
|
@ -43,7 +45,7 @@ function fmt(t: number): string {
|
|||
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [id, setId] = useState(itemId);
|
||||
|
|
@ -302,6 +304,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
[],
|
||||
);
|
||||
|
||||
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
|
||||
const qIdx = queue ? queue.indexOf(id) : -1;
|
||||
const prevId = queue ? (qIdx > 0 ? queue[qIdx - 1] : null) : detail?.prev_id;
|
||||
const nextId = queue ? (qIdx >= 0 && qIdx < queue.length - 1 ? queue[qIdx + 1] : null) : detail?.next_id;
|
||||
|
||||
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
||||
const download = useCallback(() => {
|
||||
const a = document.createElement("a");
|
||||
|
|
@ -385,11 +392,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
if (!video) return;
|
||||
const onEnded = () => {
|
||||
api.plexSetState(id, "watched").catch(() => {});
|
||||
if (detail?.next_id) go(detail.next_id);
|
||||
if (nextId) go(nextId);
|
||||
};
|
||||
video.addEventListener("ended", onEnded);
|
||||
return () => video.removeEventListener("ended", onEnded);
|
||||
}, [id, detail, go]);
|
||||
}, [id, nextId, go]);
|
||||
|
||||
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
|
||||
const wake = useCallback(() => {
|
||||
|
|
@ -418,12 +425,12 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(detail?.prev_id);
|
||||
if (e.shiftKey) go(prevId);
|
||||
else seekTo(absRef.current - 10);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) go(detail?.next_id);
|
||||
if (e.shiftKey) go(nextId);
|
||||
else seekTo(absRef.current + 10);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
|
|
@ -464,7 +471,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose, wake]);
|
||||
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, prevId, nextId, onClose, wake]);
|
||||
|
||||
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||
|
|
@ -590,12 +597,12 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
|
||||
<Square className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
{detail?.kind === "episode" && (
|
||||
{(detail?.kind === "episode" || queue) && (
|
||||
<>
|
||||
<Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
|
||||
<Ctrl label={t("plex.player.prev")} onClick={() => go(prevId)} disabled={!prevId}>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.next")} onClick={() => go(detail?.next_id)} disabled={!detail?.next_id}>
|
||||
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)} disabled={!nextId}>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
</>
|
||||
|
|
|
|||
115
frontend/src/components/PlexPlaylistAdd.tsx
Normal file
115
frontend/src/components/PlexPlaylistAdd.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { api, type PlexPlaylist } from "../lib/api";
|
||||
import Modal from "./Modal";
|
||||
|
||||
// "Add this title to a playlist" dialog — per-user (every user has their own playlists), opened from
|
||||
// the movie info page. Lists my playlists with an in/out toggle (seeded from the backend's has_item)
|
||||
// and a field to create a new one seeded with this title. Playlists are personal (no admin gate).
|
||||
export default function PlexPlaylistAdd({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: { id: string; title: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [override, setOverride] = useState<Map<number, boolean>>(new Map()); // optimistic toggle state
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ["plex-playlists", "contains", item.id],
|
||||
queryFn: () => api.plexPlaylists(item.id),
|
||||
});
|
||||
const playlists = listQ.data?.playlists ?? [];
|
||||
const isIn = (p: PlexPlaylist) => (override.has(p.id) ? override.get(p.id)! : !!p.has_item);
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||
|
||||
async function toggle(p: PlexPlaylist) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
const cur = isIn(p);
|
||||
try {
|
||||
if (cur) await api.plexPlaylistRemoveItem(p.id, item.id);
|
||||
else await api.plexPlaylistAddItem(p.id, item.id);
|
||||
setOverride((m) => new Map(m).set(p.id, !cur));
|
||||
refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNew() {
|
||||
const title = newName.trim();
|
||||
if (!title || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const pl = await api.plexCreatePlaylist(title, item.id);
|
||||
setNewName("");
|
||||
setOverride((m) => new Map(m).set(pl.id, true));
|
||||
refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={t("plex.playlistAdd.title", { title: item.title })} onClose={onClose}>
|
||||
<div className="mb-3 flex gap-2">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createNew()}
|
||||
placeholder={t("plex.playlistAdd.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={createNew}
|
||||
disabled={!newName.trim() || busy}
|
||||
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("plex.playlistAdd.create")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto">
|
||||
{listQ.isLoading ? (
|
||||
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
|
||||
) : playlists.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted">{t("plex.playlistAdd.none")}</p>
|
||||
) : (
|
||||
playlists.map((p) => {
|
||||
const inIt = isIn(p);
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => toggle(p)}
|
||||
disabled={busy}
|
||||
className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm disabled:opacity-50 ${
|
||||
inIt ? "text-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`grid h-5 w-5 shrink-0 place-items-center rounded border ${
|
||||
inIt ? "border-accent bg-accent/20" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{inIt && <Check className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">
|
||||
{t("plex.playlistAdd.count", { count: p.item_count })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Film, Layers, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ type Props = {
|
|||
setSort: (v: string) => void;
|
||||
filters: PlexFilters;
|
||||
setFilters: (f: PlexFilters) => void;
|
||||
onOpenPlaylist: (id: number) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
|
@ -45,11 +46,23 @@ export default function PlexSidebar({
|
|||
setSort,
|
||||
filters,
|
||||
setFilters,
|
||||
onOpenPlaylist,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
||||
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
||||
const [newPlaylist, setNewPlaylist] = useState("");
|
||||
const qc = useQueryClient();
|
||||
async function createPlaylist() {
|
||||
const title = newPlaylist.trim();
|
||||
if (!title) return;
|
||||
const pl = await api.plexCreatePlaylist(title);
|
||||
setNewPlaylist("");
|
||||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||
onOpenPlaylist(pl.id);
|
||||
}
|
||||
const libs = libsQ.data?.libraries ?? [];
|
||||
const activeLib = libs.find((l) => l.key === library);
|
||||
const isMovieLib = activeLib?.kind === "movie";
|
||||
|
|
@ -143,6 +156,45 @@ export default function PlexSidebar({
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Playlists — per-user, Siftlode-native ordered watch-lists. Near the top: it's navigation, not
|
||||
a filter. Available in any library. */}
|
||||
<Section label={t("plex.playlist.section")}>
|
||||
<div className="mb-1.5 flex gap-1.5">
|
||||
<input
|
||||
value={newPlaylist}
|
||||
onChange={(e) => setNewPlaylist(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
|
||||
placeholder={t("plex.playlist.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={createPlaylist}
|
||||
disabled={!newPlaylist.trim()}
|
||||
title={t("plex.playlist.create")}
|
||||
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{playlistsQ.data!.playlists.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onOpenPlaylist(p.id)}
|
||||
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
|
||||
>
|
||||
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{anyActive && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
|
|
|
|||
|
|
@ -151,5 +151,27 @@
|
|||
"direct": "Spielt direkt im Browser",
|
||||
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
|
||||
"transcode": "Braucht Transcoding (aufwendiger)"
|
||||
},
|
||||
"playlistAdd": {
|
||||
"manage": "Zur Playlist",
|
||||
"title": "Zur Playlist — {{title}}",
|
||||
"newPlaceholder": "Name der neuen Playlist…",
|
||||
"create": "Erstellen",
|
||||
"none": "Noch keine Playlists. Erstelle oben eine.",
|
||||
"count": "{{count}} Titel"
|
||||
},
|
||||
"playlist": {
|
||||
"section": "Playlists",
|
||||
"newPlaceholder": "Neue Playlist…",
|
||||
"create": "Playlist erstellen",
|
||||
"none": "Noch keine Playlists.",
|
||||
"deleteConfirm": "Playlist „{{title}}“ löschen?",
|
||||
"rename": "Umbenennen",
|
||||
"playAll": "Alle abspielen",
|
||||
"delete": "Playlist löschen",
|
||||
"empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.",
|
||||
"up": "Nach oben",
|
||||
"down": "Nach unten",
|
||||
"remove": "Entfernen"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,5 +151,27 @@
|
|||
"direct": "Plays directly in the browser",
|
||||
"remux": "Needs a light remux (no video re-encode)",
|
||||
"transcode": "Needs transcoding (heavier)"
|
||||
},
|
||||
"playlistAdd": {
|
||||
"manage": "Add to playlist",
|
||||
"title": "Add to playlist — {{title}}",
|
||||
"newPlaceholder": "New playlist name…",
|
||||
"create": "Create",
|
||||
"none": "No playlists yet. Create one above.",
|
||||
"count": "{{count}} items"
|
||||
},
|
||||
"playlist": {
|
||||
"section": "Playlists",
|
||||
"newPlaceholder": "New playlist…",
|
||||
"create": "Create playlist",
|
||||
"none": "No playlists yet.",
|
||||
"deleteConfirm": "Delete the playlist \"{{title}}\"?",
|
||||
"rename": "Rename",
|
||||
"playAll": "Play all",
|
||||
"delete": "Delete playlist",
|
||||
"empty": "This playlist is empty. Add titles from their info page.",
|
||||
"up": "Move up",
|
||||
"down": "Move down",
|
||||
"remove": "Remove"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,5 +151,27 @@
|
|||
"direct": "Közvetlenül játszható a böngészőben",
|
||||
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
||||
"transcode": "Transcode kell (nehezebb)"
|
||||
},
|
||||
"playlistAdd": {
|
||||
"manage": "Listához adás",
|
||||
"title": "Listához adás — {{title}}",
|
||||
"newPlaceholder": "Új lista neve…",
|
||||
"create": "Létrehozás",
|
||||
"none": "Még nincs lista. Hozz létre egyet fent.",
|
||||
"count": "{{count}} elem"
|
||||
},
|
||||
"playlist": {
|
||||
"section": "Lejátszási listák",
|
||||
"newPlaceholder": "Új lista…",
|
||||
"create": "Lista létrehozása",
|
||||
"none": "Még nincs lista.",
|
||||
"deleteConfirm": "Törlöd a(z) „{{title}}” listát?",
|
||||
"rename": "Átnevezés",
|
||||
"playAll": "Összes lejátszása",
|
||||
"delete": "Lista törlése",
|
||||
"empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.",
|
||||
"up": "Fel",
|
||||
"down": "Le",
|
||||
"remove": "Eltávolítás"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -652,6 +652,7 @@ export interface PlexCard {
|
|||
season_count?: number | null; // show
|
||||
season_number?: number | null; // episode
|
||||
episode_number?: number | null; // episode
|
||||
show_title?: string | null; // episode (in a mixed playlist)
|
||||
summary?: string | null; // episode
|
||||
}
|
||||
export interface PlexBrowseResult {
|
||||
|
|
@ -730,6 +731,18 @@ export interface PlexCollection {
|
|||
editable: boolean;
|
||||
can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection
|
||||
}
|
||||
export interface PlexPlaylist {
|
||||
id: number;
|
||||
title: string;
|
||||
item_count: number;
|
||||
thumb?: string | null;
|
||||
has_item?: boolean; // only when the list was fetched with ?contains=<rating_key>
|
||||
}
|
||||
export interface PlexPlaylistDetail {
|
||||
id: number;
|
||||
title: string;
|
||||
items: PlexCard[];
|
||||
}
|
||||
export interface PlexCastMember {
|
||||
name: string;
|
||||
role?: string | null;
|
||||
|
|
@ -1164,6 +1177,22 @@ export const api = {
|
|||
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
|
||||
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }),
|
||||
// --- Playlists (Siftlode-native, per-user, ordered) ---
|
||||
plexPlaylists: (contains?: string): Promise<{ playlists: PlexPlaylist[] }> =>
|
||||
req(`/api/plex/playlists${contains ? `?contains=${encodeURIComponent(contains)}` : ""}`),
|
||||
plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
|
||||
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
|
||||
req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }),
|
||||
plexRenamePlaylist: (id: number, title: string): Promise<PlexPlaylist> =>
|
||||
req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }),
|
||||
plexDeletePlaylist: (id: number): Promise<{ deleted: number }> =>
|
||||
req(`/api/plex/playlists/${id}`, { method: "DELETE" }),
|
||||
plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
|
||||
req(`/api/plex/playlists/${id}/items`, { method: "POST", body: JSON.stringify({ item_rating_key: itemRk }) }),
|
||||
plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
|
||||
req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
|
||||
plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> =>
|
||||
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
|
||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@ export interface ReleaseEntry {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.30.0",
|
||||
date: "2026-07-06",
|
||||
summary: "Plex playlists — your own ordered watch-lists.",
|
||||
features: [
|
||||
"Plex playlists: create your own ordered lists of films (each user has their own — no Plex account needed). Add a film to a playlist from its info page (\"Add to playlist\"), then find your playlists in the Plex sidebar.",
|
||||
"Plex playlists: open a playlist to reorder (up/down), remove items, rename or delete it, and hit \"Play all\" to watch straight through — the player steps through the whole list in order, auto-advancing to the next.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.29.0",
|
||||
date: "2026-07-06",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue