H1: decompose DownloadBar.tsx into a hook + panel + styles

Split the 858-line DownloadBar (~17 interdependent state hooks):
- downloadBar/useDownloadBar.ts: all state and behaviour (probe, playlist,
  trim/schedule, per-download options, dup guard, drag-drop, command preview),
  exposing wrapper handlers so preview-invalidation side-effects stay put.
- downloadBar/styles.ts: the makeStyles block.
- downloadBar/PlaylistPanel.tsx: the playlist-selection panel.

DownloadBar is now a render-only shell driven by the hook. `usingFormats` no
longer narrows `info` across the hook boundary, so the format Select guards with
`info?.formats ?? []` (only reached when usingFormats is true). Behaviour
unchanged; full typecheck + lint + build + 248 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 21:30:40 -04:00
parent e4e175736f
commit ae12e7c9cf
4 changed files with 844 additions and 695 deletions
+69 -695
View File
@@ -1,4 +1,3 @@
import { useState, useEffect, useRef } from 'react'
import {
Input,
Textarea,
@@ -8,10 +7,7 @@ import {
Spinner,
Text,
Caption1,
makeStyles,
mergeClasses,
tokens,
shorthands
mergeClasses
} from '@fluentui/react-components'
import {
ArrowDownloadRegular,
@@ -22,627 +18,70 @@ import {
ErrorCircleRegular,
LinkRegular,
DismissRegular,
AppsListRegular,
CutRegular,
CalendarClockRegular,
WarningRegular,
SettingsRegular,
WindowConsoleRegular
} from '@fluentui/react-icons'
import {
parseUrlShortcutContent,
type MediaInfo,
type FormatOption,
type PlaylistInfo,
type DownloadOptions,
DEFAULT_DOWNLOAD_OPTIONS,
type CommandPreviewResult
} from '@shared/ipc'
import { useDownloads, type MediaKind } from '../store/downloads'
import { type MediaKind } from '../store/downloads'
import { QUALITY_OPTIONS } from '../qualityOptions'
import { sameVideo } from '../store/queueStats'
import { useSettings } from '../store/settings'
import { Select } from './Select'
import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton'
import { DownloadOptionsForm } from './DownloadOptionsForm'
import { useClipboardLink, looksLikeUrl, firstUrlInText } from '../useClipboardLink'
import { logError } from '../reportError'
import { THUMB_LG } from '../thumbSizes'
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
function parseUrlFile(content: string): string | null {
const url = parseUrlShortcutContent(content)
return url && looksLikeUrl(url) ? url : null
}
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '16px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
boxShadow: tokens.shadow4
},
// Highlight while a link / .url file is dragged over the card.
rootDragging: {
outline: `2px dashed ${tokens.colorBrandStroke1}`,
outlineOffset: '-2px'
},
urlRow: {
display: 'flex',
gap: '8px'
},
suggestion: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
suggestionText: {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
url: {
flexGrow: 1
},
// --- metadata preview card ---
preview: {
display: 'flex',
gap: '12px',
padding: '10px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
previewThumb: {
flexShrink: 0,
width: `${THUMB_LG.w}px`,
height: `${THUMB_LG.h}px`,
objectFit: 'cover',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground1,
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
previewBody: {
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '2px'
},
previewTitle: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
previewMeta: {
color: tokens.colorNeutralForeground3
},
statusRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: tokens.colorNeutralForeground3
},
errorRow: {
color: tokens.colorPaletteRedForeground1
},
controls: {
display: 'flex',
alignItems: 'flex-end',
gap: '16px',
flexWrap: 'wrap'
},
control: {
display: 'flex',
flexDirection: 'column',
gap: '4px'
},
quality: {
minWidth: '220px'
},
spacer: {
flexGrow: 1
},
// --- 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'
},
plItemRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
plItem: {
display: 'flex',
alignItems: 'flex-start',
padding: '2px 0',
flexGrow: 1,
minWidth: 0
},
plItemLabel: {
display: 'flex',
flexDirection: 'column',
minWidth: 0
},
plItemTitle: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
plItemMeta: {
color: tokens.colorNeutralForeground3
},
// --- duplicate warning ---
dupRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
// --- trim / schedule panels ---
trimBlock: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'flex-start'
},
optButtons: {
display: 'flex',
gap: '8px'
},
trimPanel: {
alignSelf: 'stretch',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// Native datetime-local input, themed to sit beside the Fluent controls.
dtInput: {
fontFamily: tokens.fontFamilyBase,
fontSize: tokens.fontSizeBase300,
padding: '6px 10px',
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke1}`,
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
colorScheme: 'light dark'
},
// --- command preview & options panels ---
commandBlock: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
commandPreviewPanel: {
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
overflowX: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
color: tokens.colorNeutralForeground1
},
commandError: {
color: tokens.colorStatusDangerForeground1
},
optionsPanel: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
}
})
// 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
// then silently download immediately (L156/L57).
function toLocalDatetimeValue(d: Date): string {
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
import { useDownloadBarStyles } from './downloadBar/styles'
import { useDownloadBar, toLocalDatetimeValue } from './downloadBar/useDownloadBar'
import { PlaylistPanel } from './downloadBar/PlaylistPanel'
export function DownloadBar(): React.JSX.Element {
const styles = useStyles()
const addFromUrl = useDownloads((s) => s.addFromUrl)
const addMany = useDownloads((s) => s.addMany)
const settingsLoaded = useSettings((s) => s.loaded)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState<string>(QUALITY_OPTIONS.video[0])
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
const [showTrim, setShowTrim] = useState(false)
const [trim, setTrim] = useState('')
// Optional schedule: a datetime-local value; a future time parks the download.
const [showSchedule, setShowSchedule] = useState(false)
const [scheduleAt, setScheduleAt] = useState('')
// Drag-and-drop: highlight while a link / .url file hovers the card.
const [dragActive, setDragActive] = useState(false)
// Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null)
const confirmDup = useRef(false)
// Per-download options (M5/M6/UX1): advanced features.
const [showAdvanced, setShowAdvanced] = useState(false)
const [downloadOptions, setDownloadOptions] = useState<DownloadOptions>(DEFAULT_DOWNLOAD_OPTIONS)
const [incognito, setIncognito] = useState(false)
const [commandPreview, setCommandPreview] = useState<CommandPreviewResult | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
function onDragOver(e: React.DragEvent): void {
e.preventDefault()
if (!dragActive) setDragActive(true)
}
function onDrop(e: React.DragEvent): void {
e.preventDefault()
setDragActive(false)
const dt = e.dataTransfer
const fromText = firstUrlInText(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
if (fromText) {
onUrlChange(fromText)
return
}
// 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
.text()
.then((content) => {
const u = parseUrlFile(content)
if (u) onUrlChange(u)
})
.catch(logError('dropped .url file read'))
}
}
// When settings load or change, update kind/quality -- but only while the bar
// is idle (URL empty) so an in-progress setup isn't unexpectedly reset.
useEffect(() => {
if (!settingsLoaded || url.trim()) return
setKind(defaultKind)
setQuality(defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality)
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality, url])
// Probe state for the single-video format picker.
const [info, setInfo] = useState<MediaInfo | null>(null)
const [thumbFailed, setThumbFailed] = useState<string | 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())
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
// bar's global kind. Lets a playlist mix video and audio downloads.
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
// 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.
const styles = useDownloadBarStyles()
const bar = useDownloadBar()
const {
url,
kind,
quality,
showTrim,
trim,
showSchedule,
scheduleAt,
dragActive,
dup,
showAdvanced,
downloadOptions,
incognito,
commandPreview,
previewLoading,
info,
thumbFailed,
probing,
probeError,
usingFormats,
selectedFormat,
playlist,
selected,
allSelected,
suggestion,
source: suggestionSource,
accept: acceptLink,
dismiss: dismissSuggestion,
offer: offerLink
} = useClipboardLink(url)
useEffect(
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
[offerLink]
)
function acceptSuggestion(): void {
const link = acceptLink()
if (link) onUrlChange(link)
}
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
const selectedFormat: FormatOption | undefined = usingFormats
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
: undefined
function clearProbe(): void {
setInfo(null)
setProbeError(null)
setFormatId('')
setPlaylist(null)
setSelected(new Set())
setItemKinds({})
}
// Fetch (or toggle off) the exact yt-dlp command line for the current form
// state. The options mirror what download() sends so the preview matches the
// command that will actually run -- including a probe-selected format and any
// trim spec (M5).
async function toggleCommandPreview(): Promise<void> {
if (commandPreview) {
setCommandPreview(null)
return
}
const trimmed = url.trim()
if (!trimmed) return
setPreviewLoading(true)
try {
const result = await window.api.previewCommand({
id: 'preview',
url: trimmed,
kind: usingFormats ? 'video' : kind,
quality: usingFormats && selectedFormat ? selectedFormat.label : quality,
formatId: usingFormats ? selectedFormat?.id : undefined,
formatHasAudio: usingFormats ? selectedFormat?.hasAudio : undefined,
options: downloadOptions,
trim: trim.trim() || undefined
})
setCommandPreview(result)
} catch (e) {
setCommandPreview({ ok: false, error: (e as Error).message })
} finally {
setPreviewLoading(false)
}
}
function onUrlChange(next: string): void {
setUrl(next)
// Any probed info -- or a duplicate warning -- is stale once the URL changes.
if (info || probeError || playlist) clearProbe()
if (dup) setDup(null)
setCommandPreview(null)
confirmDup.current = false
}
function onKindChange(next: MediaKind): void {
setKind(next)
setQuality(QUALITY_OPTIONS[next][0])
// The command changes with kind/quality, so any shown preview is now stale.
setCommandPreview(null)
}
async function fetchFormats(): Promise<void> {
const trimmed = url.trim()
if (!trimmed || probing) return
setProbing(true)
setProbeError(null)
setInfo(null)
setPlaylist(null)
// Probing may switch the URL to the format-picker path, changing the command.
setCommandPreview(null)
try {
const res = await window.api.probe(trimmed)
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 {
setProbeError(res.error ?? 'Could not fetch video info.')
}
} catch (e) {
setProbeError(String(e))
} finally {
setProbing(false)
}
}
async function paste(): Promise<void> {
try {
const text = (await window.api?.readClipboard?.()) ?? ''
if (text) onUrlChange(text.trim())
} catch {
/* ignore in preview */
}
}
// 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)))
}
// Per-entry kind: the override if set, else the bar's global kind.
function effKind(index: number): MediaKind {
return itemKinds[index] ?? kind
}
function toggleItemKind(index: number): void {
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
}
function setAllKinds(k: MediaKind): void {
if (!playlist) return
const all: Record<number, MediaKind> = {}
for (const e of playlist.entries) all[e.index] = k
setItemKinds(all)
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
// 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.
if (!confirmDup.current) {
const existing = useDownloads
.getState()
.items.find(
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
)
if (existing) {
// Use the URL as fallback if the title is still the placeholder generated
// by titleFromUrl() before metadata loads (L76).
const isPlaceholder =
/video \(\w+\)$/.test(existing.title) ||
existing.title.endsWith(' download') ||
existing.title === 'New download'
setDup(isPlaceholder ? existing.url : existing.title)
return
}
}
confirmDup.current = false
setDup(null)
const meta = info
? {
title: info.title,
channel: info.channel,
durationLabel: info.durationLabel,
thumbnail: info.thumbnail
}
: {}
const trimSpec = trim.trim() || undefined
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
if (usingFormats && selectedFormat) {
addFromUrl(trimmed, 'video', selectedFormat.label, {
...meta,
trim: trimSpec,
scheduledFor,
options: downloadOptions,
incognito,
format: {
id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
}
})
} else {
addFromUrl(trimmed, kind, quality, {
...meta,
trim: trimSpec,
scheduledFor,
options: downloadOptions,
incognito
})
}
setUrl('')
setTrim('')
setShowTrim(false)
setScheduleAt('')
setShowSchedule(false)
setShowAdvanced(false)
setCommandPreview(null)
// Per-download overrides are one-shot (like trim/schedule): reset so the next
// download starts from the global defaults, and incognito never silently
// carries over to a following download.
setIncognito(false)
setDownloadOptions(DEFAULT_DOWNLOAD_OPTIONS)
clearProbe()
}
function downloadAnyway(): void {
confirmDup.current = true
download()
}
function addPlaylist(): void {
if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index))
// Each entry uses its own kind override; quality falls back to that kind's
// default unless the entry matches the bar's current kind/quality.
addMany(
chosen.map((e) => {
const k = effKind(e.index)
return {
url: e.url,
kind: k,
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
}
})
)
setUrl('')
clearProbe()
}
suggestionSource
} = bar
return (
<div
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
onDragOver={onDragOver}
onDragLeave={() => setDragActive(false)}
onDrop={onDrop}
onDragOver={bar.onDragOver}
onDragLeave={() => bar.setDragActive(false)}
onDrop={bar.onDrop}
>
<div className={styles.urlRow}>
<Input
className={styles.url}
input={{ id: 'aerofetch-url', 'aria-label': 'Video or playlist URL' }}
value={url}
onChange={(_, d) => onUrlChange(d.value)}
onChange={(_, d) => bar.onUrlChange(d.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter') return
if (info || probeError || playlist) download()
else void fetchFormats()
if (info || probeError || playlist) bar.download()
else void bar.fetchFormats()
}}
placeholder="Paste a video or playlist URL, then check…"
size="large"
@@ -652,7 +91,7 @@ export function DownloadBar(): React.JSX.Element {
<Button
size="large"
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
onClick={fetchFormats}
onClick={bar.fetchFormats}
disabled={!url.trim() || probing}
aria-label="Check URL for formats or playlist"
/>
@@ -661,7 +100,7 @@ export function DownloadBar(): React.JSX.Element {
<Button
size="large"
icon={<ClipboardPasteRegular />}
onClick={paste}
onClick={bar.paste}
aria-label="Paste from clipboard"
/>
</Hint>
@@ -674,14 +113,14 @@ export function DownloadBar(): React.JSX.Element {
{suggestionSource === 'external' ? 'Link received: ' : 'Use copied link? '}
{suggestion}
</Caption1>
<Button size="small" appearance="primary" onClick={acceptSuggestion}>
<Button size="small" appearance="primary" onClick={bar.acceptSuggestion}>
Use
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={dismissSuggestion}
onClick={bar.dismissSuggestion}
aria-label="Dismiss suggested link"
/>
</div>
@@ -691,14 +130,14 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.dupRow}>
<WarningRegular />
<Caption1 className={styles.suggestionText}>Already in your queue: "{dup}".</Caption1>
<Button size="small" appearance="primary" onClick={downloadAnyway}>
<Button size="small" appearance="primary" onClick={bar.downloadAnyway}>
Download anyway
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => setDup(null)}
onClick={() => bar.setDup(null)}
aria-label="Dismiss duplicate warning"
/>
</div>
@@ -725,7 +164,7 @@ export function DownloadBar(): React.JSX.Element {
className={styles.previewThumb}
src={info.thumbnail}
alt=""
onError={() => setThumbFailed(info.thumbnail ?? null)}
onError={() => bar.setThumbFailed(info.thumbnail ?? null)}
/>
) : (
<div className={styles.previewThumb}>
@@ -746,69 +185,16 @@ export function DownloadBar(): React.JSX.Element {
)}
{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>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('video')}>
All video
</Button>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('audio')}>
All audio
</Button>
</div>
<div className={styles.plList}>
{playlist.entries.map((e) => (
<div key={e.index} className={styles.plItemRow}>
<Checkbox
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>
}
/>
<Hint
label={
effKind(e.index) === 'audio'
? 'Audio -- click for video'
: 'Video -- click for audio'
}
placement="top"
align="end"
>
<IconButton
size="sm"
style={{ flexShrink: 0 }}
icon={
effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />
}
onClick={() => toggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
/>
</Hint>
</div>
))}
</div>
</div>
<PlaylistPanel
playlist={playlist}
selected={selected}
allSelected={allSelected}
onToggleAll={bar.toggleAll}
onSetAllKinds={bar.setAllKinds}
onToggleEntry={bar.toggleEntry}
effKind={bar.effKind}
onToggleItemKind={bar.toggleItemKind}
/>
)}
{!playlist && (
@@ -818,7 +204,7 @@ export function DownloadBar(): React.JSX.Element {
size="small"
appearance="subtle"
icon={<CutRegular />}
onClick={() => setShowTrim((v) => !v)}
onClick={() => bar.setShowTrim((v) => !v)}
>
{trim.trim() ? 'Trim · on' : 'Trim'}
</Button>
@@ -826,7 +212,7 @@ export function DownloadBar(): React.JSX.Element {
size="small"
appearance="subtle"
icon={<CalendarClockRegular />}
onClick={() => setShowSchedule((v) => !v)}
onClick={() => bar.setShowSchedule((v) => !v)}
>
{scheduleAt ? 'Scheduled' : 'Schedule'}
</Button>
@@ -839,10 +225,7 @@ export function DownloadBar(): React.JSX.Element {
>
<Textarea
value={trim}
onChange={(_, d) => {
setTrim(d.value)
setCommandPreview(null)
}}
onChange={(_, d) => bar.onTrimChange(d.value)}
placeholder={'0:30-1:45\n3:00-3:30'}
resize="vertical"
/>
@@ -860,7 +243,7 @@ export function DownloadBar(): React.JSX.Element {
className={styles.dtInput}
value={scheduleAt}
min={toLocalDatetimeValue(new Date())}
onChange={(e) => setScheduleAt(e.target.value)}
onChange={(e) => bar.setScheduleAt(e.target.value)}
/>
</Field>
</div>
@@ -875,7 +258,7 @@ export function DownloadBar(): React.JSX.Element {
size="small"
appearance="subtle"
icon={<SettingsRegular />}
onClick={() => setShowAdvanced((v) => !v)}
onClick={() => bar.setShowAdvanced((v) => !v)}
>
{showAdvanced ? 'Advanced · on' : 'Advanced'}
</Button>
@@ -883,7 +266,7 @@ export function DownloadBar(): React.JSX.Element {
size="small"
appearance="subtle"
icon={previewLoading ? <Spinner size="tiny" /> : <WindowConsoleRegular />}
onClick={toggleCommandPreview}
onClick={bar.toggleCommandPreview}
disabled={!url.trim() || previewLoading}
>
{commandPreview ? 'Hide command' : 'Show command'}
@@ -902,15 +285,12 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.optionsPanel}>
<Checkbox
checked={incognito}
onChange={(_, d) => setIncognito(!!d.checked)}
onChange={(_, d) => bar.setIncognito(!!d.checked)}
label="Incognito mode (no logging, no history, no cookies)"
/>
<DownloadOptionsForm
value={downloadOptions}
onChange={(next) => {
setDownloadOptions(next)
setCommandPreview(null)
}}
onChange={bar.onDownloadOptionsChange}
kind={kind}
/>
</div>
@@ -927,7 +307,7 @@ export function DownloadBar(): React.JSX.Element {
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]}
onChange={onKindChange}
onChange={bar.onKindChange}
ariaLabel="Format"
/>
</div>
@@ -940,11 +320,8 @@ export function DownloadBar(): React.JSX.Element {
aria-label="Quality / format"
size="large"
value={selectedFormat?.id ?? ''}
options={info.formats.map((f) => ({ value: f.id, label: f.label }))}
onChange={(v) => {
setFormatId(v)
setCommandPreview(null)
}}
options={(info?.formats ?? []).map((f) => ({ value: f.id, label: f.label }))}
onChange={bar.onFormatIdChange}
/>
) : (
<Select
@@ -953,10 +330,7 @@ export function DownloadBar(): React.JSX.Element {
size="large"
value={quality}
options={QUALITY_OPTIONS[kind].map((q) => ({ value: q, label: q }))}
onChange={(v) => {
setQuality(v)
setCommandPreview(null)
}}
onChange={bar.onQualityChange}
/>
)}
</div>
@@ -968,7 +342,7 @@ export function DownloadBar(): React.JSX.Element {
appearance="primary"
size="large"
icon={<ArrowDownloadRegular />}
onClick={addPlaylist}
onClick={bar.addPlaylist}
disabled={selected.size === 0}
>
Add {selected.size} to queue
@@ -978,7 +352,7 @@ export function DownloadBar(): React.JSX.Element {
appearance="primary"
size="large"
icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
onClick={download}
onClick={bar.download}
disabled={!url.trim()}
>
{scheduleAt ? 'Schedule' : 'Download'}
@@ -0,0 +1,94 @@
import { Button, Checkbox, Caption1 } from '@fluentui/react-components'
import { AppsListRegular, VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import { type PlaylistInfo } from '@shared/ipc'
import { type MediaKind } from '../../store/downloads'
import { Hint } from '../Hint'
import { IconButton } from '../ui/IconButton'
import { useDownloadBarStyles } from './styles'
interface PlaylistPanelProps {
playlist: PlaylistInfo
selected: Set<number>
allSelected: boolean
onToggleAll: () => void
onSetAllKinds: (k: MediaKind) => void
onToggleEntry: (index: number, on: boolean) => void
effKind: (index: number) => MediaKind
onToggleItemKind: (index: number) => void
}
export function PlaylistPanel({
playlist,
selected,
allSelected,
onToggleAll,
onSetAllKinds,
onToggleEntry,
effKind,
onToggleItemKind
}: PlaylistPanelProps): React.JSX.Element {
const styles = useDownloadBarStyles()
return (
<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={onToggleAll}>
{allSelected ? 'Select none' : 'Select all'}
</Button>
<Button size="small" appearance="subtle" onClick={() => onSetAllKinds('video')}>
All video
</Button>
<Button size="small" appearance="subtle" onClick={() => onSetAllKinds('audio')}>
All audio
</Button>
</div>
<div className={styles.plList}>
{playlist.entries.map((e) => (
<div key={e.index} className={styles.plItemRow}>
<Checkbox
className={styles.plItem}
checked={selected.has(e.index)}
onChange={(_, d) => onToggleEntry(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>
}
/>
<Hint
label={
effKind(e.index) === 'audio'
? 'Audio -- click for video'
: 'Video -- click for audio'
}
placement="top"
align="end"
>
<IconButton
size="sm"
style={{ flexShrink: 0 }}
icon={effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
onClick={() => onToggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
/>
</Hint>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,230 @@
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
import { THUMB_LG } from '../../thumbSizes'
export const useDownloadBarStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '16px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
boxShadow: tokens.shadow4
},
// Highlight while a link / .url file is dragged over the card.
rootDragging: {
outline: `2px dashed ${tokens.colorBrandStroke1}`,
outlineOffset: '-2px'
},
urlRow: {
display: 'flex',
gap: '8px'
},
suggestion: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
suggestionText: {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
url: {
flexGrow: 1
},
// --- metadata preview card ---
preview: {
display: 'flex',
gap: '12px',
padding: '10px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
previewThumb: {
flexShrink: 0,
width: `${THUMB_LG.w}px`,
height: `${THUMB_LG.h}px`,
objectFit: 'cover',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground1,
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
previewBody: {
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '2px'
},
previewTitle: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
previewMeta: {
color: tokens.colorNeutralForeground3
},
statusRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: tokens.colorNeutralForeground3
},
errorRow: {
color: tokens.colorPaletteRedForeground1
},
controls: {
display: 'flex',
alignItems: 'flex-end',
gap: '16px',
flexWrap: 'wrap'
},
control: {
display: 'flex',
flexDirection: 'column',
gap: '4px'
},
quality: {
minWidth: '220px'
},
spacer: {
flexGrow: 1
},
// --- 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'
},
plItemRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
plItem: {
display: 'flex',
alignItems: 'flex-start',
padding: '2px 0',
flexGrow: 1,
minWidth: 0
},
plItemLabel: {
display: 'flex',
flexDirection: 'column',
minWidth: 0
},
plItemTitle: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
plItemMeta: {
color: tokens.colorNeutralForeground3
},
// --- duplicate warning ---
dupRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
// --- trim / schedule panels ---
trimBlock: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'flex-start'
},
optButtons: {
display: 'flex',
gap: '8px'
},
trimPanel: {
alignSelf: 'stretch',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// Native datetime-local input, themed to sit beside the Fluent controls.
dtInput: {
fontFamily: tokens.fontFamilyBase,
fontSize: tokens.fontSizeBase300,
padding: '6px 10px',
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke1}`,
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
colorScheme: 'light dark'
},
// --- command preview & options panels ---
commandBlock: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
commandPreviewPanel: {
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
overflowX: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
color: tokens.colorNeutralForeground1
},
commandError: {
color: tokens.colorStatusDangerForeground1
},
optionsPanel: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
}
})
@@ -0,0 +1,451 @@
import { useState, useEffect, useRef } from 'react'
import {
parseUrlShortcutContent,
type MediaInfo,
type FormatOption,
type PlaylistInfo,
type DownloadOptions,
DEFAULT_DOWNLOAD_OPTIONS,
type CommandPreviewResult
} from '@shared/ipc'
import { useDownloads, type MediaKind } from '../../store/downloads'
import { QUALITY_OPTIONS } from '../../qualityOptions'
import { sameVideo } from '../../store/queueStats'
import { useSettings } from '../../store/settings'
import { useClipboardLink, looksLikeUrl, firstUrlInText } from '../../useClipboardLink'
import { logError } from '../../reportError'
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
function parseUrlFile(content: string): string | null {
const url = parseUrlShortcutContent(content)
return url && looksLikeUrl(url) ? url : null
}
// 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
// then silently download immediately (L156/L57).
export function toLocalDatetimeValue(d: Date): string {
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/**
* All state and behaviour for the DownloadBar. Extracted from the component so
* the ~17 interdependent state hooks and their handlers live in one place and
* the component is render-only.
*/
export function useDownloadBar() {
const addFromUrl = useDownloads((s) => s.addFromUrl)
const addMany = useDownloads((s) => s.addMany)
const settingsLoaded = useSettings((s) => s.loaded)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState<string>(QUALITY_OPTIONS.video[0])
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
const [showTrim, setShowTrim] = useState(false)
const [trim, setTrim] = useState('')
// Optional schedule: a datetime-local value; a future time parks the download.
const [showSchedule, setShowSchedule] = useState(false)
const [scheduleAt, setScheduleAt] = useState('')
// Drag-and-drop: highlight while a link / .url file hovers the card.
const [dragActive, setDragActive] = useState(false)
// Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null)
const confirmDup = useRef(false)
// Per-download options (M5/M6/UX1): advanced features.
const [showAdvanced, setShowAdvanced] = useState(false)
const [downloadOptions, setDownloadOptions] = useState<DownloadOptions>(DEFAULT_DOWNLOAD_OPTIONS)
const [incognito, setIncognito] = useState(false)
const [commandPreview, setCommandPreview] = useState<CommandPreviewResult | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
// Probe state for the single-video format picker.
const [info, setInfo] = useState<MediaInfo | null>(null)
const [thumbFailed, setThumbFailed] = useState<string | 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())
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
// bar's global kind. Lets a playlist mix video and audio downloads.
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
function onDragOver(e: React.DragEvent): void {
e.preventDefault()
if (!dragActive) setDragActive(true)
}
function onDrop(e: React.DragEvent): void {
e.preventDefault()
setDragActive(false)
const dt = e.dataTransfer
const fromText = firstUrlInText(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
if (fromText) {
onUrlChange(fromText)
return
}
// 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
.text()
.then((content) => {
const u = parseUrlFile(content)
if (u) onUrlChange(u)
})
.catch(logError('dropped .url file read'))
}
}
// When settings load or change, update kind/quality -- but only while the bar
// is idle (URL empty) so an in-progress setup isn't unexpectedly reset.
useEffect(() => {
if (!settingsLoaded || url.trim()) return
setKind(defaultKind)
setQuality(defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality)
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality, url])
// 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.
const {
suggestion,
source: suggestionSource,
accept: acceptLink,
dismiss: dismissSuggestion,
offer: offerLink
} = useClipboardLink(url)
useEffect(
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
[offerLink]
)
function acceptSuggestion(): void {
const link = acceptLink()
if (link) onUrlChange(link)
}
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
const selectedFormat: FormatOption | undefined = usingFormats
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
: undefined
function clearProbe(): void {
setInfo(null)
setProbeError(null)
setFormatId('')
setPlaylist(null)
setSelected(new Set())
setItemKinds({})
}
// Fetch (or toggle off) the exact yt-dlp command line for the current form
// state. The options mirror what download() sends so the preview matches the
// command that will actually run -- including a probe-selected format and any
// trim spec (M5).
async function toggleCommandPreview(): Promise<void> {
if (commandPreview) {
setCommandPreview(null)
return
}
const trimmed = url.trim()
if (!trimmed) return
setPreviewLoading(true)
try {
const result = await window.api.previewCommand({
id: 'preview',
url: trimmed,
kind: usingFormats ? 'video' : kind,
quality: usingFormats && selectedFormat ? selectedFormat.label : quality,
formatId: usingFormats ? selectedFormat?.id : undefined,
formatHasAudio: usingFormats ? selectedFormat?.hasAudio : undefined,
options: downloadOptions,
trim: trim.trim() || undefined
})
setCommandPreview(result)
} catch (e) {
setCommandPreview({ ok: false, error: (e as Error).message })
} finally {
setPreviewLoading(false)
}
}
function onUrlChange(next: string): void {
setUrl(next)
// Any probed info -- or a duplicate warning -- is stale once the URL changes.
if (info || probeError || playlist) clearProbe()
if (dup) setDup(null)
setCommandPreview(null)
confirmDup.current = false
}
function onKindChange(next: MediaKind): void {
setKind(next)
setQuality(QUALITY_OPTIONS[next][0])
// The command changes with kind/quality, so any shown preview is now stale.
setCommandPreview(null)
}
// Any change to quality / format / trim / options invalidates a shown preview.
function onQualityChange(v: string): void {
setQuality(v)
setCommandPreview(null)
}
function onFormatIdChange(v: string): void {
setFormatId(v)
setCommandPreview(null)
}
function onTrimChange(v: string): void {
setTrim(v)
setCommandPreview(null)
}
function onDownloadOptionsChange(next: DownloadOptions): void {
setDownloadOptions(next)
setCommandPreview(null)
}
async function fetchFormats(): Promise<void> {
const trimmed = url.trim()
if (!trimmed || probing) return
setProbing(true)
setProbeError(null)
setInfo(null)
setPlaylist(null)
// Probing may switch the URL to the format-picker path, changing the command.
setCommandPreview(null)
try {
const res = await window.api.probe(trimmed)
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 {
setProbeError(res.error ?? 'Could not fetch video info.')
}
} catch (e) {
setProbeError(String(e))
} finally {
setProbing(false)
}
}
async function paste(): Promise<void> {
try {
const text = (await window.api?.readClipboard?.()) ?? ''
if (text) onUrlChange(text.trim())
} catch {
/* ignore in preview */
}
}
// 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)))
}
// Per-entry kind: the override if set, else the bar's global kind.
function effKind(index: number): MediaKind {
return itemKinds[index] ?? kind
}
function toggleItemKind(index: number): void {
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
}
function setAllKinds(k: MediaKind): void {
if (!playlist) return
const all: Record<number, MediaKind> = {}
for (const e of playlist.entries) all[e.index] = k
setItemKinds(all)
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
// 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.
if (!confirmDup.current) {
const existing = useDownloads
.getState()
.items.find(
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
)
if (existing) {
// Use the URL as fallback if the title is still the placeholder generated
// by titleFromUrl() before metadata loads (L76).
const isPlaceholder =
/video \(\w+\)$/.test(existing.title) ||
existing.title.endsWith(' download') ||
existing.title === 'New download'
setDup(isPlaceholder ? existing.url : existing.title)
return
}
}
confirmDup.current = false
setDup(null)
const meta = info
? {
title: info.title,
channel: info.channel,
durationLabel: info.durationLabel,
thumbnail: info.thumbnail
}
: {}
const trimSpec = trim.trim() || undefined
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
if (usingFormats && selectedFormat) {
addFromUrl(trimmed, 'video', selectedFormat.label, {
...meta,
trim: trimSpec,
scheduledFor,
options: downloadOptions,
incognito,
format: {
id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
}
})
} else {
addFromUrl(trimmed, kind, quality, {
...meta,
trim: trimSpec,
scheduledFor,
options: downloadOptions,
incognito
})
}
setUrl('')
setTrim('')
setShowTrim(false)
setScheduleAt('')
setShowSchedule(false)
setShowAdvanced(false)
setCommandPreview(null)
// Per-download overrides are one-shot (like trim/schedule): reset so the next
// download starts from the global defaults, and incognito never silently
// carries over to a following download.
setIncognito(false)
setDownloadOptions(DEFAULT_DOWNLOAD_OPTIONS)
clearProbe()
}
function downloadAnyway(): void {
confirmDup.current = true
download()
}
function addPlaylist(): void {
if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index))
// Each entry uses its own kind override; quality falls back to that kind's
// default unless the entry matches the bar's current kind/quality.
addMany(
chosen.map((e) => {
const k = effKind(e.index)
return {
url: e.url,
kind: k,
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
}
})
)
setUrl('')
clearProbe()
}
return {
// form state
url,
kind,
quality,
// trim / schedule
showTrim,
setShowTrim,
trim,
showSchedule,
setShowSchedule,
scheduleAt,
setScheduleAt,
// drag / dup
dragActive,
setDragActive,
dup,
setDup,
// advanced / preview
showAdvanced,
setShowAdvanced,
downloadOptions,
incognito,
setIncognito,
commandPreview,
previewLoading,
// probe (single video)
info,
thumbFailed,
setThumbFailed,
probing,
probeError,
usingFormats,
selectedFormat,
// playlist
playlist,
selected,
allSelected,
// suggestion banner
suggestion,
suggestionSource,
dismissSuggestion,
// handlers
onDragOver,
onDrop,
onUrlChange,
onKindChange,
onQualityChange,
onFormatIdChange,
onTrimChange,
onDownloadOptionsChange,
toggleCommandPreview,
fetchFormats,
paste,
acceptSuggestion,
download,
downloadAnyway,
addPlaylist,
toggleEntry,
toggleAll,
effKind,
toggleItemKind,
setAllKinds
}
}