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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 & 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} />
|
||||
|
||||
@@ -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 () => [],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user