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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user