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 6520f35d88
commit af0d2ac1b7
5 changed files with 114 additions and 30 deletions

View file

@ -1,5 +1,14 @@
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 clsx from "clsx";
import type { Video } from "../lib/api";
@ -81,7 +90,7 @@ function Actions({
function openInApp(
e: React.MouseEvent,
video: Video,
onOpen?: (v: Video) => void
onOpen?: (v: Video, startAt?: number | null) => void
): void {
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
e.preventDefault();
@ -95,8 +104,23 @@ function Thumb({
}: {
video: Video;
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 (
<a
href={video.watch_url}
@ -133,6 +157,45 @@ function Thumb({
<Bookmark className="w-3.5 h-3.5" />
</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>
);
}
@ -148,7 +211,7 @@ function VideoCard({
view: "grid" | "list";
onState: (id: string, status: 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 meta = (