diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 8893f14..2ef78c1 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -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('video') - const [quality, setQuality] = useState(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(null) - const confirmDup = useRef(false) - - // Per-download options (M5/M6/UX1): advanced features. - const [showAdvanced, setShowAdvanced] = useState(false) - const [downloadOptions, setDownloadOptions] = useState(DEFAULT_DOWNLOAD_OPTIONS) - const [incognito, setIncognito] = useState(false) - const [commandPreview, setCommandPreview] = useState(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(null) - const [thumbFailed, setThumbFailed] = useState(null) - const [probing, setProbing] = useState(false) - const [probeError, setProbeError] = useState(null) - const [formatId, setFormatId] = useState('') - - // Probe state for a playlist URL. - const [playlist, setPlaylist] = useState(null) - const [selected, setSelected] = useState>(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>({}) - - // 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 { - 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 { - 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 { - 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 = {} - 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 (
setDragActive(false)} - onDrop={onDrop} + onDragOver={bar.onDragOver} + onDragLeave={() => bar.setDragActive(false)} + onDrop={bar.onDrop} >
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 {
@@ -691,14 +130,14 @@ export function DownloadBar(): React.JSX.Element {
Already in your queue: "{dup}". -
@@ -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)} /> ) : (
@@ -746,69 +185,16 @@ export function DownloadBar(): React.JSX.Element { )} {playlist && ( -
-
- - - {playlist.title} - {playlist.uploader ? ` • ${playlist.uploader}` : ''} - - - {selected.size} of {playlist.count} selected - - - - -
-
- {playlist.entries.map((e) => ( -
- toggleEntry(e.index, !!d.checked)} - label={ - - - {e.index}. {e.title} - - {(e.durationLabel || e.uploader) && ( - - {[e.durationLabel, e.uploader].filter(Boolean).join(' • ')} - - )} - - } - /> - - : - } - onClick={() => toggleItemKind(e.index)} - aria-label={`Download type for ${e.title}: ${effKind(e.index)}`} - /> - -
- ))} -
-
+ )} {!playlist && ( @@ -818,7 +204,7 @@ export function DownloadBar(): React.JSX.Element { size="small" appearance="subtle" icon={} - onClick={() => setShowTrim((v) => !v)} + onClick={() => bar.setShowTrim((v) => !v)} > {trim.trim() ? 'Trim · on' : 'Trim'} @@ -826,7 +212,7 @@ export function DownloadBar(): React.JSX.Element { size="small" appearance="subtle" icon={} - onClick={() => setShowSchedule((v) => !v)} + onClick={() => bar.setShowSchedule((v) => !v)} > {scheduleAt ? 'Scheduled' : 'Schedule'} @@ -839,10 +225,7 @@ export function DownloadBar(): React.JSX.Element { >