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.
277 lines
11 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|