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
|
||||
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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user