import { memo } from "react"; import { useTranslation } from "react-i18next"; import { Bookmark, Check, CheckCheck, Eye, EyeOff, ListFilter, Play, RotateCcw, } from "lucide-react"; import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; 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 { t } = useTranslation(); const act = (status: string) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onState(video.id, video.status === status ? "new" : status); }; return (
{onChannelFilter && ( )}
); } // 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; }) { const { t } = useTranslation(); // 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 ( 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 ? ( ) : (
)} {video.duration_seconds != null && ( {formatDuration(video.duration_seconds)} )} {video.live_status === "was_live" && ( {t("card.stream")} )} {video.status === "saved" && ( )} {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */} {onOpen && (
{inProgress ? ( <> ) : ( )}
)} {/* Resume progress bar (sits at the very bottom, under the duration badge). */} {pct > 0 && (
)}
); } 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 { t } = useTranslation(); const watched = video.status === "watched"; const meta = ( <> {video.view_count != null && ( <> {formatViews(video.view_count)} {t("card.views")} ยท{" "} )} {relativeTime(video.published_at)} ); const title = ( openInApp(e, video, onOpen)} className="font-medium leading-snug line-clamp-2 hover:text-accent" > {video.title} ); if (view === "list") { return (
{title} e.stopPropagation()} className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg" > {video.channel_title}
{meta}
); } return (
); } // 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);