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);
|
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||||
loadedRef.current = loaded;
|
loadedRef.current = loaded;
|
||||||
const items: Video[] = loaded
|
const withOverrides = (list: Video[]): Video[] =>
|
||||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
list
|
||||||
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[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 -------------------------------------------------------------
|
// --- Live YouTube search mode -------------------------------------------------------------
|
||||||
// A separate data source rendered in the same cards/player. No watch-state view filter (the
|
// 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) {
|
if (ytActive) {
|
||||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||||
loadedRef.current = ytLoaded;
|
loadedRef.current = ytLoaded;
|
||||||
const ytItems: Video[] = ytLoaded
|
const ytItems: Video[] = withOverrides(ytLoaded);
|
||||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
|
||||||
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
|
|
||||||
const ytError =
|
const ytError =
|
||||||
ytQuery.error instanceof HttpError
|
ytQuery.error instanceof HttpError
|
||||||
? ytQuery.error.detail || t("feed.yt.error")
|
? ytQuery.error.detail || t("feed.yt.error")
|
||||||
|
|
@ -446,8 +451,7 @@ export default function Feed({
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={activeVideo.video}
|
video={activeVideo.video}
|
||||||
startAt={activeVideo.startAt}
|
startAt={activeVideo.startAt}
|
||||||
queue={ytItems}
|
queue={ytPlayerQueue}
|
||||||
autoAdvance={false}
|
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
|
|
@ -656,8 +660,7 @@ export default function Feed({
|
||||||
<PlayerModal
|
<PlayerModal
|
||||||
video={activeVideo.video}
|
video={activeVideo.video}
|
||||||
startAt={activeVideo.startAt}
|
startAt={activeVideo.startAt}
|
||||||
queue={items}
|
queue={playerQueue}
|
||||||
autoAdvance={false}
|
|
||||||
onClose={() => setActiveVideo(null)}
|
onClose={() => setActiveVideo(null)}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-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 { 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 Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import DownloadButton from "./DownloadButton";
|
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.
|
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||||
const FINISH_MARGIN = 10;
|
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) ---
|
// --- IFrame Player API loader (singleton) ---
|
||||||
let apiPromise: Promise<any> | null = null;
|
let apiPromise: Promise<any> | null = null;
|
||||||
function loadYouTubeApi(): Promise<any> {
|
function loadYouTubeApi(): Promise<any> {
|
||||||
|
|
@ -42,9 +50,8 @@ function loadYouTubeApi(): Promise<any> {
|
||||||
export default function PlayerModal({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
startAt,
|
startAt,
|
||||||
queue: queueProp,
|
queue,
|
||||||
startIndex,
|
startIndex,
|
||||||
autoAdvance = true,
|
|
||||||
onClose,
|
onClose,
|
||||||
onState,
|
onState,
|
||||||
onOpenChannel,
|
onOpenChannel,
|
||||||
|
|
@ -58,9 +65,6 @@ export default function PlayerModal({
|
||||||
// resume / auto-watch / progress logic.
|
// resume / auto-watch / progress logic.
|
||||||
queue?: Video[];
|
queue?: Video[];
|
||||||
startIndex?: number;
|
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;
|
onClose: () => void;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
// Open the active video's channel page in-app (closes the player first). The small external
|
// 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();
|
const qc = useQueryClient();
|
||||||
// Browser/mouse Back closes the player instead of leaving the page.
|
// Browser/mouse Back closes the player instead of leaving the page.
|
||||||
useBackToClose(onClose);
|
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").
|
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
|
||||||
const fullDate = (d: string | null | undefined) =>
|
const fullDate = (d: string | null | undefined) =>
|
||||||
d
|
d
|
||||||
|
|
@ -214,6 +213,68 @@ export default function PlayerModal({
|
||||||
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
|
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
|
// 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
|
// 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.
|
// 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;
|
autoMarkedRef.current = true;
|
||||||
setWatched(true);
|
setWatched(true);
|
||||||
}
|
}
|
||||||
if (autoAdvance) {
|
advanceOnEnd();
|
||||||
const { queue: q, index: i } = navRef.current;
|
|
||||||
if (q && i < q.length - 1) setPlayingId(q[i + 1].id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// The embed couldn't play this video (embedding disabled, removed, private…).
|
// The embed couldn't play this video (embedding disabled, removed, private…).
|
||||||
|
|
@ -521,7 +579,7 @@ export default function PlayerModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasQueue && (
|
{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
|
<button
|
||||||
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
|
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
|
|
@ -541,6 +599,34 @@ export default function PlayerModal({
|
||||||
>
|
>
|
||||||
{t("player.next")} <SkipForward className="w-4 h-4" />
|
{t("player.next")} <SkipForward className="w-4 h-4" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"previous": "Vorheriges",
|
"previous": "Vorheriges",
|
||||||
"next": "Nächstes",
|
"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",
|
"close": "Schließen",
|
||||||
"closeEsc": "Schließen (Esc)",
|
"closeEsc": "Schließen (Esc)",
|
||||||
"description": "Beschreibung",
|
"description": "Beschreibung",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"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",
|
"close": "Close",
|
||||||
"closeEsc": "Close (Esc)",
|
"closeEsc": "Close (Esc)",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,21 @@
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
"previous": "Előző",
|
"previous": "Előző",
|
||||||
"next": "Következő",
|
"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",
|
"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