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:
parent
dea740b728
commit
4689d2cbbd
5 changed files with 99 additions and 43 deletions
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue