feat(player): persistent auto-advance + loop playback modes

Two saved-to-account playback settings on the player's prev/next bar (any queued
player — feed or playlist): Auto-advance (Off/Next/Prev/Random — what plays when a
video ends) and Loop (Off / One = repeat the current video / All = wrap the list
at its ends; a single-item list repeats). Stored in users.preferences
(playerAutoAdvance/playerLoop), read from the cached me + written via savePrefs so
they apply everywhere and survive reloads.

The feed's player queue is now the live filtered feed with the watch-state filter
NOT applied — marking the current video watched keeps it in the sequence (no
reindex/reload mid-play), while a hidden video still drops out. Removes the old
frozen-queue workaround and the boolean autoAdvance prop. i18n en/hu/de.
This commit is contained in:
npeter83 2026-07-05 00:20:50 +02:00
parent 597cec6517
commit d02c338465
5 changed files with 161 additions and 27 deletions

View file

@ -306,10 +306,16 @@ export default function Feed({
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
loadedRef.current = loaded;
const items: Video[] = loaded
const withOverrides = (list: Video[]): Video[] =>
list
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
.filter((v) => matchesView(v.status, filters.show));
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
const items: Video[] = withOverrides(loaded).filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
// watch-state view filter is NOT applied — so marking the current video watched keeps it in the
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
// goes hidden still drops out ("visible affects").
const playerQueue: Video[] = withOverrides(loaded).filter((v) => v.status !== "hidden");
// --- Live YouTube search mode -------------------------------------------------------------
// A separate data source rendered in the same cards/player. No watch-state view filter (the
@ -317,9 +323,8 @@ export default function Feed({
if (ytActive) {
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
loadedRef.current = ytLoaded;
const ytItems: Video[] = ytLoaded
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
const ytItems: Video[] = withOverrides(ytLoaded);
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
const ytError =
ytQuery.error instanceof HttpError
? ytQuery.error.detail || t("feed.yt.error")
@ -446,8 +451,7 @@ export default function Feed({
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
queue={ytItems}
autoAdvance={false}
queue={ytPlayerQueue}
onClose={() => setActiveVideo(null)}
onState={onState}
onOpenChannel={onOpenChannel}
@ -656,8 +660,7 @@ export default function Feed({
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
queue={items}
autoAdvance={false}
queue={playerQueue}
onClose={() => setActiveVideo(null)}
onState={onState}
onOpenChannel={onOpenChannel}

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
@ -20,6 +20,14 @@ import { useBackToClose } from "../lib/history";
// How close to the end (seconds) counts as "finished" → auto-mark watched.
const FINISH_MARGIN = 10;
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a
// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends
// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
type AutoMode = "off" | "next" | "prev" | "random";
type LoopMode = "off" | "one" | "all";
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
// --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<any> | null = null;
function loadYouTubeApi(): Promise<any> {
@ -42,9 +50,8 @@ function loadYouTubeApi(): Promise<any> {
export default function PlayerModal({
video,
startAt,
queue: queueProp,
queue,
startIndex,
autoAdvance = true,
onClose,
onState,
onOpenChannel,
@ -58,9 +65,6 @@ export default function PlayerModal({
// resume / auto-watch / progress logic.
queue?: Video[];
startIndex?: number;
// Auto-play the next queue item when one ends. On for playlists; off for the feed queue, where
// stepping is explicit (the feed isn't a playlist — auto-advancing it would surprise).
autoAdvance?: boolean;
onClose: () => void;
onState: (id: string, status: string) => void;
// Open the active video's channel page in-app (closes the player first). The small external
@ -71,11 +75,6 @@ export default function PlayerModal({
const qc = useQueryClient();
// Browser/mouse Back closes the player instead of leaving the page.
useBackToClose(onClose);
// Freeze the queue at mount. The feed's list is view-filtered + live, so marking the current
// video watched would drop it (and reindex → reload a different video mid-play). A frozen
// snapshot keeps the current item put and lets you step back to ones you've watched. (New feed
// pages loaded while the player is open won't appear in the sequence — reopen to include them.)
const queue = useRef(queueProp).current;
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
const fullDate = (d: string | null | undefined) =>
d
@ -214,6 +213,68 @@ export default function PlayerModal({
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
};
// Auto-advance + loop are persistent per-user settings. Read the current values from the cached
// `me`, mirror them in local state for instant UI, and write both to the server + cache on change
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
const savedPrefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])
?.preferences ?? {}) as { playerAutoAdvance?: AutoMode; playerLoop?: LoopMode };
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
const modeRef = useRef({ autoMode, loopMode });
modeRef.current = { autoMode, loopMode };
const persistPref = (patch: Record<string, string>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m
);
};
const cycleAuto = () => {
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length];
setAutoMode(next);
persistPref({ playerAutoAdvance: next });
};
const cycleLoop = () => {
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length];
setLoopMode(next);
persistPref({ playerLoop: next });
};
const replayCurrent = () => {
const p = playerRef.current;
if (p && typeof p.seekTo === "function") {
p.seekTo(0, true);
p.playVideo?.();
}
};
// Run when the current video ends, per the saved settings. Loop "one" repeats it; otherwise
// advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item
// list repeats). Uses the live queue + settings via refs so it's correct from the bound handler.
const advanceOnEnd = () => {
const { autoMode: a, loopMode: l } = modeRef.current;
if (l === "one") return replayCurrent();
const { queue: q, index: i } = navRef.current;
if (!q || q.length === 0) return;
const wrap = l === "all";
if (a === "next") {
if (i < q.length - 1) setPlayingId(q[i + 1].id);
else if (wrap) {
if (q.length === 1) replayCurrent();
else setPlayingId(q[0].id);
}
} else if (a === "prev") {
if (i > 0) setPlayingId(q[i - 1].id);
else if (wrap) {
if (q.length === 1) replayCurrent();
else setPlayingId(q[q.length - 1].id);
}
} else if (a === "random" && q.length > 1) {
let r = i;
while (r === i) r = Math.floor(Math.random() * q.length);
setPlayingId(q[r].id);
} else if (a === "random" && wrap) {
replayCurrent();
}
};
// Lazy description (fetched only when the title is hovered). The popover is
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
// can't clip it. A small close grace lets the mouse travel title → popover.
@ -393,10 +454,7 @@ export default function PlayerModal({
autoMarkedRef.current = true;
setWatched(true);
}
if (autoAdvance) {
const { queue: q, index: i } = navRef.current;
if (q && i < q.length - 1) setPlayingId(q[i + 1].id);
}
advanceOnEnd();
}
},
// The embed couldn't play this video (embedding disabled, removed, private…).
@ -521,7 +579,7 @@ export default function PlayerModal({
</div>
{hasQueue && (
<div className="flex items-center justify-center gap-5 px-4 py-2 border-b border-border text-sm">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<button
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
disabled={index === 0}
@ -541,6 +599,34 @@ export default function PlayerModal({
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-border" />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
autoMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
loopMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`}
>
{loopMode === "one" ? <Repeat1 className="h-3.5 w-3.5" /> : <Repeat className="h-3.5 w-3.5" />}
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</button>
</div>
)}

View file

@ -4,6 +4,21 @@
"back": "Zurück",
"previous": "Vorheriges",
"next": "Nächstes",
"autoAdvance": {
"label": "Auto",
"hint": "Was am Videoende folgt. Klicken zum Wechseln: Aus, Nächstes, Vorheriges, Zufall. In deinem Konto gespeichert.",
"off": "Aus",
"next": "Nächstes",
"prev": "Vorheriges",
"random": "Zufall"
},
"loop": {
"label": "Wiederholen",
"hint": "Aktuelles Video wiederholen (Eins), ganze Liste wiederholen (Alle) oder keines (Aus). In deinem Konto gespeichert.",
"off": "Aus",
"one": "Eins",
"all": "Alle"
},
"close": "Schließen",
"closeEsc": "Schließen (Esc)",
"description": "Beschreibung",

View file

@ -4,6 +4,21 @@
"back": "Back",
"previous": "Previous",
"next": "Next",
"autoAdvance": {
"label": "Auto",
"hint": "When a video ends, what plays next. Click to cycle Off, Next, Previous, Random. Saved to your account.",
"off": "Off",
"next": "Next",
"prev": "Previous",
"random": "Random"
},
"loop": {
"label": "Loop",
"hint": "Repeat the current video (One), loop the whole list (All), or neither (Off). Saved to your account.",
"off": "Off",
"one": "One",
"all": "All"
},
"close": "Close",
"closeEsc": "Close (Esc)",
"description": "Description",

View file

@ -4,6 +4,21 @@
"back": "Vissza",
"previous": "Előző",
"next": "Következő",
"autoAdvance": {
"label": "Auto",
"hint": "A videó végén mi jöjjön. Kattintásra vált: Ki, Következő, Előző, Véletlen. A fiókodhoz mentve.",
"off": "Ki",
"next": "Következő",
"prev": "Előző",
"random": "Véletlen"
},
"loop": {
"label": "Ismétlés",
"hint": "Az aktuális videó ismétlése (Egy), a teljes lista körbe (Mind), vagy egyik sem (Ki). A fiókodhoz mentve.",
"off": "Ki",
"one": "Egy",
"all": "Mind"
},
"close": "Bezárás",
"closeEsc": "Bezárás (Esc)",
"description": "Leírás",