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
+6
View File
@@ -62,6 +62,12 @@ impl Db {
Self::init(conn)
}
/// In-memory database, for tests only.
pub fn open_in_memory_pub() -> Result<Db, String> {
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
Self::init(conn)
}
#[cfg(test)]
pub fn open_in_memory() -> Result<Db, String> {
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
+13
View File
@@ -0,0 +1,13 @@
Channel Id,Channel Url,Channel Title
UCXuqSBlHAE6Xw-yeJA0Tunw,http://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw,"Linus Tech Tips"
UCsXVk37bltHxD1rDPwtNM8Q,http://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q,"Kurzgesagt In a Nutshell"
UCHnyfMqiRRG1u-2MsSQLbXA,http://www.youtube.com/channel/UCHnyfMqiRRG1u-2MsSQLbXA,"Veritasium"
UC6nSFpj9HTCZ5t-N3Rm3-HA,http://www.youtube.com/channel/UC6nSFpj9HTCZ5t-N3Rm3-HA,"Vsauce"
UCYO_jab_esuFRV4b17AJtAw,http://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw,"3Blue1Brown"
UC9-y-6csu5WGm29I7JiwpnA,http://www.youtube.com/channel/UC9-y-6csu5WGm29I7JiwpnA,"Computerphile"
UC2C_jShtL725hvbm1arSV9w,http://www.youtube.com/channel/UC2C_jShtL725hvbm1arSV9w,"CGP Grey"
UCsooa4yRKGN_zEE8iknghZA,http://www.youtube.com/channel/UCsooa4yRKGN_zEE8iknghZA,"TED-Ed"
UCBJycsmduvYEL83R_U4JriQ,http://www.youtube.com/channel/UCBJycsmduvYEL83R_U4JriQ,"Marques Brownlee"
UCJ0-OtVpF0wOKEqT2Z1HEtA,http://www.youtube.com/channel/UCJ0-OtVpF0wOKEqT2Z1HEtA,"ElectroBOOM"
UCR1IuLEqb6UEA_zQ81kwXfg,http://www.youtube.com/channel/UCR1IuLEqb6UEA_zQ81kwXfg,"Real Engineering"
UC7_gcs09iThXybpVgjHZ_7g,http://www.youtube.com/channel/UC7_gcs09iThXybpVgjHZ_7g,"PBS Space Time"
1 Channel Id Channel Url Channel Title
2 UCXuqSBlHAE6Xw-yeJA0Tunw http://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw Linus Tech Tips
3 UCsXVk37bltHxD1rDPwtNM8Q http://www.youtube.com/channel/UCsXVk37bltHxD1rDPwtNM8Q Kurzgesagt – In a Nutshell
4 UCHnyfMqiRRG1u-2MsSQLbXA http://www.youtube.com/channel/UCHnyfMqiRRG1u-2MsSQLbXA Veritasium
5 UC6nSFpj9HTCZ5t-N3Rm3-HA http://www.youtube.com/channel/UC6nSFpj9HTCZ5t-N3Rm3-HA Vsauce
6 UCYO_jab_esuFRV4b17AJtAw http://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw 3Blue1Brown
7 UC9-y-6csu5WGm29I7JiwpnA http://www.youtube.com/channel/UC9-y-6csu5WGm29I7JiwpnA Computerphile
8 UC2C_jShtL725hvbm1arSV9w http://www.youtube.com/channel/UC2C_jShtL725hvbm1arSV9w CGP Grey
9 UCsooa4yRKGN_zEE8iknghZA http://www.youtube.com/channel/UCsooa4yRKGN_zEE8iknghZA TED-Ed
10 UCBJycsmduvYEL83R_U4JriQ http://www.youtube.com/channel/UCBJycsmduvYEL83R_U4JriQ Marques Brownlee
11 UCJ0-OtVpF0wOKEqT2Z1HEtA http://www.youtube.com/channel/UCJ0-OtVpF0wOKEqT2Z1HEtA ElectroBOOM
12 UCR1IuLEqb6UEA_zQ81kwXfg http://www.youtube.com/channel/UCR1IuLEqb6UEA_zQ81kwXfg Real Engineering
13 UC7_gcs09iThXybpVgjHZ_7g http://www.youtube.com/channel/UC7_gcs09iThXybpVgjHZ_7g PBS Space Time
+68
View File
@@ -0,0 +1,68 @@
//! End-to-end check of the real pipeline: Takeout CSV -> live Atom feeds ->
//! SQLite -> feed query. Hits the network, so it is ignored by default.
//! Run with: cargo test --test pipeline -- --ignored --nocapture
use flighttube_lib::{db::Db, feed, models::FeedFilter, takeout};
const CSV: &str = include_str!("fixtures/subscriptions.csv");
#[tokio::test]
#[ignore]
async fn full_pipeline_against_live_feeds() {
let channels = takeout::parse_csv(CSV).expect("CSV should parse");
println!("parsed {} channels", channels.len());
assert!(channels.len() >= 10);
let mut db = Db::open_in_memory_pub().expect("db");
db.upsert_channels(&channels).unwrap();
let http = reqwest::Client::builder()
.user_agent("FlightTube/0.1 (+desktop)")
.timeout(std::time::Duration::from_secs(20))
.build()
.unwrap();
let mut total = 0;
for c in &channels {
match feed::fetch_channel(&http, &c.id).await {
Ok(v) => {
println!(" {:<30} {} videos", c.title, v.len());
total += v.len();
db.upsert_videos(&v).unwrap();
}
Err(e) => println!(" {:<30} FAILED: {e}", c.title),
}
}
assert!(total > 50, "expected a real feed, got {total} videos");
let all = db.list_feed(&FeedFilter::default()).unwrap();
println!("\nfeed rows: {}", all.len());
// The whole point: newest first, across all channels.
for w in all.windows(2) {
assert!(w[0].published >= w[1].published, "feed must be newest-first");
}
println!("top 5 newest across all subscriptions:");
for item in all.iter().take(5) {
println!(
" [{}] {}{}",
item.channel_title,
item.title.chars().take(60).collect::<String>(),
item.published
);
}
let no_shorts = db
.list_feed(&FeedFilter { hide_shorts: true, ..Default::default() })
.unwrap();
println!("\nwith shorts hidden: {} (was {})", no_shorts.len(), all.len());
assert!(no_shorts.len() <= all.len());
assert!(no_shorts.iter().all(|i| !i.is_short));
// Nothing is downloaded, so the offline view must be empty.
let offline = db
.list_feed(&FeedFilter { downloaded_only: true, ..Default::default() })
.unwrap();
assert_eq!(offline.len(), 0, "nothing downloaded yet");
println!("offline view with no downloads: {} rows (correct)", offline.len());
}
+167 -2
View File
@@ -1,7 +1,172 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress,
openExternal, refreshFeeds,
} from "./api";
import Player from "./components/Player";
import Settings from "./components/Settings";
import Sidebar from "./components/Sidebar";
import TopBar from "./components/TopBar";
import VideoRow from "./components/VideoRow";
import { useConnectivity } from "./hooks/useConnectivity";
import { useDownloads } from "./hooks/useDownloads";
import { useFeed } from "./hooks/useFeed";
import type { FeedFilter, FeedItem, RefreshProgress } from "./types";
export default function App() {
const [channelId, setChannelId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [downloadedOnly, setDownloadedOnly] = useState(false);
const [hideShorts, setHideShorts] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [playing, setPlaying] = useState<{ item: FeedItem; path: string } | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
// Offline, the only videos that can be played are the ones already on disk,
// so the feed collapses to those regardless of the toggle.
const effectiveDownloadedOnly = downloadedOnly || !online;
const filter: FeedFilter = useMemo(
() => ({
channel_id: channelId,
search: search.trim() || null,
downloaded_only: effectiveDownloadedOnly,
hide_shorts: hideShorts,
limit: 500,
}),
[channelId, search, effectiveDownloadedOnly, hideShorts],
);
const { items, channels, loading, error, reload } = useFeed(filter);
const live = useDownloads(reload);
useEffect(() => {
let un: (() => void) | undefined;
onRefreshProgress(setRefreshProgress).then((u) => (un = u));
return () => un?.();
}, []);
const totalVideos = useMemo(
() => channels.reduce((n, c) => n + c.video_count, 0),
[channels],
);
const doRefresh = useCallback(async () => {
setRefreshing(true);
setNotice(null);
try {
const s = await refreshFeeds();
const failed = s.failures.length;
setNotice(
`Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}` +
(failed ? ` · ${failed} failed` : ""),
);
await reload();
} catch (e) {
setNotice(String(e));
} finally {
setRefreshing(false);
setRefreshProgress(null);
}
}, [reload]);
const openItem = useCallback(
async (item: FeedItem) => {
const path = live[item.id]?.path ?? item.path;
const done = (live[item.id]?.state ?? item.state) === "done";
if (done && path) {
setPlaying({ item, path });
} else if (online) {
await openExternal(`https://www.youtube.com/watch?v=${item.id}`);
} else {
setNotice("That video isn't downloaded, and you're offline.");
}
},
[live, online],
);
const emptyMessage = () => {
if (loading) return "Loading…";
if (channels.length === 0)
return "No subscriptions yet. Open Settings and import your Takeout subscriptions.csv.";
if (totalVideos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos.";
if (!online) return "You're offline, and nothing has been downloaded yet.";
if (effectiveDownloadedOnly) return "No downloaded videos match this filter.";
return "Nothing matches this filter.";
};
return (
<div className="min-h-screen bg-ink text-white grid place-items-center">
<h1 className="text-3xl font-semibold tracking-tight">FlightTube</h1>
<div className="h-screen flex bg-ink text-white overflow-hidden">
<Sidebar channels={channels} activeChannel={channelId} onSelect={setChannelId}
onOpenSettings={() => setShowSettings(true)} totalVideos={totalVideos} />
<div className="flex-1 min-w-0 flex flex-col">
<TopBar
search={search} onSearch={setSearch}
downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly}
hideShorts={hideShorts} onHideShorts={setHideShorts}
online={online} reachable={reachable} forcedOffline={forcedOffline}
onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
/>
{!online && (
<div className="px-5 py-2 bg-amber-500/15 text-amber-200 text-xs border-b border-amber-500/25">
Offline showing only videos you've downloaded.
{forcedOffline && " (Offline mode is forced on.)"}
</div>
)}
{(notice || error) && (
<div className="px-5 py-2 bg-surface text-xs text-muted border-b border-edge flex items-center justify-between gap-3">
<span className="truncate">{error ?? notice}</span>
<button onClick={() => setNotice(null)}
className="shrink-0 hover:text-white cursor-pointer">
Dismiss
</button>
</div>
)}
<main className="flex-1 overflow-y-auto px-3 py-3">
{items.length === 0 ? (
<div className="h-full grid place-items-center">
<p className="text-muted text-sm max-w-md text-center leading-relaxed">
{emptyMessage()}
</p>
</div>
) : (
<div className="max-w-5xl mx-auto">
{items.map((item) => (
<VideoRow key={item.id} item={item} live={live[item.id]} online={online}
onOpen={() => openItem(item)}
onDownload={() => downloadVideo(item.id).catch((e) => setNotice(String(e)))}
onCancel={() => cancelDownload(item.id).catch((e) => setNotice(String(e)))}
onDelete={() =>
deleteDownload(item.id).then(reload).catch((e) => setNotice(String(e)))
} />
))}
</div>
)}
</main>
</div>
{playing && (
<Player item={playing.item} path={playing.path}
onClose={() => setPlaying(null)}
onDelete={async () => {
await deleteDownload(playing.item.id);
setPlaying(null);
reload();
}} />
)}
{showSettings && (
<Settings onClose={() => setShowSettings(false)} onImported={() => reload()} />
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import type {
ChannelWithCount,
DownloadProgressEvent,
DownloadStateEvent,
FeedFilter,
FeedItem,
Prereqs,
RefreshProgress,
RefreshSummary,
} from "./types";
export const checkPrereqs = () => invoke<Prereqs>("check_prereqs");
export const listChannels = () => invoke<ChannelWithCount[]>("list_channels");
export const listFeed = (filter: FeedFilter) =>
invoke<FeedItem[]>("list_feed", { filter });
export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds");
export const downloadVideo = (videoId: string) =>
invoke<void>("download_video", { videoId });
export const cancelDownload = (videoId: string) =>
invoke<void>("cancel_download", { videoId });
export const deleteDownload = (videoId: string) =>
invoke<void>("delete_download", { videoId });
export const getConnectivity = () => invoke<boolean>("get_connectivity");
export const setLibraryPath = (path: string) =>
invoke<string>("set_library_path", { path });
export const openExternal = (url: string) =>
invoke<void>("open_external", { url });
/** Opens the native file picker for a Takeout subscriptions.csv. */
export async function pickAndImportTakeout(): Promise<number | null> {
const path = await open({
multiple: false,
directory: false,
filters: [{ name: "Takeout subscriptions", extensions: ["csv"] }],
});
if (typeof path !== "string") return null;
return invoke<number>("import_takeout_csv", { path });
}
export async function pickLibraryFolder(): Promise<string | null> {
const path = await open({ directory: true, multiple: false });
if (typeof path !== "string") return null;
return setLibraryPath(path);
}
export const onRefreshProgress = (cb: (p: RefreshProgress) => void) =>
listen<RefreshProgress>("refresh:progress", (e) => cb(e.payload));
export const onDownloadProgress = (cb: (p: DownloadProgressEvent) => void) =>
listen<DownloadProgressEvent>("download:progress", (e) => cb(e.payload));
export const onDownloadState = (cb: (p: DownloadStateEvent) => void) =>
listen<DownloadStateEvent>("download:state", (e) => cb(e.payload));
export type { UnlistenFn };
/** Local file path -> a URL the webview is allowed to load. */
export const fileUrl = (path: string) => convertFileSrc(path);
/**
* Prefers the on-disk thumbnail so the feed still renders with no network,
* falling back to the remote URL for videos not yet cached.
*/
export const thumbSrc = (item: FeedItem, online: boolean) => {
if (item.thumb_path) return convertFileSrc(item.thumb_path);
return online ? item.thumb_url : "";
};
+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`;
}
+44
View File
@@ -0,0 +1,44 @@
import { useCallback, useEffect, useState } from "react";
import { getConnectivity } from "../api";
const POLL_MS = 30_000;
/**
* Real reachability, not `navigator.onLine` — which reports link state and is
* true on a plane's captive-portal wifi with no actual internet. The manual
* override exists so offline mode can be exercised without touching wifi.
*/
export function useConnectivity() {
const [reachable, setReachable] = useState(true);
const [forcedOffline, setForcedOffline] = useState(false);
const probe = useCallback(async () => {
try {
setReachable(await getConnectivity());
} catch {
setReachable(false);
}
}, []);
useEffect(() => {
probe();
const id = setInterval(probe, POLL_MS);
const onUp = () => probe();
const onDown = () => setReachable(false);
window.addEventListener("online", onUp);
window.addEventListener("offline", onDown);
return () => {
clearInterval(id);
window.removeEventListener("online", onUp);
window.removeEventListener("offline", onDown);
};
}, [probe]);
return {
online: reachable && !forcedOffline,
reachable,
forcedOffline,
setForcedOffline,
probe,
};
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { onDownloadProgress, onDownloadState } from "../api";
import type { DownloadState } from "../types";
export interface LiveDownload {
state: DownloadState;
pct: number | null;
speed: number | null;
eta: number | null;
error: string | null;
path: string | null;
}
/**
* Live download state keyed by video id, driven by backend events. Overlays the
* snapshot the feed query returns, so progress updates don't require refetching.
*/
export function useDownloads(onFinished: () => void) {
const [live, setLive] = useState<Record<string, LiveDownload>>({});
useEffect(() => {
const unlisteners: Array<() => void> = [];
onDownloadProgress((p) => {
setLive((prev) => ({
...prev,
[p.video_id]: {
state: "running",
pct: p.pct,
speed: p.speed,
eta: p.eta,
error: null,
path: prev[p.video_id]?.path ?? null,
},
}));
}).then((u) => unlisteners.push(u));
onDownloadState((s) => {
setLive((prev) => ({
...prev,
[s.video_id]: {
state: s.state,
pct: s.state === "done" ? 100 : (prev[s.video_id]?.pct ?? null),
speed: null,
eta: null,
error: s.error,
path: s.path,
},
}));
if (s.state === "done" || s.state === "failed" || s.state === "cancelled") {
onFinished();
}
}).then((u) => unlisteners.push(u));
return () => unlisteners.forEach((u) => u());
}, [onFinished]);
return live;
}
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useEffect, useState } from "react";
import { listChannels, listFeed } from "../api";
import type { ChannelWithCount, FeedFilter, FeedItem } from "../types";
export function useFeed(filter: FeedFilter) {
const [items, setItems] = useState<FeedItem[]>([]);
const [channels, setChannels] = useState<ChannelWithCount[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
try {
const [feed, chans] = await Promise.all([listFeed(filter), listChannels()]);
setItems(feed);
setChannels(chans);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
// Filter is a plain object rebuilt each render; compare by value.
}, [JSON.stringify(filter)]);
useEffect(() => {
reload();
}, [reload]);
return { items, channels, loading, error, reload };
}
+77
View File
@@ -0,0 +1,77 @@
// Mirrors of the Rust structs in src-tauri/src/models.rs. Field names match
// serde's output exactly so no mapping layer is needed.
export type DownloadState =
| "queued"
| "running"
| "done"
| "failed"
| "cancelled";
export interface ChannelWithCount {
id: string;
title: string;
url: string;
video_count: number;
downloaded_count: number;
}
export interface FeedItem {
id: string;
channel_id: string;
channel_title: string;
title: string;
description: string;
/** Unix seconds. */
published: number;
thumb_url: string;
thumb_path: string | null;
views: number;
is_short: boolean;
state: DownloadState | null;
path: string | null;
pct: number | null;
error: string | null;
}
export interface FeedFilter {
channel_id?: string | null;
search?: string | null;
downloaded_only: boolean;
hide_shorts: boolean;
limit?: number | null;
}
export interface Prereqs {
yt_dlp: string | null;
ffmpeg: string | null;
library_path: string;
}
export interface RefreshSummary {
channels: number;
new_videos: number;
failures: string[];
}
export interface RefreshProgress {
done: number;
total: number;
channel: string;
}
export interface DownloadProgressEvent {
video_id: string;
pct: number | null;
bytes_done: number;
bytes_total: number | null;
speed: number | null;
eta: number | null;
}
export interface DownloadStateEvent {
video_id: string;
state: DownloadState;
error: string | null;
path: string | null;
}