Files
FlightTube/src/components/Settings.tsx
T
vincent 3296b5436a feat: bundle yt-dlp and ffmpeg as sidecars
The app is now self-contained: yt-dlp, ffmpeg and ffprobe ship inside
the bundle and are resolved beside the executable, so a fresh Mac needs
nothing installed. A system copy still wins when present, which is how
to run a newer yt-dlp than the bundled one. yt-dlp is pointed at the
bundled ffmpeg explicitly, since a bundled app has no reason to have one
on PATH.

The binaries stay out of git (~140MB); scripts/fetch-binaries.sh pulls
them. Homebrew's ffmpeg cannot be used — it links a dozen dylibs and
does not relocate — so the static arm64 build is used instead.

Version checks moved to the async Command. yt-dlp is a PyInstaller
bundle that unpacks ~37MB on first run, and the blocking call was
stalling a runtime worker for roughly twenty seconds.

Bundle size goes from 19MB to 153MB.
2026-08-29 11:41:14 +02:00

277 lines
11 KiB
TypeScript

import { useEffect, useState } from "react";
import {
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
} from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import {
QUALITIES, STREAM_QUALITIES, type ImportPreview, type Prereqs, type Quality,
} from "../types";
import TakeoutGuide from "./TakeoutGuide";
import {
BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented, SUBPANEL,
} from "./ui";
interface Props {
onClose: () => void;
onImported: (count: number) => void;
appearance: Appearance;
onAppearance: (a: Appearance) => void;
quality: Quality;
onQuality: (q: Quality) => void;
streamQuality: Quality;
onStreamQuality: (q: Quality) => void;
onError: (message: string) => void;
}
const SELECT =
`w-full ${CONTROL_H} cursor-pointer rounded-lg border border-slate-300 bg-white px-2 ` +
"text-[12px] outline-none dark:border-slate-700 dark:bg-slate-800";
function StatusRow({ label, value }: { label: string; value: string | null }) {
return (
<div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 last:border-b-0 dark:border-slate-800">
<span className={LABEL}>{label}</span>
<span
className={`text-right text-[12px] ${
value ? "text-slate-600 dark:text-slate-300" : "text-red-600 dark:text-red-400"
}`}
>
{value ?? "Not found"}
</span>
</div>
);
}
export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality,
streamQuality, onStreamQuality, onError,
}: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
const [busy, setBusy] = useState(false);
const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null));
useEffect(() => { load(); }, []);
const startImport = async () => {
try {
const path = await pickTakeoutFile();
if (!path) return;
setPending({ path, preview: await previewTakeoutImport(path) });
} catch (e) {
onError(String(e));
}
};
const confirmImport = async () => {
if (!pending) return;
setBusy(true);
try {
const n = await importTakeoutCsv(pending.path);
setPending(null);
onImported(n);
} catch (e) {
setPending(null);
onError(String(e));
} finally {
setBusy(false);
}
};
const pickFolder = async () => {
try {
if (await pickLibraryFolder()) load();
} catch (e) {
onError(String(e));
}
};
const missing = prereqs && (!prereqs.yt_dlp || !prereqs.ffmpeg);
const p = pending?.preview;
const destructive = !!p && (p.removed_channels > 0 || p.removed_downloads > 0);
return (
<>
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="flex max-h-[82vh] w-full max-w-lg flex-col rounded-2xl border border-slate-300
bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
>
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<h2 className="text-[15px] font-semibold tracking-tight">Settings</h2>
<button
onClick={onClose}
title="Close settings"
aria-label="Close settings"
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900
dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</header>
<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>
<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.
</p>
<div className={`mt-3 ${SUBPANEL}`}>
<TakeoutGuide />
</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}`}>
Above 1080p YouTube only serves VP9 and AV1. Those play here, but the
files are several times larger and older Macs may struggle. Pick 1080p
for H.264, which plays anywhere. Audio is AAC at every setting.
</p>
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
<span className={LABEL}>Quality</span>
<select
value={quality}
onChange={(e) => onQuality(e.target.value as Quality)}
className={SELECT}
>
{QUALITIES.map((q) => (
<option key={q.value} value={q.value}>
{q.label}
</option>
))}
</select>
</label>
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
<span className={LABEL}>Streaming</span>
<select
value={streamQuality}
onChange={(e) => onStreamQuality(e.target.value as Quality)}
className={SELECT}
>
{STREAM_QUALITIES.map((q) => (
<option key={q.value} value={q.value}>
{q.label}
</option>
))}
</select>
</label>
<p className={`mt-2 ${HELP}`}>
Streaming quality applies when you play something you have not downloaded.
Best lets the player adapt to your connection; a fixed height pins it.
</p>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Appearance</SectionHeading>
<div className="mt-2 flex items-center justify-between gap-3">
<span className={LABEL}>Theme</span>
<Segmented
value={appearance}
onChange={onAppearance}
options={APPEARANCE_MODES.map((m) => ({
value: m,
label: m[0].toUpperCase() + m.slice(1),
}))}
/>
</div>
</section>
<section className="px-5 py-4">
<SectionHeading>Status</SectionHeading>
<div className="mt-2">
<StatusRow label="yt-dlp" value={prereqs?.yt_dlp ?? null} />
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
<div className="flex items-start justify-between gap-4 py-2">
<span className={LABEL}>Library</span>
<button
onClick={pickFolder}
className="cursor-pointer break-all text-right text-[12px] text-sky-600
underline underline-offset-2 hover:text-sky-500 dark:text-sky-400"
>
{prereqs?.library_path ?? "…"}
</button>
</div>
</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.
</p>
{missing && (
<div className="mt-2 rounded-lg border border-red-500/30 bg-red-500/5 p-3">
<p className="text-[11px] leading-snug text-red-700 dark:text-red-300">
A bundled tool is missing, which should not happen. Reinstalling the app
will restore it; meanwhile <code>brew install yt-dlp ffmpeg</code> also
works.
</p>
</div>
)}
</section>
</div>
</div>
</div>
{pending && p && (
<Dialog
title={destructive ? "Replace your subscriptions?" : "Import subscriptions"}
onCancel={() => setPending(null)}
onConfirm={busy ? undefined : confirmImport}
confirmLabel={destructive ? "Replace" : "Import"}
destructive={destructive}
>
<p>
The file lists <b>{p.incoming}</b> subscription{p.incoming === 1 ? "" : "s"}, which
will become your complete list.
</p>
{destructive ? (
<ul className="mt-2 space-y-1">
<li>
<b>{p.removed_channels}</b> channel{p.removed_channels === 1 ? "" : "s"} no longer
subscribed will be removed
</li>
<li>
<b>{p.removed_videos}</b> of their video{p.removed_videos === 1 ? "" : "s"} will
disappear from your feed
</li>
{p.removed_downloads > 0 && (
<li className="text-red-600 dark:text-red-400">
<b>{p.removed_downloads}</b> downloaded file
{p.removed_downloads === 1 ? "" : "s"} will be deleted from disk
</li>
)}
</ul>
) : (
<p className="mt-2">Nothing currently stored will be removed.</p>
)}
</Dialog>
)}
</>
);
}