feat: Phase N power-user surface (terminal, palette, per-item, sort, url-templates)

- Built-in yt-dlp terminal: src/main/terminal.ts streams raw yt-dlp runs
  (binary fixed, gated on customCommandEnabled), new TerminalView + sidebar tab
- Command palette (Ctrl/Cmd+K), portal-free CommandPalette.tsx wired in App
- Per-playlist-item editing: per-row video/audio toggle + All video/All audio
  batch; addPlaylist enqueues via addMany with per-item kind
- Weighted format sorting: DownloadOptions.formatSort -> raw -S (overrides codec)
- URL-regex template auto-matching: CommandTemplate.urlPattern + selectExtraArgs
  matchesUrl, threaded through resolveExtraArgs; field in TemplateManager

typecheck + test (192) + electron-vite build all clean. Roadmap: Phase N COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:10:22 -04:00
parent 9ac11ceb6c
commit da37690b42
18 changed files with 820 additions and 58 deletions
+44
View File
@@ -4,8 +4,10 @@ import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView'
import { LibraryView } from './components/LibraryView'
import { HistoryView } from './components/HistoryView'
import { TerminalView } from './components/TerminalView'
import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useResolvedDark } from './store/systemTheme'
@@ -34,6 +36,43 @@ function App(): React.JSX.Element {
const updateSettings = useSettings((s) => s.update)
const isDark = useResolvedDark()
const [tab, setTab] = useState<TabValue>('downloads')
const [paletteOpen, setPaletteOpen] = useState(false)
// Ctrl/Cmd+K toggles the command palette.
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
setPaletteOpen((o) => !o)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const paletteActions: PaletteAction[] = [
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') },
{
id: 'new-download',
label: 'New download',
hint: 'Focus URL',
run: () => {
setTab('downloads')
// Wait for the download bar to mount after the tab switch, then focus.
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
}
},
{
id: 'toggle-theme',
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance',
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
}
]
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('')
@@ -89,8 +128,13 @@ function App(): React.JSX.Element {
{tab === 'downloads' && <DownloadsView />}
{tab === 'library' && <LibraryView />}
{tab === 'history' && <HistoryView />}
{tab === 'terminal' && <TerminalView />}
{tab === 'settings' && <SettingsView />}
</main>
{paletteOpen && (
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
)}
</>
)}
</div>
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react'
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
export interface PaletteAction {
id: string
label: string
/** short right-aligned hint, e.g. a shortcut or category */
hint?: string
run: () => void
}
const useStyles = makeStyles({
// A plain fixed overlay — NOT a Fluent Dialog, since this app avoids Fluent's
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
backdrop: {
position: 'fixed',
inset: 0,
zIndex: 1000,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
paddingTop: '14vh',
backgroundColor: 'rgba(0,0,0,0.32)'
},
panel: {
width: 'min(560px, 92vw)',
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: tokens.shadow28,
overflow: 'hidden'
},
input: {
appearance: 'none',
border: 'none',
outline: 'none',
padding: '14px 16px',
fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase,
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`
},
list: {
maxHeight: '50vh',
overflowY: 'auto',
padding: '6px'
},
item: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
width: '100%',
padding: '9px 12px',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
itemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
itemHint: {
flexShrink: 0,
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200
},
empty: {
padding: '14px 12px',
color: tokens.colorNeutralForeground3
}
})
/**
* A Ctrl/Cmd+K command palette (Phase N). Filter by typing, ↑/↓ to move, Enter to
* run, Esc or a backdrop click to dismiss. Actions are supplied by App.
*/
export function CommandPalette({
actions,
onClose
}: {
actions: PaletteAction[]
onClose: () => void
}): React.JSX.Element {
const styles = useStyles()
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
setSel(0)
}, [q])
function onKeyDown(e: React.KeyboardEvent): void {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSel((s) => Math.min(filtered.length - 1, s + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSel((s) => Math.max(0, s - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
const a = filtered[sel]
if (a) {
a.run()
onClose()
}
}
}
return (
<div className={styles.backdrop} onClick={onClose}>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command palette"
>
<input
ref={inputRef}
className={styles.input}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Type a command…"
aria-label="Command palette search"
/>
<div className={styles.list}>
{filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div>
) : (
filtered.map((a, i) => (
<button
key={a.id}
type="button"
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
onClick={() => {
a.run()
onClose()
}}
onMouseEnter={() => setSel(i)}
>
<span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</button>
))
)}
</div>
</div>
</div>
)
}
+98 -24
View File
@@ -223,10 +223,35 @@ const useStyles = makeStyles({
overflowY: 'auto',
paddingRight: '4px'
},
plItemRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
plItem: {
display: 'flex',
alignItems: 'flex-start',
padding: '2px 0'
padding: '2px 0',
flexGrow: 1,
minWidth: 0
},
plKindBtn: {
flexShrink: 0,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
}
},
plItemLabel: {
display: 'flex',
@@ -285,6 +310,7 @@ const useStyles = makeStyles({
export function DownloadBar(): React.JSX.Element {
const styles = useStyles()
const addFromUrl = useDownloads((s) => s.addFromUrl)
const addMany = useDownloads((s) => s.addMany)
const settingsLoaded = useSettings((s) => s.loaded)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
@@ -354,6 +380,9 @@ export function DownloadBar(): React.JSX.Element {
// Probe state for a playlist URL.
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set())
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
// bar's global kind. Lets a playlist mix video and audio downloads.
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
// Clipboard auto-detect: when the window gains focus and the clipboard holds a
// fresh link, offer it (without clobbering anything the user is already typing).
@@ -424,6 +453,7 @@ export function DownloadBar(): React.JSX.Element {
setFormatId('')
setPlaylist(null)
setSelected(new Set())
setItemKinds({})
}
function onUrlChange(next: string): void {
@@ -489,6 +519,20 @@ export function DownloadBar(): React.JSX.Element {
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
}
// Per-entry kind: the override if set, else the bar's global kind.
function effKind(index: number): MediaKind {
return itemKinds[index] ?? kind
}
function toggleItemKind(index: number): void {
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
}
function setAllKinds(k: MediaKind): void {
if (!playlist) return
const all: Record<number, MediaKind> = {}
for (const e of playlist.entries) all[e.index] = k
setItemKinds(all)
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
@@ -553,13 +597,19 @@ export function DownloadBar(): React.JSX.Element {
function addPlaylist(): void {
if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index))
for (const e of chosen) {
addFromUrl(e.url, kind, quality, {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel
// Each entry uses its own kind override; quality falls back to that kind's
// default unless the entry matches the bar's current kind/quality.
addMany(
chosen.map((e) => {
const k = effKind(e.index)
return {
url: e.url,
kind: k,
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
}
})
}
)
setUrl('')
clearProbe()
}
@@ -574,6 +624,7 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.urlRow}>
<Input
className={styles.url}
input={{ id: 'aerofetch-url' }}
value={url}
onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
@@ -687,27 +738,50 @@ export function DownloadBar(): React.JSX.Element {
<Button size="small" appearance="subtle" onClick={toggleAll}>
{allSelected ? 'Select none' : 'Select all'}
</Button>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('video')}>
All video
</Button>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('audio')}>
All audio
</Button>
</div>
<div className={styles.plList}>
{playlist.entries.map((e) => (
<Checkbox
key={e.index}
className={styles.plItem}
checked={selected.has(e.index)}
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
label={
<span className={styles.plItemLabel}>
<span className={styles.plItemTitle}>
{e.index}. {e.title}
<div key={e.index} className={styles.plItemRow}>
<Checkbox
className={styles.plItem}
checked={selected.has(e.index)}
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
label={
<span className={styles.plItemLabel}>
<span className={styles.plItemTitle}>
{e.index}. {e.title}
</span>
{(e.durationLabel || e.uploader) && (
<Caption1 className={styles.plItemMeta}>
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
</Caption1>
)}
</span>
{(e.durationLabel || e.uploader) && (
<Caption1 className={styles.plItemMeta}>
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
</Caption1>
)}
</span>
}
/>
}
/>
<Hint
label={
effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
}
placement="top"
align="end"
>
<button
type="button"
className={styles.plKindBtn}
onClick={() => toggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
>
{effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
</button>
</Hint>
</div>
))}
</div>
</div>
@@ -133,6 +133,17 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</Field>
</div>
<Field
label="Format sorting (advanced)"
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
>
<Input
value={value.formatSort}
placeholder="res,fps,vcodec:av01"
onChange={(_, d) => setOpt('formatSort', d.value)}
/>
</Field>
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
<Switch
checked={value.embedSubtitles}
+3 -1
View File
@@ -4,6 +4,7 @@ import {
ArrowDownloadRegular,
HistoryRegular,
LibraryRegular,
WindowConsoleRegular,
SettingsRegular,
WeatherMoonRegular,
WeatherSunnyRegular,
@@ -14,7 +15,7 @@ import {
import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint'
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
const useStyles = makeStyles({
root: {
@@ -179,6 +180,7 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
{ value: 'library', label: 'Library', icon: <LibraryRegular /> },
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
]
@@ -74,8 +74,10 @@ interface Draft {
id: string | null
name: string
args: string
/** optional regex; auto-applies the template to matching URLs */
urlPattern: string
}
const BLANK_DRAFT: Draft = { id: null, name: '', args: '' }
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
@@ -95,14 +97,20 @@ export function TemplateManager(): React.JSX.Element {
const [draft, setDraft] = useState<Draft | null>(null)
function startEdit(t: CommandTemplate): void {
setDraft({ id: t.id, name: t.name, args: t.args })
setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
}
function commit(): void {
if (!draft) return
const name = draft.name.trim()
if (!name) return
save({ id: draft.id ?? newId(), name, args: draft.args.trim() })
const urlPattern = draft.urlPattern.trim()
save({
id: draft.id ?? newId(),
name,
args: draft.args.trim(),
...(urlPattern ? { urlPattern } : {})
})
setDraft(null)
}
@@ -154,6 +162,11 @@ export function TemplateManager(): React.JSX.Element {
resize="vertical"
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
/>
<Input
value={draft.urlPattern}
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
/>
<div className={styles.formActions}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
Cancel
@@ -0,0 +1,203 @@
import { useEffect, useRef, useState } from 'react'
import {
Button,
Textarea,
Subtitle2,
Body1,
Caption1,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
text: string
kind: LineKind
}
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
gate: {
padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
inputRow: { display: 'flex', gap: '8px', alignItems: 'flex-end' },
inputCol: { flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '4px' },
prefix: { color: tokens.colorNeutralForeground3, fontFamily: tokens.fontFamilyMonospace },
buttons: { display: 'flex', gap: '8px' },
log: {
flexGrow: 1,
minHeight: '160px',
overflowY: 'auto',
margin: 0,
padding: '12px 14px',
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase300,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
},
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
stderr: { color: tokens.colorPaletteRedForeground1 },
sys: { color: tokens.colorNeutralForeground3 },
empty: { color: tokens.colorNeutralForeground3 }
})
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
}
/**
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
* binary, and watch its output stream. Gated on the customCommandEnabled consent
* flag (enforced again in main — see src/main/terminal.ts).
*/
export function TerminalView(): React.JSX.Element {
const styles = useStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([])
const [running, setRunning] = useState(false)
const runId = useRef<string | null>(null)
const logRef = useRef<HTMLPreElement>(null)
// Stream terminal output for the current run into the log.
useEffect(
() =>
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
if (ev.type === 'output') {
setLines((ls) => [...ls, { text: ev.line, kind: ev.stream }])
} else if (ev.type === 'error') {
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
setRunning(false)
runId.current = null
} else {
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }])
setRunning(false)
runId.current = null
}
}),
[]
)
// Keep the log pinned to the latest output.
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [lines])
function run(): void {
const a = args.trim()
if (!a || running) return
const id = newId()
runId.current = id
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
setRunning(true)
window.api
.runTerminal(id, a)
.then((res) => {
if (!res.ok) {
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
setRunning(false)
runId.current = null
}
})
.catch((e: unknown) => {
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
setRunning(false)
runId.current = null
})
}
function stop(): void {
if (runId.current) window.api.cancelTerminal(runId.current)
}
const lineClass = (kind: LineKind): string | undefined =>
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Terminal</Subtitle2>
<Caption1 className={styles.sub}>
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
</Caption1>
</div>
{!customCommandEnabled && (
<Body1 className={styles.gate}>
The terminal is part of custom commands. Turn on Run custom commands in Settings
Custom commands to use it.
</Body1>
)}
<div className={styles.inputRow}>
<div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
run()
}
}}
placeholder="--version (Ctrl+Enter to run)"
resize="vertical"
disabled={!customCommandEnabled}
/>
</div>
<div className={styles.buttons}>
{running ? (
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
Stop
</Button>
) : (
<Button
appearance="primary"
icon={<PlayRegular />}
onClick={run}
disabled={!customCommandEnabled || !args.trim()}
>
Run
</Button>
)}
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setLines([])}
disabled={lines.length === 0}
aria-label="Clear output"
/>
</div>
</div>
<pre className={styles.log} ref={logRef}>
{lines.length === 0 ? (
<span className={styles.empty}>Output will appear here.</span>
) : (
lines.map((l, i) => (
<div key={i} className={lineClass(l.kind)}>
{l.text}
</div>
))
)}
</pre>
</div>
)
}
+4 -1
View File
@@ -220,7 +220,10 @@ if (import.meta.env.DEV && !window.api) {
durationLabel: `${10 + i}:0${i}`,
downloaded: i < 2
})),
onIndexProgress: () => () => {}
onIndexProgress: () => () => {},
runTerminal: async () => ({ ok: true }),
cancelTerminal: async () => {},
onTerminalOutput: () => () => {}
}
}