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