import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Bookmark,
Check,
CheckCheck,
Eye,
EyeOff,
Play,
RotateCcw,
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import clsx from "clsx";
import type { Video } from "../lib/api";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
video,
onState,
onResetState,
onToggleSave,
}: {
video: Video;
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
}) {
const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
};
// Pristine = never opened: default status and no resume position. The reset clears the
// whole state (incl. an in-progress position the un-watch toggle can't touch).
const resettable = video.status !== "new" || video.position_seconds > 0;
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
return (
{video.status === "watched" ? (
) : (
)}
{video.status === "hidden" ? (
) : (
)}
{onResetState && resettable && (
{
e.preventDefault();
e.stopPropagation();
onResetState(video.id);
}}
title={t("card.resetState")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
)}
);
}
// 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 === "live" || video.live_status === "upcoming" ? (
{t(`card.${video.live_status}`)}
) : null}
{video.live_status === "was_live" && (
{t("card.stream")}
)}
{video.saved && (
)}
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */}
{onOpen && (
{inProgress ? (
<>
{t("card.continue")}
{t("card.restart")}
>
) : (
)}
)}
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
)}
);
}
function VideoCard({
video,
view,
onState,
onResetState,
onToggleSave,
onOpenChannel,
onOpen,
}: {
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpenChannel?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t, i18n } = useTranslation();
const watched = video.status === "watched";
const meta = (
<>
{video.view_count != null && (
<>
{formatViews(video.view_count)} {t("card.views")}
{" "}
·{" "}
>
)}
{relativeTime(video.published_at)}
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}>}
>
);
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).
const titleRef = useRef(null);
const [titleClamped, setTitleClamped] = useState(false);
useEffect(() => {
const el = titleRef.current;
if (!el) return;
const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 1);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
return () => ro.disconnect();
}, [video.title]);
const title = (
openInApp(e, video, onOpen)}
title={titleClamped ? video.title ?? undefined : undefined}
className="font-medium leading-snug line-clamp-2 hover:text-accent"
>
{video.title}
);
if (view === "list") {
return (
{title}
{
e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
{meta}
);
}
return (
{title}
{
e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
{meta}
);
}
// 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);