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
+86
View File
@@ -0,0 +1,86 @@
import type { LiveDownload } from "../hooks/useDownloads";
import type { FeedItem } from "../types";
import { humanEta } from "./format";
interface Props {
item: FeedItem;
live?: LiveDownload;
online: boolean;
onDownload: () => void;
onCancel: () => void;
onDelete: () => void;
}
/** A ring that fills as the download progresses; indeterminate until yt-dlp
* knows the total size, which it doesn't until the stream is resolved. */
function ProgressRing({ pct }: { pct: number | null }) {
const r = 9;
const circumference = 2 * Math.PI * r;
const offset = pct == null ? circumference * 0.7 : circumference * (1 - pct / 100);
return (
<svg viewBox="0 0 24 24" className={`size-5 ${pct == null ? "animate-spin" : ""}`}>
<circle cx="12" cy="12" r={r} fill="none" stroke="currentColor"
strokeWidth="2.5" className="opacity-25" />
<circle cx="12" cy="12" r={r} fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset}
transform="rotate(-90 12 12)"
className={pct == null ? "" : "transition-[stroke-dashoffset] duration-300"} />
</svg>
);
}
export default function DownloadButton({
item, live, online, onDownload, onCancel, onDelete,
}: Props) {
const state = live?.state ?? item.state ?? null;
const pct = live?.pct ?? item.pct ?? null;
const error = live?.error ?? item.error ?? null;
const base =
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors shrink-0";
if (state === "done") {
return (
<button onClick={onDelete} title="Delete the downloaded file"
className={`${base} bg-emerald-500/15 text-emerald-300 hover:bg-red-500/20 hover:text-red-300 group`}>
<span className="group-hover:hidden"> Saved</span>
<span className="hidden group-hover:inline">Delete</span>
</button>
);
}
if (state === "running" || state === "queued") {
const label =
state === "queued" ? "Queued" : pct != null ? `${pct.toFixed(0)}%` : "Starting";
return (
<button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"}
className={`${base} bg-raised text-white hover:bg-red-500/20 hover:text-red-300 group`}>
<ProgressRing pct={state === "queued" ? null : pct} />
<span className="group-hover:hidden tabular-nums">{label}</span>
<span className="hidden group-hover:inline">Cancel</span>
</button>
);
}
const failed = state === "failed";
return (
<button onClick={onDownload} disabled={!online}
title={
!online
? "You are offline — downloading needs a connection"
: failed && error
? error
: "Download for offline viewing"
}
className={`${base} ${
failed
? "bg-red-500/15 text-red-300 hover:bg-red-500/25"
: "bg-raised text-white hover:bg-edge"
} disabled:opacity-35 disabled:cursor-not-allowed`}>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
</svg>
{failed ? "Retry" : "Download"}
</button>
);
}