From bd085fc88fa8eaad3edb64d87505d7b4e5baf5f0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 16:11:37 +0200 Subject: [PATCH] feat(playlists): queue playback in the player (auto-advance + prev/next) PlayerModal now accepts an optional queue + startIndex. The active item drives the player; it recreates per item (reusing the single-video resume / auto-watch / progress logic), auto-advances to the next item when one ends, and shows Previous / Next controls with an N / M indicator. The Playlists page passes the playlist as the queue from Play all or a clicked row. Trilingual previous/next strings. --- frontend/src/components/PlayerModal.tsx | 119 ++++++++++++++++------- frontend/src/components/Playlists.tsx | 17 ++-- frontend/src/i18n/locales/de/player.json | 2 + frontend/src/i18n/locales/en/player.json | 2 + frontend/src/i18n/locales/hu/player.json | 2 + 5 files changed, 99 insertions(+), 43 deletions(-) diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 50ff483..508b941 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Check, CheckCheck, X } from "lucide-react"; +import { ArrowLeft, Check, CheckCheck, SkipBack, SkipForward, X } from "lucide-react"; import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { api, type Video } from "../lib/api"; @@ -184,6 +184,8 @@ function loadYouTubeApi(): Promise { export default function PlayerModal({ video, startAt, + queue, + startIndex, onClose, onState, }: { @@ -191,30 +193,40 @@ export default function PlayerModal({ // Where to start the opened video: a number of seconds (0 = Restart), or null/undefined // to resume from the server-saved position carried on the video. startAt?: number | null; + // Optional playback queue (e.g. a playlist). When present, the player advances through + // it (auto-advance on end + prev/next), recreating per item to reuse the single-video + // resume / auto-watch / progress logic. + queue?: Video[]; + startIndex?: number; onClose: () => void; onState: (id: string, status: string) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); - // Resume point for the video we opened with. - const resumeAt = startAt != null ? startAt : video.position_seconds || 0; + // The active item is queue[index] (falls back to the single opened video). + const [index, setIndex] = useState(startIndex ?? 0); + const active = queue && queue[index] ? queue[index] : video; + const hasQueue = !!queue && queue.length > 1; + // startAt only applies to the item we opened with; advanced items resume their own pos. + const resumeAt = + startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; const mountRef = useRef(null); const playerRef = useRef(null); const autoMarkedRef = useRef(false); // The player can navigate to other videos (YouTube links in a description). The - // currently-playing id may differ from the feed video we opened with. - const [currentVideoId, setCurrentVideoId] = useState(video.id); - const currentIdRef = useRef(video.id); - const navigated = currentVideoId !== video.id; + // currently-playing id may differ from the active item. + const [currentVideoId, setCurrentVideoId] = useState(active.id); + const currentIdRef = useRef(active.id); + const navigated = currentVideoId !== active.id; // Title/author of a navigated-to video, read from the player (free, no API call). const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null); const loadVideo = (id: string, start: number | null) => { const p = playerRef.current; if (!p || typeof p.loadVideoById !== "function") return; - // Explicit start wins; otherwise resume the opened video, start others at 0. - const startSeconds = start != null ? start : id === video.id ? resumeAt : 0; + // Explicit start wins; otherwise resume the active item, start others at 0. + const startSeconds = start != null ? start : id === active.id ? resumeAt : 0; p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); currentIdRef.current = id; setCurrentVideoId(id); @@ -223,14 +235,22 @@ export default function PlayerModal({ // Local mirror of watch status so the toggle reacts instantly; changes are // propagated to the feed (and server) via onState. - const [status, setStatus] = useState(video.status); + const [status, setStatus] = useState(active.status); const watched = status === "watched"; const setWatched = (on: boolean) => { const next = on ? "watched" : "new"; setStatus(next); - onState(video.id, next); + onState(active.id, next); }; + // On queue advance (active changes), sync local state to the new item. + useEffect(() => { + setStatus(active.status); + setCurrentVideoId(active.id); + currentIdRef.current = active.id; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active.id]); + const seekTo = (seconds: number) => { const p = playerRef.current; if (p && typeof p.seekTo === "function") { @@ -292,10 +312,11 @@ export default function PlayerModal({ // auto-mark watched once playback reaches the end. useEffect(() => { let cancelled = false; - const id = video.id; + const id = active.id; + autoMarkedRef.current = false; - // Auto-watch only applies to the feed video we opened with — not to other - // videos the player navigated to via description links. + // Auto-watch only applies to the active item — not to other videos the player + // navigated to via description links. const maybeAutoWatch = (current: number, duration: number) => { if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return; if (current > duration - FINISH_MARGIN) { @@ -342,10 +363,13 @@ export default function PlayerModal({ const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; if (d) setLiveData({ title: d.title, author: d.author }); } - // 0 === ended → mark watched (guarded to the feed video inside maybeAutoWatch). - if (e?.data === 0 && !autoMarkedRef.current && currentIdRef.current === id) { - autoMarkedRef.current = true; - setWatched(true); + // 0 === ended → mark watched, then advance to the next queue item if any. + if (e?.data === 0 && currentIdRef.current === id) { + if (!autoMarkedRef.current) { + autoMarkedRef.current = true; + setWatched(true); + } + if (queue && index < queue.length - 1) setIndex(index + 1); } }, }, @@ -358,10 +382,11 @@ export default function PlayerModal({ return () => { cancelled = true; window.clearInterval(timer); - // Final flush, then refresh the feed so the card's resume bar reflects this session. - Promise.resolve(persist()).finally(() => - qc.invalidateQueries({ queryKey: ["feed"] }) - ); + // Final flush, then refresh the feed + playlists so resume bars reflect this session. + Promise.resolve(persist()).finally(() => { + qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + }); const p = playerRef.current; if (p && typeof p.destroy === "function") { try { @@ -373,7 +398,7 @@ export default function PlayerModal({ playerRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [video.id]); + }, [active.id]); return (
+ {hasQueue && ( +
+ + + {index + 1} / {queue!.length} + + +
+ )} +
{/* Title row — title (with hover description) on the left, Close on the right. */}
@@ -401,12 +448,12 @@ export default function PlayerModal({ onMouseEnter={openDesc} onMouseLeave={scheduleCloseDesc} > - {navigated ? liveData?.title ?? t("player.loading") : video.title} + {navigated ? liveData?.title ?? t("player.loading") : active.title} {navigated && (