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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user