feat(playlists): keep the player queue live across tabs

Track the playing item by id instead of a frozen index, deriving the index from the
live queue so the N / M counter and prev/next neighbours stay correct when the playlist
changes underneath (e.g. an item removed in another tab) without disrupting playback of
the current video. Refetch the playlist list/detail on window focus so such changes flow
in, updating the player's queue length in near-real-time.
This commit is contained in:
npeter83 2026-06-15 16:27:28 +02:00
parent bd085fc88f
commit 639d761bef
2 changed files with 20 additions and 6 deletions

View file

@ -203,8 +203,15 @@ export default function PlayerModal({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// The active item is queue[index] (falls back to the single opened video). // Track the playing item by id (not a frozen index) so it survives the queue changing
const [index, setIndex] = useState(startIndex ?? 0); // under us — e.g. an item removed in another tab. The index is derived from the *live*
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting
// playback of the current video.
const [playingId, setPlayingId] = useState<string>(
queue?.[startIndex ?? 0]?.id ?? video.id
);
let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
if (index < 0) index = 0;
const active = queue && queue[index] ? queue[index] : video; const active = queue && queue[index] ? queue[index] : video;
const hasQueue = !!queue && queue.length > 1; const hasQueue = !!queue && queue.length > 1;
// startAt only applies to the item we opened with; advanced items resume their own pos. // startAt only applies to the item we opened with; advanced items resume their own pos.
@ -369,7 +376,7 @@ export default function PlayerModal({
autoMarkedRef.current = true; autoMarkedRef.current = true;
setWatched(true); setWatched(true);
} }
if (queue && index < queue.length - 1) setIndex(index + 1); if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id);
} }
}, },
}, },
@ -418,7 +425,7 @@ export default function PlayerModal({
{hasQueue && ( {hasQueue && (
<div className="flex items-center justify-center gap-5 px-4 py-2 border-b border-border text-sm"> <div className="flex items-center justify-center gap-5 px-4 py-2 border-b border-border text-sm">
<button <button
onClick={() => setIndex((i) => Math.max(0, i - 1))} onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
disabled={index === 0} disabled={index === 0}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition" className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
> >
@ -428,7 +435,7 @@ export default function PlayerModal({
{index + 1} / {queue!.length} {index + 1} / {queue!.length}
</span> </span>
<button <button
onClick={() => setIndex((i) => Math.min(queue!.length - 1, i + 1))} onClick={() => index < queue!.length - 1 && setPlayingId(queue![index + 1].id)}
disabled={index === queue!.length - 1} disabled={index === queue!.length - 1}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition" className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
> >

View file

@ -108,7 +108,11 @@ export default function Playlists() {
// The open player, as an index into `items` (the queue); null = closed. // The open player, as an index into `items` (the queue); null = closed.
const [playingIndex, setPlayingIndex] = useState<number | null>(null); const [playingIndex, setPlayingIndex] = useState<number | null>(null);
const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() }); const listQuery = useQuery({
queryKey: ["playlists"],
queryFn: () => api.playlists(),
refetchOnWindowFocus: true,
});
const playlists = listQuery.data ?? []; const playlists = listQuery.data ?? [];
// Keep the selection valid: default to the first playlist, and if the selected one // Keep the selection valid: default to the first playlist, and if the selected one
@ -127,6 +131,9 @@ export default function Playlists() {
queryKey: ["playlist", selectedId], queryKey: ["playlist", selectedId],
queryFn: () => api.playlist(selectedId as number), queryFn: () => api.playlist(selectedId as number),
enabled: selectedId != null, enabled: selectedId != null,
// Refetch when returning to the tab so a change made elsewhere (another tab/device)
// shows up — including the player's live N / M count, which reads the queue length.
refetchOnWindowFocus: true,
}); });
const detail = detailQuery.data; const detail = detailQuery.data;