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:
vincent
2026-08-29 02:34:14 +02:00
parent a30423e4b5
commit e75d896933
16 changed files with 1059 additions and 2 deletions
+72
View File
@@ -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>
);
}