Files
FlightTube/src/App.tsx
T
vincent 22c7a3a5ec feat: sign in to YouTube with browser cookies
Answers the bot challenge from inside the app. Settings gains a browser
picker listing only the browsers actually installed; choosing one passes
--cookies-from-browser to every yt-dlp call, so YouTube sees an
authenticated session. Verified against a live block: refused without
cookies, resolved with them.

Arc is Chromium underneath but is not one of yt-dlp's known names, so it
is addressed by its profile directory instead.

yt-dlp's errors are translated into something actionable. The stock bot
message points at command-line flags a user cannot type; it now names
the setting that fixes it, and Safari's protected cookie store gets its
own message about Full Disk Access rather than a bare 'Operation not
permitted'.

A Check connection button reports whether YouTube is reachable, testing
against the newest video in the feed — the hardcoded id it used at first
had been taken down, so it reported a dead video rather than the
connection.
2026-08-29 14:21:35 +02:00

514 lines
18 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
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 {
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. This was once every 12s and it got the whole IP
* challenged by YouTube, which broke playback and downloads too — the feed
* being fully annotated is not worth that.
*/
const DURATION_FILL_MS = 5 * 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<string | null>(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<ViewMode>(() => {
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<number | null>(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! : "off";
} catch {
return "off";
}
});
const [streamQuality, setStreamQuality] = useState<Quality>(() => {
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<Quality>(() => {
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<RefreshProgress | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [failure, setFailure] = useState<string | null>(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<number | undefined>(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.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, 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],
);
// yt-dlp wants a pattern; "en.*" catches "en" and "en-orig" and the
// auto-generated "en" track alike. English is always fetched as a fallback.
const subLangArg = subLang === "off" ? "en.*" : `${subLang}.*,en.*`;
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]);
// 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()) > 0 && !stop) await reload();
} catch {
refused = true;
}
};
void tick();
const id = setInterval(tick, DURATION_FILL_MS);
return () => {
stop = true;
clearInterval(id);
};
}, [online, playingIndex, reload]);
// 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 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 (
<div className="flex h-screen flex-col">
<div className="relative flex min-h-0 flex-1 flex-col lg:flex-row">
{/* With the sidebar hidden, a thin strip along the left edge brings it
back on hover. */}
{sidebarHidden && (
<div
onMouseEnter={() => setSidebarPeek(true)}
className="absolute inset-y-0 left-0 z-20 w-3"
aria-hidden
/>
)}
{sidebarHidden && sidebarPeek && (
<div
onMouseLeave={() => setSidebarPeek(false)}
className="absolute inset-y-0 left-0 z-30 w-[280px]"
>
<Sidebar
floating
channels={channels}
activeChannel={channelId}
onSelect={(id) => { setChannelId(id); setSidebarPeek(false); }}
onOpenSettings={() => setShowSettings(true)}
totalVideos={totals.videos}
totalDownloaded={totals.downloaded}
onHide={() => { setSidebarHidden(true); setSidebarPeek(false); }}
titleBarInset={titleBarInset}
/>
</div>
)}
{!sidebarHidden && (
<Sidebar
channels={channels}
activeChannel={channelId}
onSelect={setChannelId}
onOpenSettings={() => setShowSettings(true)}
totalVideos={totals.videos}
totalDownloaded={totals.downloaded}
onHide={() => setSidebarHidden(true)}
titleBarInset={titleBarInset}
/>
)}
<main className="flex min-h-0 min-w-0 flex-1 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}
resultCount={items.length}
view={view} onView={setView}
onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined}
sidebarHidden={sidebarHidden}
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
titleBarInset={titleBarInset}
/>
{!online && (
<div
className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-[11px]
leading-snug text-amber-700 dark:text-amber-300"
>
Offline showing only videos you've downloaded.
{forcedOffline && " Offline mode is forced on."}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-3 lg:p-5">
{items.length === 0 ? (
<div className="grid h-full place-items-center">
<p className="max-w-sm text-center text-[12px] leading-relaxed text-slate-500 dark:text-slate-400">
{emptyMessage()}
</p>
</div>
) : (
<ul
className={
view === "grid"
? "mx-auto grid max-w-7xl grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-x-4 gap-y-6"
: "mx-auto max-w-4xl space-y-1.5"
}
>
{items.map((item, idx) => {
const shared = {
item,
live: live[item.id],
online,
onOpen: () => openIndex(idx),
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" ? (
<VideoTile key={item.id} {...shared} />
) : (
<VideoRow key={item.id} {...shared} />
);
})}
</ul>
)}
</div>
</main>
</div>
{playing && playingIndex != null && (
<Player
key={playing.item.id}
item={playing.item}
path={playing.path}
index={playingIndex}
total={items.length}
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
subLang={subLang}
onSubLang={setSubLang}
titleBarInset={titleBarInset}
onPrev={
stepFrom(playingIndex, -1) != 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 && (
<Settings
onClose={() => setShowSettings(false)}
appearance={mode}
onAppearance={setMode}
quality={quality}
onQuality={setQuality}
streamQuality={streamQuality}
onStreamQuality={setStreamQuality}
subLang={subLang}
onSubLang={setSubLang}
browser={browser}
onBrowser={setBrowser}
onError={setFailure}
onImported={(n) => {
reload();
say(`Imported ${n} subscription${n === 1 ? "" : "s"}`);
}}
/>
)}
{confirmWipe && (
<Dialog
title="Delete every download?"
onCancel={() => setConfirmWipe(false)}
onConfirm={wipeDownloads}
confirmLabel="Delete all"
destructive
>
<p>
All <b>{totals.downloaded}</b> downloaded video
{totals.downloaded === 1 ? "" : "s"} and their subtitles will be removed from
disk. Your subscriptions and the feed are untouched.
</p>
</Dialog>
)}
{failure && (
<Dialog title="Something went wrong" onCancel={() => setFailure(null)}>
<p className="break-words">{failure}</p>
</Dialog>
)}
<Toast message={toast} />
</div>
);
}