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.
298 lines
9.5 KiB
TypeScript
298 lines
9.5 KiB
TypeScript
import { memo } from "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";
|
|
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
|
|
|
function Actions({
|
|
video,
|
|
onState,
|
|
onChannelFilter,
|
|
}: {
|
|
video: Video;
|
|
onState: (id: string, status: string) => void;
|
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
|
}) {
|
|
const act = (status: string) => (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onState(video.id, video.status === status ? "new" : status);
|
|
};
|
|
return (
|
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
|
<button
|
|
onClick={act("watched")}
|
|
title={video.status === "watched" ? "Watched — click to unmark" : "Mark watched"}
|
|
className={clsx(
|
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
|
video.status === "watched" && "text-accent"
|
|
)}
|
|
>
|
|
{video.status === "watched" ? (
|
|
<CheckCheck className="w-4 h-4" />
|
|
) : (
|
|
<Check className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={act("saved")}
|
|
title={video.status === "saved" ? "Saved — click to remove" : "Save for later"}
|
|
className={clsx(
|
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
|
video.status === "saved" && "fill-current text-accent"
|
|
)}
|
|
>
|
|
<Bookmark className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={act("hidden")}
|
|
title={video.status === "hidden" ? "Unhide" : "Hide"}
|
|
className={clsx(
|
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
|
video.status === "hidden" && "text-accent"
|
|
)}
|
|
>
|
|
{video.status === "hidden" ? (
|
|
<Eye className="w-4 h-4" />
|
|
) : (
|
|
<EyeOff className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
{onChannelFilter && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onChannelFilter(video.channel_id, video.channel_title ?? "This channel");
|
|
}}
|
|
title="Only this channel"
|
|
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
|
|
>
|
|
<ListFilter className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
|
|
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
|
|
function openInApp(
|
|
e: React.MouseEvent,
|
|
video: Video,
|
|
onOpen?: (v: Video, startAt?: number | null) => void
|
|
): void {
|
|
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
e.preventDefault();
|
|
onOpen(video);
|
|
}
|
|
|
|
function Thumb({
|
|
video,
|
|
className,
|
|
onOpen,
|
|
}: {
|
|
video: Video;
|
|
className?: string;
|
|
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}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
onClick={(e) => openInApp(e, video, onOpen)}
|
|
className={clsx(
|
|
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
|
|
className
|
|
)}
|
|
>
|
|
{video.thumbnail_url ? (
|
|
<img
|
|
src={video.thumbnail_url}
|
|
alt=""
|
|
loading="lazy"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full" />
|
|
)}
|
|
{video.duration_seconds != null && (
|
|
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded">
|
|
{formatDuration(video.duration_seconds)}
|
|
</span>
|
|
)}
|
|
{video.live_status === "was_live" && (
|
|
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
|
stream
|
|
</span>
|
|
)}
|
|
{video.status === "saved" && (
|
|
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function VideoCard({
|
|
video,
|
|
view,
|
|
onState,
|
|
onChannelFilter,
|
|
onOpen,
|
|
}: {
|
|
video: Video;
|
|
view: "grid" | "list";
|
|
onState: (id: string, status: string) => void;
|
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
|
}) {
|
|
const watched = video.status === "watched";
|
|
const meta = (
|
|
<>
|
|
{video.view_count != null && <>{formatViews(video.view_count)} views · </>}
|
|
{relativeTime(video.published_at)}
|
|
</>
|
|
);
|
|
const title = (
|
|
<a
|
|
href={video.watch_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
onClick={(e) => openInApp(e, video, onOpen)}
|
|
className="font-medium leading-snug line-clamp-2 hover:text-accent"
|
|
>
|
|
{video.title}
|
|
</a>
|
|
);
|
|
|
|
if (view === "list") {
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
|
watched && "opacity-55"
|
|
)}
|
|
>
|
|
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
|
|
<div className="min-w-0 flex-1">
|
|
{title}
|
|
<a
|
|
href={video.channel_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
|
>
|
|
{video.channel_title}
|
|
</a>
|
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
|
</div>
|
|
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"cv-card group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
|
|
watched && "opacity-55"
|
|
)}
|
|
>
|
|
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
|
|
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
|
|
<Avatar
|
|
src={video.channel_thumbnail}
|
|
fallback={video.channel_title ?? ""}
|
|
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
{title}
|
|
<a
|
|
href={video.channel_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
|
>
|
|
{video.channel_title}
|
|
</a>
|
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
|
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Memoized so appending a feed page only renders the new cards, not every existing
|
|
// one (the callbacks from Feed are stable; non-overridden video objects keep their
|
|
// identity across renders).
|
|
export default memo(VideoCard);
|