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