8ab85da67e
H5: HistoryEntry now stores formatId+formatHasAudio; redownload passes them
back to addFromUrl so the original yt-dlp format is reused, not guessed.
Compound quality labels stripped to the preset prefix for old history.
L72: thumbnail now passed in redownload so re-queued rows keep their art.
H6: TerminalView log capped at MAX_LOG_LINES=2000 to prevent unbounded growth.
M21: --audio-quality omitted for lossless audioFormats (flac/wav).
L166: fmtEta hour rollover fixed in all 3 locations -- 2h00:00 not 120:00.
L165: queueStats formatSpeed precision aligned with fmtBytes.
L55: QueueItem suppresses sizeLabel when already in probed-format quality label.
L47: probeMeta null sends empty meta event so Resolving clears via SR6.
L68: notifiedBackground resets on window show for subsequent close-while-downloading.
L76: dup warning falls back to URL when title is still a placeholder.
L91: SettingsView onFormatSelect uses indexOf instead of unsafe tuple cast.
L98: Embed chapters field gets a hint text.
L108: BrowserWindow created with explicit title AeroFetch.
L37: pure formatters extracted to src/main/lib/formatters.ts and unit-tested.
L38: buildArgs test covers webm thumbnail suppression.
L39: CROP_SQUARE_PPA test is structural not exact-string.
L40: looksLikeUrl/looksLikeSingleVideo extracted to src/renderer/src/lib/urlHelpers.ts.
typecheck + 242 tests + eslint + prettier green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1168 lines
40 KiB
TypeScript
1168 lines
40 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import {
|
||
Field,
|
||
Input,
|
||
SpinButton,
|
||
Switch,
|
||
Button,
|
||
Card,
|
||
Subtitle2,
|
||
Caption1,
|
||
Spinner,
|
||
Text,
|
||
ProgressBar,
|
||
makeStyles,
|
||
mergeClasses,
|
||
tokens,
|
||
shorthands
|
||
} from '@fluentui/react-components'
|
||
import {
|
||
FolderRegular,
|
||
ArrowDownloadRegular,
|
||
DocumentRegular,
|
||
OptionsRegular,
|
||
InfoRegular,
|
||
GlobeRegular,
|
||
CookiesRegular,
|
||
CodeRegular,
|
||
ArrowClockwiseRegular,
|
||
DocumentArrowDownRegular,
|
||
DocumentArrowUpRegular,
|
||
BugRegular,
|
||
CopyRegular,
|
||
DeleteRegular,
|
||
PaintBucketRegular,
|
||
AccessibilityRegular,
|
||
ArrowSyncRegular,
|
||
OpenRegular,
|
||
SearchRegular
|
||
} from '@fluentui/react-icons'
|
||
import {
|
||
COOKIE_BROWSERS,
|
||
type YtdlpVersionResult,
|
||
type YtdlpUpdateChannel,
|
||
type YtdlpUpdateResult,
|
||
type FfmpegVersionResult,
|
||
type AppUpdateInfo,
|
||
type MediaKind,
|
||
type CookieSource,
|
||
type CookieBrowser,
|
||
type CookiesStatus,
|
||
type BackupExportResult,
|
||
type BackupImportResult,
|
||
type ThemeMode,
|
||
type AccentColor
|
||
} from '@shared/ipc'
|
||
import { useSettings } from '../store/settings'
|
||
import { useSystemTheme } from '../store/systemTheme'
|
||
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
|
||
import { useTemplates } from '../store/templates'
|
||
import { useErrorLog } from '../store/errorlog'
|
||
import { Select } from './Select'
|
||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||
import { TemplateManager } from './TemplateManager'
|
||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||
import { useErrorTextStyles } from './ui/errorText'
|
||
import { ACCENT_OPTIONS } from '../theme'
|
||
import { logError } from '../reportError'
|
||
|
||
const useStyles = makeStyles({
|
||
root: {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '16px'
|
||
},
|
||
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'
|
||
}
|
||
})
|
||
|
||
// 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}` }))
|
||
]
|
||
|
||
const THEME_MODE_OPTIONS = [
|
||
{ value: 'light', label: 'Light' },
|
||
{ value: 'dark', label: 'Dark' },
|
||
{ value: 'system', label: 'Follow system' }
|
||
]
|
||
|
||
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)
|
||
}))
|
||
|
||
const UPDATE_CHANNEL_OPTIONS = [
|
||
{ value: 'stable', label: 'Stable' },
|
||
{ value: 'nightly', label: 'Nightly' }
|
||
]
|
||
|
||
export function SettingsView(): React.JSX.Element {
|
||
const styles = useStyles()
|
||
const errText = useErrorTextStyles()
|
||
const screen = useScreenStyles()
|
||
|
||
// Settings search (Phase O). Rather than thread a query through all ~11 cards,
|
||
// filter by toggling each card's `display` based on whether its text matches.
|
||
// The cards are the root's direct children; the search box (marked
|
||
// data-settings-search) is skipped. React never sets `style` on the cards, so
|
||
// our inline display survives their re-renders.
|
||
const [search, setSearch] = useState('')
|
||
const [noResults, setNoResults] = useState(false)
|
||
const rootRef = useRef<HTMLDivElement>(null)
|
||
useEffect(() => {
|
||
const root = rootRef.current
|
||
if (!root) return
|
||
const q = search.trim().toLowerCase()
|
||
let shown = 0
|
||
for (const el of Array.from(root.children) as HTMLElement[]) {
|
||
if (el.dataset.settingsSearch !== undefined) continue
|
||
const match = !q || (el.textContent ?? '').toLowerCase().includes(q)
|
||
el.style.display = match ? '' : 'none'
|
||
if (match) shown++
|
||
}
|
||
setNoResults(q !== '' && shown === 0)
|
||
}, [search])
|
||
|
||
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 filenameTemplate = useSettings((s) => s.filenameTemplate)
|
||
const theme = useSettings((s) => s.theme)
|
||
const accentColor = useSettings((s) => s.accentColor)
|
||
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
|
||
const clipboardWatch = useSettings((s) => s.clipboardWatch)
|
||
const downloadOptions = useSettings((s) => s.downloadOptions)
|
||
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 cookieSource = useSettings((s) => s.cookieSource)
|
||
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
|
||
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
||
const downloadArchive = useSettings((s) => s.downloadArchive)
|
||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
||
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
||
const minimizeToTray = useSettings((s) => s.minimizeToTray)
|
||
const launchAtStartup = useSettings((s) => s.launchAtStartup)
|
||
const updateToken = useSettings((s) => s.updateToken)
|
||
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 templates = useTemplates((s) => s.templates)
|
||
const errorEntries = useErrorLog((s) => s.entries)
|
||
const clearErrorLog = useErrorLog((s) => s.clear)
|
||
|
||
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
|
||
const formatValue = `${defaultKind}|${formatQuality}`
|
||
|
||
const [checking, setChecking] = useState(false)
|
||
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
|
||
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
|
||
const [mintingPot, setMintingPot] = useState(false)
|
||
const [potHint, setPotHint] = useState<string | null>(null)
|
||
|
||
const [updating, setUpdating] = useState(false)
|
||
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
||
|
||
// --- AeroFetch app self-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)
|
||
|
||
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)
|
||
|
||
const [exporting, setExporting] = useState(false)
|
||
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
|
||
const [importing, setImporting] = useState(false)
|
||
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
|
||
const [confirmClearLog, setConfirmClearLog] = useState(false)
|
||
|
||
useEffect(() => {
|
||
window.api.cookiesStatus().then(setCookiesStatus)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
|
||
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
|
||
}, [])
|
||
|
||
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 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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
function copyErrorReport(): void {
|
||
// The button is disabled when there are no entries, so `report` is always
|
||
// non-empty here — no need for an unreachable "No errors logged." 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(() => {})
|
||
}
|
||
|
||
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 })
|
||
}
|
||
|
||
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 }
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
|
||
<div data-settings-search>
|
||
<ScreenHeader
|
||
title="Settings"
|
||
description="Folders, formats, network, cookies, and more."
|
||
/>
|
||
</div>
|
||
<div data-settings-search>
|
||
<Input
|
||
input={{ 'aria-label': 'Search settings' }}
|
||
value={search}
|
||
onChange={(_, d) => setSearch(d.value)}
|
||
placeholder="Search settings…"
|
||
contentBefore={<SearchRegular />}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
{noResults && (
|
||
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
|
||
No settings match “{search}”.
|
||
</Caption1>
|
||
)}
|
||
</div>
|
||
|
||
<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
|
||
readOnly
|
||
className={styles.folderInput}
|
||
value={videoDir}
|
||
placeholder="Documents\Video (default)"
|
||
contentBefore={<FolderRegular />}
|
||
/>
|
||
<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
|
||
readOnly
|
||
className={styles.folderInput}
|
||
value={audioDir}
|
||
placeholder="Documents\Audio (default)"
|
||
contentBefore={<FolderRegular />}
|
||
/>
|
||
<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>
|
||
|
||
<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-pressed={accentColor === opt.value}
|
||
aria-label={opt.label}
|
||
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>
|
||
|
||
<Card className={styles.card}>
|
||
<div className={styles.sectionHeader}>
|
||
<OptionsRegular className={styles.sectionIcon} />
|
||
<Subtitle2>Format & 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}
|
||
onChange={(o) => update({ downloadOptions: o })}
|
||
/>
|
||
</Card>
|
||
|
||
<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
|
||
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 the YouTube extraction client when downloads start failing the bot check. Try web_safari, tv, or mweb. Leave blank for yt-dlp's default."
|
||
>
|
||
<Input
|
||
value={youtubePlayerClient}
|
||
placeholder="web_safari"
|
||
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
|
||
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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<Card className={styles.card}>
|
||
<div className={styles.sectionHeader}>
|
||
<DocumentRegular className={styles.sectionIcon} />
|
||
<Subtitle2>Filenames</Subtitle2>
|
||
</div>
|
||
<Field
|
||
label="Filename template"
|
||
hint="yt-dlp output template. Tokens like %(title)s and %(ext)s are filled in per video."
|
||
>
|
||
<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>
|
||
|
||
<Card className={styles.card}>
|
||
<div className={styles.sectionHeader}>
|
||
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
||
<Subtitle2>Backup & 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>
|
||
|
||
<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 logged.</Caption1>
|
||
) : (
|
||
<div className={styles.errorList}>
|
||
{errorEntries.slice(0, 20).map((e) => (
|
||
<div key={e.id + String(e.occurredAt)} 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>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
<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={<ArrowSyncRegular />}
|
||
onClick={checkAppUpdate}
|
||
disabled={appChecking || appDownloading}
|
||
>
|
||
{appChecking ? 'Checking…' : 'Check for updates'}
|
||
</Button>
|
||
{appChecking && <Spinner size="tiny" />}
|
||
</div>
|
||
|
||
<Field
|
||
label="Update access token"
|
||
hint="Only needed if the update server requires sign-in (you'll see “could not reach the update server” without it). 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'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={() => window.open(appUpd.htmlUrl, '_blank')}
|
||
>
|
||
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} />}
|
||
</>
|
||
)}
|
||
|
||
{appUpd?.ok && !appUpd.available && (
|
||
<Text>You're up to date — v{appUpd.currentVersion} is the latest.</Text>
|
||
)}
|
||
|
||
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
|
||
</Card>
|
||
|
||
<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 && (
|
||
<>
|
||
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
|
||
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
|
||
</Text>
|
||
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
|
||
ffprobe {ffmpeg.ffprobe ?? 'not found'}
|
||
</Text>
|
||
</>
|
||
)}
|
||
{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={<ArrowClockwiseRegular />} onClick={runUpdate} disabled={updating}>
|
||
{updating ? 'Updating…' : 'Update yt-dlp'}
|
||
</Button>
|
||
{updating && <Spinner size="tiny" />}
|
||
</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>
|
||
</div>
|
||
)
|
||
}
|