Files
FlightTube/src/components/Settings.tsx
T
vincent b9e8a9db84 feat: play the next video automatically when one ends
A toggle under Playback in Settings, off by default and remembered.

Verified offline with forced offline mode on: a downloaded video ran to
its end and the next one started by itself, 6/51 to 7/51, with no
network.

It follows the same list the Next button does, which already steps to
the next *playable* video — offline that is the next download, so it
plays through what is on the Mac one after another. Online it will
stream the next video, and the Settings text says so: a toggle that
quietly does nothing outside one hidden condition is how the subtitle
preference went wrong three times.

The last video in the list simply stops; there is nowhere to go and
Next is already absent there.
2026-09-01 18:57:07 +02:00

490 lines
19 KiB
TypeScript

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 (
<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,
bulkLimit, onBulkLimit,
streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts,
autoplayNext, onAutoplayNext,
browser, onBrowser, 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 [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([]));
}, []);
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 (
<>
<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>Subscriptions</SectionHeading>
<p className={`mt-1.5 ${HELP}`}>
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 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>Download quality</SectionHeading>
<p className={`mt-1.5 ${HELP}`}>
Above 1080p YouTube only serves VP9 and AV1, and <b>4K AV1 currently plays
back with visible artefacts in this player</b> — 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.
</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}>Download all</span>
<select
value={bulkLimit}
onChange={(e) => onBulkLimit(Number(e.target.value))}
className={SELECT}
>
{BULK_LIMITS.map((b) => (
<option key={b.value} value={b.value}>
{b.label}
</option>
))}
</select>
</label>
<p className={`mt-2 ${HELP}`}>
The cap on one <b>Download all</b>. 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.
</p>
<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>
<label className="mt-3 grid grid-cols-[92px_1fr] items-center gap-2">
<span className={LABEL}>Subtitles</span>
<select
value={subLang}
onChange={(e) => onSubLang(e.target.value)}
className={SELECT}
>
{SUB_LANGS.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</label>
<p className={`mt-2 ${HELP}`}>
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. <b>Off</b> only means
nothing comes on by itself: subtitles are still embedded in downloads and
still listed in the player's subtitle menu.
</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>Playback</SectionHeading>
<label className="mt-2 flex cursor-pointer items-center justify-between gap-3">
<span className={LABEL}>Play next automatically</span>
<input
type="checkbox"
checked={autoplayNext}
onChange={(e) => onAutoplayNext(e.target.checked)}
className="size-4 cursor-pointer accent-sky-500"
/>
</label>
<p className={`mt-2 ${HELP}`}>
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.
</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}`}>
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.
</p>
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
<span className={LABEL}>Use cookies</span>
<select
value={browser}
onChange={(e) => void chooseBrowser(e.target.value)}
className={SELECT}
>
<option value="">Not signed in</option>
{browsers.map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
<div className="mt-2 flex items-center gap-2">
<button onClick={runCheck} disabled={checking} className={`${BTN} cursor-pointer`}>
{checking ? "Checking…" : "Check connection"}
</button>
{check && (
<span
className={`text-[11px] leading-snug ${
check.ok
? "text-slate-500 dark:text-slate-400"
: "text-red-600 dark:text-red-400"
}`}
>
{check.message}
</span>
)}
</div>
</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">
<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>
<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}`}>
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 && (
<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>
{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"}
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>
)}
</>
);
}