Complete Phase C (custom commands & yt-dlp updater) and Phase D (library, UX & notifications)

Phase C had been implemented in the working tree but never committed:
named custom-command templates (CRUD + per-download override), command
preview, and the in-app yt-dlp self-updater.

Phase D adds: history search/kind-filter/multi-select/bulk-delete/
re-download; native OS notifications on completion/failure; a private/
incognito download mode that skips history; settings+template backup/
restore via JSON; and a persistent error log surfaced as a Diagnostics
report in Settings.

See ROADMAP.md for the full per-item breakdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 06:19:45 -04:00
parent 67133f13b5
commit a822a2bb52
24 changed files with 1678 additions and 129 deletions
+212 -5
View File
@@ -3,6 +3,8 @@ import {
Input,
Button,
Checkbox,
Switch,
Field,
Spinner,
Text,
Caption1,
@@ -24,11 +26,23 @@ import {
OptionsRegular,
ChevronDownRegular,
ChevronUpRegular,
AppsListRegular
AppsListRegular,
CodeRegular,
EyeRegular,
EyeOffRegular,
CopyRegular
} from '@fluentui/react-icons'
import type { MediaInfo, FormatOption, PlaylistInfo, DownloadOptions } from '@shared/ipc'
import type {
MediaInfo,
FormatOption,
PlaylistInfo,
DownloadOptions,
CommandPreviewResult,
StartDownloadOptions
} from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { useSettings } from '../store/settings'
import { useTemplates } from '../store/templates'
import { Select } from './Select'
import { Hint } from './Hint'
import { DownloadOptionsForm } from './DownloadOptionsForm'
@@ -182,6 +196,21 @@ const useStyles = makeStyles({
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// --- command preview ---
previewPanel: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
previewCommandText: {
fontFamily: tokens.fontFamilyMonospace,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
},
// --- playlist selection ---
plPanel: {
display: 'flex',
@@ -264,6 +293,28 @@ export function DownloadBar(): React.JSX.Element {
const [showOptions, setShowOptions] = useState(false)
const effectiveOptions = override ?? downloadOptions
// Private mode — sticky like an incognito tab; the download still runs and
// appears in the queue, but its completion is never recorded to history.
const [incognito, setIncognito] = useState(false)
// Per-download custom-command override: undefined = defer to the settings
// default (customCommandEnabled + defaultTemplateId); null = explicit
// "None" for just this download; a string = a chosen template id override.
const [templateOverride, setTemplateOverride] = useState<string | null | undefined>(undefined)
const [showCustomCommand, setShowCustomCommand] = useState(false)
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const settingsDefaultTemplateId = useSettings((s) => s.defaultTemplateId)
const templates = useTemplates((s) => s.templates)
const effectiveTemplateId =
templateOverride !== undefined
? templateOverride
: customCommandEnabled
? settingsDefaultTemplateId
: null
const [previewResult, setPreviewResult] = useState<CommandPreviewResult | null>(null)
const [previewing, setPreviewing] = useState(false)
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
useEffect(() => {
@@ -400,6 +451,61 @@ export function DownloadBar(): React.JSX.Element {
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
}
// Resolves the per-download custom-command override to the raw extra-args
// string sent to main; undefined defers to the settings default there.
function resolveExtraArgs(): string | undefined {
if (templateOverride === undefined) return undefined
if (templateOverride === null) return ''
return templates.find((t) => t.id === templateOverride)?.args ?? ''
}
// The StartDownloadOptions the current form state would produce — shared by
// the real download() call and the command-preview button so they can never
// drift apart.
function currentStartOptions(): StartDownloadOptions {
const base: StartDownloadOptions = {
id: 'preview',
url: url.trim(),
kind,
quality,
outputDir: outputDir || undefined,
options: override ?? undefined,
extraArgs: resolveExtraArgs()
}
if (usingFormats && selectedFormat) {
return {
...base,
kind: 'video',
quality: selectedFormat.label,
formatId: selectedFormat.id,
formatHasAudio: selectedFormat.hasAudio
}
}
return base
}
async function previewCommand(): Promise<void> {
if (!url.trim() || previewing) return
setPreviewing(true)
setPreviewResult(null)
try {
setPreviewResult(await window.api.previewCommand(currentStartOptions()))
} catch (e) {
setPreviewResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setPreviewing(false)
}
}
async function copyPreview(): Promise<void> {
if (!previewResult?.command) return
try {
await navigator.clipboard.writeText(previewResult.command)
} catch {
/* clipboard blocked — ignore in preview */
}
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
@@ -421,10 +527,17 @@ export function DownloadBar(): React.JSX.Element {
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
},
options: override ?? undefined
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
} else {
addFromUrl(trimmed, kind, quality, { ...meta, options: override ?? undefined })
addFromUrl(trimmed, kind, quality, {
...meta,
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
}
setUrl('')
@@ -439,7 +552,9 @@ export function DownloadBar(): React.JSX.Element {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel,
options: override ?? undefined
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
}
setUrl('')
@@ -635,6 +750,15 @@ export function DownloadBar(): React.JSX.Element {
)}
</div>
<div className={styles.optionsBar}>
{incognito ? <EyeOffRegular /> : <EyeRegular />}
<Switch
checked={incognito}
onChange={(_, d) => setIncognito(d.checked)}
label={incognito ? 'Private — wont be saved to history' : 'Private mode'}
/>
</div>
<div className={styles.optionsBar}>
<Button
appearance="subtle"
@@ -661,6 +785,89 @@ export function DownloadBar(): React.JSX.Element {
</div>
)}
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<CodeRegular />}
iconPosition="before"
onClick={() => setShowCustomCommand((v) => !v)}
>
Custom command {showCustomCommand ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{templateOverride !== undefined && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setTemplateOverride(undefined)}>
Reset to default
</Button>
</>
)}
<div className={styles.spacer} />
<Hint label="Build and show the exact yt-dlp command line" placement="top" align="end">
<Button
appearance="subtle"
size="small"
icon={previewing ? <Spinner size="tiny" /> : <EyeRegular />}
iconPosition="before"
onClick={previewCommand}
disabled={!url.trim() || previewing}
>
Preview command
</Button>
</Hint>
</div>
{showCustomCommand && (
<div className={styles.optionsPanel}>
<Field label="Template" hint="Extra yt-dlp flags layered onto this download, after every other option.">
<Select
aria-label="Custom command template"
value={effectiveTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
...templates.map((t) => ({ value: t.id, label: t.name }))
]}
onChange={(v) => setTemplateOverride(v === 'none' ? null : v)}
/>
</Field>
{templates.length === 0 && (
<Caption1 className={styles.previewMeta}>
No templates yet add one in Settings Custom commands.
</Caption1>
)}
</div>
)}
{previewResult && (
<div className={styles.previewPanel}>
<div className={styles.plHeader}>
<Caption1 className={styles.plHeaderText}>
{previewResult.ok ? 'Command preview' : 'Could not build the command'}
</Caption1>
{previewResult.ok && (
<Button size="small" icon={<CopyRegular />} onClick={copyPreview}>
Copy
</Button>
)}
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => setPreviewResult(null)}
aria-label="Dismiss preview"
/>
</div>
{previewResult.ok ? (
<Text className={styles.previewCommandText}>{previewResult.command}</Text>
) : (
<Caption1 className={mergeClasses(styles.previewMeta, styles.errorRow)}>
{previewResult.error}
</Caption1>
)}
</div>
)}
<div className={styles.folder}>
<FolderRegular />
<Caption1 className={styles.folderPath} title={folder}>
+207 -56
View File
@@ -1,7 +1,10 @@
import { useMemo, useState } from 'react'
import {
Text,
Caption1,
Button,
Checkbox,
Input,
Body1,
makeStyles,
tokens,
@@ -13,13 +16,19 @@ import {
DeleteRegular,
VideoClipRegular,
MusicNote2Regular,
HistoryRegular
HistoryRegular,
SearchRegular,
ArrowClockwiseRegular,
CheckmarkSquareRegular,
DismissRegular
} from '@fluentui/react-icons'
import type { HistoryEntry } from '@shared/ipc'
import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history'
import { useSettings } from '../store/settings'
import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme'
import { Hint } from './Hint'
import { Select } from './Select'
const useStyles = makeStyles({
root: {
@@ -30,10 +39,24 @@ const useStyles = makeStyles({
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
gap: '8px',
flexWrap: 'wrap'
},
count: {
color: tokens.colorNeutralForeground3
color: tokens.colorNeutralForeground3,
flexShrink: 0
},
search: {
minWidth: '180px',
flexGrow: 1,
maxWidth: '320px'
},
kindFilter: {
width: '130px',
flexShrink: 0
},
spacer: {
flexGrow: 1
},
list: {
display: 'flex',
@@ -97,9 +120,20 @@ const useStyles = makeStyles({
},
emptyHint: {
color: tokens.colorNeutralForeground3
},
noMatches: {
padding: '32px 16px',
textAlign: 'center',
color: tokens.colorNeutralForeground3
}
})
const KIND_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]
function formatWhen(ts: number): string {
const d = new Date(ts)
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
@@ -119,7 +153,55 @@ export function HistoryView(): React.JSX.Element {
const openFile = useHistory((s) => s.openFile)
const showInFolder = useHistory((s) => s.showInFolder)
const remove = useHistory((s) => s.remove)
const removeMany = useHistory((s) => s.removeMany)
const clear = useHistory((s) => s.clear)
const addFromUrl = useDownloads((s) => s.addFromUrl)
const [query, setQuery] = useState('')
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
const [selectMode, setSelectMode] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return entries.filter((h) => {
if (kindFilter !== 'all' && h.kind !== kindFilter) return false
if (!q) return true
return (
h.title.toLowerCase().includes(q) ||
(h.channel?.toLowerCase().includes(q) ?? false) ||
h.url.toLowerCase().includes(q)
)
})
}, [entries, query, kindFilter])
function redownload(h: HistoryEntry): void {
addFromUrl(h.url, h.kind, h.quality, { title: h.title, channel: h.channel })
}
function toggleSelected(id: string, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
}
const allFilteredSelected = filtered.length > 0 && filtered.every((h) => selected.has(h.id))
function toggleAll(): void {
setSelected(allFilteredSelected ? new Set() : new Set(filtered.map((h) => h.id)))
}
function exitSelectMode(): void {
setSelectMode(false)
setSelected(new Set())
}
function deleteSelected(): void {
removeMany([...selected])
exitSelectMode()
}
if (entries.length === 0) {
return (
@@ -139,62 +221,131 @@ export function HistoryView(): React.JSX.Element {
return (
<div className={styles.root}>
<div className={styles.header}>
<Caption1 className={styles.count}>
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
</Caption1>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
Clear history
</Button>
{selectMode ? (
<>
<Caption1 className={styles.count}>{selected.size} selected</Caption1>
<Button size="small" appearance="subtle" onClick={toggleAll}>
{allFilteredSelected ? 'Select none' : 'Select all'}
</Button>
<div className={styles.spacer} />
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={deleteSelected}
disabled={selected.size === 0}
>
Delete selected
</Button>
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={exitSelectMode}>
Cancel
</Button>
</>
) : (
<>
<Caption1 className={styles.count}>
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
</Caption1>
<Input
className={styles.search}
size="small"
value={query}
onChange={(_, d) => setQuery(d.value)}
placeholder="Search title, channel, URL…"
contentBefore={<SearchRegular />}
/>
<Select
className={styles.kindFilter}
aria-label="Filter by type"
value={kindFilter}
options={KIND_FILTER_OPTIONS}
onChange={(v) => setKindFilter(v as 'all' | MediaKind)}
/>
<div className={styles.spacer} />
<Button
size="small"
appearance="subtle"
icon={<CheckmarkSquareRegular />}
onClick={() => setSelectMode(true)}
>
Select
</Button>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
Clear history
</Button>
</>
)}
</div>
<div className={styles.list}>
{entries.map((h: HistoryEntry) => {
const t = h.kind === 'audio' ? tc.audio : tc.video
return (
<div key={h.id} className={styles.row}>
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
{h.kind === 'audio' ? (
<MusicNote2Regular fontSize={22} />
) : (
<VideoClipRegular fontSize={22} />
{filtered.length === 0 ? (
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
) : (
<div className={styles.list}>
{filtered.map((h: HistoryEntry) => {
const t = h.kind === 'audio' ? tc.audio : tc.video
return (
<div key={h.id} className={styles.row}>
{selectMode && (
<Checkbox
checked={selected.has(h.id)}
onChange={(_, d) => toggleSelected(h.id, !!d.checked)}
aria-label={`Select ${h.title}`}
/>
)}
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
{h.kind === 'audio' ? (
<MusicNote2Regular fontSize={22} />
) : (
<VideoClipRegular fontSize={22} />
)}
</div>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
.filter(Boolean)
.join(' • ')}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Re-download" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={() => redownload(h)}
aria-label="Re-download"
/>
</Hint>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(h.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(h.id)}
aria-label="Show in folder"
/>
</Hint>
<Hint label="Remove from history" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(h.id)}
aria-label="Remove from history"
/>
</Hint>
</div>
</div>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)].filter(Boolean).join(' • ')}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(h.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(h.id)}
aria-label="Show in folder"
/>
</Hint>
<Hint label="Remove from history" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(h.id)}
aria-label="Remove from history"
/>
</Hint>
</div>
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
)
}
+7 -1
View File
@@ -18,7 +18,8 @@ import {
VideoClipRegular,
MusicNote2Regular,
CheckmarkCircleFilled,
ErrorCircleFilled
ErrorCircleFilled,
EyeOffRegular
} from '@fluentui/react-icons'
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
import { useSettings } from '../store/settings'
@@ -149,6 +150,11 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
<Badge appearance="tint" color={badge.color}>
{badge.label}
</Badge>
{item.incognito && (
<Hint label="Private — not saved to history" placement="top" align="start">
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
</Hint>
)}
</div>
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
+277 -2
View File
@@ -21,20 +21,34 @@ import {
OptionsRegular,
InfoRegular,
GlobeRegular,
CookiesRegular
CookiesRegular,
CodeRegular,
ArrowClockwiseRegular,
DocumentArrowDownRegular,
DocumentArrowUpRegular,
BugRegular,
CopyRegular,
DeleteRegular
} from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type MediaKind,
type CookieSource,
type CookieBrowser,
type CookiesStatus
type CookiesStatus,
type BackupExportResult,
type BackupImportResult
} from '@shared/ipc'
import { useSettings } from '../store/settings'
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'
const useStyles = makeStyles({
root: {
@@ -69,6 +83,35 @@ const useStyles = makeStyles({
},
hint: {
color: tokens.colorNeutralForeground3
},
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'
},
errorRowText: {
color: tokens.colorPaletteRedForeground1,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}
})
@@ -89,6 +132,11 @@ const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
label: b[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()
@@ -108,7 +156,13 @@ export function SettingsView(): React.JSX.Element {
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 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}`
@@ -116,15 +170,69 @@ export function SettingsView(): React.JSX.Element {
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [updateChannel, setUpdateChannel] = useState<YtdlpUpdateChannel>('stable')
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | 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)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
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 {
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 || 'No errors logged.').catch(() => {})
}
async function signIn(): Promise<void> {
setSigningIn(true)
setLoginError(null)
@@ -165,6 +273,20 @@ export function SettingsView(): React.JSX.Element {
}
}
async function runUpdate(): Promise<void> {
setUpdating(true)
setUpdateResult(null)
try {
const result = await window.api.updateYtdlp(updateChannel)
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={styles.root}>
<Card className={styles.card}>
@@ -225,6 +347,17 @@ export function SettingsView(): React.JSX.Element {
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>
</Card>
<Card className={styles.card}>
@@ -354,6 +487,45 @@ export function SettingsView(): React.JSX.Element {
)}
</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} />
@@ -395,6 +567,84 @@ export function SettingsView(): React.JSX.Element {
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentArrowDownRegular className={styles.sectionIcon} />
<Subtitle2>Backup &amp; restore</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Save your settings and custom-command templates to a JSON file, or restore them on
another machine. Does not include download history.
</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 style={{ color: tokens.colorPaletteRedForeground1 }}>
{exportResult.error}
</Caption1>
)}
{importResult?.ok && (
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
)}
{importResult && !importResult.ok && importResult.error && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
{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>
<Button icon={<DeleteRegular />} onClick={clearErrorLog} 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={styles.errorRowText}>{e.error}</Caption1>
</div>
))}
</div>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
@@ -417,6 +667,31 @@ export function SettingsView(): React.JSX.Element {
{version.error}
</Caption1>
)}
<Field label="Update channel" hint="Nightly tracks yt-dlp's daily build; stable is the tagged release.">
<Select
aria-label="Update channel"
value={updateChannel}
options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => setUpdateChannel(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 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{updateResult.error}
</Caption1>
)}
</Card>
</div>
)
@@ -0,0 +1,178 @@
import { useState } from 'react'
import {
Button,
Input,
Textarea,
Caption1,
Text,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import { AddRegular, EditRegular, DeleteRegular, SaveRegular, DismissRegular } from '@fluentui/react-icons'
import type { CommandTemplate } from '@shared/ipc'
import { useTemplates } from '../store/templates'
import { Hint } from './Hint'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '10px'
},
row: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 10px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
rowBody: {
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column'
},
rowName: {
fontWeight: tokens.fontWeightSemibold
},
rowArgs: {
color: tokens.colorNeutralForeground3,
fontFamily: tokens.fontFamilyMonospace,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
actions: {
display: 'flex',
gap: '4px',
flexShrink: 0
},
form: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
formActions: {
display: 'flex',
gap: '8px',
justifyContent: 'flex-end'
},
empty: {
color: tokens.colorNeutralForeground3
}
})
interface Draft {
/** null while adding a brand-new template */
id: string | null
name: string
args: string
}
const BLANK_DRAFT: Draft = { id: null, name: '', args: '' }
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
}
/**
* Inline (no-modal — see the Hint/Select comments re: composited overlays
* flickering on this dev machine's GPU) CRUD list + add/edit form for
* CommandTemplate. Used standalone from SettingsView's "Custom commands" card.
*/
export function TemplateManager(): React.JSX.Element {
const styles = useStyles()
const templates = useTemplates((s) => s.templates)
const save = useTemplates((s) => s.save)
const remove = useTemplates((s) => s.remove)
const [draft, setDraft] = useState<Draft | null>(null)
function startEdit(t: CommandTemplate): void {
setDraft({ id: t.id, name: t.name, args: t.args })
}
function commit(): void {
if (!draft) return
const name = draft.name.trim()
if (!name) return
save({ id: draft.id ?? newId(), name, args: draft.args.trim() })
setDraft(null)
}
return (
<div className={styles.root}>
{templates.length === 0 && !draft && (
<Caption1 className={styles.empty}>No custom command templates yet.</Caption1>
)}
{templates.map((t) => (
<div key={t.id} className={styles.row}>
<div className={styles.rowBody}>
<Text className={styles.rowName}>{t.name}</Text>
<Caption1 className={styles.rowArgs} title={t.args}>
{t.args || '(no extra args)'}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Edit" placement="top" align="end">
<Button
appearance="subtle"
icon={<EditRegular />}
onClick={() => startEdit(t)}
aria-label="Edit template"
/>
</Hint>
<Hint label="Delete" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(t.id)}
aria-label="Delete template"
/>
</Hint>
</div>
</div>
))}
{draft ? (
<div className={styles.form}>
<Input
value={draft.name}
placeholder="Template name"
onChange={(_, d) => setDraft({ ...draft, name: d.value })}
/>
<Textarea
value={draft.args}
placeholder="Extra yt-dlp flags, e.g. --write-thumbnail --no-mtime"
resize="vertical"
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
/>
<div className={styles.formActions}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
Cancel
</Button>
<Button
appearance="primary"
icon={<SaveRegular />}
onClick={commit}
disabled={!draft.name.trim()}
>
Save
</Button>
</div>
</div>
) : (
<Button appearance="subtle" icon={<AddRegular />} onClick={() => setDraft({ ...BLANK_DRAFT })}>
Add template
</Button>
)}
</div>
)
}
+38 -2
View File
@@ -1,7 +1,7 @@
import './assets/base.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings, type CommandTemplate } from '@shared/ipc'
import App from './App'
// In the standalone UI preview (browser, no Electron preload) window.api isn't
@@ -26,11 +26,19 @@ if (import.meta.env.DEV && !window.api) {
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
}
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
// sign-in/clear flow be exercised in this browser-only preview.
let mockCookiesSavedAt: number | null = null
// Stands in for templates.json — lets the Custom commands card's CRUD and
// the download bar's template picker be exercised in this browser-only preview.
let mockTemplates: CommandTemplate[] = [
{ id: 'tpl1', name: 'Write thumbnail, no mtime', args: '--write-thumbnail --no-mtime' }
]
window.api = {
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
@@ -86,6 +94,7 @@ if (import.meta.env.DEV && !window.api) {
listHistory: async () => [],
addHistory: async () => [],
removeHistory: async () => [],
removeManyHistory: async () => [],
clearHistory: async () => [],
cookiesLogin: async () => {
await new Promise((r) => setTimeout(r, 600)) // simulate the sign-in window
@@ -97,6 +106,33 @@ if (import.meta.env.DEV && !window.api) {
cookiesClear: async () => {
mockCookiesSavedAt = null
},
listTemplates: async () => mockTemplates,
saveTemplate: async (template) => {
mockTemplates = [template, ...mockTemplates.filter((t) => t.id !== template.id)]
return mockTemplates
},
removeTemplate: async (id) => {
mockTemplates = mockTemplates.filter((t) => t.id !== id)
return mockTemplates
},
previewCommand: async (opts) => {
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
return {
ok: true,
command:
`yt-dlp.exe -f "bv*+ba/b" -o "${MOCK_SETTINGS.outputDir}\\%(title)s.%(ext)s"` +
`${extra} -- ${opts.url}`
}
},
updateYtdlp: async (channel) => {
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
},
listErrorLog: async () => [],
clearErrorLog: async () => [],
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
importBackup: async () => ({ ok: true }),
onDownloadEvent: () => () => {}
}
}
+14 -5
View File
@@ -36,6 +36,10 @@ export interface DownloadItem {
formatHasAudio?: boolean
/** per-download post-processing override (omitted = use persisted defaults) */
options?: DownloadOptions
/** per-download custom-command override (omitted = use the persisted default template) */
extraArgs?: string
/** private download — completion is never recorded to history */
incognito?: boolean
}
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
@@ -60,6 +64,8 @@ interface DownloadState {
durationLabel?: string
thumbnail?: string
options?: DownloadOptions
extraArgs?: string
incognito?: boolean
}
) => void
retry: (id: string) => void
@@ -188,7 +194,7 @@ function startFakeTicker(
if (done) {
clearInterval(timer)
const finished = get().items.find((i) => i.id === id)
if (finished) {
if (finished && !finished.incognito) {
useHistory.getState().add({
id: finished.id,
title: finished.title,
@@ -230,7 +236,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
outputDir: useSettings.getState().outputDir || undefined,
formatId: item.formatId,
formatHasAudio: item.formatHasAudio,
options: item.options
options: item.options,
extraArgs: item.extraArgs
})
.then((res) => {
if (!res.ok) markError(item.id, res.error)
@@ -276,7 +283,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options
options: opts?.options,
extraArgs: opts?.extraArgs,
incognito: opts?.incognito
}
set((s) => ({ items: [item, ...s.items] }))
pump()
@@ -369,10 +378,10 @@ export const useDownloads = create<DownloadState>((set, get) => {
}
})
}))
// Record completed downloads to history.
// Record completed downloads to history (unless this was a private download).
if (ev.type === 'done') {
const item = get().items.find((i) => i.id === ev.id)
if (item && item.status === 'completed') {
if (item && item.status === 'completed' && !item.incognito) {
useHistory.getState().add({
id: item.id,
title: item.title,
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand'
import type { ErrorLogEntry } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Sample entries so the Diagnostics card has content during UI design.
const seed: ErrorLogEntry[] = PREVIEW
? [
{
id: 'e1',
title: 'Private video',
url: 'https://youtube.com/watch?v=err0r',
kind: 'video',
error: 'ERROR: [youtube] err0r: Private video. Sign in if you have access.',
occurredAt: Date.now() - 1000 * 60 * 12
}
]
: []
interface ErrorLogState {
entries: ErrorLogEntry[]
clear: () => void
}
export const useErrorLog = create<ErrorLogState>((set) => ({
entries: seed,
clear: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearErrorLog().catch(() => {})
}
}))
// Load the persisted error log on startup.
if (!PREVIEW) {
window.api.listErrorLog().then((entries) => useErrorLog.setState({ entries }))
}
+7
View File
@@ -47,6 +47,7 @@ interface HistoryState {
entries: HistoryEntry[]
add: (entry: HistoryEntry) => void
remove: (id: string) => void
removeMany: (ids: string[]) => void
clear: () => void
openFile: (id: string) => void
showInFolder: (id: string) => void
@@ -65,6 +66,12 @@ export const useHistory = create<HistoryState>((set, get) => ({
if (!PREVIEW) window.api.removeHistory(id).catch(() => {})
},
removeMany: (ids) => {
const remove = new Set(ids)
set((s) => ({ entries: s.entries.filter((e) => !remove.has(e.id)) }))
if (!PREVIEW) window.api.removeManyHistory(ids).catch(() => {})
},
clear: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearHistory().catch(() => {})
+4 -1
View File
@@ -20,7 +20,10 @@ const FALLBACK: Settings = {
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
}
interface SettingsState extends Settings {
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand'
import type { CommandTemplate } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Sample templates so the Settings tab has content during UI design.
const seed: CommandTemplate[] = PREVIEW
? [
{ id: 't1', name: 'Write thumbnail, no mtime', args: '--write-thumbnail --no-mtime' },
{ id: 't2', name: 'Verbose log', args: '--verbose' }
]
: []
interface TemplatesState {
templates: CommandTemplate[]
/** add a new template or update an existing one (matched by id) */
save: (template: CommandTemplate) => void
remove: (id: string) => void
}
export const useTemplates = create<TemplatesState>((set) => ({
templates: seed,
save: (template) => {
set((s) => ({ templates: [template, ...s.templates.filter((t) => t.id !== template.id)] }))
if (!PREVIEW) window.api.saveTemplate(template).catch(() => {})
},
remove: (id) => {
set((s) => ({ templates: s.templates.filter((t) => t.id !== id) }))
if (!PREVIEW) window.api.removeTemplate(id).catch(() => {})
}
}))
// Load persisted templates on startup.
if (!PREVIEW) {
window.api.listTemplates().then((templates) => useTemplates.setState({ templates }))
}