Files
AeroFetch/src/renderer/src/components/downloadBar/useDownloadBar.ts
T
debont80 71f0b2b68f feat(audit): Batch 13 — copy polish + misc-L cleanup
Contained fixes:
- L158: drag-highlight flicker. useDownloadBar tracks a dragDepth counter —
  onDragEnter increments, onDragLeave decrements, the highlight clears only when
  depth returns to 0 (the drag truly left the card). onDragOver just
  preventDefaults; onDrop resets. No more blink when crossing child elements.
- L92: preload method names aligned with main — markSourceItemDownloaded →
  setMediaItemDownloaded, syncSources → syncWatchedSources (callers + mock
  updated; compiler-enforced via the derived Api type).
- L149: the three-sentence "Keep running in the tray" hint is now one line.
- L171: one MOCK_CURRENT_VERSION backs both the preview mock's getAppVersion and
  checkForAppUpdate.currentVersion, so they agree.
- L5 (representative): the About card's repeated monospace inline styles → shared
  mono/monoBlock/monoPre classes; convention set (makeStyles for static, inline
  style only for data-driven values).

Resolved by verification/convention (CODE-AUDIT.md): L21 (moot after the H1
SettingsView decomposition), L31 (dual theme switcher is width-driven), L130
(lowercase fragments vs shown-raw sentences), L152 (search sizes are role-
appropriate), UI22 (brand/entity/section icon-emphasis set now defined).

Deferred with rationale: L51/L64 (features), L93 (CC13 view-model pass), L104
(moved to Batch 14 perf), L109/UI21 (visual), L161/UI23 (layout/live-verify).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:26:01 -04:00

607 lines
20 KiB
TypeScript

import { useState, useEffect, useRef, type Dispatch, type SetStateAction } 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 { useHistory } from '../../store/history'
import { QUALITY_OPTIONS } from '../../qualityOptions'
import { sameVideo } from '../../store/queueStats'
import { useSettings } from '../../store/settings'
import { useNav } from '../../store/nav'
import {
useClipboardLink,
looksLikeUrl,
looksLikeChannelOrPlaylist,
firstUrlInText,
type SuggestionSource
} 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())}`
}
/** Everything the DownloadBar render needs — the hook's public surface. */
export interface DownloadBarController {
// form state
url: string
kind: MediaKind
quality: string
// trim / schedule
showTrim: boolean
setShowTrim: Dispatch<SetStateAction<boolean>>
trim: string
showSchedule: boolean
setShowSchedule: Dispatch<SetStateAction<boolean>>
scheduleAt: string
setScheduleAt: Dispatch<SetStateAction<string>>
// drag / dup
dragActive: boolean
setDragActive: Dispatch<SetStateAction<boolean>>
dup: string | null
setDup: Dispatch<SetStateAction<string | null>>
/** true when the duplicate was found in history (already downloaded) vs the live queue (L144). */
dupInHistory: boolean
// advanced / preview
showAdvanced: boolean
setShowAdvanced: Dispatch<SetStateAction<boolean>>
downloadOptions: DownloadOptions
incognito: boolean
setIncognito: Dispatch<SetStateAction<boolean>>
commandPreview: CommandPreviewResult | null
previewLoading: boolean
// probe (single video)
info: MediaInfo | null
thumbFailed: string | null
setThumbFailed: Dispatch<SetStateAction<string | null>>
probing: boolean
probeError: string | null
usingFormats: boolean
selectedFormat: FormatOption | undefined
// playlist
playlist: PlaylistInfo | null
selected: Set<number>
allSelected: boolean
// suggestion banner
suggestion: string | null
suggestionSource: SuggestionSource
dismissSuggestion: () => void
// channel/playlist nudge → Library (UX3)
channelHint: boolean
openInLibrary: () => void
dismissChannelHint: () => void
// handlers
onDragEnter: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent) => void
onUrlChange: (next: string) => void
onKindChange: (next: MediaKind) => void
onQualityChange: (v: string) => void
onFormatIdChange: (v: string) => void
onTrimChange: (v: string) => void
onDownloadOptionsChange: (next: DownloadOptions) => void
toggleCommandPreview: () => Promise<void>
fetchFormats: () => Promise<void>
paste: () => Promise<void>
acceptSuggestion: () => void
download: () => void
downloadAnyway: () => void
addPlaylist: () => void
toggleEntry: (index: number, on: boolean) => void
toggleAll: () => void
effKind: (index: number) => MediaKind
toggleItemKind: (index: number) => void
setAllKinds: (k: MediaKind) => void
}
/**
* 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(): DownloadBarController {
const addFromUrl = useDownloads((s) => s.addFromUrl)
const addMany = useDownloads((s) => s.addMany)
const openLibraryWith = useNav((s) => s.openLibraryWith)
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)
// Channel/playlist nudge: a whole channel/playlist belongs in the Library, not
// the one-off queue. Once dismissed for the current URL, stay quiet (UX3).
const [channelHintDismissed, setChannelHintDismissed] = useState(false)
// Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null)
// Whether the duplicate matched a history entry (already downloaded) rather than
// a live queue item (L144) — the banner wording differs.
const [dupInHistory, setDupInHistory] = useState(false)
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>>({})
// Drag highlight is gated on a depth counter, not raw enter/leave (L158): a
// dragleave fires every time the cursor crosses onto a child element, so toggling
// on leave made the dashed outline blink. Count enters vs leaves and only clear
// the highlight when the drag has actually left the whole card (depth back to 0).
const dragDepth = useRef(0)
function onDragEnter(e: React.DragEvent): void {
e.preventDefault()
dragDepth.current += 1
if (!dragActive) setDragActive(true)
}
function onDragOver(e: React.DragEvent): void {
// Required so the element is a valid drop target; the highlight is owned by
// onDragEnter/onDragLeave.
e.preventDefault()
}
function onDragLeave(): void {
dragDepth.current = Math.max(0, dragDepth.current - 1)
if (dragDepth.current === 0) setDragActive(false)
}
function onDrop(e: React.DragEvent): void {
e.preventDefault()
dragDepth.current = 0
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
// Show the "add this in the Library" nudge for a clear channel/playlist URL,
// but only while the bar is still idle for it (nothing probed/resolved yet) and
// the user hasn't waved it away (UX3).
const channelHint =
looksLikeChannelOrPlaylist(url) &&
!channelHintDismissed &&
info === null &&
playlist === null &&
probeError === null
function clearProbe(): void {
setInfo(null)
setProbeError(null)
setFormatId('')
setPlaylist(null)
setSelected(new Set())
setItemKinds({})
}
// Reset the whole bar after a download / playlist is enqueued: clear the URL +
// probe and drop the one-shot overrides (trim / schedule / advanced / incognito /
// per-download options) so the next download starts from the global defaults.
// Shared by download() and addPlaylist() so a playlist add resets the same state
// a single download does (L132 — addPlaylist previously left trim/schedule open).
function resetForm(): void {
setUrl('')
setTrim('')
setShowTrim(false)
setScheduleAt('')
setShowSchedule(false)
setShowAdvanced(false)
setCommandPreview(null)
setIncognito(false)
setDownloadOptions(DEFAULT_DOWNLOAD_OPTIONS)
clearProbe()
}
// 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
// A new URL gets a fresh chance to nudge toward the Library.
setChannelHintDismissed(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 */
}
}
// Hand the current channel/playlist URL to the Library tab, which pre-fills its
// add field with it, and clear the bar (the URL now lives over there) (UX3).
function openInLibrary(): void {
const trimmed = url.trim()
if (!trimmed) return
openLibraryWith(trimmed)
setUrl('')
// Clear the URL-specific command preview too, so no stale command lingers over
// the now-empty bar (the same invariant onUrlChange keeps).
setCommandPreview(null)
clearProbe()
}
function dismissChannelHint(): void {
setChannelHintDismissed(true)
}
// 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)
setDupInHistory(false)
return
}
// Not in the queue, but maybe already downloaded earlier (L144): warn so a
// re-download is a deliberate choice, not an accidental duplicate.
const downloaded = useHistory.getState().entries.find((h) => sameVideo(h.url, trimmed))
if (downloaded) {
setDup(downloaded.title || downloaded.url)
setDupInHistory(true)
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
})
}
resetForm()
}
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 }
}
})
)
resetForm()
}
return {
// form state
url,
kind,
quality,
// trim / schedule
showTrim,
setShowTrim,
trim,
showSchedule,
setShowSchedule,
scheduleAt,
setScheduleAt,
// drag / dup
dupInHistory,
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,
// channel/playlist nudge → Library (UX3)
channelHint,
openInLibrary,
dismissChannelHint,
// handlers
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
onUrlChange,
onKindChange,
onQualityChange,
onFormatIdChange,
onTrimChange,
onDownloadOptionsChange,
toggleCommandPreview,
fetchFormats,
paste,
acceptSuggestion,
download,
downloadAnyway,
addPlaylist,
toggleEntry,
toggleAll,
effKind,
toggleItemKind,
setAllKinds
}
}