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:
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 & 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 & 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'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'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'
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user