feat(feed): resume progress bar, play/continue/restart, in-progress filter

Video cards show a resume progress bar for started-but-unfinished videos and a
hover overlay: Play on every card, Continue + Restart on in-progress ones. The
in-app player now resumes from (and checkpoints to) the server position instead
of localStorage, accepts an explicit startAt (Restart -> 0), and refreshes the
feed on close so the card bar reflects the session. Sidebar gains an
'In progress' show filter.
This commit is contained in:
npeter83 2026-06-14 18:40:12 +02:00
parent 686c40cbb9
commit 04c971f623
5 changed files with 114 additions and 30 deletions

View file

@ -16,6 +16,9 @@ function matchesView(status: string, show: string): boolean {
case "saved": case "saved":
return status === "saved"; return status === "saved";
case "unwatched": case "unwatched":
case "in_progress":
// (in_progress is further narrowed server-side by resume position; here we only
// need to drop a card once it's optimistically marked watched/hidden.)
return status !== "watched" && status !== "hidden"; return status !== "watched" && status !== "hidden";
default: default:
return status !== "hidden"; // all return status !== "hidden"; // all
@ -36,9 +39,17 @@ export default function Feed({
onOpenWizard: () => void; onOpenWizard: () => void;
}) { }) {
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
const [activeVideo, setActiveVideo] = useState<Video | null>(null); // The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
null
);
const qc = useQueryClient(); const qc = useQueryClient();
const openVideo = useCallback(
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
[]
);
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["feed", filters], queryKey: ["feed", filters],
queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE), queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE),
@ -166,7 +177,7 @@ export default function Feed({
view="grid" view="grid"
onState={onState} onState={onState}
onChannelFilter={onChannelFilter} onChannelFilter={onChannelFilter}
onOpen={setActiveVideo} onOpen={openVideo}
/> />
))} ))}
</div> </div>
@ -179,14 +190,15 @@ export default function Feed({
view="list" view="list"
onState={onState} onState={onState}
onChannelFilter={onChannelFilter} onChannelFilter={onChannelFilter}
onOpen={setActiveVideo} onOpen={openVideo}
/> />
))} ))}
</div> </div>
)} )}
{activeVideo && ( {activeVideo && (
<PlayerModal <PlayerModal
video={activeVideo} video={activeVideo.video}
startAt={activeVideo.startAt}
onClose={() => setActiveVideo(null)} onClose={() => setActiveVideo(null)}
onState={onState} onState={onState}
/> />

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react"; import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
@ -176,31 +176,22 @@ function loadYouTubeApi(): Promise<any> {
return apiPromise; return apiPromise;
} }
// --- Per-video resume position, persisted in localStorage so it survives a refresh ---
const posKey = (id: string) => `subfeed:player-pos:${id}`;
function savePos(id: string, seconds: number, duration: number): void {
// Don't store trivially-early or near-finished positions (start fresh next time).
if (!Number.isFinite(seconds) || seconds < 5 || (duration > 0 && seconds > duration - FINISH_MARGIN)) {
localStorage.removeItem(posKey(id));
return;
}
localStorage.setItem(posKey(id), String(Math.floor(seconds)));
}
function loadPos(id: string): number {
const v = localStorage.getItem(posKey(id));
const n = v ? parseInt(v, 10) : 0;
return Number.isFinite(n) && n > 0 ? n : 0;
}
export default function PlayerModal({ export default function PlayerModal({
video, video,
startAt,
onClose, onClose,
onState, onState,
}: { }: {
video: Video; video: Video;
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
// to resume from the server-saved position carried on the video.
startAt?: number | null;
onClose: () => void; onClose: () => void;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
}) { }) {
const qc = useQueryClient();
// Resume point for the video we opened with.
const resumeAt = startAt != null ? startAt : video.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<any>(null);
const autoMarkedRef = useRef(false); const autoMarkedRef = useRef(false);
@ -216,7 +207,8 @@ export default function PlayerModal({
const loadVideo = (id: string, start: number | null) => { const loadVideo = (id: string, start: number | null) => {
const p = playerRef.current; const p = playerRef.current;
if (!p || typeof p.loadVideoById !== "function") return; if (!p || typeof p.loadVideoById !== "function") return;
const startSeconds = start != null ? start : loadPos(id); // Explicit start wins; otherwise resume the opened video, start others at 0.
const startSeconds = start != null ? start : id === video.id ? resumeAt : 0;
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
currentIdRef.current = id; currentIdRef.current = id;
setCurrentVideoId(id); setCurrentVideoId(id);
@ -305,14 +297,18 @@ export default function PlayerModal({
setWatched(true); setWatched(true);
} }
}; };
const persist = () => { const persist = (): Promise<unknown> | void => {
const p = playerRef.current; const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return; if (!p || typeof p.getCurrentTime !== "function") return;
try { try {
const cur = p.getCurrentTime(); const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0; const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
maybeAutoWatch(cur, dur); maybeAutoWatch(cur, dur);
savePos(currentIdRef.current, cur, dur); // key progress by what's actually playing // Only checkpoint the feed video we opened with — a navigated-to video may not
// be in this user's library, so the server would 404 on it.
if (currentIdRef.current === id) {
return api.saveProgress(id, cur, dur).catch(() => {});
}
} catch { } catch {
/* player may be tearing down */ /* player may be tearing down */
} }
@ -326,7 +322,7 @@ export default function PlayerModal({
videoId: id, videoId: id,
playerVars: { playerVars: {
autoplay: 1, autoplay: 1,
start: loadPos(id) || undefined, start: resumeAt || undefined,
rel: 0, // limit "related" to the same channel (full removal is no longer possible) rel: 0, // limit "related" to the same channel (full removal is no longer possible)
enablejsapi: 1, enablejsapi: 1,
origin: window.location.origin, origin: window.location.origin,
@ -356,7 +352,10 @@ export default function PlayerModal({
return () => { return () => {
cancelled = true; cancelled = true;
window.clearInterval(timer); window.clearInterval(timer);
persist(); // Final flush, then refresh the feed so the card's resume bar reflects this session.
Promise.resolve(persist()).finally(() =>
qc.invalidateQueries({ queryKey: ["feed"] })
);
const p = playerRef.current; const p = playerRef.current;
if (p && typeof p.destroy === "function") { if (p && typeof p.destroy === "function") {
try { try {

View file

@ -47,6 +47,7 @@ const SORTS = [
const SHOWS = [ const SHOWS = [
{ id: "unwatched", label: "Unwatched" }, { id: "unwatched", label: "Unwatched" },
{ id: "in_progress", label: "In progress" },
{ id: "all", label: "All" }, { id: "all", label: "All" },
{ id: "watched", label: "Watched" }, { id: "watched", label: "Watched" },
{ id: "saved", label: "Saved" }, { id: "saved", label: "Saved" },

View file

@ -1,5 +1,14 @@
import { memo } from "react"; import { memo } from "react";
import { Bookmark, Check, CheckCheck, Eye, EyeOff, ListFilter } from "lucide-react"; import {
Bookmark,
Check,
CheckCheck,
Eye,
EyeOff,
ListFilter,
Play,
RotateCcw,
} from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import clsx from "clsx"; import clsx from "clsx";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
@ -81,7 +90,7 @@ function Actions({
function openInApp( function openInApp(
e: React.MouseEvent, e: React.MouseEvent,
video: Video, video: Video,
onOpen?: (v: Video) => void onOpen?: (v: Video, startAt?: number | null) => void
): void { ): void {
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
e.preventDefault(); e.preventDefault();
@ -95,8 +104,23 @@ function Thumb({
}: { }: {
video: Video; video: Video;
className?: string; className?: string;
onOpen?: (v: Video) => void; onOpen?: (v: Video, startAt?: number | null) => void;
}) { }) {
// A non-zero saved position on an unfinished video = "in progress": show a resume
// progress bar and offer Continue / Restart instead of a plain Play.
const inProgress = video.position_seconds > 0 && video.status !== "watched";
const pct =
inProgress && video.duration_seconds
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
: 0;
// Open in the in-app player at an explicit position (null = resume from saved).
const open = (startAt: number | null) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onOpen?.(video, startAt);
};
return ( return (
<a <a
href={video.watch_url} href={video.watch_url}
@ -133,6 +157,45 @@ function Thumb({
<Bookmark className="w-3.5 h-3.5" /> <Bookmark className="w-3.5 h-3.5" />
</span> </span>
)} )}
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */}
{onOpen && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
{inProgress ? (
<>
<button
onClick={open(null)}
title="Continue where you left off"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition"
>
<Play className="w-4 h-4 fill-current" />
Continue
</button>
<button
onClick={open(0)}
title="Play from the beginning"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition"
>
<RotateCcw className="w-4 h-4" />
Restart
</button>
</>
) : (
<button
onClick={open(null)}
title="Play"
className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition"
>
<Play className="w-5 h-5 fill-current translate-x-[1px]" />
</button>
)}
</div>
)}
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</a> </a>
); );
} }
@ -148,7 +211,7 @@ function VideoCard({
view: "grid" | "list"; view: "grid" | "list";
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onChannelFilter?: (channelId: string, channelName: string) => void; onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video) => void; onOpen?: (v: Video, startAt?: number | null) => void;
}) { }) {
const watched = video.status === "watched"; const watched = video.status === "watched";
const meta = ( const meta = (

View file

@ -45,6 +45,7 @@ export interface Video {
is_short: boolean; is_short: boolean;
live_status: string; live_status: string;
status: string; status: string;
position_seconds: number;
watch_url: string; watch_url: string;
} }
@ -227,6 +228,14 @@ export const api = {
req(`/api/feed/count?${feedQuery(f, 0, 0)}`), req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
setState: (id: string, status: string) => setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }), req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
req(`/api/videos/${id}/progress`, {
method: "POST",
body: JSON.stringify({
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
}),
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`), videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
pauseSync: () => req("/api/sync/pause", { method: "POST" }), pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }), resumeSync: () => req("/api/sync/resume", { method: "POST" }),