From e4e175736f10c291ea2e191ce74772c378ef386e Mon Sep 17 00:00:00 2001 From: debont80 Date: Tue, 30 Jun 2026 21:25:58 -0400 Subject: [PATCH] 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 DOM node, so the display-toggle search filter is unchanged. Behaviour identical; 248 tests + typecheck + lint green. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/components/SettingsView.tsx | 1168 +---------------- .../src/components/settings/AboutCard.tsx | 181 +++ .../components/settings/AppearanceCard.tsx | 73 ++ .../src/components/settings/BackupCard.tsx | 84 ++ .../src/components/settings/CookiesCard.tsx | 138 ++ .../settings/CustomCommandsCard.tsx | 56 + .../components/settings/DiagnosticsCard.tsx | 92 ++ .../src/components/settings/DownloadsCard.tsx | 172 +++ .../src/components/settings/FilenamesCard.tsx | 52 + .../src/components/settings/NetworkCard.tsx | 118 ++ .../settings/PostProcessingCard.tsx | 30 + .../settings/SoftwareUpdateCard.tsx | 156 +++ .../src/components/settings/settingsStyles.ts | 86 ++ 13 files changed, 1267 insertions(+), 1139 deletions(-) create mode 100644 src/renderer/src/components/settings/AboutCard.tsx create mode 100644 src/renderer/src/components/settings/AppearanceCard.tsx create mode 100644 src/renderer/src/components/settings/BackupCard.tsx create mode 100644 src/renderer/src/components/settings/CookiesCard.tsx create mode 100644 src/renderer/src/components/settings/CustomCommandsCard.tsx create mode 100644 src/renderer/src/components/settings/DiagnosticsCard.tsx create mode 100644 src/renderer/src/components/settings/DownloadsCard.tsx create mode 100644 src/renderer/src/components/settings/FilenamesCard.tsx create mode 100644 src/renderer/src/components/settings/NetworkCard.tsx create mode 100644 src/renderer/src/components/settings/PostProcessingCard.tsx create mode 100644 src/renderer/src/components/settings/SoftwareUpdateCard.tsx create mode 100644 src/renderer/src/components/settings/settingsStyles.ts diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 3fa1320..b13747c 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -1,196 +1,36 @@ -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, - 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 { useDownloads } from '../store/downloads' -import { QUALITY_OPTIONS } from '../qualityOptions' -import { useTemplates } from '../store/templates' -import { useErrorLog } from '../store/errorlog' -import { Select } from './Select' -import { DownloadOptionsForm } from './DownloadOptionsForm' -import { TemplateManager } from './TemplateManager' +import { useEffect, useRef, useState } from 'react' +import { Input, Caption1, makeStyles, mergeClasses, tokens } from '@fluentui/react-components' +import { SearchRegular } from '@fluentui/react-icons' import { ScreenHeader, useScreenStyles } from './ui/Screen' -import { useErrorTextStyles } from './ui/errorText' -import { ACCENT_OPTIONS } from '../theme' -import { logError } from '../reportError' +import { DownloadsCard } from './settings/DownloadsCard' +import { AppearanceCard } from './settings/AppearanceCard' +import { PostProcessingCard } from './settings/PostProcessingCard' +import { NetworkCard } from './settings/NetworkCard' +import { CookiesCard } from './settings/CookiesCard' +import { CustomCommandsCard } from './settings/CustomCommandsCard' +import { FilenamesCard } from './settings/FilenamesCard' +import { BackupCard } from './settings/BackupCard' +import { DiagnosticsCard } from './settings/DiagnosticsCard' +import { SoftwareUpdateCard } from './settings/SoftwareUpdateCard' +import { AboutCard } from './settings/AboutCard' 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. + // The cards are the root's direct children (one DOM node each); 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(null) @@ -208,254 +48,10 @@ export function SettingsView(): React.JSX.Element { 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(null) - const [ffmpeg, setFfmpeg] = useState(null) - const [mintingPot, setMintingPot] = useState(false) - const [potHint, setPotHint] = useState(null) - - const [updating, setUpdating] = useState(false) - const [updateResult, setUpdateResult] = useState(null) - - // --- AeroFetch app self-update --- - const [appVersion, setAppVersion] = useState('') - const [appUpd, setAppUpd] = useState(null) - const [appChecking, setAppChecking] = useState(false) - const [appDownloading, setAppDownloading] = useState(false) - const [appFraction, setAppFraction] = useState(undefined) - const [appUpdError, setAppUpdError] = useState(null) - - const [cookiesStatus, setCookiesStatus] = useState(null) - const [loginUrl, setLoginUrl] = useState('https://www.youtube.com') - const [signingIn, setSigningIn] = useState(false) - const [loginError, setLoginError] = useState(null) - - const [exporting, setExporting] = useState(false) - const [exportResult, setExportResult] = useState(null) - const [importing, setImporting] = useState(false) - const [importResult, setImportResult] = useState(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 { - 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 { - 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 { - 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 { - 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 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(() => {}) - } - - async function signIn(): Promise { - 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 { - 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 { - 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 { - 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 (
- +
- -
- - Downloads -
- - -
- } - onChange={(_, d) => update({ videoDir: d.value })} - /> - - {videoDir && ( - - )} -
-
- - -
- } - onChange={(_, d) => update({ audioDir: d.value })} - /> - - {audioDir && ( - - )} -
-
- - - update({ theme: v as ThemeMode })} - /> - - - -
- {ACCENT_OPTIONS.map((opt) => ( -
-
- - - {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.'} - - {highContrast && ( -
- -
- )} -
- - -
- - Format & post-processing -
- - Defaults applied to every new download (yt-dlp and ffmpeg do the work). - - - update({ downloadOptions: o })} - /> -
- - -
- - Network -
- - - update({ proxy: d.value })} - /> - - - - update({ rateLimit: d.value })} - /> - - - - update({ useAria2c: d.checked })} - label={useAria2c ? 'On' : 'Off'} - /> - - - - - {['web', 'web_safari', 'web_embedded', 'mweb', 'tv', 'ios', 'android'].map((c) => ( - - update({ youtubePlayerClient: d.value })} - /> - - - -
- { - update({ youtubePoToken: d.value }) - setPotHint(null) - }} - /> - -
-
-
- - -
- - Cookies -
- - 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. - - - - update({ cookiesBrowser: v as CookieBrowser })} - /> - - )} - - {cookieSource === 'login' && ( - <> - -
- setLoginUrl(d.value)} - /> - -
-
- -
- - {cookiesStatus?.exists - ? `Cookies saved ${new Date(cookiesStatus.savedAt!).toLocaleString()}.` - : 'Not signed in yet.'} - - {cookiesStatus?.exists && ( - - )} -
- - {loginError && {loginError}} - - )} -
- - -
- - Custom commands -
- - 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. - - - - update({ customCommandEnabled: d.checked })} - label={customCommandEnabled ? 'On' : 'Off'} - /> - - - {customCommandEnabled && ( - - update({ filenameTemplate: d.value })} - /> - - - Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')} - - - - update({ restrictFilenames: d.checked })} - label={restrictFilenames ? 'On' : 'Off'} - /> - - - - update({ downloadArchive: d.checked })} - label={downloadArchive ? 'On' : 'Off'} - /> - -
- - -
- - Backup & restore -
- - 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). - - -
- - -
- {exportResult?.ok && exportResult.path && ( - Saved to {exportResult.path} - )} - {exportResult && !exportResult.ok && exportResult.error && ( - {exportResult.error} - )} - {importResult?.ok && ( - Settings and templates restored. - )} - {importResult && !importResult.ok && importResult.error && ( - {importResult.error} - )} -
- - -
- - Diagnostics -
- - Failed downloads are logged here even after you clear the queue, so you can copy the - details into a bug report. - - -
- - {confirmClearLog ? ( - <> - - Clear all {errorEntries.length} {errorEntries.length === 1 ? 'error' : 'errors'}? - - - - - ) : ( - - )} -
- - {errorEntries.length === 0 ? ( - No errors yet. - ) : ( -
- {errorEntries.slice(0, 20).map((e) => ( -
-
- {e.title ?? e.url} - - {new Date(e.occurredAt).toLocaleString()} - -
- {e.error} -
- ))} - {errorEntries.length > 20 && ( - - Showing 20 of {errorEntries.length} errors. - - )} -
- )} -
- - -
- - Software update -
- - {appVersion - ? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.` - : 'Checking your AeroFetch version…'} - - -
- -
- - {(updateToken.trim() !== '' || !!appUpdError) && ( - - update({ updateToken: d.value })} - contentBefore={} - /> - - )} - - {appUpd?.ok && appUpd.available && ( - <> - - Version {appUpd.latestVersion} is available -- here's what changed: - - {appUpd.notes &&
{appUpd.notes}
} -
- - {appUpd.htmlUrl && ( - - )} -
- {!appUpd.downloadUrl && ( - - This release has no installer attached -- use "View release" to download it - manually. - - )} - {appDownloading && } - - )} - - {appUpd?.ok && !appUpd.available && ( - You're up to date -- v{appUpd.currentVersion} is the latest. - )} - - {appUpdError && {appUpdError}} -
- - -
- - About -
- - 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. - - - - update({ autoUpdateYtdlp: d.checked })} - label={autoUpdateYtdlp ? 'On' : 'Off'} - /> - - - {version?.ok && ( - yt-dlp {version.version} - )} - {version && !version.ok && ( - {version.error} - )} - {ffmpeg && ( -
-
- - ffmpeg {ffmpeg.ffmpeg ?? 'not found'} - - - ffprobe {ffmpeg.ffprobe ?? 'not found'} - -
-
- )} - {ytdlpLastUpdateCheck > 0 && ( - - Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}. - - )} -
- - {checking && } -
- - - update({ ytdlpChannel: v as YtdlpUpdateChannel })} + /> + +
+ +
+ {updateResult?.ok && ( + + {updateResult.output || 'yt-dlp is already up to date.'} + + )} + {updateResult && !updateResult.ok && ( + {updateResult.error} + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/AppearanceCard.tsx b/src/renderer/src/components/settings/AppearanceCard.tsx new file mode 100644 index 0000000..4107751 --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceCard.tsx @@ -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 ( + +
+ + Appearance +
+ + + update({ cookieSource: v as CookieSource })} + /> + + + {cookieSource === 'browser' && ( + + setLoginUrl(d.value)} + /> + +
+ + +
+ + {cookiesStatus?.exists + ? `Cookies saved ${new Date(cookiesStatus.savedAt!).toLocaleString()}.` + : 'Not signed in yet.'} + + {cookiesStatus?.exists && ( + + )} +
+ + {loginError && {loginError}} + + )} + + ) +} diff --git a/src/renderer/src/components/settings/CustomCommandsCard.tsx b/src/renderer/src/components/settings/CustomCommandsCard.tsx new file mode 100644 index 0000000..c866c1c --- /dev/null +++ b/src/renderer/src/components/settings/CustomCommandsCard.tsx @@ -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 ( + +
+ + Custom commands +
+ + 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. + + + + update({ customCommandEnabled: d.checked })} + label={customCommandEnabled ? 'On' : 'Off'} + /> + + + {customCommandEnabled && ( + + } + onChange={(_, d) => update({ videoDir: d.value })} + /> + + {videoDir && ( + + )} +
+ + + +
+ } + onChange={(_, d) => update({ audioDir: d.value })} + /> + + {audioDir && ( + + )} +
+
+ + + update({ filenameTemplate: d.value })} /> + + + Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')} + + + + update({ restrictFilenames: d.checked })} + label={restrictFilenames ? 'On' : 'Off'} + /> + + + + update({ downloadArchive: d.checked })} + label={downloadArchive ? 'On' : 'Off'} + /> + +
+ ) +} diff --git a/src/renderer/src/components/settings/NetworkCard.tsx b/src/renderer/src/components/settings/NetworkCard.tsx new file mode 100644 index 0000000..023e9ea --- /dev/null +++ b/src/renderer/src/components/settings/NetworkCard.tsx @@ -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(null) + + return ( + +
+ + Network +
+ + + update({ proxy: d.value })} + /> + + + + update({ rateLimit: d.value })} /> + + + + update({ useAria2c: d.checked })} + label={useAria2c ? 'On' : 'Off'} + /> + + + + + {['web', 'web_safari', 'web_embedded', 'mweb', 'tv', 'ios', 'android'].map((c) => ( + + update({ youtubePlayerClient: d.value })} + /> + + + +
+ { + update({ youtubePoToken: d.value }) + setPotHint(null) + }} + /> + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/PostProcessingCard.tsx b/src/renderer/src/components/settings/PostProcessingCard.tsx new file mode 100644 index 0000000..0d445be --- /dev/null +++ b/src/renderer/src/components/settings/PostProcessingCard.tsx @@ -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 ( + +
+ + Format & post-processing +
+ + Defaults applied to every new download (yt-dlp and ffmpeg do the work). + + + update({ downloadOptions: o })} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/SoftwareUpdateCard.tsx b/src/renderer/src/components/settings/SoftwareUpdateCard.tsx new file mode 100644 index 0000000..8cdc230 --- /dev/null +++ b/src/renderer/src/components/settings/SoftwareUpdateCard.tsx @@ -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(null) + const [appChecking, setAppChecking] = useState(false) + const [appDownloading, setAppDownloading] = useState(false) + const [appFraction, setAppFraction] = useState(undefined) + const [appUpdError, setAppUpdError] = useState(null) + + useEffect(() => { + window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion')) + return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction)) + }, []) + + async function checkAppUpdate(): Promise { + 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 { + 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 ( + +
+ + Software update +
+ + {appVersion + ? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.` + : 'Checking your AeroFetch version…'} + + +
+ +
+ + {(updateToken.trim() !== '' || !!appUpdError) && ( + + update({ updateToken: d.value })} + contentBefore={} + /> + + )} + + {appUpd?.ok && appUpd.available && ( + <> + + Version {appUpd.latestVersion} is available -- here's what changed: + + {appUpd.notes &&
{appUpd.notes}
} +
+ + {appUpd.htmlUrl && ( + + )} +
+ {!appUpd.downloadUrl && ( + + This release has no installer attached -- use "View release" to download it manually. + + )} + {appDownloading && ( + + )} + + )} + + {appUpd?.ok && !appUpd.available && ( + You're up to date -- v{appUpd.currentVersion} is the latest. + )} + + {appUpdError && {appUpdError}} +
+ ) +} diff --git a/src/renderer/src/components/settings/settingsStyles.ts b/src/renderer/src/components/settings/settingsStyles.ts new file mode 100644 index 0000000..83d0bcb --- /dev/null +++ b/src/renderer/src/components/settings/settingsStyles.ts @@ -0,0 +1,86 @@ +import { makeStyles, tokens, shorthands } from '@fluentui/react-components' + +// Shared styles for the Settings cards. Each card renders a single 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' + } +})