import { useEffect, useState } from "react"; import { 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, BULK_LIMITS, 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, } from "./ui"; interface Props { onClose: () => void; onImported: (count: number) => void; appearance: Appearance; onAppearance: (a: Appearance) => void; quality: Quality; onQuality: (q: Quality) => void; bulkLimit: number; onBulkLimit: (n: number) => void; streamQuality: Quality; onStreamQuality: (q: Quality) => void; subLang: string; onSubLang: (l: string) => void; hideShorts: boolean; onHideShorts: (v: boolean) => void; autoplayNext: boolean; onAutoplayNext: (v: boolean) => void; browser: string; onBrowser: (b: string) => 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 (
{label} {value ?? "Not found"}
); } export default function Settings({ onClose, onImported, appearance, onAppearance, quality, onQuality, bulkLimit, onBulkLimit, streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts, autoplayNext, onAutoplayNext, browser, onBrowser, onError, }: Props) { const [prereqs, setPrereqs] = useState(null); const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null); const [busy, setBusy] = useState(false); const [browsers, setBrowsers] = useState>([]); const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null); const [checking, setChecking] = useState(false); const [guide, setGuide] = useState(false); const [update, setUpdate] = useState(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([])); }, []); const chooseBrowser = async (id: string) => { onBrowser(id); setCheck(null); try { await setCookieSource(id); } catch (e) { onError(String(e)); } }; const runCheck = async () => { setChecking(true); setCheck(null); try { setCheck({ ok: true, message: await testYoutube() }); } catch (e) { setCheck({ ok: false, message: String(e) }); } finally { setChecking(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 ( <>
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" >

Settings

Subscriptions

Importing replaces 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.

Download quality

Above 1080p YouTube only serves VP9 and AV1, and 4K AV1 currently plays back with visible artefacts in this player — the downloaded file is fine, it is the built-in decoder that struggles. 1080p gets H.264, which plays cleanly and is several times smaller. Audio is AAC at every setting.

The cap on one Download all. It takes the newest first, so from All subscriptions you get the latest across every channel rather than several hundred videos at once. Press it again for the next batch.

Streaming quality applies when you play something you have not downloaded. Best lets the player adapt to your connection; a fixed height pins it.

Which language switches itself on, including YouTube's auto-generated captions — on most videos those are the only ones there are. Downloads keep them beside the file for offline use; streams fetch them separately, since YouTube's live manifest carries no subtitles at all. Off only means nothing comes on by itself: subtitles are still embedded in downloads and still listed in the player's subtitle menu.

Feed

Keeps YouTube Shorts out of the feed entirely.

Playback

When a video ends, the next one in the list starts on its own. Meant for offline viewing, where it plays through your downloads one after another; online it will stream the next video, and it stops at the end of the list.

Sign in to YouTube

YouTube sometimes asks a machine to prove it is not a bot, and then nothing will stream or download. Pointing the app at a browser you are already signed into clears that. The cookies are read on this Mac and sent only to YouTube.

{check && ( {check.message} )}
Appearance
Theme ({ value: m, label: m[0].toUpperCase() + m.slice(1), }))} />
Status
yt-dlp {prereqs?.yt_dlp ?? "Not found"} {update && !update.up_to_date && update.latest && ( )} {update?.up_to_date && ( up to date )}
Library

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.

{missing && (

A bundled tool is missing, which should not happen. Reinstalling the app will restore it; meanwhile brew install yt-dlp ffmpeg also works.

)}
{guide && ( setGuide(false)} wide>

YouTube has no public API for someone else's subscription list, so FlightTube reads the export Google gives you. It takes about two minutes.

)} {pending && p && ( setPending(null)} onConfirm={busy ? undefined : confirmImport} confirmLabel={destructive ? "Replace" : "Import"} destructive={destructive} >

The file lists {p.incoming} subscription{p.incoming === 1 ? "" : "s"}, which will become your complete list.

{destructive ? (
  • {p.removed_channels} channel{p.removed_channels === 1 ? "" : "s"} no longer subscribed will be removed
  • {p.removed_videos} of their video{p.removed_videos === 1 ? "" : "s"} will disappear from your feed
  • {p.removed_downloads > 0 && (
  • {p.removed_downloads} downloaded file {p.removed_downloads === 1 ? "" : "s"} will be deleted from disk
  • )}
) : (

Nothing currently stored will be removed.

)}
)} ); }