feat: channel failures, updater, and a tidier Settings
Failing channels are visible instead of buried in a toast count. Each refresh records its outcome per channel, the sidebar marks the failures and counts them in its heading, and selecting one explains why above its videos. Yours turn out to be three channels returning HTTP 404 — removed or renamed on YouTube. Video lengths now fill for what is on screen. Filling the newest across all subscriptions meant a channel's videos stayed blank forever, since the global newest always won the queue. yt-dlp can be updated from Settings, which also says whether it is current. It breaks whenever YouTube changes something, so it lands in app data and takes precedence over the bundled copy; ffmpeg is stable and ships with each release, so it is shown but not updated. Also: 'Downloaded only' is now 'Local'; Hide Shorts moved to Settings; the seven-step Takeout guide moved behind a button, since it dominated the panel; the player names the height it is actually streaming, which changes as an adaptive stream switches rendition; the player's delete is an icon; and the window minimum drops to 1080 now that the control row has one fewer button, while still never wrapping.
This commit is contained in:
+36
-7
@@ -24,11 +24,12 @@ 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.
|
||||
* 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 = 5 * 60 * 1000;
|
||||
const DURATION_FILL_MS = 30 * 1000;
|
||||
|
||||
function remembered(key: string): boolean {
|
||||
try {
|
||||
@@ -241,6 +242,14 @@ export default function App() {
|
||||
return () => clearInterval(id);
|
||||
}, [online, refreshing, playingIndex, doRefresh]);
|
||||
|
||||
// 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(() => {
|
||||
@@ -252,7 +261,7 @@ export default function App() {
|
||||
const tick = async () => {
|
||||
if (stop || refused || playingIndex != null) return;
|
||||
try {
|
||||
if ((await fetchDurations()) > 0 && !stop) await reload();
|
||||
if ((await fetchDurations(missingDurations)) > 0 && !stop) await reload();
|
||||
} catch {
|
||||
refused = true;
|
||||
}
|
||||
@@ -263,7 +272,10 @@ export default function App() {
|
||||
stop = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [online, playingIndex, reload]);
|
||||
// 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.
|
||||
@@ -288,6 +300,11 @@ export default function App() {
|
||||
}
|
||||
}, [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)
|
||||
@@ -352,7 +369,6 @@ export default function App() {
|
||||
<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}
|
||||
@@ -364,6 +380,17 @@ export default function App() {
|
||||
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]
|
||||
@@ -476,6 +503,8 @@ export default function App() {
|
||||
onStreamQuality={setStreamQuality}
|
||||
subLang={subLang}
|
||||
onSubLang={setSubLang}
|
||||
hideShorts={hideShorts}
|
||||
onHideShorts={setHideShorts}
|
||||
browser={browser}
|
||||
onBrowser={setBrowser}
|
||||
onError={setFailure}
|
||||
|
||||
+9
-1
@@ -13,6 +13,7 @@ import type {
|
||||
RefreshProgress,
|
||||
RefreshSummary,
|
||||
Stream,
|
||||
UpdateStatus,
|
||||
} from "./types";
|
||||
|
||||
export const checkPrereqs = () => invoke<Prereqs>("check_prereqs");
|
||||
@@ -30,7 +31,9 @@ export const downloadVideo = (videoId: string, quality: Quality, subLangs: strin
|
||||
export const deleteAllDownloads = () => invoke<number>("delete_all_downloads");
|
||||
|
||||
/** Fills in missing video lengths, a batch at a time. Returns how many. */
|
||||
export const fetchDurations = () => invoke<number>("fetch_durations");
|
||||
/** Fills lengths for the videos on screen first. */
|
||||
export const fetchDurations = (visible: string[]) =>
|
||||
invoke<number>("fetch_durations", { visible });
|
||||
|
||||
/** Browsers installed here that yt-dlp can read cookies from: [id, label]. */
|
||||
export const listBrowsers = () => invoke<Array<[string, string]>>("list_browsers");
|
||||
@@ -42,6 +45,11 @@ export const setCookieSource = (browser: string) =>
|
||||
/** Resolves a known video to check whether YouTube is currently reachable. */
|
||||
export const testYoutube = () => invoke<string>("test_youtube");
|
||||
|
||||
export const checkYtDlpUpdate = () => invoke<UpdateStatus>("check_yt_dlp_update");
|
||||
|
||||
/** Downloads the newest yt-dlp and switches to it. Returns its version. */
|
||||
export const updateYtDlp = () => invoke<string>("update_yt_dlp");
|
||||
|
||||
/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */
|
||||
export const listSubtitles = (videoId: string) =>
|
||||
invoke<Array<[string, string]>>("list_subtitles", { videoId });
|
||||
|
||||
@@ -107,6 +107,10 @@ export default function Player({
|
||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [buffering, setBuffering] = useState(true);
|
||||
// The height actually being decoded. With an adaptive stream this changes as
|
||||
// the player switches rendition, so it is read from the element rather than
|
||||
// assumed from the setting.
|
||||
const [height, setHeight] = useState(0);
|
||||
// WebVTT files yt-dlp saved next to a download, so subtitles work offline.
|
||||
const [sidecars, setSidecars] = useState<Array<[string, string]>>([]);
|
||||
|
||||
@@ -264,7 +268,11 @@ export default function Player({
|
||||
|
||||
{streaming && (
|
||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"}
|
||||
{error
|
||||
? "Unavailable"
|
||||
: src && !buffering
|
||||
? `Streaming${height ? ` · ${height}p` : ""}`
|
||||
: "Loading…"}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
@@ -330,6 +338,8 @@ export default function Player({
|
||||
onCanPlay={() => setBuffering(false)}
|
||||
onPlaying={() => setBuffering(false)}
|
||||
onSeeked={() => setBuffering(false)}
|
||||
onResize={() => setHeight(videoRef.current?.videoHeight ?? 0)}
|
||||
onLoadedData={() => setHeight(videoRef.current?.videoHeight ?? 0)}
|
||||
className="absolute inset-0 size-full object-contain"
|
||||
>
|
||||
{sidecars.map(([lang, file]) => (
|
||||
@@ -409,10 +419,14 @@ export default function Player({
|
||||
<button
|
||||
onClick={onDelete}
|
||||
title="Delete this download"
|
||||
className={`${navBtn} whitespace-nowrap hover:border-red-500! hover:text-red-600!
|
||||
aria-label="Delete download"
|
||||
className={`${navIcon} hover:border-red-500! hover:text-red-600!
|
||||
dark:hover:border-red-500! dark:hover:text-red-400!`}
|
||||
>
|
||||
Delete
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
+92
-26
@@ -1,17 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
checkPrereqs, importTakeoutCsv, listBrowsers, pickLibraryFolder, pickTakeoutFile,
|
||||
previewTakeoutImport, setCookieSource, testYoutube,
|
||||
checkPrereqs, checkYtDlpUpdate, importTakeoutCsv, listBrowsers, pickLibraryFolder,
|
||||
pickTakeoutFile, previewTakeoutImport, setCookieSource, testYoutube, updateYtDlp,
|
||||
} from "../api";
|
||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||
import {
|
||||
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||
type ImportPreview, type Prereqs, type Quality,
|
||||
type ImportPreview, type Prereqs, type Quality, type UpdateStatus,
|
||||
} from "../types";
|
||||
import TakeoutGuide from "./TakeoutGuide";
|
||||
import {
|
||||
BTN, BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented,
|
||||
SUBPANEL,
|
||||
|
||||
} from "./ui";
|
||||
|
||||
interface Props {
|
||||
@@ -25,6 +25,8 @@ interface Props {
|
||||
onStreamQuality: (q: Quality) => void;
|
||||
subLang: string;
|
||||
onSubLang: (l: string) => void;
|
||||
hideShorts: boolean;
|
||||
onHideShorts: (v: boolean) => void;
|
||||
browser: string;
|
||||
onBrowser: (b: string) => void;
|
||||
onError: (message: string) => void;
|
||||
@@ -51,7 +53,8 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
|
||||
|
||||
export default function Settings({
|
||||
onClose, onImported, appearance, onAppearance, quality, onQuality,
|
||||
streamQuality, onStreamQuality, subLang, onSubLang, browser, onBrowser, onError,
|
||||
streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts,
|
||||
browser, onBrowser, onError,
|
||||
}: Props) {
|
||||
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
||||
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
||||
@@ -59,6 +62,26 @@ export default function Settings({
|
||||
const [browsers, setBrowsers] = useState<Array<[string, string]>>([]);
|
||||
const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [guide, setGuide] = useState(false);
|
||||
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkYtDlpUpdate().then(setUpdate).catch(() => setUpdate(null));
|
||||
}, []);
|
||||
|
||||
const runUpdate = async () => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const version = await updateYtDlp();
|
||||
setUpdate({ current: version, latest: update?.latest ?? version, up_to_date: true });
|
||||
await load();
|
||||
} catch (e) {
|
||||
onError(String(e));
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
listBrowsers().then(setBrowsers).catch(() => setBrowsers([]));
|
||||
@@ -156,28 +179,22 @@ export default function Settings({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<SectionHeading>Get your subscriptions</SectionHeading>
|
||||
<SectionHeading>Subscriptions</SectionHeading>
|
||||
<p className={`mt-1.5 ${HELP}`}>
|
||||
YouTube has no public API for someone else's subscription list, so FlightTube
|
||||
reads the export Google gives you. It takes about two minutes.
|
||||
Importing <b>replaces</b> your current list — the CSV becomes the whole truth.
|
||||
Channels no longer in it are removed along with their videos and downloads.
|
||||
You'll see exactly what goes before anything is deleted.
|
||||
</p>
|
||||
<div className={`mt-3 ${SUBPANEL}`}>
|
||||
<TakeoutGuide />
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button onClick={startImport} className={`${BTN_PRIMARY} cursor-pointer`}>
|
||||
Import subscriptions.csv
|
||||
</button>
|
||||
<button onClick={() => setGuide(true)} className={`${BTN} cursor-pointer`}>
|
||||
How do I get the file?
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<SectionHeading>Import</SectionHeading>
|
||||
<p className={`mt-1.5 ${HELP}`}>
|
||||
Importing <b>replaces</b> your current subscription list — the CSV becomes the
|
||||
whole truth. Channels no longer in it are removed along with their videos and
|
||||
downloads. You'll see exactly what goes before anything is deleted.
|
||||
</p>
|
||||
<button onClick={startImport} className={`${BTN_PRIMARY} mt-3 cursor-pointer`}>
|
||||
Import subscriptions.csv
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<SectionHeading>Download quality</SectionHeading>
|
||||
<p className={`mt-1.5 ${HELP}`}>
|
||||
@@ -241,6 +258,22 @@ export default function Settings({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<SectionHeading>Feed</SectionHeading>
|
||||
<label className="mt-2 flex cursor-pointer items-center justify-between gap-3">
|
||||
<span className={LABEL}>Hide Shorts</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideShorts}
|
||||
onChange={(e) => onHideShorts(e.target.checked)}
|
||||
className="size-4 cursor-pointer accent-sky-500"
|
||||
/>
|
||||
</label>
|
||||
<p className={`mt-2 ${HELP}`}>
|
||||
Keeps YouTube Shorts out of the feed entirely.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<SectionHeading>Sign in to YouTube</SectionHeading>
|
||||
<p className={`mt-1.5 ${HELP}`}>
|
||||
@@ -301,7 +334,29 @@ export default function Settings({
|
||||
<section className="px-5 py-4">
|
||||
<SectionHeading>Status</SectionHeading>
|
||||
<div className="mt-2">
|
||||
<StatusRow label="yt-dlp" value={prereqs?.yt_dlp ?? null} />
|
||||
<div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 dark:border-slate-800">
|
||||
<span className={LABEL}>yt-dlp</span>
|
||||
<span className="flex items-center gap-2 text-right">
|
||||
<span className="text-[12px] text-slate-600 dark:text-slate-300">
|
||||
{prereqs?.yt_dlp ?? "Not found"}
|
||||
</span>
|
||||
{update && !update.up_to_date && update.latest && (
|
||||
<button
|
||||
onClick={runUpdate}
|
||||
disabled={updating}
|
||||
title={`Update to ${update.latest}`}
|
||||
className={`${BTN} cursor-pointer`}
|
||||
>
|
||||
{updating ? "Updating…" : `Update to ${update.latest}`}
|
||||
</button>
|
||||
)}
|
||||
{update?.up_to_date && (
|
||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||
up to date
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
|
||||
<div className="flex items-start justify-between gap-4 py-2">
|
||||
<span className={LABEL}>Library</span>
|
||||
@@ -316,9 +371,10 @@ export default function Settings({
|
||||
</div>
|
||||
|
||||
<p className={`mt-2 ${HELP}`}>
|
||||
yt-dlp and ffmpeg ship inside the app, so nothing needs installing. A copy
|
||||
on your system is used instead if one is present, which is how you can run
|
||||
a newer yt-dlp than the bundled one.
|
||||
Both ship inside the app, so nothing needs installing. yt-dlp breaks
|
||||
whenever YouTube changes something, so it can be updated here; ffmpeg is
|
||||
stable and comes with each release. A copy on your system takes precedence
|
||||
over either.
|
||||
</p>
|
||||
|
||||
{missing && (
|
||||
@@ -335,6 +391,16 @@ export default function Settings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{guide && (
|
||||
<Dialog title="Getting your subscriptions" onCancel={() => setGuide(false)} wide>
|
||||
<p className={`mb-3 ${HELP}`}>
|
||||
YouTube has no public API for someone else's subscription list, so FlightTube
|
||||
reads the export Google gives you. It takes about two minutes.
|
||||
</p>
|
||||
<TakeoutGuide />
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{pending && p && (
|
||||
<Dialog
|
||||
title={destructive ? "Replace your subscriptions?" : "Import subscriptions"}
|
||||
|
||||
@@ -19,6 +19,8 @@ export default function Sidebar({
|
||||
channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
|
||||
onHide, floating, titleBarInset,
|
||||
}: Props) {
|
||||
const failing = channels.filter((c) => c.last_error).length;
|
||||
|
||||
const row =
|
||||
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " +
|
||||
"text-[13px] transition-colors";
|
||||
@@ -88,7 +90,17 @@ export default function Sidebar({
|
||||
</button>
|
||||
|
||||
{channels.length > 0 && (
|
||||
<h2 className={`${HEADING} px-2 pb-1 pt-4`}>Channels</h2>
|
||||
<h2 className={`${HEADING} flex items-center justify-between px-2 pb-1 pt-4`}>
|
||||
<span>Channels</span>
|
||||
{failing > 0 && (
|
||||
<span
|
||||
title={`${failing} channel${failing === 1 ? "" : "s"} failed to refresh`}
|
||||
className="font-mono text-[10px] normal-case tracking-normal text-red-500"
|
||||
>
|
||||
{failing} failing
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<ul className="space-y-0.5">
|
||||
@@ -99,7 +111,15 @@ export default function Sidebar({
|
||||
title={c.title}
|
||||
className={`${row} ${activeChannel === c.id ? active : inactive}`}
|
||||
>
|
||||
<span className="truncate">{c.title}</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{c.last_error && (
|
||||
<span
|
||||
title={`Last refresh failed: ${c.last_error}`}
|
||||
className="size-1.5 shrink-0 rounded-full bg-red-500"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{c.title}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
|
||||
{c.downloaded_count > 0 && `${c.downloaded_count}/`}
|
||||
{c.video_count}
|
||||
|
||||
@@ -87,11 +87,11 @@ const STEPS: Step[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Import it below",
|
||||
title: "Import it",
|
||||
body: (
|
||||
<>
|
||||
Pick that <code>subscriptions.csv</code> with the Import button, then hit{" "}
|
||||
<b>Refresh</b> to pull in each channel's latest videos.
|
||||
Close this, then pick that <code>subscriptions.csv</code> with{" "}
|
||||
<b>Import subscriptions.csv</b>. The feed refreshes itself afterwards.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7,8 +7,6 @@ interface Props {
|
||||
onSearch: (v: string) => void;
|
||||
downloadedOnly: boolean;
|
||||
onDownloadedOnly: (v: boolean) => void;
|
||||
hideShorts: boolean;
|
||||
onHideShorts: (v: boolean) => void;
|
||||
online: boolean;
|
||||
reachable: boolean;
|
||||
forcedOffline: boolean;
|
||||
@@ -57,7 +55,7 @@ function Toggle({
|
||||
}
|
||||
|
||||
export default function TopBar({
|
||||
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
|
||||
search, onSearch, downloadedOnly, onDownloadedOnly,
|
||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
||||
sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset,
|
||||
@@ -92,7 +90,7 @@ export default function TopBar({
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search videos and channels"
|
||||
className={`${INPUT} min-w-[17rem] max-w-md flex-1`}
|
||||
className={`${INPUT} min-w-[17rem] max-w-sm flex-1`}
|
||||
/>
|
||||
|
||||
<span className="shrink-0 font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
||||
@@ -103,8 +101,8 @@ export default function TopBar({
|
||||
|
||||
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)}
|
||||
disabled={!online}
|
||||
title={online ? "Show only downloaded videos" : "Offline: showing downloads only"}>
|
||||
Downloaded only
|
||||
title={online ? "Show only what is on this Mac" : "Offline: showing local videos only"}>
|
||||
Local
|
||||
</Toggle>
|
||||
|
||||
{downloadedOnly && onDeleteAll && (
|
||||
@@ -121,11 +119,6 @@ export default function TopBar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)}
|
||||
title="Hide Shorts from the feed">
|
||||
Hide Shorts
|
||||
</Toggle>
|
||||
|
||||
<Segmented
|
||||
value={view}
|
||||
onChange={onView}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface ChannelWithCount {
|
||||
url: string;
|
||||
video_count: number;
|
||||
downloaded_count: number;
|
||||
/** Why this channel's last refresh failed, if it did. */
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface FeedItem {
|
||||
@@ -122,3 +124,9 @@ export const SUB_LANGS: Array<{ value: string; label: string }> = [
|
||||
{ value: "it", label: "Italiano" },
|
||||
{ value: "pt", label: "Português" },
|
||||
];
|
||||
|
||||
export interface UpdateStatus {
|
||||
current: string | null;
|
||||
latest: string | null;
|
||||
up_to_date: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user