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>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { fileUrl } from "../api";
import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format";
interface Props {
item: FeedItem;
path: string;
onClose: () => void;
onDelete: () => void;
}
/**
* Plays the local file through Tauri's asset protocol. Every download is
* H.264/AAC in MP4 precisely so WKWebView can decode it natively.
*/
export default function Player({ item, path, onClose, onDelete }: Props) {
return (
<div className="fixed inset-0 z-50 bg-ink/97 flex flex-col">
<div className="flex items-center gap-3 px-5 py-3 border-b border-edge">
<button onClick={onClose}
className="rounded-full px-3 py-1.5 text-xs font-medium bg-surface hover:bg-raised cursor-pointer">
Back
</button>
<span className="text-sm text-muted truncate flex-1">{item.channel_title}</span>
<button onClick={onDelete}
className="rounded-full px-3 py-1.5 text-xs font-medium bg-surface text-muted
hover:bg-red-500/20 hover:text-red-300 cursor-pointer">
Delete download
</button>
</div>
<div className="flex-1 min-h-0 bg-black grid place-items-center">
<video key={path} src={fileUrl(path)} controls autoPlay
className="max-h-full max-w-full" />
</div>
<div className="px-5 py-4 max-h-56 overflow-y-auto border-t border-edge">
<h2 className="font-semibold text-lg leading-snug">{item.title}</h2>
<div className="mt-1 text-xs text-muted">
{[compactViews(item.views), relativeTime(item.published)]
.filter(Boolean)
.join(" · ")}
</div>
{item.description && (
<p className="mt-3 text-sm text-muted whitespace-pre-wrap leading-relaxed">
{item.description}
</p>
)}
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import { checkPrereqs, pickAndImportTakeout, pickLibraryFolder } from "../api";
import type { Prereqs } from "../types";
interface Props {
onClose: () => void;
onImported: (count: number) => void;
}
function StatusRow({ label, value, hint }: { label: string; value: string | null; hint?: string }) {
return (
<div className="flex items-start justify-between gap-4 py-2 border-b border-edge/60">
<span className="text-sm text-muted shrink-0">{label}</span>
<span className={`text-sm text-right ${value ? "text-emerald-300" : "text-red-300"}`}>
{value ?? hint ?? "Not found"}
</span>
</div>
);
}
export default function Settings({ onClose, onImported }: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null));
useEffect(() => { load(); }, []);
const doImport = async () => {
setBusy(true);
setMessage(null);
try {
const n = await pickAndImportTakeout();
if (n != null) {
setMessage(`Imported ${n} subscription${n === 1 ? "" : "s"}.`);
onImported(n);
}
} catch (e) {
setMessage(String(e));
} finally {
setBusy(false);
}
};
const doPickFolder = async () => {
try {
const p = await pickLibraryFolder();
if (p) { setMessage(`Library moved to ${p}`); load(); }
} catch (e) {
setMessage(String(e));
}
};
const missing = prereqs && (!prereqs.yt_dlp || !prereqs.ffmpeg);
return (
<div className="fixed inset-0 z-50 bg-black/60 grid place-items-center p-6" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}
className="w-full max-w-lg rounded-2xl bg-surface border border-edge p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Settings</h2>
<button onClick={onClose}
className="rounded-full px-3 py-1.5 text-xs bg-raised hover:bg-edge cursor-pointer">
Close
</button>
</div>
<section className="space-y-2">
<h3 className="text-sm font-medium">Subscriptions</h3>
<p className="text-xs text-muted leading-relaxed">
Export <span className="text-white">YouTube subscriptions</span> from Google Takeout,
then import the <code className="text-white">subscriptions.csv</code> file here.
Re-importing merges with what you already have.
</p>
<button onClick={doImport} disabled={busy}
className="rounded-full bg-accent/90 hover:bg-accent text-black px-4 py-2 text-xs
font-semibold cursor-pointer disabled:opacity-40">
{busy ? "Importing…" : "Import subscriptions.csv"}
</button>
</section>
<section className="space-y-1">
<h3 className="text-sm font-medium mb-2">Status</h3>
<StatusRow label="yt-dlp" value={prereqs?.yt_dlp ?? null} />
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
<div className="flex items-start justify-between gap-4 py-2">
<span className="text-sm text-muted shrink-0">Library</span>
<button onClick={doPickFolder}
className="text-sm text-right text-accent hover:underline cursor-pointer break-all">
{prereqs?.library_path ?? "…"}
</button>
</div>
{missing && (
<div className="mt-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
<p className="text-xs text-red-200 leading-relaxed">
Downloads need both tools. Install them with:
</p>
<code className="mt-1.5 block text-xs text-white bg-black/40 rounded px-2 py-1.5">
brew install yt-dlp ffmpeg
</code>
</div>
)}
</section>
{message && <p className="text-xs text-muted break-words">{message}</p>}
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { ChannelWithCount } from "../types";
interface Props {
channels: ChannelWithCount[];
activeChannel: string | null;
onSelect: (id: string | null) => void;
onOpenSettings: () => void;
totalVideos: number;
}
export default function Sidebar({
channels, activeChannel, onSelect, onOpenSettings, totalVideos,
}: Props) {
const rowBase =
"w-full text-left px-3 py-2 rounded-lg text-sm flex items-center justify-between gap-2 transition-colors cursor-pointer";
return (
<aside className="w-64 shrink-0 border-r border-edge flex flex-col bg-ink">
<div className="px-4 py-4 flex items-center gap-2">
<span className="text-xl"></span>
<span className="font-semibold tracking-tight">FlightTube</span>
</div>
<nav className="flex-1 overflow-y-auto px-2 pb-2 space-y-0.5">
<button onClick={() => onSelect(null)}
className={`${rowBase} ${
activeChannel === null ? "bg-raised text-white" : "text-muted hover:bg-surface"
}`}>
<span className="font-medium">All subscriptions</span>
<span className="text-xs tabular-nums opacity-70">{totalVideos}</span>
</button>
{channels.length > 0 && (
<div className="pt-3 pb-1 px-3 text-[11px] uppercase tracking-wider text-muted/70">
Channels
</div>
)}
{channels.map((c) => (
<button key={c.id} onClick={() => onSelect(c.id)} title={c.title}
className={`${rowBase} ${
activeChannel === c.id ? "bg-raised text-white" : "text-muted hover:bg-surface"
}`}>
<span className="truncate">{c.title}</span>
<span className="text-xs tabular-nums opacity-70 shrink-0">
{c.downloaded_count > 0 && (
<span className="text-emerald-400">{c.downloaded_count}/</span>
)}
{c.video_count}
</span>
</button>
))}
</nav>
<button onClick={onOpenSettings}
className="m-2 px-3 py-2 rounded-lg text-sm text-muted hover:bg-surface text-left cursor-pointer">
Settings
</button>
</aside>
);
}
+92
View File
@@ -0,0 +1,92 @@
interface Props {
search: string;
onSearch: (v: string) => void;
downloadedOnly: boolean;
onDownloadedOnly: (v: boolean) => void;
hideShorts: boolean;
onHideShorts: (v: boolean) => void;
online: boolean;
reachable: boolean;
forcedOffline: boolean;
onToggleForcedOffline: () => void;
onRefresh: () => void;
refreshing: boolean;
refreshProgress: { done: number; total: number } | null;
}
function Toggle({
active, onClick, children, title, disabled,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
title?: string;
disabled?: boolean;
}) {
return (
<button onClick={onClick} title={title} disabled={disabled}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer whitespace-nowrap ${
active ? "bg-white text-black" : "bg-surface text-muted hover:bg-raised hover:text-white"
} disabled:opacity-40 disabled:cursor-not-allowed`}>
{children}
</button>
);
}
export default function TopBar({
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress,
}: Props) {
return (
<header className="border-b border-edge px-5 py-3 flex items-center gap-3 flex-wrap bg-ink">
<div className="relative flex-1 min-w-52 max-w-md">
<input value={search} onChange={(e) => onSearch(e.target.value)}
placeholder="Search videos and channels"
className="w-full rounded-full bg-surface border border-edge px-4 py-2 text-sm
placeholder:text-muted/70 focus:outline-none focus:border-accent" />
</div>
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)}
disabled={!online}
title={online ? "Show only downloaded videos" : "Offline: showing downloads only"}>
Downloaded only
</Toggle>
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)}
title="Hide Shorts from the feed">
Hide Shorts
</Toggle>
<button onClick={onRefresh} disabled={refreshing || !online}
title={online ? "Fetch the latest videos" : "Refreshing needs a connection"}
className="rounded-full bg-accent/90 hover:bg-accent text-black px-4 py-1.5 text-xs
font-semibold transition-colors cursor-pointer disabled:opacity-40
disabled:cursor-not-allowed tabular-nums whitespace-nowrap">
{refreshing
? refreshProgress
? `${refreshProgress.done}/${refreshProgress.total}`
: "Refreshing…"
: "Refresh"}
</button>
<button onClick={onToggleForcedOffline}
title={
!reachable
? "No connection detected"
: forcedOffline
? "Offline mode is forced on — click to go back online"
: "Simulate being offline"
}
className={`rounded-full px-3 py-1.5 text-xs font-medium flex items-center gap-1.5
cursor-pointer transition-colors whitespace-nowrap ${
online
? "bg-surface text-emerald-300 hover:bg-raised"
: "bg-amber-500/20 text-amber-300 hover:bg-amber-500/30"
}`}>
<span className={`size-2 rounded-full ${online ? "bg-emerald-400" : "bg-amber-400"}`} />
{online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"}
</button>
</header>
);
}
+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>
);
}
+43
View File
@@ -0,0 +1,43 @@
export function relativeTime(unixSeconds: number): string {
if (!unixSeconds) return "";
const diff = Date.now() / 1000 - unixSeconds;
const units: Array<[number, string]> = [
[31536000, "year"],
[2592000, "month"],
[604800, "week"],
[86400, "day"],
[3600, "hour"],
[60, "minute"],
];
for (const [secs, name] of units) {
const n = Math.floor(diff / secs);
if (n >= 1) return `${n} ${name}${n > 1 ? "s" : ""} ago`;
}
return "just now";
}
export function compactViews(n: number): string {
if (n <= 0) return "";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M views`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K views`;
return `${n} views`;
}
export function humanBytes(n: number | null): string {
if (!n) return "";
const units = ["B", "KB", "MB", "GB"];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
export function humanEta(seconds: number | null): string {
if (seconds == null) return "";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return m > 0 ? `${m}m ${s}s left` : `${s}s left`;
}