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
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue