import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, refreshFeeds, } 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 { QUALITIES, STREAM_QUALITIES, 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; 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 [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 [refreshing, setRefreshing] = useState(false); const [refreshProgress, setRefreshProgress] = useState(null); const [toast, setToast] = useState(null); const [failure, setFailure] = useState(null); 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.streamQuality", streamQuality); 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, streamQuality, 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], ); 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]); 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."; }; return (
{/* The webview paints the title bar itself. It sits directly above the sidebar and top bar, so it takes the panel colour, not the page's. In macOS window fullscreen the traffic lights are gone, so the strip would just be a blank bar — it collapses instead. */} {!windowFullscreen && (
)}
{/* 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); }} />
)} {!sidebarHidden && ( setShowSettings(true)} totalVideos={totals.videos} totalDownloaded={totals.downloaded} onHide={() => setSidebarHidden(true)} /> )}
{ setForcedOffline(!forcedOffline); probe(); }} onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} resultCount={items.length} view={view} onView={setView} sidebarHidden={sidebarHidden} onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }} /> {!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), onDownload: () => downloadVideo(item.id, quality).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).catch((e) => setFailure(String(e))); say("Download started"); } : undefined } downloading={["queued", "running"].includes( live[playing.item.id]?.state ?? playing.item.state ?? "", )} onClose={() => { setPlayingIndex(null); 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} streamQuality={streamQuality} onStreamQuality={setStreamQuality} onError={setFailure} onImported={(n) => { reload(); say(`Imported ${n} subscription${n === 1 ? "" : "s"}`); }} /> )} {failure && ( setFailure(null)}>

{failure}

)}
); }