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:
parent
597cec6517
commit
d02c338465
5 changed files with 161 additions and 27 deletions
|
|
@ -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
|
||||
.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));
|
||||
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));
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue