import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo, fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource, } from "./api"; import Player from "./components/Player"; import Settings from "./components/Settings"; import Sidebar from "./components/Sidebar"; import TopBar, { type ViewMode } from "./components/TopBar"; import { Dialog, Toast } from "./components/ui"; import VideoRow from "./components/VideoRow"; import VideoTile from "./components/VideoTile"; import { useAppearance } from "./hooks/useAppearance"; import { useConnectivity } from "./hooks/useConnectivity"; import { useDownloads } from "./hooks/useDownloads"; import { useFeed } from "./hooks/useFeed"; import { useWindowFullscreen } from "./hooks/useWindowFullscreen"; import { BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG, QUALITIES, STREAM_QUALITIES, SUB_LANGS, type FeedFilter, type FeedItem, type Quality, type RefreshProgress, } from "./types"; const TOAST_MS = 2400; /** How often to pull new videos while online, so the feed stays live. */ const AUTO_REFRESH_MS = 10 * 60 * 1000; /** * Video lengths trickle in, four at a time, for whatever is on screen. An * early version fetched two pages a second across the whole feed and got the * IP challenged by YouTube, breaking playback and downloads too — so this stays * slow on purpose. */ const DURATION_FILL_MS = 30 * 1000; function remembered(key: string): boolean { try { return localStorage.getItem(`flighttube.${key}`) === "1"; } catch { return false; } } export default function App() { const [channelId, setChannelId] = useState(null); const [search, setSearch] = useState(""); const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly")); const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts")); const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden")); const [sidebarPeek, setSidebarPeek] = useState(false); const [view, setView] = useState(() => { try { return localStorage.getItem("flighttube.view") === "grid" ? "grid" : "list"; } catch { return "list"; } }); const [showSettings, setShowSettings] = useState(false); // Index into the current feed, so the player can step through it. const [playingIndex, setPlayingIndex] = useState(null); const [browser, setBrowser] = useState(() => { try { return localStorage.getItem("flighttube.browser") ?? ""; } catch { return ""; } }); const [subLang, setSubLang] = useState(() => { try { const stored = localStorage.getItem("flighttube.subLang"); return SUB_LANGS.some((l) => l.value === stored) ? stored! : DEFAULT_SUB_LANG; } catch { return DEFAULT_SUB_LANG; } }); const [streamQuality, setStreamQuality] = useState(() => { try { const stored = localStorage.getItem("flighttube.streamQuality"); return STREAM_QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best"; } catch { return "best"; } }); const [quality, setQuality] = useState(() => { try { const stored = localStorage.getItem("flighttube.quality"); return QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best"; } catch { return "best"; } }); const [bulkLimit, setBulkLimit] = useState(() => { try { const stored = Number(localStorage.getItem("flighttube.bulkLimit")); return BULK_LIMITS.some((b) => b.value === stored) ? stored : DEFAULT_BULK_LIMIT; } catch { return DEFAULT_BULK_LIMIT; } }); const [refreshing, setRefreshing] = useState(false); const [refreshProgress, setRefreshProgress] = useState(null); const [toast, setToast] = useState(null); const [failure, setFailure] = useState(null); // The backend holds no state across launches, so the stored choice has to be // handed back to it before the first yt-dlp call. useEffect(() => { setCookieSource(browser).catch(() => { /* falls back to no cookies */ }); }, [browser]); const { mode, setMode } = useAppearance(); const windowFullscreen = useWindowFullscreen(); const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity(); // A toast reports success and fades; a modal reports a failure or asks a // question. Never make someone dismiss a box to be told it worked. const toastTimer = useRef(undefined); const say = useCallback((message: string) => { setToast(message); window.clearTimeout(toastTimer.current); toastTimer.current = window.setTimeout(() => setToast(null), TOAST_MS); }, []); useEffect(() => () => window.clearTimeout(toastTimer.current), []); useEffect(() => { try { localStorage.setItem("flighttube.view", view); localStorage.setItem("flighttube.quality", quality); localStorage.setItem("flighttube.bulkLimit", String(bulkLimit)); localStorage.setItem("flighttube.streamQuality", streamQuality); localStorage.setItem("flighttube.subLang", subLang); localStorage.setItem("flighttube.browser", browser); localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0"); localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0"); localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0"); } catch { /* storage blocked */ } }, [view, quality, bulkLimit, streamQuality, subLang, browser, downloadedOnly, hideShorts, sidebarHidden]); // 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, clear: clearLive } = useDownloads(reload); useEffect(() => { let un: (() => void) | undefined; onRefreshProgress(setRefreshProgress).then((u) => (un = u)); return () => un?.(); }, []); useEffect(() => { if (error) setFailure(error); }, [error]); const totals = useMemo( () => channels.reduce( (acc, c) => ({ videos: acc.videos + c.video_count, downloaded: acc.downloaded + c.downloaded_count, }), { videos: 0, downloaded: 0 }, ), [channels], ); // Named exactly, never as a wildcard: "en.*" also matches every // machine-translated variant YouTube offers, and asking for all of them // earns an HTTP 429. Off means no subtitle requests at all. const subLangArg = subLang === "off" ? "" : `${subLang},${subLang}-orig`; const doRefresh = useCallback(async () => { setRefreshing(true); try { const s = await refreshFeeds(); await reload(); const failed = s.failures.length; say( failed ? `Checked ${s.channels} channels · ${failed} failed` : `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}`, ); } catch (e) { setFailure(String(e)); } finally { setRefreshing(false); setRefreshProgress(null); } }, [reload, say]); // Downloaded plays from disk; anything else streams. Only being offline with // no local copy leaves nothing to play. const playableAt = useCallback( (i: number): { item: FeedItem; path: string | null } | null => { const item = items[i]; if (!item) return null; const path = live[item.id]?.path ?? item.path; const done = (live[item.id]?.state ?? item.state) === "done"; if (done && path) return { item, path }; return online ? { item, path: null } : null; }, [items, live, online], ); const openIndex = useCallback( (i: number) => { if (playableAt(i)) setPlayingIndex(i); else setFailure("That video isn't downloaded, and you're offline."); }, [playableAt], ); /** Next/previous item that can actually be played right now. */ const stepFrom = useCallback( (from: number, dir: 1 | -1): number | null => { for (let i = from + dir; i >= 0 && i < items.length; i += dir) { if (playableAt(i)) return i; } return null; }, [items.length, playableAt], ); const playing = playingIndex == null ? null : playableAt(playingIndex); // The feed can change under an open player (a refresh, a filter change). useEffect(() => { if (playingIndex != null && !items[playingIndex]) setPlayingIndex(null); }, [items, playingIndex]); // Keep the feed live while online. Skipped whenever a refresh is already // running or the player is open, so it never yanks the list under you. useEffect(() => { if (!online) return; const id = setInterval(() => { if (!refreshing && playingIndex == null) void doRefresh(); }, AUTO_REFRESH_MS); return () => clearInterval(id); }, [online, refreshing, playingIndex, doRefresh]); // Anything in flight, whether or not it is currently listed — a download // started on one channel keeps running while you look at another. const activeDownloads = useMemo(() => { const ids = new Set(); for (const [id, l] of Object.entries(live)) { if (l.state === "queued" || l.state === "running") ids.add(id); } for (const i of items) { const state = live[i.id]?.state ?? i.state; if (state === "queued" || state === "running") ids.add(i.id); } return ids.size; }, [live, items]); const stopAll = useCallback(() => { cancelAllDownloads() .then((n) => { reload(); say(n === 1 ? "Stopped 1 download" : `Stopped ${n} downloads`); }) .catch((e) => setFailure(String(e))); }, [reload, say]); // Everything listed that is not already here or on its way. const pendingDownloads = useMemo( () => items.filter((i) => { const state = live[i.id]?.state ?? i.state; return state !== "done" && state !== "queued" && state !== "running"; }), [items, live], ); // Queues the lot in one go. The backend runs two at a time and the rest wait // their turn, so this is a queue rather than a stampede; a video that fails // reports it on its own row instead of throwing a dialog for each one. const bulkTargets = useMemo( () => (bulkLimit > 0 ? pendingDownloads.slice(0, bulkLimit) : pendingDownloads), [pendingDownloads, bulkLimit], ); const downloadAll = useCallback(() => { if (bulkTargets.length === 0) return; const capped = bulkTargets.length < pendingDownloads.length; say( capped ? `Queued the newest ${bulkTargets.length} of ${pendingDownloads.length} — limit set in Settings` : `Queued ${bulkTargets.length} video${bulkTargets.length === 1 ? "" : "s"}`, ); for (const i of bulkTargets) { downloadVideo(i.id, quality, subLangArg).catch(() => {}); } }, [bulkTargets, pendingDownloads.length, quality, subLangArg, say]); // Ids on screen still lacking a length, newest first. Joined into a string // so the effect below only re-runs when the set actually changes. const missingDurations = useMemo( () => items.filter((i) => i.duration == null).slice(0, 40).map((i) => i.id), [items], ); const missingKey = missingDurations.join(","); // The Atom feed carries no duration, so lengths are looked up a batch at a // time in the background and cached. Paused while the player is open. useEffect(() => { if (!online) return; let stop = false; // Set when the backend reports it is being refused, so we stop for the // rest of the session rather than making the block worse. let refused = false; const tick = async () => { if (stop || refused || playingIndex != null) return; try { if ((await fetchDurations(missingDurations)) > 0 && !stop) await reload(); } catch { refused = true; } }; void tick(); const id = setInterval(tick, DURATION_FILL_MS); return () => { stop = true; clearInterval(id); }; // Re-runs when the visible set changes, so switching channel fills that // channel rather than whatever is newest overall. // eslint-disable-next-line react-hooks/exhaustive-deps }, [online, playingIndex, missingKey]); // Refresh once on launch, as soon as there is a connection and something to // refresh, so the feed is current without anyone pressing anything. const launched = useRef(false); useEffect(() => { if (launched.current || !online || channels.length === 0) return; launched.current = true; void doRefresh(); }, [online, channels.length, doRefresh]); const [confirmWipe, setConfirmWipe] = useState(false); const wipeDownloads = useCallback(async () => { setConfirmWipe(false); try { const n = await deleteAllDownloads(); items.forEach((i) => clearLive(i.id)); await reload(); say(`Deleted ${n} download${n === 1 ? "" : "s"}`); } catch (e) { setFailure(String(e)); } }, [items, clearLive, reload, say]); const activeChannelError = channelId == null ? null : (channels.find((c) => c.id === channelId)?.last_error ?? null); const emptyMessage = () => { if (loading) return "Loading…"; if (channels.length === 0) return "No subscriptions yet. Open Settings — it walks you through exporting them from Google Takeout."; if (totals.videos === 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."; }; // Each pane reserves its own strip for the traffic lights instead of one // band across the top, so the sidebar's right border runs unbroken from the // very top of the window. In macOS window fullscreen there are no traffic // lights, so the inset collapses. const titleBarInset = !windowFullscreen; return (
{/* With the sidebar hidden, a thin strip along the left edge brings it back on hover. */} {sidebarHidden && (
setSidebarPeek(true)} className="absolute inset-y-0 left-0 z-20 w-3" aria-hidden /> )} {sidebarHidden && sidebarPeek && (
setSidebarPeek(false)} className="absolute inset-y-0 left-0 z-30 w-[280px]" > { setChannelId(id); setSidebarPeek(false); }} onOpenSettings={() => setShowSettings(true)} totalVideos={totals.videos} totalDownloaded={totals.downloaded} onHide={() => { setSidebarHidden(true); setSidebarPeek(false); }} titleBarInset={titleBarInset} />
)} {!sidebarHidden && ( setShowSettings(true)} totalVideos={totals.videos} totalDownloaded={totals.downloaded} onHide={() => setSidebarHidden(true)} titleBarInset={titleBarInset} /> )}
{ setForcedOffline(!forcedOffline); probe(); }} onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} resultCount={items.length} view={view} onView={setView} onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined} onDownloadAll={online && bulkTargets.length > 0 ? downloadAll : undefined} downloadAllCount={bulkTargets.length} downloadAllTotal={pendingDownloads.length} onStopAll={activeDownloads > 0 ? stopAll : undefined} stopAllCount={activeDownloads} sidebarHidden={sidebarHidden} onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }} titleBarInset={titleBarInset} /> {activeChannelError && (
This channel failed to refresh.{" "} {activeChannelError.replace(/\.?$/, ".")} Anything listed below is from the last successful check.
)} {!online && (
Offline — showing only videos you've downloaded. {forcedOffline && " Offline mode is forced on."}
)}
{items.length === 0 ? (

{emptyMessage()}

) : (
    {items.map((item, idx) => { const shared = { item, live: live[item.id], online, onOpen: () => openIndex(idx), onOpenChannel: () => { setChannelId(item.channel_id); setSearch(""); }, onDownload: () => downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))), onCancel: () => cancelDownload(item.id).catch((e) => setFailure(String(e))), onDelete: () => deleteDownload(item.id) .then(() => { clearLive(item.id); reload(); say("Download deleted"); }) .catch((e) => setFailure(String(e))), }; return view === "grid" ? ( ) : ( ); })}
)}
{playing && playingIndex != null && ( setPlayingIndex(stepFrom(playingIndex, -1)) : undefined } onNext={ stepFrom(playingIndex, 1) != null ? () => setPlayingIndex(stepFrom(playingIndex, 1)) : undefined } onDownload={ playing.path === null ? () => { downloadVideo(playing.item.id, quality, subLangArg).catch((e) => setFailure(String(e))); say("Download started"); } : undefined } downloading={["queued", "running"].includes( live[playing.item.id]?.state ?? playing.item.state ?? "", )} onClose={() => { setPlayingIndex(null); // Coming back from a video is the natural moment to pick up // whatever has been posted since. if (online && !refreshing) void doRefresh(); else void reload(); }} onDelete={async () => { await deleteDownload(playing.item.id); clearLive(playing.item.id); setPlayingIndex(null); reload(); say("Download deleted"); }} /> )} {showSettings && ( setShowSettings(false)} appearance={mode} onAppearance={setMode} quality={quality} onQuality={setQuality} bulkLimit={bulkLimit} onBulkLimit={setBulkLimit} streamQuality={streamQuality} onStreamQuality={setStreamQuality} subLang={subLang} onSubLang={setSubLang} hideShorts={hideShorts} onHideShorts={setHideShorts} browser={browser} onBrowser={setBrowser} onError={setFailure} onImported={(n) => { reload(); say(`Imported ${n} subscription${n === 1 ? "" : "s"}`); }} /> )} {confirmWipe && ( setConfirmWipe(false)} onConfirm={wipeDownloads} confirmLabel="Delete all" destructive >

All {totals.downloaded} downloaded video {totals.downloaded === 1 ? "" : "s"} and their subtitles will be removed from disk. Your subscriptions and the feed are untouched.

)} {failure && ( setFailure(null)}>

{failure}

)}
); }