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:
@@ -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 — won’t 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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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: () => () => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
Reference in New Issue
Block a user