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(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(null); const [notice, setNotice] = useState(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 (
setShowSettings(true)} totalVideos={totalVideos} />
{ setForcedOffline(!forcedOffline); probe(); }} onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} /> {!online && (
Offline — showing only videos you've downloaded. {forcedOffline && " (Offline mode is forced on.)"}
)} {(notice || error) && (
{error ?? notice}
)}
{items.length === 0 ? (

{emptyMessage()}

) : (
{items.map((item) => ( 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))) } /> ))}
)}
{playing && ( setPlaying(null)} onDelete={async () => { await deleteDownload(playing.item.id); setPlaying(null); reload(); }} /> )} {showSettings && ( setShowSettings(false)} onImported={() => reload()} /> )}
); }