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
not-ok result into a dedicated `appUpdError` slot but keeps not-ok duals for
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.).
- [x] **L23 — Diagnostics shows first 20 errors** with no "showing 20 of N" hint (the copied
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`.
- [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
tick); throttle or dedupe by computed value.
- [ ] **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.
- [ ] **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
runs its primary action). Inconsistent.
- [ ] **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.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.clipboardRead, () => clipboard.readText())
+2
View File
@@ -77,6 +77,8 @@ const api = {
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),
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 {
Input,
Textarea,
@@ -254,7 +254,7 @@ const useStyles = makeStyles({
})
// 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).
function toLocalDatetimeValue(d: Date): string {
const pad = (n: number): string => String(n).padStart(2, '0')
@@ -302,7 +302,7 @@ export function DownloadBar(): React.JSX.Element {
onUrlChange(fromText)
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'))
if (file) {
file
@@ -342,7 +342,7 @@ export function DownloadBar(): React.JSX.Element {
// Clipboard auto-detect and links handed to AeroFetch from outside (the
// 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
// 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 {
suggestion,
source: suggestionSource,
@@ -376,7 +376,7 @@ export function DownloadBar(): React.JSX.Element {
function onUrlChange(next: string): void {
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 (dup) setDup(null)
confirmDup.current = false
@@ -418,7 +418,7 @@ export function DownloadBar(): React.JSX.Element {
const text = await navigator.clipboard.readText()
if (text) onUrlChange(text.trim())
} 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
// 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) {
const existing = useDownloads
.getState()
@@ -551,7 +551,11 @@ export function DownloadBar(): React.JSX.Element {
input={{ id: 'aerofetch-url', 'aria-label': 'Video or playlist URL' }}
value={url}
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…"
size="large"
contentBefore={<ArrowDownloadRegular />}
@@ -598,7 +602,7 @@ export function DownloadBar(): React.JSX.Element {
{dup && (
<div className={styles.dupRow}>
<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}>
Download anyway
</Button>
@@ -622,7 +626,7 @@ export function DownloadBar(): React.JSX.Element {
{probeError && (
<div className={mergeClasses(styles.statusRow, styles.errorRow)}>
<ErrorCircleRegular />
<Caption1>{probeError} you can still download with the presets below.</Caption1>
<Caption1>{probeError} -- you can still download with the presets below.</Caption1>
</div>
)}
@@ -697,8 +701,8 @@ export function DownloadBar(): React.JSX.Element {
<Hint
label={
effKind(e.index) === 'audio'
? 'Audio click for video'
: 'Video click for audio'
? 'Audio -- click for video'
: 'Video -- click for audio'
}
placement="top"
align="end"
@@ -743,7 +747,7 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.trimPanel}>
<Field
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
value={trim}
@@ -83,6 +83,8 @@ const useStyles = makeStyles({
interface Props {
value: DownloadOptions
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
* 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()
function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void {
@@ -107,33 +109,39 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
return (
<div className={styles.root}>
<div className={styles.grid}>
<Field label="Audio format" hint="For audio-only downloads.">
<Select
aria-label="Audio format"
value={value.audioFormat}
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
/>
</Field>
<Field label="Video container" hint="The file format used when video and audio are combined into one file.">
<Select
aria-label="Video container"
value={value.videoContainer}
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
/>
</Field>
<Field
label="Preferred codec"
hint="A preference when several formats match -- not a strict filter."
>
<Select
aria-label="Preferred video codec"
value={value.preferredVideoCodec}
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
/>
</Field>
{kind !== 'video' && (
<Field label="Audio format" hint="For audio-only downloads.">
<Select
aria-label="Audio format"
value={value.audioFormat}
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
/>
</Field>
)}
{kind !== 'audio' && (
<Field label="Video container" hint="The file format used when video and audio are combined into one file.">
<Select
aria-label="Video container"
value={value.videoContainer}
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
/>
</Field>
)}
{kind !== 'audio' && (
<Field
label="Preferred codec"
hint="A preference when several formats match -- not a strict filter."
>
<Select
aria-label="Preferred video codec"
value={value.preferredVideoCodec}
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
/>
</Field>
)}
</div>
<Field
+2 -1
View File
@@ -662,6 +662,7 @@ export function SettingsView(): React.JSX.Element {
<DownloadOptionsForm
value={downloadOptions}
kind={defaultKind}
onChange={(o) => update({ downloadOptions: o })}
/>
</Card>
@@ -1080,7 +1081,7 @@ export function SettingsView(): React.JSX.Element {
{appUpd.htmlUrl && (
<Button
icon={<OpenRegular />}
onClick={() => window.open(appUpd.htmlUrl, '_blank')}
onClick={() => void window.api?.openUrl?.(appUpd.htmlUrl ?? '')}
>
View release
</Button>
+1
View File
@@ -154,6 +154,7 @@ if (import.meta.env.DEV && !window.api) {
pauseDownload: async () => {},
chooseFolder: async () => null,
openPath: async () => '',
openUrl: async () => {},
showInFolder: async () => {},
readClipboard: async () => '',
getSettings: async () => MOCK_SETTINGS,
+1
View File
@@ -22,6 +22,7 @@ export const IpcChannels = {
downloadPause: 'download:pause',
chooseFolder: 'download:choose-folder',
openPath: 'shell:open-path',
openUrl: 'shell:open-url',
showInFolder: 'shell:show-in-folder',
clipboardRead: 'clipboard:read',
settingsGet: 'settings:get',