Add Seal-parity download options + playlist support (Phase A)

Phase A of the Seal feature-parity roadmap: configurable post-processing via a
shared DownloadOptions model (src/shared/ipc.ts), editable as persisted defaults
in Settings and overridable per-download in the download bar.

- audio formats (mp3/m4a/opus/flac/wav/aac), video container (mp4/mkv/webm),
  preferred-codec sort (-S vcodec)
- subtitles (download/embed/langs/auto), SponsorBlock (remove/mark + categories
  + force-keyframes-at-cuts), embed chapters/metadata/thumbnail, square-crop
  audio artwork
- playlist downloads: probe via `-J --flat-playlist`, inline selection UI
  (checkbox list, select-all/none, live count); each entry enqueued as its own
  single-video download, reusing the existing queue/concurrency/history
- reusable DownloadOptionsForm shared by Settings and the download bar so the
  defaults and per-download override never drift
- ROADMAP.md tracking full Seal parity (phases A-E)

Also includes in-tree security hardening that landed alongside: preload sandbox
(CJS preload output), http(s)-only window-open + blocked renderer navigation,
argv-injection guard for URLs (src/main/url.ts), extension-allowlisted file
open/reveal (src/main/reveal.ts), yt-dlp version-check timeout, and a minimal
window.electron presence marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 19:32:59 -04:00
parent d4b9a0eefa
commit bb5dd6c438
21 changed files with 1101 additions and 91 deletions
+219 -27
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import {
Input,
Button,
Checkbox,
Spinner,
Text,
Caption1,
@@ -19,13 +20,18 @@ import {
MusicNote2Regular,
ErrorCircleRegular,
LinkRegular,
DismissRegular
DismissRegular,
OptionsRegular,
ChevronDownRegular,
ChevronUpRegular,
AppsListRegular
} from '@fluentui/react-icons'
import type { MediaInfo, FormatOption } from '@shared/ipc'
import type { MediaInfo, FormatOption, PlaylistInfo, DownloadOptions } from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { useSettings } from '../store/settings'
import { Select } from './Select'
import { Hint } from './Hint'
import { DownloadOptionsForm } from './DownloadOptionsForm'
/** A quick heuristic for "this clipboard text is a link worth offering". */
function looksLikeUrl(text: string): boolean {
@@ -164,6 +170,66 @@ const useStyles = makeStyles({
spacer: {
flexGrow: 1
},
// --- per-download options panel ---
optionsBar: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
optionsPanel: {
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// --- playlist selection ---
plPanel: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
plHeader: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
plHeaderText: {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: tokens.fontWeightSemibold
},
plList: {
display: 'flex',
flexDirection: 'column',
maxHeight: '260px',
overflowY: 'auto',
paddingRight: '4px'
},
plItem: {
display: 'flex',
alignItems: 'flex-start',
padding: '2px 0'
},
plItemLabel: {
display: 'flex',
flexDirection: 'column',
minWidth: 0
},
plItemTitle: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
plItemMeta: {
color: tokens.colorNeutralForeground3
},
folder: {
display: 'flex',
alignItems: 'center',
@@ -187,11 +253,17 @@ export function DownloadBar(): React.JSX.Element {
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const downloadOptions = useSettings((s) => s.downloadOptions)
const [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
// Per-download options override (null = use the persisted defaults).
const [override, setOverride] = useState<DownloadOptions | null>(null)
const [showOptions, setShowOptions] = useState(false)
const effectiveOptions = override ?? downloadOptions
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
useEffect(() => {
@@ -202,12 +274,16 @@ export function DownloadBar(): React.JSX.Element {
}
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality])
// Probe state for the format picker.
// Probe state for the single-video format picker.
const [info, setInfo] = useState<MediaInfo | null>(null)
const [probing, setProbing] = useState(false)
const [probeError, setProbeError] = useState<string | null>(null)
const [formatId, setFormatId] = useState<string>('')
// Probe state for a playlist URL.
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set())
// 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).
const [suggestion, setSuggestion] = useState<string | null>(null)
@@ -255,14 +331,18 @@ export function DownloadBar(): React.JSX.Element {
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
: undefined
function clearProbe(): void {
setInfo(null)
setProbeError(null)
setFormatId('')
setPlaylist(null)
setSelected(new Set())
}
function onUrlChange(next: string): void {
setUrl(next)
// Any probed info is stale once the URL changes.
if (info || probeError) {
setInfo(null)
setProbeError(null)
setFormatId('')
}
if (info || probeError || playlist) clearProbe()
}
function onKindChange(next: MediaKind): void {
@@ -276,9 +356,14 @@ export function DownloadBar(): React.JSX.Element {
setProbing(true)
setProbeError(null)
setInfo(null)
setPlaylist(null)
try {
const res = await window.api.probe(trimmed)
if (res.ok && res.info) {
if (res.ok && res.kind === 'playlist' && res.playlist) {
setPlaylist(res.playlist)
// Pre-select every entry; the user can trim the list.
setSelected(new Set(res.playlist.entries.map((e) => e.index)))
} else if (res.ok && res.info) {
setInfo(res.info)
setFormatId(res.info.formats[0]?.id ?? '')
} else {
@@ -300,6 +385,21 @@ export function DownloadBar(): React.JSX.Element {
}
}
// Selection helpers for the playlist panel.
const allSelected = playlist !== null && selected.size === playlist.entries.length
function toggleEntry(index: number, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(index)
else next.delete(index)
return next
})
}
function toggleAll(): void {
if (!playlist) return
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
@@ -320,16 +420,30 @@ export function DownloadBar(): React.JSX.Element {
id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
}
},
options: override ?? undefined
})
} else {
addFromUrl(trimmed, kind, quality, meta)
addFromUrl(trimmed, kind, quality, { ...meta, options: override ?? undefined })
}
setUrl('')
setInfo(null)
setProbeError(null)
setFormatId('')
clearProbe()
}
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,
options: override ?? undefined
})
}
setUrl('')
clearProbe()
}
return (
@@ -340,17 +454,17 @@ export function DownloadBar(): React.JSX.Element {
value={url}
onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
placeholder="Paste a video URL, then fetch formats…"
placeholder="Paste a video or playlist URL, then fetch…"
size="large"
contentBefore={<ArrowDownloadRegular />}
/>
<Hint label="Fetch available formats" placement="bottom" align="end">
<Hint label="Fetch formats / playlist" placement="bottom" align="end">
<Button
size="large"
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
onClick={fetchFormats}
disabled={!url.trim() || probing}
aria-label="Fetch available formats"
aria-label="Fetch formats or playlist"
/>
</Hint>
<Hint label="Paste from clipboard" placement="bottom" align="end">
@@ -383,7 +497,7 @@ export function DownloadBar(): React.JSX.Element {
{probing && (
<div className={styles.statusRow}>
<Spinner size="tiny" />
<Caption1>Fetching available formats</Caption1>
<Caption1>Fetching</Caption1>
</div>
)}
@@ -416,6 +530,46 @@ export function DownloadBar(): React.JSX.Element {
</div>
)}
{playlist && (
<div className={styles.plPanel}>
<div className={styles.plHeader}>
<AppsListRegular />
<Caption1 className={styles.plHeaderText}>
{playlist.title}
{playlist.uploader ? `${playlist.uploader}` : ''}
</Caption1>
<Caption1 className={styles.previewMeta}>
{selected.size} of {playlist.count} selected
</Caption1>
<Button size="small" appearance="subtle" onClick={toggleAll}>
{allSelected ? 'Select none' : 'Select all'}
</Button>
</div>
<div className={styles.plList}>
{playlist.entries.map((e) => (
<Checkbox
key={e.index}
className={styles.plItem}
checked={selected.has(e.index)}
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
label={
<span className={styles.plItemLabel}>
<span className={styles.plItemTitle}>
{e.index}. {e.title}
</span>
{(e.durationLabel || e.uploader) && (
<Caption1 className={styles.plItemMeta}>
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
</Caption1>
)}
</span>
}
/>
))}
</div>
</div>
)}
<div className={styles.controls}>
<div className={styles.control}>
<Caption1>Format</Caption1>
@@ -458,17 +612,55 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.spacer} />
<Button
appearance="primary"
size="large"
icon={<ArrowDownloadRegular />}
onClick={download}
disabled={!url.trim()}
>
Download
</Button>
{playlist ? (
<Button
appearance="primary"
size="large"
icon={<ArrowDownloadRegular />}
onClick={addPlaylist}
disabled={selected.size === 0}
>
Add {selected.size} to queue
</Button>
) : (
<Button
appearance="primary"
size="large"
icon={<ArrowDownloadRegular />}
onClick={download}
disabled={!url.trim()}
>
Download
</Button>
)}
</div>
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<OptionsRegular />}
iconPosition="before"
onClick={() => setShowOptions((v) => !v)}
>
Options {showOptions ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{override && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setOverride(null)}>
Reset to defaults
</Button>
</>
)}
</div>
{showOptions && (
<div className={styles.optionsPanel}>
<DownloadOptionsForm value={effectiveOptions} onChange={(o) => setOverride(o)} />
</div>
)}
<div className={styles.folder}>
<FolderRegular />
<Caption1 className={styles.folderPath} title={folder}>
@@ -0,0 +1,235 @@
import {
Field,
Input,
Switch,
Checkbox,
Caption1,
makeStyles,
tokens
} from '@fluentui/react-components'
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
type AudioFormat,
type VideoContainer,
type VideoCodecPref,
type SponsorBlockMode,
type SponsorBlockCategory,
type DownloadOptions
} from '@shared/ipc'
import { Select } from './Select'
// Human labels for the format/category enums.
const AUDIO_FORMAT_LABELS: Record<AudioFormat, string> = {
best: 'Best (keep source)',
mp3: 'MP3',
m4a: 'M4A (AAC)',
opus: 'Opus',
flac: 'FLAC (lossless)',
wav: 'WAV (lossless)',
aac: 'AAC'
}
const VIDEO_CONTAINER_LABELS: Record<VideoContainer, string> = {
mp4: 'MP4',
mkv: 'MKV',
webm: 'WebM'
}
const VIDEO_CODEC_LABELS: Record<VideoCodecPref, string> = {
any: 'No preference',
h264: 'H.264 (most compatible)',
vp9: 'VP9',
av1: 'AV1 (smallest)'
}
const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = {
sponsor: 'Sponsor',
intro: 'Intro / intermission',
outro: 'Endcards / credits',
selfpromo: 'Self-promotion',
preview: 'Preview / recap',
filler: 'Filler / tangent',
interaction: 'Interaction reminder',
music_offtopic: 'Non-music section'
}
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '16px'
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))',
gap: '16px'
},
// Indented reveal for options that only matter when their parent is enabled.
subGroup: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
paddingLeft: '14px',
borderLeft: `2px solid ${tokens.colorNeutralStroke2}`
},
categoryGrid: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
columnGap: '16px',
rowGap: '2px'
}
})
interface Props {
value: DownloadOptions
onChange: (next: DownloadOptions) => void
}
/**
* The full set of post-processing controls, driven by a DownloadOptions value.
* 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 {
const styles = useStyles()
function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void {
onChange({ ...value, [key]: v })
}
function toggleCategory(cat: SponsorBlockCategory, on: boolean): void {
const next = on
? [...value.sponsorBlockCategories, cat]
: value.sponsorBlockCategories.filter((c) => c !== cat)
setOpt('sponsorBlockCategories', next)
}
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="Container for merged video.">
<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="Tiebreaker, not a hard 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 label="Embed subtitles" hint="Download subtitles and mux them into the video.">
<Switch
checked={value.embedSubtitles}
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
label={value.embedSubtitles ? 'On' : 'Off'}
/>
</Field>
{value.embedSubtitles && (
<div className={styles.subGroup}>
<Field
label="Subtitle languages"
hint="Comma-separated, e.g. en, es. Use a pattern like en.* to include regional variants."
>
<Input
value={value.subtitleLanguages}
onChange={(_, d) => setOpt('subtitleLanguages', d.value)}
/>
</Field>
<Checkbox
checked={value.autoSubtitles}
onChange={(_, d) => setOpt('autoSubtitles', !!d.checked)}
label="Also include auto-generated captions"
/>
</div>
)}
<Field
label="SponsorBlock"
hint="Skip or label sponsor and other segments using the SponsorBlock database."
>
<Switch
checked={value.sponsorBlock}
onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
label={value.sponsorBlock ? 'On' : 'Off'}
/>
</Field>
{value.sponsorBlock && (
<div className={styles.subGroup}>
<Field label="Action">
<Select
aria-label="SponsorBlock action"
value={value.sponsorBlockMode}
options={[
{ value: 'remove', label: 'Remove segments (cut from file)' },
{ value: 'mark', label: 'Mark as chapters (keep, but labelled)' }
]}
onChange={(v) => setOpt('sponsorBlockMode', v as SponsorBlockMode)}
/>
</Field>
<Field label="Categories">
<div className={styles.categoryGrid}>
{SPONSORBLOCK_CATEGORIES.map((cat) => (
<Checkbox
key={cat}
checked={value.sponsorBlockCategories.includes(cat)}
onChange={(_, d) => toggleCategory(cat, !!d.checked)}
label={SPONSORBLOCK_LABELS[cat]}
/>
))}
</div>
</Field>
</div>
)}
<Field label="Embed chapters">
<Switch
checked={value.embedChapters}
onChange={(_, d) => setOpt('embedChapters', d.checked)}
label={value.embedChapters ? 'On' : 'Off'}
/>
</Field>
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch
checked={value.embedMetadata}
onChange={(_, d) => setOpt('embedMetadata', d.checked)}
label={value.embedMetadata ? 'On' : 'Off'}
/>
</Field>
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
<Switch
checked={value.embedThumbnail}
onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
label={value.embedThumbnail ? 'On' : 'Off'}
/>
</Field>
{value.embedThumbnail && (
<div className={styles.subGroup}>
<Checkbox
checked={value.cropThumbnail}
onChange={(_, d) => setOpt('cropThumbnail', !!d.checked)}
label="Crop audio artwork to a square"
/>
<Caption1 style={{ color: tokens.colorNeutralForeground3 }}>
Squares the embedded cover for audio files.
</Caption1>
</div>
)}
</div>
)
}
+2 -2
View File
@@ -21,11 +21,11 @@ const useStyles = makeStyles({
...shorthands.borderRadius(tokens.borderRadiusMedium),
cursor: 'pointer',
':hover': {
borderColor: tokens.colorNeutralStroke1Hover
...shorthands.borderColor(tokens.colorNeutralStroke1Hover)
},
':focus-visible': {
outline: 'none',
borderColor: tokens.colorCompoundBrandStroke
...shorthands.borderColor(tokens.colorCompoundBrandStroke)
}
}
})
+19 -1
View File
@@ -18,12 +18,14 @@ import {
FolderRegular,
ArrowDownloadRegular,
DocumentRegular,
OptionsRegular,
InfoRegular
} from '@fluentui/react-icons'
import type { YtdlpVersionResult, MediaKind } from '@shared/ipc'
import { type YtdlpVersionResult, type MediaKind } from '@shared/ipc'
import { useSettings } from '../store/settings'
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
import { Select } from './Select'
import { DownloadOptionsForm } from './DownloadOptionsForm'
const useStyles = makeStyles({
root: {
@@ -78,6 +80,7 @@ export function SettingsView(): React.JSX.Element {
const maxConcurrent = useSettings((s) => s.maxConcurrent)
const filenameTemplate = useSettings((s) => s.filenameTemplate)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const downloadOptions = useSettings((s) => s.downloadOptions)
const update = useSettings((s) => s.update)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
@@ -169,6 +172,21 @@ export function SettingsView(): React.JSX.Element {
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
<Subtitle2>Format &amp; post-processing</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
</Caption1>
<DownloadOptionsForm
value={downloadOptions}
onChange={(o) => update({ downloadOptions: o })}
/>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} />
+26 -2
View File
@@ -1,6 +1,7 @@
import './assets/base.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { DEFAULT_DOWNLOAD_OPTIONS } from '@shared/ipc'
import App from './App'
// In the standalone UI preview (browser, no Electron preload) window.api isn't
@@ -11,10 +12,31 @@ import App from './App'
if (import.meta.env.DEV && !window.api) {
window.api = {
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
probe: async (_url: string) => {
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
if (/list=|playlist/i.test(url)) {
return {
ok: true,
kind: 'playlist',
playlist: {
title: 'Full Electron Course — All Episodes',
uploader: 'DevChannel',
count: 5,
entries: Array.from({ length: 5 }, (_, i) => ({
index: i + 1,
id: `vid${i + 1}`,
title: `Episode ${i + 1}${['Setup', 'Main Process', 'IPC', 'Packaging', 'Release'][i]}`,
url: `https://youtube.com/watch?v=vid${i + 1}`,
durationLabel: `${10 + i}:0${i}`,
uploader: 'DevChannel'
}))
}
}
}
return {
ok: true,
kind: 'video',
info: {
title: 'Building a Desktop App with Electron — Full Course',
channel: 'DevChannel',
@@ -45,7 +67,8 @@ if (import.meta.env.DEV && !window.api) {
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
}),
setSettings: async (partial) => ({
outputDir: 'C:\\Users\\you\\Downloads',
@@ -56,6 +79,7 @@ if (import.meta.env.DEV && !window.api) {
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
...partial
}),
listHistory: async () => [],
+15 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'
import type { DownloadEvent } from '@shared/ipc'
import type { DownloadEvent, DownloadOptions } from '@shared/ipc'
import { useSettings } from './settings'
import { useHistory } from './history'
@@ -34,6 +34,8 @@ export interface DownloadItem {
/** exact format carried so retry re-downloads the same thing */
formatId?: string
formatHasAudio?: boolean
/** per-download post-processing override (omitted = use persisted defaults) */
options?: DownloadOptions
}
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
@@ -51,7 +53,14 @@ interface DownloadState {
url: string,
kind: MediaKind,
quality: string,
opts?: { format?: ChosenFormat; title?: string; channel?: string; durationLabel?: string; thumbnail?: string }
opts?: {
format?: ChosenFormat
title?: string
channel?: string
durationLabel?: string
thumbnail?: string
options?: DownloadOptions
}
) => void
retry: (id: string) => void
cancel: (id: string) => void
@@ -220,7 +229,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
quality: item.quality,
outputDir: useSettings.getState().outputDir || undefined,
formatId: item.formatId,
formatHasAudio: item.formatHasAudio
formatHasAudio: item.formatHasAudio,
options: item.options
})
.then((res) => {
if (!res.ok) markError(item.id, res.error)
@@ -265,7 +275,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
status: 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options
}
set((s) => ({ items: [item, ...s.items] }))
pump()
+3 -2
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'
import type { Settings } from '@shared/ipc'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -12,7 +12,8 @@ const FALLBACK: Settings = {
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
}
interface SettingsState extends Settings {