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