diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 950ef8e..04a0ef5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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({ setActiveVideo(null)} onState={onState} onOpenChannel={onOpenChannel} @@ -656,8 +660,7 @@ export default function Feed({ setActiveVideo(null)} onState={onState} onOpenChannel={onOpenChannel} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index f684ea4..76b41eb 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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 | null = null; function loadYouTubeApi(): Promise { @@ -42,9 +50,8 @@ function loadYouTubeApi(): Promise { 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 }>(["me"]) + ?.preferences ?? {}) as { playerAutoAdvance?: AutoMode; playerLoop?: LoopMode }; + const [autoMode, setAutoMode] = useState(savedPrefs.playerAutoAdvance ?? "off"); + const [loopMode, setLoopMode] = useState(savedPrefs.playerLoop ?? "off"); + const modeRef = useRef({ autoMode, loopMode }); + modeRef.current = { autoMode, loopMode }; + const persistPref = (patch: Record) => { + api.savePrefs(patch).catch(() => {}); + qc.setQueryData<{ preferences?: Record } | 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 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({ {hasQueue && ( -
+
+ + {/* Persistent playback settings — cycle on click, saved to your account. */} + +
)} diff --git a/frontend/src/i18n/locales/de/player.json b/frontend/src/i18n/locales/de/player.json index f1d5758..1fe30aa 100644 --- a/frontend/src/i18n/locales/de/player.json +++ b/frontend/src/i18n/locales/de/player.json @@ -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", diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json index aaa7f3f..4ef4ace 100644 --- a/frontend/src/i18n/locales/en/player.json +++ b/frontend/src/i18n/locales/en/player.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json index 005f653..12efb36 100644 --- a/frontend/src/i18n/locales/hu/player.json +++ b/frontend/src/i18n/locales/hu/player.json @@ -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",