A stop button appears beside Download all whenever anything is
downloading or waiting to, and disappears when nothing is. It kills the
running processes, marks everything the database still calls queued or
running as cancelled, and clears the part files. Downloads already
finished are untouched.
That also covers two cases a per-video Cancel cannot reach: a download
parked on a slot, which stands down when its turn comes, and a row left
"running" by a crash that no process backs any more.
Cancelling no longer reports itself as a failure. download_video
returned Err("Download was cancelled.") when it found its child gone,
which the caller turned into an error dialog — pressing Stop all would
have raised one per download. Stopping something is a normal outcome:
the state is already recorded and the event already sent, so it returns
cleanly.
617 lines
22 KiB
TypeScript
617 lines
22 KiB
TypeScript
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<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! : DEFAULT_SUB_LANG;
|
|
} catch {
|
|
return DEFAULT_SUB_LANG;
|
|
}
|
|
});
|
|
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 [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<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.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<string>();
|
|
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 (
|
|
<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}
|
|
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}
|
|
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 && (
|
|
<div
|
|
className="border-b border-red-500/30 bg-red-500/10 px-4 py-2 text-[11px]
|
|
leading-snug text-red-700 dark:text-red-300"
|
|
>
|
|
<b>This channel failed to refresh.</b>{" "}
|
|
{activeChannelError.replace(/\.?$/, ".")} Anything listed below is from the
|
|
last successful check.
|
|
</div>
|
|
)}
|
|
|
|
{!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),
|
|
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" ? (
|
|
<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}
|
|
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 && (
|
|
<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>
|
|
);
|
|
}
|