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:
+3
-3
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,33 +109,39 @@ 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}>
|
||||||
<Field label="Audio format" hint="For audio-only downloads.">
|
{kind !== 'video' && (
|
||||||
<Select
|
<Field label="Audio format" hint="For audio-only downloads.">
|
||||||
aria-label="Audio format"
|
<Select
|
||||||
value={value.audioFormat}
|
aria-label="Audio format"
|
||||||
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
|
value={value.audioFormat}
|
||||||
onChange={(v) => setOpt('audioFormat', v as 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.">
|
</Field>
|
||||||
<Select
|
)}
|
||||||
aria-label="Video container"
|
{kind !== 'audio' && (
|
||||||
value={value.videoContainer}
|
<Field label="Video container" hint="The file format used when video and audio are combined into one file.">
|
||||||
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
|
<Select
|
||||||
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
aria-label="Video container"
|
||||||
/>
|
value={value.videoContainer}
|
||||||
</Field>
|
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
|
||||||
<Field
|
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
||||||
label="Preferred codec"
|
/>
|
||||||
hint="A preference when several formats match -- not a strict filter."
|
</Field>
|
||||||
>
|
)}
|
||||||
<Select
|
{kind !== 'audio' && (
|
||||||
aria-label="Preferred video codec"
|
<Field
|
||||||
value={value.preferredVideoCodec}
|
label="Preferred codec"
|
||||||
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
|
hint="A preference when several formats match -- not a strict filter."
|
||||||
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
|
>
|
||||||
/>
|
<Select
|
||||||
</Field>
|
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>
|
</div>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user