From da37690b42af7e3a2d6fb4a84d29a208ca232c5c Mon Sep 17 00:00:00 2001 From: debont80 Date: Fri, 26 Jun 2026 11:10:22 -0400 Subject: [PATCH] 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 --- ROADMAP.md | 44 ++-- src/main/buildArgs.ts | 32 ++- src/main/download.ts | 3 +- src/main/index.ts | 5 + src/main/settings.ts | 1 + src/main/templates.ts | 5 +- src/main/terminal.ts | 99 +++++++++ src/preload/index.ts | 20 +- src/renderer/src/App.tsx | 44 ++++ .../src/components/CommandPalette.tsx | 167 ++++++++++++++ src/renderer/src/components/DownloadBar.tsx | 122 ++++++++--- .../src/components/DownloadOptionsForm.tsx | 11 + src/renderer/src/components/Sidebar.tsx | 4 +- .../src/components/TemplateManager.tsx | 19 +- src/renderer/src/components/TerminalView.tsx | 203 ++++++++++++++++++ src/renderer/src/main.tsx | 5 +- src/shared/ipc.ts | 40 +++- test/buildArgs.test.ts | 54 +++++ 18 files changed, 820 insertions(+), 58 deletions(-) create mode 100644 src/main/terminal.ts create mode 100644 src/renderer/src/components/CommandPalette.tsx create mode 100644 src/renderer/src/components/TerminalView.tsx diff --git a/ROADMAP.md b/ROADMAP.md index f08e69a..9ccad93 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -342,29 +342,31 @@ Today `src/renderer/src/store/downloads.ts` has cancel + retry only. for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while AeroFetch is running** — noted in the UI hint and the code.* -## Phase N — Power-user surface +## Phase N — Power-user surface ✅ COMPLETE -YTDLnis leans hard into this; AeroFetch has the building blocks (command preview, templates, -per-item `options`) but not the surfaces. +YTDLnis leans hard into this; AeroFetch had the building blocks (command preview, templates, +per-item `options`) but not the surfaces. typecheck + test + `npm run build` clean. -- [ ] **Built-in terminal mode.** An interactive raw-yt-dlp console with live stdout/stderr — - YTDLnis's "terminal." AeroFetch already has command *preview* (`previewCommand` + - `formatCommandLine` in `src/main/download.ts`) and a streaming spawn path; a terminal is - "spawn arbitrary argv → stream to a log pane → input box." New view + one IPC channel. -- [ ] **Per-playlist-item editing.** Edit each playlist entry separately — different - format/type per item, multiple audio formats, batch-update type in one click (YTDLnis). - Extend the playlist selection panel in `src/renderer/src/components/DownloadBar.tsx`; the - queue already carries per-item `DownloadItem.options`, so the model supports it. -- [ ] **Weighted format sorting ("format aspect importance").** Rank codec / container / - quality and auto-pick by weighted priority. Extends today's single codec preference - (`-S res,fps,vcodec:…`) into a fuller `-S` builder. -- [ ] **URL-regex template auto-matching.** A `CommandTemplate` gains an optional URL pattern; - `buildCommand` auto-applies the matching template (e.g. always use template X for - `soundcloud.com`). Small extension to the existing `defaultTemplateId` logic in - `src/main/templates.ts`. -- [ ] **Command palette + keyboard shortcuts.** A Ctrl+K palette and a few global shortcuts - (paste-and-download, focus URL, switch tabs). Today keyboard handling is just Enter in a - couple of inputs. +- [x] **Built-in terminal mode.** *Done: `src/main/terminal.ts` runs the bundled yt-dlp with + raw user args (binary fixed; gated on `customCommandEnabled`, enforced in main), streaming + stdout/stderr line-by-line over a new `terminal:output` channel (`terminal:run`/`cancel` + IPC + preload). New `TerminalView.tsx` (args box, Run/Stop/Clear, live log, Ctrl+Enter) + and a Terminal sidebar tab.* +- [x] **Per-playlist-item editing.** *Done: the playlist panel in `DownloadBar.tsx` gained a + per-row video/audio toggle + **All video** / **All audio** batch buttons; `addPlaylist` + enqueues via `addMany` with each entry's own kind (quality falls back to that kind's + default). Per-item exact-format selection (needs probing each entry) left as a further + extension.* +- [x] **Weighted format sorting ("format aspect importance").** *Done: a `formatSort` field on + `DownloadOptions` (raw yt-dlp `-S` string); when set it emits `-S `, overriding the + codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.* +- [x] **URL-regex template auto-matching.** *Done: `CommandTemplate.urlPattern` (regex); + `selectExtraArgs` auto-applies a matching template ahead of the global default + (`matchesUrl`, bad patterns never match), URL threaded via `resolveExtraArgs`. urlPattern + field in `TemplateManager` + persisted in `templates.ts`. Unit-tested.* +- [x] **Command palette + keyboard shortcuts.** *Done: a portal-free `CommandPalette.tsx` + (filter, ↑/↓, Enter, Esc) opened with Ctrl/Cmd+K from `App.tsx`; actions = navigate tabs, + new download (focuses the `aerofetch-url` input), toggle theme.* ## Phase O — Windows-native integration & discoverability diff --git a/src/main/buildArgs.ts b/src/main/buildArgs.ts index b3e6ce2..23fd4e8 100644 --- a/src/main/buildArgs.ts +++ b/src/main/buildArgs.ts @@ -143,6 +143,7 @@ export function parseExtraArgs(raw: string): string[] { * * - customCommandEnabled off → [] (always) * - perDownloadExtraArgs defined (even '') → those args + * - else a template whose urlPattern matches → that template's args (most specific) * - else a matching defaultTemplateId → that template's args * - else → [] */ @@ -150,10 +151,20 @@ export function selectExtraArgs(params: { customCommandEnabled: boolean perDownloadExtraArgs: string | undefined defaultTemplateId: string | null - templates: Pick[] + templates: Pick[] + /** the download URL, for urlPattern auto-matching */ + url?: string }): string[] { if (!params.customCommandEnabled) return [] if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs) + // A template whose urlPattern matches this URL auto-applies, ahead of the global + // default — it's the more specific choice. + if (params.url) { + const matched = params.templates.find( + (t) => t.urlPattern && matchesUrl(t.urlPattern, params.url!) + ) + if (matched) return parseExtraArgs(matched.args) + } if (params.defaultTemplateId) { const tpl = params.templates.find((t) => t.id === params.defaultTemplateId) if (tpl) return parseExtraArgs(tpl.args) @@ -161,6 +172,15 @@ export function selectExtraArgs(params: { return [] } +/** Case-insensitive regex test of a template's urlPattern; a bad pattern never matches. */ +export function matchesUrl(pattern: string, url: string): boolean { + try { + return new RegExp(pattern, 'i').test(url) + } catch { + return false + } +} + /** * Parse a raw trim string (one or more time ranges, comma- or newline-separated) * into yt-dlp `--download-sections` specs. Each range is normalised to the @@ -347,9 +367,13 @@ export function buildArgs( } } else { args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer) - // Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the - // pick toward the chosen codec without overriding the requested quality. - if (o.preferredVideoCodec !== 'any') { + // A raw format-sort string (advanced) wins outright; otherwise the codec + // preference is a sort tiebreaker AFTER resolution/fps, nudging the pick toward + // the chosen codec without overriding the requested quality. + const sort = o.formatSort.trim() + if (sort) { + args.push('-S', sort) + } else if (o.preferredVideoCodec !== 'any') { const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec args.push('-S', `res,fps,vcodec:${token}`) } diff --git a/src/main/download.ts b/src/main/download.ts index b21575a..23ac459 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -170,7 +170,8 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin customCommandEnabled: settings.customCommandEnabled, perDownloadExtraArgs: opts.extraArgs, defaultTemplateId: settings.defaultTemplateId, - templates: listTemplates() + templates: listTemplates(), + url: opts.url }) } diff --git a/src/main/index.ts b/src/main/index.ts index 271b8f3..1b5852a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,7 @@ import { getFfmpegVersions } from './ffmpeg' import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' import { probeMedia } from './probe' import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download' +import { runTerminal, cancelTerminal } from './terminal' import { getSettings, setSettings, ensureMediaDirs } from './settings' import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history' import { listTemplates, saveTemplate, removeTemplate } from './templates' @@ -212,6 +213,10 @@ function registerIpcHandlers(): void { }) ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id)) ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id)) + ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) => + runTerminal(e.sender, id, args) + ) + ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id)) ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads')) diff --git a/src/main/settings.ts b/src/main/settings.ts index 8fc8c74..908040c 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -101,6 +101,7 @@ function sanitizeOptions(input: unknown): DownloadOptions { preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never) ? o.preferredVideoCodec! : d.preferredVideoCodec, + formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort, embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles), subtitleLanguages: typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim() diff --git a/src/main/templates.ts b/src/main/templates.ts index 17b7420..3f12c89 100644 --- a/src/main/templates.ts +++ b/src/main/templates.ts @@ -36,10 +36,13 @@ function save(templates: CommandTemplate[]): void { } function sanitize(t: CommandTemplate): CommandTemplate { + const urlPattern = typeof t.urlPattern === 'string' ? t.urlPattern.trim() : '' return { id: String(t.id), name: (t.name ?? '').trim() || 'Untitled command', - args: (t.args ?? '').trim() + args: (t.args ?? '').trim(), + // Only persist a urlPattern when one was provided, keeping older files clean. + ...(urlPattern ? { urlPattern } : {}) } } diff --git a/src/main/terminal.ts b/src/main/terminal.ts new file mode 100644 index 0000000..77dd2ee --- /dev/null +++ b/src/main/terminal.ts @@ -0,0 +1,99 @@ +/** + * Built-in yt-dlp terminal (Phase N): runs the bundled yt-dlp with raw, user-typed + * arguments and streams stdout/stderr back to the renderer line-by-line. + * + * SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe), + * and the whole feature is gated on Settings.customCommandEnabled — the same consent + * flag that guards per-download extra args, because yt-dlp args can run arbitrary + * code (`--exec`). The gate is enforced here in main, not just in the renderer UI. + */ +import { spawn, execFile, type ChildProcess } from 'child_process' +import { existsSync } from 'fs' +import { type WebContents } from 'electron' +import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries' +import { getSettings } from './settings' +import { ensureManagedYtdlp } from './ytdlp' +import { parseExtraArgs } from './buildArgs' +import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc' + +const active = new Map() + +function send(wc: WebContents, ev: TerminalEvent): void { + if (!wc.isDestroyed()) wc.send(IpcChannels.terminalOutput, ev) +} + +export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult { + if (!getSettings().customCommandEnabled) { + return { + ok: false, + error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.' + } + } + ensureManagedYtdlp() + const ytdlp = getYtdlpPath() + if (!existsSync(ytdlp)) { + return { ok: false, error: 'yt-dlp.exe is missing and could not be restored.' } + } + if (active.has(id)) return { ok: false, error: 'A command is already running.' } + + // Always pin ffmpeg so post-processing works; the rest is whatever the user typed. + const args = ['--ffmpeg-location', getBinDir(), ...parseExtraArgs(argsRaw)] + let child: ChildProcess + try { + child = spawn(ytdlp, args, { windowsHide: true }) + } catch (e) { + return { ok: false, error: (e as Error).message } + } + active.set(id, child) + + // Buffer each stream and emit on newline boundaries (flush the remainder on close). + const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' } + function feed(stream: 'stdout' | 'stderr', chunk: string): void { + buffers[stream] += chunk + let nl: number + while ((nl = buffers[stream].indexOf('\n')) >= 0) { + const line = buffers[stream].slice(0, nl).replace(/\r$/, '') + buffers[stream] = buffers[stream].slice(nl + 1) + send(wc, { type: 'output', id, line, stream }) + } + } + child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString())) + child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString())) + + let settled = false + child.on('error', (err) => { + if (settled) return + settled = true + active.delete(id) + send(wc, { type: 'error', id, error: err.message }) + }) + child.on('close', (code) => { + if (settled) return + settled = true + active.delete(id) + // Flush any trailing partial lines that never hit a newline. + for (const stream of ['stdout', 'stderr'] as const) { + if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream }) + } + send(wc, { type: 'done', id, code }) + }) + + return { ok: true } +} + +export function cancelTerminal(id: string): void { + const child = active.get(id) + if (!child) return + const pid = child.pid + if (pid != null) { + // Tree-kill via System32 taskkill by absolute path (same hardening as download.ts). + execFile( + getSystem32Path('taskkill.exe'), + ['/pid', String(pid), '/T', '/F'], + { windowsHide: true }, + () => {} + ) + } else { + child.kill() + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index e5ec0d1..0e89991 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,7 +28,9 @@ import { type IndexProgress, type IndexSourceResult, type SyncResult, - type ScheduledSyncStatus + type ScheduledSyncStatus, + type TerminalEvent, + type TerminalRunResult } from '@shared/ipc' // The surface exposed to the renderer. Keep this thin: it only forwards to IPC. @@ -209,6 +211,22 @@ const api = { const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p) ipcRenderer.on(IpcChannels.indexProgress, listener) return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener) + }, + + // --- Built-in yt-dlp terminal (Phase N) --- + + /** Run the bundled yt-dlp with raw args; output streams via onTerminalOutput. */ + runTerminal: (id: string, args: string): Promise => + ipcRenderer.invoke(IpcChannels.terminalRun, id, args), + + cancelTerminal: (id: string): Promise => + ipcRenderer.invoke(IpcChannels.terminalCancel, id), + + /** Subscribe to live terminal output. Returns an unsubscribe function. */ + onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, ev: TerminalEvent): void => cb(ev) + ipcRenderer.on(IpcChannels.terminalOutput, listener) + return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c90b0aa..537336b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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('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' && } {tab === 'library' && } {tab === 'history' && } + {tab === 'terminal' && } {tab === 'settings' && } + + {paletteOpen && ( + setPaletteOpen(false)} /> + )} )} diff --git a/src/renderer/src/components/CommandPalette.tsx b/src/renderer/src/components/CommandPalette.tsx new file mode 100644 index 0000000..ac4ecae --- /dev/null +++ b/src/renderer/src/components/CommandPalette.tsx @@ -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(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 ( +
+
e.stopPropagation()} + role="dialog" + aria-label="Command palette" + > + setQ(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Type a command…" + aria-label="Command palette search" + /> +
+ {filtered.length === 0 ? ( +
No matching commands
+ ) : ( + filtered.map((a, i) => ( + + )) + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index b0f6ae8..9fea3e1 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -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(null) const [selected, setSelected] = useState>(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>({}) // 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 = {} + 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 {
onUrlChange(d.value)} onKeyDown={(e) => e.key === 'Enter' && fetchFormats()} @@ -687,27 +738,50 @@ export function DownloadBar(): React.JSX.Element { + +
{playlist.entries.map((e) => ( - toggleEntry(e.index, !!d.checked)} - label={ - - - {e.index}. {e.title} +
+ toggleEntry(e.index, !!d.checked)} + label={ + + + {e.index}. {e.title} + + {(e.durationLabel || e.uploader) && ( + + {[e.durationLabel, e.uploader].filter(Boolean).join(' • ')} + + )} - {(e.durationLabel || e.uploader) && ( - - {[e.durationLabel, e.uploader].filter(Boolean).join(' • ')} - - )} - - } - /> + } + /> + + + +
))}
diff --git a/src/renderer/src/components/DownloadOptionsForm.tsx b/src/renderer/src/components/DownloadOptionsForm.tsx index 0123a31..d287f8b 100644 --- a/src/renderer/src/components/DownloadOptionsForm.tsx +++ b/src/renderer/src/components/DownloadOptionsForm.tsx @@ -133,6 +133,17 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme + + setOpt('formatSort', d.value)} + /> + + }, { value: 'library', label: 'Library', icon: }, { value: 'history', label: 'History', icon: }, + { value: 'terminal', label: 'Terminal', icon: }, { value: 'settings', label: 'Settings', icon: } ] diff --git a/src/renderer/src/components/TemplateManager.tsx b/src/renderer/src/components/TemplateManager.tsx index 4a6e05c..412961c 100644 --- a/src/renderer/src/components/TemplateManager.tsx +++ b/src/renderer/src/components/TemplateManager.tsx @@ -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(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 })} /> + setDraft({ ...draft, urlPattern: d.value })} + />