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.
514 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|