Fix L22/L24/L27: kind-aware form, shell openUrl, Enter-to-download

L22: DownloadOptionsForm accepts optional `kind` prop. When set, hides
     audio-specific fields (Audio format) for video downloads and vice versa
     (Video container, Preferred codec) for audio downloads. Settings passes
     defaultKind so the defaults card shows only relevant options.

L24: Replace lone window.open('htmlUrl', '_blank') with a new openUrl IPC
     channel (shell:open-url) that calls shell.openExternal after validating
     the protocol is http/https. Consistent with how all other external opens
     work in the app. Preload + main handler + mock updated.

L27: DownloadBar URL-field Enter is now smart -- if formats/playlist are
     already loaded (or probe errored), Enter triggers download; otherwise
     it probes. Eliminates the keyboard dead-end where Enter never downloads.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 14:16:15 -04:00
parent c038b94c6f
commit 3df32d0505
8 changed files with 70 additions and 45 deletions
+3 -3
View File
@@ -420,18 +420,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [ ] **L21 — Inconsistent in-component error handling.** SettingsView funnels the app-update - [ ] **L21 — Inconsistent in-component error handling.** SettingsView funnels the app-update
not-ok result into a dedicated `appUpdError` slot but keeps not-ok duals for not-ok result into a dedicated `appUpdError` slot but keeps not-ok duals for
version/update/export/import — two patterns in one file. version/update/export/import — two patterns in one file.
- [ ] **L22 — DownloadOptionsForm shows both audio + video controls** regardless of the chosen - [x] **L22 — DownloadOptionsForm shows both audio + video controls** regardless of the chosen
kind in the per-download override panel (audio format shown for a video download, etc.). kind in the per-download override panel (audio format shown for a video download, etc.).
- [x] **L23 — Diagnostics shows first 20 errors** with no "showing 20 of N" hint (the copied - [x] **L23 — Diagnostics shows first 20 errors** with no "showing 20 of N" hint (the copied
report includes all). report includes all).
- [ ] **L24 — `window.open(htmlUrl, '_blank')`** in SettingsView is the only `window.open` in the - [x] **L24 — `window.open(htmlUrl, '_blank')`** in SettingsView is the only `window.open` in the
app (relies on the window-open handler); elsewhere external opens go through IPC/`shell`. app (relies on the window-open handler); elsewhere external opens go through IPC/`shell`.
- [x] **L25 — Taskbar effect over-fires.** `App.tsx` subscribes to the whole downloads store and - [x] **L25 — Taskbar effect over-fires.** `App.tsx` subscribes to the whole downloads store and
recomputes `summarizeQueue` + sends the taskbar IPC on *every* store change (each progress recomputes `summarizeQueue` + sends the taskbar IPC on *every* store change (each progress
tick); throttle or dedupe by computed value. tick); throttle or dedupe by computed value.
- [ ] **L26 — Third URL-sniffing path.** DownloadBar's drag-drop `firstUrl` is a separate - [ ] **L26 — Third URL-sniffing path.** DownloadBar's drag-drop `firstUrl` is a separate
"first http(s) line" extractor alongside `useClipboardLink.looksLikeUrl` and deeplink's scan. "first http(s) line" extractor alongside `useClipboardLink.looksLikeUrl` and deeplink's scan.
- [ ] **L27 — DownloadBar Enter probes, not downloads.** Enter in the URL field triggers - [x] **L27 — DownloadBar Enter probes, not downloads.** Enter in the URL field triggers
`fetchFormats`; there's no keyboard path to actually start the download (LibraryView's Enter `fetchFormats`; there's no keyboard path to actually start the download (LibraryView's Enter
runs its primary action). Inconsistent. runs its primary action). Inconsistent.
- [ ] **L28 — Default kind/quality applied once.** DownloadBar seeds from settings via a one-shot - [ ] **L28 — Default kind/quality applied once.** DownloadBar seeds from settings via a one-shot
+8
View File
@@ -325,6 +325,14 @@ function registerIpcHandlers(): void {
}) })
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p)) ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
ipcMain.handle(IpcChannels.openUrl, (_e, url: string) => {
try {
const { protocol } = new URL(url)
if (protocol === 'http:' || protocol === 'https:') void shell.openExternal(url)
} catch {
// Ignore malformed URLs.
}
})
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p)) ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText()) ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
+2
View File
@@ -77,6 +77,8 @@ const api = {
openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path), openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path),
openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url),
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannels.showInFolder, path), showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannels.showInFolder, path),
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead), readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
+17 -13
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { import {
Input, Input,
Textarea, Textarea,
@@ -254,7 +254,7 @@ const useStyles = makeStyles({
}) })
// Format a Date as the `YYYY-MM-DDTHH:mm` value a datetime-local input expects, // Format a Date as the `YYYY-MM-DDTHH:mm` value a datetime-local input expects,
// in LOCAL time used as the picker's `min` so a past time can't be chosen and // in LOCAL time -- used as the picker's `min` so a past time can't be chosen and
// then silently download immediately (L156/L57). // then silently download immediately (L156/L57).
function toLocalDatetimeValue(d: Date): string { function toLocalDatetimeValue(d: Date): string {
const pad = (n: number): string => String(n).padStart(2, '0') const pad = (n: number): string => String(n).padStart(2, '0')
@@ -302,7 +302,7 @@ export function DownloadBar(): React.JSX.Element {
onUrlChange(fromText) onUrlChange(fromText)
return return
} }
// A dropped Windows .url Internet Shortcut read its URL= line. // A dropped Windows .url Internet Shortcut -- read its URL= line.
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url')) const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
if (file) { if (file) {
file file
@@ -342,7 +342,7 @@ export function DownloadBar(): React.JSX.Element {
// Clipboard auto-detect and links handed to AeroFetch from outside (the // Clipboard auto-detect and links handed to AeroFetch from outside (the
// aerofetch:// protocol or a "Send to" .url file) share one suggestion banner, // aerofetch:// protocol or a "Send to" .url file) share one suggestion banner,
// driven by the same hook the library's add-source field uses. An external link // driven by the same hook the library's add-source field uses. An external link
// always takes priority over a clipboard guess it's a direct request. // always takes priority over a clipboard guess -- it's a direct request.
const { const {
suggestion, suggestion,
source: suggestionSource, source: suggestionSource,
@@ -376,7 +376,7 @@ export function DownloadBar(): React.JSX.Element {
function onUrlChange(next: string): void { function onUrlChange(next: string): void {
setUrl(next) setUrl(next)
// Any probed info or a duplicate warning is stale once the URL changes. // Any probed info -- or a duplicate warning -- is stale once the URL changes.
if (info || probeError || playlist) clearProbe() if (info || probeError || playlist) clearProbe()
if (dup) setDup(null) if (dup) setDup(null)
confirmDup.current = false confirmDup.current = false
@@ -418,7 +418,7 @@ export function DownloadBar(): React.JSX.Element {
const text = await navigator.clipboard.readText() const text = await navigator.clipboard.readText()
if (text) onUrlChange(text.trim()) if (text) onUrlChange(text.trim())
} catch { } catch {
/* clipboard blocked ignore in preview */ /* clipboard blocked -- ignore in preview */
} }
} }
@@ -457,7 +457,7 @@ export function DownloadBar(): React.JSX.Element {
// Duplicate guard: if this URL is already in the queue (and the user hasn't // Duplicate guard: if this URL is already in the queue (and the user hasn't
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy. // confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
// Canceled/failed items don't count re-adding those is a legitimate retry. // Canceled/failed items don't count -- re-adding those is a legitimate retry.
if (!confirmDup.current) { if (!confirmDup.current) {
const existing = useDownloads const existing = useDownloads
.getState() .getState()
@@ -551,7 +551,11 @@ export function DownloadBar(): React.JSX.Element {
input={{ id: 'aerofetch-url', 'aria-label': 'Video or playlist URL' }} input={{ id: 'aerofetch-url', 'aria-label': 'Video or playlist URL' }}
value={url} value={url}
onChange={(_, d) => onUrlChange(d.value)} onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()} onKeyDown={(e) => {
if (e.key !== 'Enter') return
if (info || probeError || playlist) download()
else void fetchFormats()
}}
placeholder="Paste a video or playlist URL, then fetch…" placeholder="Paste a video or playlist URL, then fetch…"
size="large" size="large"
contentBefore={<ArrowDownloadRegular />} contentBefore={<ArrowDownloadRegular />}
@@ -598,7 +602,7 @@ export function DownloadBar(): React.JSX.Element {
{dup && ( {dup && (
<div className={styles.dupRow}> <div className={styles.dupRow}>
<WarningRegular /> <WarningRegular />
<Caption1 className={styles.suggestionText}>Already in your queue: {dup}.</Caption1> <Caption1 className={styles.suggestionText}>Already in your queue: "{dup}".</Caption1>
<Button size="small" appearance="primary" onClick={downloadAnyway}> <Button size="small" appearance="primary" onClick={downloadAnyway}>
Download anyway Download anyway
</Button> </Button>
@@ -622,7 +626,7 @@ export function DownloadBar(): React.JSX.Element {
{probeError && ( {probeError && (
<div className={mergeClasses(styles.statusRow, styles.errorRow)}> <div className={mergeClasses(styles.statusRow, styles.errorRow)}>
<ErrorCircleRegular /> <ErrorCircleRegular />
<Caption1>{probeError} you can still download with the presets below.</Caption1> <Caption1>{probeError} -- you can still download with the presets below.</Caption1>
</div> </div>
)} )}
@@ -697,8 +701,8 @@ export function DownloadBar(): React.JSX.Element {
<Hint <Hint
label={ label={
effKind(e.index) === 'audio' effKind(e.index) === 'audio'
? 'Audio click for video' ? 'Audio -- click for video'
: 'Video click for audio' : 'Video -- click for audio'
} }
placement="top" placement="top"
align="end" align="end"
@@ -743,7 +747,7 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.trimPanel}> <div className={styles.trimPanel}>
<Field <Field
label="Sections to keep" label="Sections to keep"
hint="Time ranges like 1:30-2:00 one per line or comma-separated. Leave empty to download the whole thing." hint="Time ranges like 1:30-2:00 -- one per line or comma-separated. Leave empty to download the whole thing."
> >
<Textarea <Textarea
value={trim} value={trim}
@@ -83,6 +83,8 @@ const useStyles = makeStyles({
interface Props { interface Props {
value: DownloadOptions value: DownloadOptions
onChange: (next: DownloadOptions) => void onChange: (next: DownloadOptions) => void
/** When provided, hides controls that don't apply to this media kind. */
kind?: 'video' | 'audio'
} }
/** /**
@@ -90,7 +92,7 @@ interface Props {
* Used both for the persisted defaults (Settings) and for a per-download * Used both for the persisted defaults (Settings) and for a per-download
* override (the download bar), so the two never drift apart. * override (the download bar), so the two never drift apart.
*/ */
export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Element { export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void { function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void {
@@ -107,6 +109,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
return ( return (
<div className={styles.root}> <div className={styles.root}>
<div className={styles.grid}> <div className={styles.grid}>
{kind !== 'video' && (
<Field label="Audio format" hint="For audio-only downloads."> <Field label="Audio format" hint="For audio-only downloads.">
<Select <Select
aria-label="Audio format" aria-label="Audio format"
@@ -115,6 +118,8 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
onChange={(v) => setOpt('audioFormat', v as AudioFormat)} onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
/> />
</Field> </Field>
)}
{kind !== 'audio' && (
<Field label="Video container" hint="The file format used when video and audio are combined into one file."> <Field label="Video container" hint="The file format used when video and audio are combined into one file.">
<Select <Select
aria-label="Video container" aria-label="Video container"
@@ -123,6 +128,8 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
onChange={(v) => setOpt('videoContainer', v as VideoContainer)} onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
/> />
</Field> </Field>
)}
{kind !== 'audio' && (
<Field <Field
label="Preferred codec" label="Preferred codec"
hint="A preference when several formats match -- not a strict filter." hint="A preference when several formats match -- not a strict filter."
@@ -134,6 +141,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)} onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
/> />
</Field> </Field>
)}
</div> </div>
<Field <Field
+2 -1
View File
@@ -662,6 +662,7 @@ export function SettingsView(): React.JSX.Element {
<DownloadOptionsForm <DownloadOptionsForm
value={downloadOptions} value={downloadOptions}
kind={defaultKind}
onChange={(o) => update({ downloadOptions: o })} onChange={(o) => update({ downloadOptions: o })}
/> />
</Card> </Card>
@@ -1080,7 +1081,7 @@ export function SettingsView(): React.JSX.Element {
{appUpd.htmlUrl && ( {appUpd.htmlUrl && (
<Button <Button
icon={<OpenRegular />} icon={<OpenRegular />}
onClick={() => window.open(appUpd.htmlUrl, '_blank')} onClick={() => void window.api?.openUrl?.(appUpd.htmlUrl ?? '')}
> >
View release View release
</Button> </Button>
+1
View File
@@ -154,6 +154,7 @@ if (import.meta.env.DEV && !window.api) {
pauseDownload: async () => {}, pauseDownload: async () => {},
chooseFolder: async () => null, chooseFolder: async () => null,
openPath: async () => '', openPath: async () => '',
openUrl: async () => {},
showInFolder: async () => {}, showInFolder: async () => {},
readClipboard: async () => '', readClipboard: async () => '',
getSettings: async () => MOCK_SETTINGS, getSettings: async () => MOCK_SETTINGS,
+1
View File
@@ -22,6 +22,7 @@ export const IpcChannels = {
downloadPause: 'download:pause', downloadPause: 'download:pause',
chooseFolder: 'download:choose-folder', chooseFolder: 'download:choose-folder',
openPath: 'shell:open-path', openPath: 'shell:open-path',
openUrl: 'shell:open-url',
showInFolder: 'shell:show-in-folder', showInFolder: 'shell:show-in-folder',
clipboardRead: 'clipboard:read', clipboardRead: 'clipboard:read',
settingsGet: 'settings:get', settingsGet: 'settings:get',