feat: feed UI, in-app player, offline mode, settings
Adds an end-to-end integration test that runs the real pipeline against live YouTube Atom feeds, verifying the merged feed is newest-first across channels.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { thumbSrc } from "../api";
|
||||
import type { LiveDownload } from "../hooks/useDownloads";
|
||||
import type { FeedItem } from "../types";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { compactViews, relativeTime } from "./format";
|
||||
|
||||
interface Props {
|
||||
item: FeedItem;
|
||||
live?: LiveDownload;
|
||||
online: boolean;
|
||||
onOpen: () => void;
|
||||
onDownload: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function VideoRow({
|
||||
item, live, online, onOpen, onDownload, onCancel, onDelete,
|
||||
}: Props) {
|
||||
const downloaded = (live?.state ?? item.state) === "done";
|
||||
const src = thumbSrc(item, online);
|
||||
|
||||
return (
|
||||
<div className="group flex gap-4 rounded-xl p-3 hover:bg-surface transition-colors">
|
||||
<button onClick={onOpen}
|
||||
className="relative shrink-0 w-44 aspect-video rounded-lg overflow-hidden bg-raised cursor-pointer">
|
||||
{src ? (
|
||||
<img src={src} alt="" loading="lazy"
|
||||
className="size-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||
) : (
|
||||
<div className="size-full grid place-items-center text-muted text-xs px-2 text-center">
|
||||
No thumbnail cached
|
||||
</div>
|
||||
)}
|
||||
{item.is_short && (
|
||||
<span className="absolute top-1.5 left-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
SHORT
|
||||
</span>
|
||||
)}
|
||||
{downloaded && (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-emerald-500/90 px-1.5 py-0.5 text-[10px] font-semibold text-black">
|
||||
OFFLINE
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<button onClick={onOpen} className="text-left w-full cursor-pointer">
|
||||
<h3 className="font-medium leading-snug line-clamp-2 group-hover:text-white">
|
||||
{item.title}
|
||||
</h3>
|
||||
</button>
|
||||
<div className="mt-1 text-sm text-muted truncate">{item.channel_title}</div>
|
||||
<div className="mt-0.5 text-xs text-muted">
|
||||
{[compactViews(item.views), relativeTime(item.published)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
|
||||
<div className="mt-1 text-xs text-red-400 line-clamp-1">
|
||||
{live?.error ?? item.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 self-center">
|
||||
<DownloadButton item={item} live={live} online={online}
|
||||
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user