H1: decompose SettingsView.tsx into one component per card

Split the 1104-line SettingsView (~11 cards, ~30 selector subs, ~20 useState)
into components/settings/*.tsx, each owning its own store subscriptions, local
state, and handlers:
- DownloadsCard, AppearanceCard, PostProcessingCard, NetworkCard, CookiesCard,
  CustomCommandsCard, FilenamesCard, BackupCard, DiagnosticsCard,
  SoftwareUpdateCard, AboutCard
- settingsStyles.ts holds the shared makeStyles hook.

SettingsView is now a ~90-line shell: the search box + rootRef filter and the
card list. Each card still renders exactly one <Card> DOM node, so the
display-toggle search filter is unchanged. Behaviour identical; 248 tests +
typecheck + lint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:25:58 -04:00
parent ac99fd86a5
commit e4e175736f
13 changed files with 1267 additions and 1139 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react'
import {
Field,
Switch,
Button,
Card,
Subtitle2,
Caption1,
Text,
Spinner,
tokens
} from '@fluentui/react-components'
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import {
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type FfmpegVersionResult
} from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText'
import { logError } from '../../reportError'
import { useSettingsStyles } from './settingsStyles'
const UPDATE_CHANNEL_OPTIONS = [
{ value: 'stable', label: 'Stable' },
{ value: 'nightly', label: 'Nightly' }
]
export function AboutCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
const update = useSettings((s) => s.update)
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
useEffect(() => {
// Show the current yt-dlp version without a manual click, and reflect a
// background auto-update (which may run on launch) live in this panel.
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
return window.api.onYtdlpAutoUpdateStatus((s) => {
if (s.phase === 'checking') {
setChecking(true)
return
}
setChecking(false)
if (s.version) setVersion({ ok: true, version: s.version })
if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
if (s.phase === 'updated') {
setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
} else if (s.phase === 'error' && s.error) {
setUpdateResult({ ok: false, error: s.error })
}
})
}, [])
async function checkVersion(): Promise<void> {
setChecking(true)
setVersion(null)
try {
setVersion(await window.api.getYtdlpVersion())
} catch (e) {
setVersion({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setChecking(false)
}
}
async function runUpdate(): Promise<void> {
setUpdating(true)
setUpdateResult(null)
try {
const result = await window.api.updateYtdlp(ytdlpChannel)
setUpdateResult(result)
if (result.ok) setVersion(null) // stale -- prompt a re-check rather than show a wrong version
} catch (e) {
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setUpdating(false)
}
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
<Subtitle2>About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
yt-dlp, keeping it up to date automatically.
</Caption1>
<Field
label="Keep yt-dlp updated automatically"
hint="Checks once a day on launch and updates the downloader in the background, so site changes don't start breaking your downloads."
>
<Switch
checked={autoUpdateYtdlp}
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
label={autoUpdateYtdlp ? 'On' : 'Off'}
/>
</Field>
{version?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
)}
{version && !version.ok && <Caption1 className={errText.errorPre}>{version.error}</Caption1>}
{ffmpeg && (
<div className={styles.folderRow}>
<div>
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
</Text>
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
ffprobe {ffmpeg.ffprobe ?? 'not found'}
</Text>
</div>
<Button
appearance="subtle"
icon={<ArrowSyncRegular />}
onClick={() =>
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
}
aria-label="Re-check ffmpeg versions"
/>
</div>
)}
{ytdlpLastUpdateCheck > 0 && (
<Caption1 className={styles.hint}>
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
</Caption1>
)}
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}>
Check yt-dlp version
</Button>
{checking && <Spinner size="tiny" />}
</div>
<Field
label="Update channel"
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
>
<Select
aria-label="Update channel"
value={ytdlpChannel}
options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
/>
</Field>
<div className={styles.folderRow}>
<Button
icon={updating ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={runUpdate}
disabled={updating}
>
{updating ? 'Updating…' : 'Update yt-dlp'}
</Button>
</div>
{updateResult?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace, whiteSpace: 'pre-wrap' }}>
{updateResult.output || 'yt-dlp is already up to date.'}
</Text>
)}
{updateResult && !updateResult.ok && (
<Caption1 className={errText.errorPre}>{updateResult.error}</Caption1>
)}
</Card>
)
}
@@ -0,0 +1,73 @@
import { Field, Button, Card, Subtitle2, Caption1, mergeClasses } from '@fluentui/react-components'
import { PaintBucketRegular, AccessibilityRegular } from '@fluentui/react-icons'
import { type ThemeMode, type AccentColor } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useSystemTheme } from '../../store/systemTheme'
import { Select } from '../Select'
import { ACCENT_OPTIONS } from '../../theme'
import { useSettingsStyles } from './settingsStyles'
const THEME_MODE_OPTIONS = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'Follow system' }
]
export function AppearanceCard(): React.JSX.Element {
const styles = useSettingsStyles()
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
const update = useSettings((s) => s.update)
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<PaintBucketRegular className={styles.sectionIcon} />
<Subtitle2>Appearance</Subtitle2>
</div>
<Field label="Theme">
<Select
aria-label="Theme"
value={theme}
options={THEME_MODE_OPTIONS}
onChange={(v) => update({ theme: v as ThemeMode })}
/>
</Field>
<Field label="Accent color">
<div className={styles.swatchRow}>
{ACCENT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={mergeClasses(styles.swatch, accentColor === opt.value && styles.swatchActive)}
style={{ backgroundColor: opt.swatch }}
onClick={() => update({ accentColor: opt.value as AccentColor })}
aria-label={`${opt.label}${accentColor === opt.value ? ' (selected)' : ''}`}
title={opt.label}
/>
))}
</div>
</Field>
<Caption1 className={styles.hint}>
{highContrast
? 'A Windows high-contrast theme is active -- AeroFetch follows your system colors.'
: 'AeroFetch automatically follows a Windows high-contrast theme if you turn one on.'}
</Caption1>
{highContrast && (
<div className={styles.folderRow}>
<Button
size="small"
icon={<AccessibilityRegular />}
onClick={() => window.api.openHighContrastSettings()}
>
Open accessibility settings
</Button>
</div>
)}
</Card>
)
}
@@ -0,0 +1,84 @@
import { useState } from 'react'
import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons'
import { type BackupExportResult, type BackupImportResult } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useTemplates } from '../../store/templates'
import { useErrorTextStyles } from '../ui/errorText'
import { useSettingsStyles } from './settingsStyles'
export function BackupCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
async function exportBackup(): Promise<void> {
setExporting(true)
setExportResult(null)
try {
setExportResult(await window.api.exportBackup())
} catch (e) {
setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setExporting(false)
}
}
async function importBackup(): Promise<void> {
setImporting(true)
setImportResult(null)
try {
const result = await window.api.importBackup()
setImportResult(result)
if (result.ok) {
// Settings + templates changed underneath the stores -- reload both.
const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
useSettings.setState({ ...s, loaded: true })
useTemplates.setState({ templates: t })
}
} catch (e) {
setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setImporting(false)
}
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentArrowDownRegular className={styles.sectionIcon} />
<Subtitle2>Backup &amp; restore</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Save your settings and custom-command templates to a JSON file, or restore them on another
machine. Does not include download history or credentials (proxy, API tokens -- re-enter
those after import).
</Caption1>
<div className={styles.folderRow}>
<Button icon={<DocumentArrowDownRegular />} onClick={exportBackup} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export backup…'}
</Button>
<Button icon={<DocumentArrowUpRegular />} onClick={importBackup} disabled={importing}>
{importing ? 'Importing…' : 'Import backup…'}
</Button>
</div>
{exportResult?.ok && exportResult.path && (
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
)}
{exportResult && !exportResult.ok && exportResult.error && (
<Caption1 className={errText.error}>{exportResult.error}</Caption1>
)}
{importResult?.ok && (
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
)}
{importResult && !importResult.ok && importResult.error && (
<Caption1 className={errText.error}>{importResult.error}</Caption1>
)}
</Card>
)
}
@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react'
import { Field, Input, Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { CookiesRegular } from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
type CookieSource,
type CookieBrowser,
type CookiesStatus
} from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { Select } from '../Select'
import { useErrorTextStyles } from '../ui/errorText'
import { useSettingsStyles } from './settingsStyles'
const COOKIE_SOURCE_OPTIONS = [
{ value: 'none', label: 'None' },
{ value: 'browser', label: "From a browser's cookie store" },
{ value: 'login', label: 'Sign-in window (built into AeroFetch)' }
]
const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
value: b,
label: b.charAt(0).toUpperCase() + b.slice(1)
}))
export function CookiesCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const cookieSource = useSettings((s) => s.cookieSource)
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const update = useSettings((s) => s.update)
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
const [signingIn, setSigningIn] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
async function signIn(): Promise<void> {
setSigningIn(true)
setLoginError(null)
try {
const result = await window.api.cookiesLogin(loginUrl)
if (result.ok && result.cookieCount === 0) {
// Window closed without capturing anything -- don't imply success (L50).
setLoginError('No cookies were captured -- did you sign in before closing the window?')
} else if (result.ok) {
setCookiesStatus(await window.api.cookiesStatus())
} else {
setLoginError(result.error ?? 'Sign-in failed.')
}
} catch (e) {
setLoginError(e instanceof Error ? e.message : String(e))
} finally {
setSigningIn(false)
}
}
async function clearSavedCookies(): Promise<void> {
await window.api.cookiesClear()
setCookiesStatus({ exists: false })
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CookiesRegular className={styles.sectionIcon} />
<Subtitle2>Cookies</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
session. Supply cookies so yt-dlp can act like one.
</Caption1>
<Field label="Cookie source">
<Select
aria-label="Cookie source"
value={cookieSource}
options={COOKIE_SOURCE_OPTIONS}
onChange={(v) => update({ cookieSource: v as CookieSource })}
/>
</Field>
{cookieSource === 'browser' && (
<Field
label="Browser"
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
>
<Select
aria-label="Browser"
value={cookiesBrowser}
options={COOKIE_BROWSER_OPTIONS}
onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })}
/>
</Field>
)}
{cookieSource === 'login' && (
<>
<Field
label="Site to sign in to"
hint="Opens a sign-in window. Log in, then close the window -- your cookies are saved automatically."
>
<div className={styles.folderRow}>
<Input
className={styles.folderInput}
value={loginUrl}
placeholder="https://www.youtube.com"
onChange={(_, d) => setLoginUrl(d.value)}
/>
<Button appearance="primary" onClick={signIn} disabled={signingIn}>
{signingIn ? 'Signing in…' : 'Sign in…'}
</Button>
</div>
</Field>
<div className={styles.folderRow}>
<Caption1 className={styles.hint}>
{cookiesStatus?.exists
? `Cookies saved ${new Date(cookiesStatus.savedAt!).toLocaleString()}.`
: 'Not signed in yet.'}
</Caption1>
{cookiesStatus?.exists && (
<Button size="small" onClick={clearSavedCookies}>
Clear saved cookies
</Button>
)}
</div>
{loginError && <Caption1 className={errText.error}>{loginError}</Caption1>}
</>
)}
</Card>
)
}
@@ -0,0 +1,56 @@
import { Field, Switch, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { CodeRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useTemplates } from '../../store/templates'
import { Select } from '../Select'
import { TemplateManager } from '../TemplateManager'
import { useSettingsStyles } from './settingsStyles'
export function CustomCommandsCard(): React.JSX.Element {
const styles = useSettingsStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const update = useSettings((s) => s.update)
const templates = useTemplates((s) => s.templates)
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CodeRegular className={styles.sectionIcon} />
<Subtitle2>Custom commands</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other
option, so a template flag can override a setting above it.
</Caption1>
<Field
label="Run custom command"
hint="Applies the default template below to every new download (still overridable per download)."
>
<Switch
checked={customCommandEnabled}
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
label={customCommandEnabled ? 'On' : 'Off'}
/>
</Field>
{customCommandEnabled && (
<Field label="Default template">
<Select
aria-label="Default template"
value={defaultTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
...templates.map((t) => ({ value: t.id, label: t.name }))
]}
onChange={(v) => update({ defaultTemplateId: v === 'none' ? null : v })}
/>
</Field>
)}
<TemplateManager />
</Card>
)
}
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components'
import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons'
import { useErrorLog } from '../../store/errorlog'
import { useErrorTextStyles } from '../ui/errorText'
import { useSettingsStyles } from './settingsStyles'
export function DiagnosticsCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const errorEntries = useErrorLog((s) => s.entries)
const clearErrorLog = useErrorLog((s) => s.clear)
const [confirmClearLog, setConfirmClearLog] = useState(false)
function copyErrorReport(): void {
// The button is disabled when there are no entries, so `report` is always
// non-empty here -- no need for an unreachable empty-report fallback (L159).
const report = errorEntries
.map((e) =>
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
)
.join('\n\n---\n\n')
navigator.clipboard.writeText(report).catch(() => {})
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<BugRegular className={styles.sectionIcon} />
<Subtitle2>Diagnostics</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Failed downloads are logged here even after you clear the queue, so you can copy the details
into a bug report.
</Caption1>
<div className={styles.folderRow}>
<Button icon={<CopyRegular />} onClick={copyErrorReport} disabled={errorEntries.length === 0}>
Copy full report
</Button>
{confirmClearLog ? (
<>
<Caption1>
Clear all {errorEntries.length} {errorEntries.length === 1 ? 'error' : 'errors'}?
</Caption1>
<Button
icon={<DeleteRegular />}
appearance="primary"
onClick={() => {
clearErrorLog()
setConfirmClearLog(false)
}}
>
Clear log
</Button>
<Button appearance="subtle" onClick={() => setConfirmClearLog(false)}>
Cancel
</Button>
</>
) : (
<Button
icon={<DeleteRegular />}
onClick={() => setConfirmClearLog(true)}
disabled={errorEntries.length === 0}
>
Clear log
</Button>
)}
</div>
{errorEntries.length === 0 ? (
<Caption1 className={styles.hint}>No errors yet.</Caption1>
) : (
<div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => (
<div key={e.id} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}>{new Date(e.occurredAt).toLocaleString()}</Caption1>
</div>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
</div>
))}
{errorEntries.length > 20 && (
<Caption1 className={styles.hint}>Showing 20 of {errorEntries.length} errors.</Caption1>
)}
</div>
)}
</Card>
)
}
@@ -0,0 +1,172 @@
import { Field, Input, SpinButton, Switch, Button, Card, Subtitle2 } from '@fluentui/react-components'
import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
import { type MediaKind } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useDownloads } from '../../store/downloads'
import { QUALITY_OPTIONS } from '../../qualityOptions'
import { Select } from '../Select'
import { useSettingsStyles } from './settingsStyles'
// Combined "Type -- Quality" options for the default-format dropdown.
const FORMAT_OPTIONS = [
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video -- ${q}` })),
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio -- ${q}` }))
]
export function DownloadsCard(): React.JSX.Element {
const styles = useSettingsStyles()
const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir)
const clearDir = useSettings((s) => s.clearDir)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const maxConcurrent = useSettings((s) => s.maxConcurrent)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
const minimizeToTray = useSettings((s) => s.minimizeToTray)
const launchAtStartup = useSettings((s) => s.launchAtStartup)
const update = useSettings((s) => s.update)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
const formatValue = `${defaultKind}|${formatQuality}`
function onFormatSelect(value: string): void {
const sep = value.indexOf('|')
const kind: MediaKind = sep >= 0 && value.slice(0, sep) === 'audio' ? 'audio' : 'video'
const quality = sep >= 0 ? value.slice(sep + 1) : value
update(
kind === 'audio'
? { defaultKind: 'audio', defaultAudioQuality: quality }
: { defaultKind: 'video', defaultVideoQuality: quality }
)
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} />
<Subtitle2>Downloads</Subtitle2>
</div>
<Field
label="Video folder"
hint="Where video downloads are saved. Leave blank to use Documents\Video."
>
<div className={styles.folderRow}>
<Input
className={styles.folderInput}
value={videoDir}
placeholder="Documents\Video (default)"
contentBefore={<FolderRegular />}
onChange={(_, d) => update({ videoDir: d.value })}
/>
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
Browse
</Button>
{videoDir && (
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
Reset
</Button>
)}
</div>
</Field>
<Field
label="Audio folder"
hint="Where audio downloads are saved. Leave blank to use Documents\Audio."
>
<div className={styles.folderRow}>
<Input
className={styles.folderInput}
value={audioDir}
placeholder="Documents\Audio (default)"
contentBefore={<FolderRegular />}
onChange={(_, d) => update({ audioDir: d.value })}
/>
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
Browse
</Button>
{audioDir && (
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
Reset
</Button>
)}
</div>
</Field>
<Field label="Default format">
<Select
aria-label="Default format"
value={formatValue}
options={FORMAT_OPTIONS}
onChange={onFormatSelect}
/>
</Field>
<Field
label="Maximum simultaneous downloads"
hint="How many downloads run at once. 2-3 is a good balance."
>
<SpinButton
value={maxConcurrent}
min={1}
max={5}
onChange={(_, d) => {
const v = d.value ?? Number(d.displayValue)
if (typeof v === 'number' && Number.isFinite(v)) {
update({ maxConcurrent: Math.min(5, Math.max(1, Math.round(v))) })
// Raising the cap should start queued items right away.
useDownloads.getState().pump()
}
}}
/>
</Field>
<Field
label="Detect links from clipboard"
hint="When AeroFetch gains focus, offer a video link you've just copied."
>
<Switch
checked={clipboardWatch}
onChange={(_, d) => update({ clipboardWatch: d.checked })}
label={clipboardWatch ? 'On' : 'Off'}
/>
</Field>
<Field
label="Notify when downloads finish"
hint="Shows a native Windows notification when a download completes or fails."
>
<Switch
checked={notifyOnComplete}
onChange={(_, d) => update({ notifyOnComplete: d.checked })}
label={notifyOnComplete ? 'On' : 'Off'}
/>
</Field>
<Field
label="Keep running in the tray"
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting -- so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)"
>
<Switch
checked={minimizeToTray}
onChange={(_, d) => update({ minimizeToTray: d.checked })}
label={minimizeToTray ? 'On' : 'Off'}
/>
</Field>
<Field
label="Start with Windows"
hint="Launch AeroFetch automatically when you sign in -- useful with auto-download so watched channels stay current in the background."
>
<Switch
checked={launchAtStartup}
onChange={(_, d) => update({ launchAtStartup: d.checked })}
label={launchAtStartup ? 'On' : 'Off'}
/>
</Field>
</Card>
)
}
@@ -0,0 +1,52 @@
import { Field, Input, Switch, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { DocumentRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useSettingsStyles } from './settingsStyles'
export function FilenamesCard(): React.JSX.Element {
const styles = useSettingsStyles()
const filenameTemplate = useSettings((s) => s.filenameTemplate)
const restrictFilenames = useSettings((s) => s.restrictFilenames)
const downloadArchive = useSettings((s) => s.downloadArchive)
const update = useSettings((s) => s.update)
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} />
<Subtitle2>Filenames</Subtitle2>
</div>
<Field
label="Filename template"
hint="Controls how saved files are named. Use %(title)s for the video title, %(ext)s for the extension, and similar tokens."
>
<Input value={filenameTemplate} onChange={(_, d) => update({ filenameTemplate: d.value })} />
</Field>
<Caption1 className={styles.hint}>
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
</Caption1>
<Field
label="Restrict filenames"
hint="Sanitize titles to plain ASCII letters/digits, no spaces -- safer for old filesystems and shells."
>
<Switch
checked={restrictFilenames}
onChange={(_, d) => update({ restrictFilenames: d.checked })}
label={restrictFilenames ? 'On' : 'Off'}
/>
</Field>
<Field
label="Skip already-downloaded videos"
hint="Keeps a record of completed downloads (--download-archive) and skips them on repeat playlist/channel runs."
>
<Switch
checked={downloadArchive}
onChange={(_, d) => update({ downloadArchive: d.checked })}
label={downloadArchive ? 'On' : 'Off'}
/>
</Field>
</Card>
)
}
@@ -0,0 +1,118 @@
import { useState } from 'react'
import { Field, Input, Switch, Button, Card, Subtitle2, Spinner } from '@fluentui/react-components'
import { GlobeRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { useSettingsStyles } from './settingsStyles'
export function NetworkCard(): React.JSX.Element {
const styles = useSettingsStyles()
const proxy = useSettings((s) => s.proxy)
const rateLimit = useSettings((s) => s.rateLimit)
const useAria2c = useSettings((s) => s.useAria2c)
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
const youtubePoToken = useSettings((s) => s.youtubePoToken)
const update = useSettings((s) => s.update)
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<GlobeRegular className={styles.sectionIcon} />
<Subtitle2>Network</Subtitle2>
</div>
<Field
label="Proxy"
hint="HTTP/HTTPS/SOCKS proxy URL, e.g. socks5://127.0.0.1:1080. Leave blank to use the system default."
>
<Input
type="password"
value={proxy}
placeholder="socks5://127.0.0.1:1080"
onChange={(_, d) => update({ proxy: d.value })}
/>
</Field>
<Field
label="Rate limit"
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
>
<Input value={rateLimit} placeholder="2M" onChange={(_, d) => update({ rateLimit: d.value })} />
</Field>
<Field
label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
>
<Switch
checked={useAria2c}
onChange={(_, d) => update({ useAria2c: d.checked })}
label={useAria2c ? 'On' : 'Off'}
/>
</Field>
<Field
label="YouTube client (advanced)"
hint="Override how AeroFetch identifies itself to YouTube when downloads start failing. Try web_safari, tv, or mweb. Leave blank for the automatic default."
>
<datalist id="yt-client-list">
{['web', 'web_safari', 'web_embedded', 'mweb', 'tv', 'ios', 'android'].map((c) => (
<option key={c} value={c} />
))}
</datalist>
<Input
value={youtubePlayerClient}
placeholder="web_safari"
list="yt-client-list"
onChange={(_, d) => update({ youtubePlayerClient: d.value })}
/>
</Field>
<Field
label="YouTube PO Token (advanced)"
hint={
potHint ??
'A token that helps get past YouTube\'s bot check. Click "Fetch" to grab one automatically, or paste it manually. Signing in with cookies (above) is usually the easier fix.'
}
>
<div className={styles.folderRow}>
<Input
type="password"
style={{ flexGrow: 1 }}
value={youtubePoToken}
placeholder="(none)"
onChange={(_, d) => {
update({ youtubePoToken: d.value })
setPotHint(null)
}}
/>
<Button
size="small"
disabled={mintingPot}
icon={mintingPot ? <Spinner size="tiny" /> : undefined}
onClick={async () => {
setMintingPot(true)
setPotHint(null)
try {
const token = await window.api.mintPoToken()
if (token) {
setPotHint('Token saved.')
} else {
setPotHint('Token not found -- try signing into YouTube first via Cookies above.')
}
} catch {
setPotHint('Failed to open YouTube window.')
} finally {
setMintingPot(false)
}
}}
>
{mintingPot ? 'Fetching…' : 'Fetch'}
</Button>
</div>
</Field>
</Card>
)
}
@@ -0,0 +1,30 @@
import { Card, Subtitle2, Caption1 } from '@fluentui/react-components'
import { OptionsRegular } from '@fluentui/react-icons'
import { useSettings } from '../../store/settings'
import { DownloadOptionsForm } from '../DownloadOptionsForm'
import { useSettingsStyles } from './settingsStyles'
export function PostProcessingCard(): React.JSX.Element {
const styles = useSettingsStyles()
const defaultKind = useSettings((s) => s.defaultKind)
const downloadOptions = useSettings((s) => s.downloadOptions)
const update = useSettings((s) => s.update)
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
<Subtitle2>Format &amp; post-processing</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
</Caption1>
<DownloadOptionsForm
value={downloadOptions}
kind={defaultKind}
onChange={(o) => update({ downloadOptions: o })}
/>
</Card>
)
}
@@ -0,0 +1,156 @@
import { useEffect, useState } from 'react'
import {
Field,
Input,
Button,
Card,
Subtitle2,
Caption1,
Text,
Spinner,
ProgressBar,
tokens
} from '@fluentui/react-components'
import { ArrowSyncRegular, ArrowDownloadRegular, OpenRegular } from '@fluentui/react-icons'
import { type AppUpdateInfo } from '@shared/ipc'
import { useSettings } from '../../store/settings'
import { useErrorTextStyles } from '../ui/errorText'
import { logError } from '../../reportError'
import { useSettingsStyles } from './settingsStyles'
export function SoftwareUpdateCard(): React.JSX.Element {
const styles = useSettingsStyles()
const errText = useErrorTextStyles()
const updateToken = useSettings((s) => s.updateToken)
const update = useSettings((s) => s.update)
const [appVersion, setAppVersion] = useState('')
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null)
const [appChecking, setAppChecking] = useState(false)
const [appDownloading, setAppDownloading] = useState(false)
const [appFraction, setAppFraction] = useState<number | undefined>(undefined)
const [appUpdError, setAppUpdError] = useState<string | null>(null)
useEffect(() => {
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
}, [])
async function checkAppUpdate(): Promise<void> {
setAppChecking(true)
setAppUpd(null)
setAppUpdError(null)
try {
const info = await window.api.checkForAppUpdate()
// Funnel a failed check into the single error slot rather than storing a
// second not-ok AppUpdateInfo, so only one error message can ever render.
if (info.ok) setAppUpd(info)
else setAppUpdError(info.error ?? 'Update check failed.')
} catch (e) {
setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppChecking(false)
}
}
async function installAppUpdate(): Promise<void> {
if (!appUpd?.downloadUrl) return
setAppDownloading(true)
setAppFraction(undefined)
setAppUpdError(null)
try {
const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
if (!dl.ok || !dl.filePath) {
setAppUpdError(dl.error ?? 'Download failed.')
return
}
const run = await window.api.runAppUpdate(dl.filePath)
// On success the app quits as the installer launches; only errors return here.
if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
} catch (e) {
setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppDownloading(false)
}
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowSyncRegular className={styles.sectionIcon} />
<Subtitle2>Software update</Subtitle2>
</div>
<Caption1 className={styles.hint}>
{appVersion
? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.`
: 'Checking your AeroFetch version…'}
</Caption1>
<div className={styles.folderRow}>
<Button
icon={appChecking ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
onClick={checkAppUpdate}
disabled={appChecking || appDownloading}
>
{appChecking ? 'Checking…' : 'Check for updates'}
</Button>
</div>
{(updateToken.trim() !== '' || !!appUpdError) && (
<Field
label="Update access token"
hint="Only needed if the update server requires sign-in. Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
>
<Input
type="password"
value={updateToken}
placeholder="Optional -- access token"
onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />}
/>
</Field>
)}
{appUpd?.ok && appUpd.available && (
<>
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
Version {appUpd.latestVersion} is available -- here&apos;s what changed:
</Text>
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
<div className={styles.folderRow}>
<Button
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={installAppUpdate}
disabled={appDownloading || !appUpd.downloadUrl}
>
{appDownloading ? 'Downloading…' : 'Update now'}
</Button>
{appUpd.htmlUrl && (
<Button
icon={<OpenRegular />}
onClick={() => void window.api?.openUrl?.(appUpd.htmlUrl ?? '')}
>
View release
</Button>
)}
</div>
{!appUpd.downloadUrl && (
<Caption1 className={styles.hint}>
This release has no installer attached -- use "View release" to download it manually.
</Caption1>
)}
{appDownloading && (
<ProgressBar value={appFraction} aria-label="App update download progress" />
)}
</>
)}
{appUpd?.ok && !appUpd.available && (
<Text>You&apos;re up to date -- v{appUpd.currentVersion} is the latest.</Text>
)}
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
</Card>
)
}
@@ -0,0 +1,86 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
// Shared styles for the Settings cards. Each card renders a single <Card> so it
// stays one direct child of SettingsView's root -- the search filter toggles
// `display` per child, so the one-DOM-node-per-card shape must be preserved.
export const useSettingsStyles = makeStyles({
card: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '20px',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
},
notesBox: {
maxHeight: '180px',
overflowY: 'auto',
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke2}`,
whiteSpace: 'pre-wrap',
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase300
},
sectionHeader: {
display: 'flex',
alignItems: 'center',
gap: '10px'
},
sectionIcon: {
fontSize: '20px',
color: tokens.colorCompoundBrandForeground1
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
},
hint: {
color: tokens.colorNeutralForeground3
},
swatchRow: {
display: 'flex',
gap: '10px'
},
swatch: {
boxSizing: 'border-box',
width: '28px',
height: '28px',
flexShrink: 0,
...shorthands.borderRadius(tokens.borderRadiusCircular),
...shorthands.border('2px', 'solid', 'transparent'),
padding: 0,
cursor: 'pointer'
},
swatchActive: {
...shorthands.borderColor(tokens.colorNeutralForeground1)
},
errorList: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
errorRow: {
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
errorRowHeader: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
errorRowTitle: {
flexGrow: 1,
minWidth: 0,
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
})