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:
vincent
2026-08-29 15:17:15 +02:00
parent 923fd3273f
commit 48acaa359f
13 changed files with 416 additions and 74 deletions
+92 -26
View File
@@ -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"}