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.
This commit is contained in:
npeter83 2026-06-15 16:11:37 +02:00
parent dea740b728
commit 4689d2cbbd
5 changed files with 99 additions and 43 deletions

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; 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 Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
@ -184,6 +184,8 @@ function loadYouTubeApi(): Promise<any> {
export default function PlayerModal({ export default function PlayerModal({
video, video,
startAt, startAt,
queue,
startIndex,
onClose, onClose,
onState, onState,
}: { }: {
@ -191,30 +193,40 @@ export default function PlayerModal({
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined // 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. // to resume from the server-saved position carried on the video.
startAt?: number | null; 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; onClose: () => void;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// Resume point for the video we opened with. // The active item is queue[index] (falls back to the single opened video).
const resumeAt = startAt != null ? startAt : video.position_seconds || 0; 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<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<any>(null);
const autoMarkedRef = useRef(false); const autoMarkedRef = useRef(false);
// The player can navigate to other videos (YouTube links in a description). The // 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. // currently-playing id may differ from the active item.
const [currentVideoId, setCurrentVideoId] = useState(video.id); const [currentVideoId, setCurrentVideoId] = useState(active.id);
const currentIdRef = useRef(video.id); const currentIdRef = useRef(active.id);
const navigated = currentVideoId !== video.id; const navigated = currentVideoId !== active.id;
// Title/author of a navigated-to video, read from the player (free, no API call). // 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 [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
const loadVideo = (id: string, start: number | null) => { const loadVideo = (id: string, start: number | null) => {
const p = playerRef.current; const p = playerRef.current;
if (!p || typeof p.loadVideoById !== "function") return; if (!p || typeof p.loadVideoById !== "function") return;
// Explicit start wins; otherwise resume the opened video, start others at 0. // Explicit start wins; otherwise resume the active item, start others at 0.
const startSeconds = start != null ? start : id === video.id ? resumeAt : 0; const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
currentIdRef.current = id; currentIdRef.current = id;
setCurrentVideoId(id); setCurrentVideoId(id);
@ -223,14 +235,22 @@ export default function PlayerModal({
// Local mirror of watch status so the toggle reacts instantly; changes are // Local mirror of watch status so the toggle reacts instantly; changes are
// propagated to the feed (and server) via onState. // 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 watched = status === "watched";
const setWatched = (on: boolean) => { const setWatched = (on: boolean) => {
const next = on ? "watched" : "new"; const next = on ? "watched" : "new";
setStatus(next); 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 seekTo = (seconds: number) => {
const p = playerRef.current; const p = playerRef.current;
if (p && typeof p.seekTo === "function") { if (p && typeof p.seekTo === "function") {
@ -292,10 +312,11 @@ export default function PlayerModal({
// auto-mark watched once playback reaches the end. // auto-mark watched once playback reaches the end.
useEffect(() => { useEffect(() => {
let cancelled = false; 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 // Auto-watch only applies to the active item — not to other videos the player
// videos the player navigated to via description links. // navigated to via description links.
const maybeAutoWatch = (current: number, duration: number) => { const maybeAutoWatch = (current: number, duration: number) => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return; if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return;
if (current > duration - FINISH_MARGIN) { if (current > duration - FINISH_MARGIN) {
@ -342,11 +363,14 @@ export default function PlayerModal({
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
if (d) setLiveData({ title: d.title, author: d.author }); if (d) setLiveData({ title: d.title, author: d.author });
} }
// 0 === ended → mark watched (guarded to the feed video inside maybeAutoWatch). // 0 === ended → mark watched, then advance to the next queue item if any.
if (e?.data === 0 && !autoMarkedRef.current && currentIdRef.current === id) { if (e?.data === 0 && currentIdRef.current === id) {
if (!autoMarkedRef.current) {
autoMarkedRef.current = true; autoMarkedRef.current = true;
setWatched(true); setWatched(true);
} }
if (queue && index < queue.length - 1) setIndex(index + 1);
}
}, },
}, },
}); });
@ -358,10 +382,11 @@ export default function PlayerModal({
return () => { return () => {
cancelled = true; cancelled = true;
window.clearInterval(timer); window.clearInterval(timer);
// Final flush, then refresh the feed so the card's resume bar reflects this session. // Final flush, then refresh the feed + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: ["feed"] }) qc.invalidateQueries({ queryKey: ["feed"] });
); qc.invalidateQueries({ queryKey: ["playlist"] });
});
const p = playerRef.current; const p = playerRef.current;
if (p && typeof p.destroy === "function") { if (p && typeof p.destroy === "function") {
try { try {
@ -373,7 +398,7 @@ export default function PlayerModal({
playerRef.current = null; playerRef.current = null;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [video.id]); }, [active.id]);
return ( return (
<div <div
@ -390,6 +415,28 @@ export default function PlayerModal({
<div ref={mountRef} className="w-full h-full" /> <div ref={mountRef} className="w-full h-full" />
</div> </div>
{hasQueue && (
<div className="flex items-center justify-center gap-5 px-4 py-2 border-b border-border text-sm">
<button
onClick={() => setIndex((i) => Math.max(0, i - 1))}
disabled={index === 0}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={() => setIndex((i) => Math.min(queue!.length - 1, i + 1))}
disabled={index === queue!.length - 1}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
</div>
)}
<div className="p-4 sm:p-5"> <div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */} {/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@ -401,12 +448,12 @@ export default function PlayerModal({
onMouseEnter={openDesc} onMouseEnter={openDesc}
onMouseLeave={scheduleCloseDesc} onMouseLeave={scheduleCloseDesc}
> >
{navigated ? liveData?.title ?? t("player.loading") : video.title} {navigated ? liveData?.title ?? t("player.loading") : active.title}
</span> </span>
</h2> </h2>
{navigated && ( {navigated && (
<button <button
onClick={() => loadVideo(video.id, null)} onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")} title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition" className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
> >
@ -463,8 +510,8 @@ export default function PlayerModal({
<div className="flex items-center gap-3 mt-3"> <div className="flex items-center gap-3 mt-3">
{!navigated && ( {!navigated && (
<Avatar <Avatar
src={video.channel_thumbnail} src={active.channel_thumbnail}
fallback={video.channel_title ?? ""} fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0" className="w-9 h-9 rounded-full shrink-0"
/> />
)} )}
@ -483,24 +530,24 @@ export default function PlayerModal({
) )
) : ( ) : (
<a <a
href={video.channel_url} href={active.channel_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="font-medium hover:text-accent shrink-0" className="font-medium hover:text-accent shrink-0"
> >
{video.channel_title} {active.channel_title}
</a> </a>
)} )}
{!navigated ? ( {!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0"> <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{video.view_count != null && ( {active.view_count != null && (
<span>· {t("player.views", { count: video.view_count, formattedCount: formatViews(video.view_count) })}</span> <span>· {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })}</span>
)} )}
<span>· {relativeTime(video.published_at)}</span> <span>· {relativeTime(active.published_at)}</span>
{video.duration_seconds != null && ( {active.duration_seconds != null && (
<span>· {formatDuration(video.duration_seconds)}</span> <span>· {formatDuration(active.duration_seconds)}</span>
)} )}
{video.live_status === "was_live" && ( {active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded"> <span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
{t("player.stream")} {t("player.stream")}
</span> </span>
@ -521,7 +568,7 @@ export default function PlayerModal({
{!navigated && ( {!navigated && (
<AddToPlaylist <AddToPlaylist
videoId={video.id} videoId={active.id}
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition" className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/> />
)} )}

View file

@ -105,7 +105,8 @@ export default function Playlists() {
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [items, setItems] = useState<Video[]>([]); const [items, setItems] = useState<Video[]>([]);
const [active, setActive] = useState<{ video: Video; startAt: number | null } | null>(null); // The open player, as an index into `items` (the queue); null = closed.
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() }); const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() });
const playlists = listQuery.data ?? []; const playlists = listQuery.data ?? [];
@ -312,7 +313,7 @@ export default function Playlists() {
</div> </div>
<div className="flex gap-2 mt-3 flex-wrap"> <div className="flex gap-2 mt-3 flex-wrap">
<button <button
onClick={() => items[0] && setActive({ video: items[0], startAt: null })} onClick={() => items.length && setPlayingIndex(0)}
disabled={!items.length} disabled={!items.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition" className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
> >
@ -353,7 +354,7 @@ export default function Playlists() {
key={v.id} key={v.id}
video={v} video={v}
index={i} index={i}
onPlay={() => setActive({ video: v, startAt: null })} onPlay={() => setPlayingIndex(i)}
onRemove={() => removeItem(v.id)} onRemove={() => removeItem(v.id)}
/> />
))} ))}
@ -365,11 +366,13 @@ export default function Playlists() {
)} )}
</main> </main>
{active && ( {playingIndex != null && items[playingIndex] && (
<PlayerModal <PlayerModal
video={active.video} video={items[playingIndex]}
startAt={active.startAt} queue={items}
onClose={() => setActive(null)} startIndex={playingIndex}
startAt={null}
onClose={() => setPlayingIndex(null)}
onState={playerState} onState={playerState}
/> />
)} )}

View file

@ -2,6 +2,8 @@
"loading": "Wird geladen…", "loading": "Wird geladen…",
"backToOriginal": "Zurück zum ursprünglichen Video", "backToOriginal": "Zurück zum ursprünglichen Video",
"back": "Zurück", "back": "Zurück",
"previous": "Vorheriges",
"next": "Nächstes",
"close": "Schließen", "close": "Schließen",
"closeEsc": "Schließen (Esc)", "closeEsc": "Schließen (Esc)",
"description": "Beschreibung", "description": "Beschreibung",

View file

@ -2,6 +2,8 @@
"loading": "Loading…", "loading": "Loading…",
"backToOriginal": "Back to the original video", "backToOriginal": "Back to the original video",
"back": "Back", "back": "Back",
"previous": "Previous",
"next": "Next",
"close": "Close", "close": "Close",
"closeEsc": "Close (Esc)", "closeEsc": "Close (Esc)",
"description": "Description", "description": "Description",

View file

@ -2,6 +2,8 @@
"loading": "Betöltés…", "loading": "Betöltés…",
"backToOriginal": "Vissza az eredeti videóhoz", "backToOriginal": "Vissza az eredeti videóhoz",
"back": "Vissza", "back": "Vissza",
"previous": "Előző",
"next": "Következő",
"close": "Bezárás", "close": "Bezárás",
"closeEsc": "Bezárás (Esc)", "closeEsc": "Bezárás (Esc)",
"description": "Leírás", "description": "Leírás",