36699531cf
Aligns with CC2 enforcement: ESLint + Prettier + noImplicitAny now enforced. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import {
|
|
Input,
|
|
Textarea,
|
|
Field,
|
|
Button,
|
|
Checkbox,
|
|
Spinner,
|
|
Text,
|
|
Caption1,
|
|
mergeClasses
|
|
} from '@fluentui/react-components'
|
|
import {
|
|
ArrowDownloadRegular,
|
|
ClipboardPasteRegular,
|
|
SearchRegular,
|
|
VideoClipRegular,
|
|
MusicNote2Regular,
|
|
ErrorCircleRegular,
|
|
LinkRegular,
|
|
LibraryRegular,
|
|
DismissRegular,
|
|
CutRegular,
|
|
CalendarClockRegular,
|
|
WarningRegular,
|
|
SettingsRegular,
|
|
WindowConsoleRegular
|
|
} from '@fluentui/react-icons'
|
|
import { type MediaKind } from '../store/downloads'
|
|
import { QUALITY_OPTIONS } from '../qualityOptions'
|
|
import { Select } from './Select'
|
|
import { Hint } from './Hint'
|
|
import { SegmentedControl } from './ui/SegmentedControl'
|
|
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
|
import { useDownloadBarStyles } from './downloadBar/styles'
|
|
import { useDownloadBar, toLocalDatetimeValue } from './downloadBar/useDownloadBar'
|
|
import { PlaylistPanel } from './downloadBar/PlaylistPanel'
|
|
|
|
export function DownloadBar(): React.JSX.Element {
|
|
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,
|
|
suggestionSource,
|
|
channelHint
|
|
} = bar
|
|
|
|
return (
|
|
<div
|
|
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
|
|
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) => bar.onUrlChange(d.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key !== 'Enter') return
|
|
// Enter is "go": start the download (or add a fetched playlist). Preview
|
|
// formats stays an explicit, optional click on the button (UX2).
|
|
if (playlist) {
|
|
if (selected.size > 0) bar.addPlaylist()
|
|
} else bar.download()
|
|
}}
|
|
placeholder="Paste a video or playlist URL, then press Enter to download"
|
|
size="large"
|
|
contentBefore={<ArrowDownloadRegular />}
|
|
/>
|
|
<Hint label="Preview available formats (optional)" placement="bottom" align="end">
|
|
<Button
|
|
size="large"
|
|
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
|
|
onClick={bar.fetchFormats}
|
|
disabled={!url.trim() || probing}
|
|
aria-label="Preview available formats (optional)"
|
|
/>
|
|
</Hint>
|
|
<Hint label="Paste from clipboard" placement="bottom" align="end">
|
|
<Button
|
|
size="large"
|
|
icon={<ClipboardPasteRegular />}
|
|
onClick={bar.paste}
|
|
aria-label="Paste from clipboard"
|
|
/>
|
|
</Hint>
|
|
</div>
|
|
|
|
{suggestion && (
|
|
<div className={styles.suggestion}>
|
|
<LinkRegular />
|
|
<Caption1 className={styles.suggestionText}>
|
|
{suggestionSource === 'external' ? 'Link received: ' : 'Use copied link? '}
|
|
{suggestion}
|
|
</Caption1>
|
|
<Button size="small" appearance="primary" onClick={bar.acceptSuggestion}>
|
|
Use
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<DismissRegular />}
|
|
onClick={bar.dismissSuggestion}
|
|
aria-label="Dismiss suggested link"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{channelHint && (
|
|
<div className={styles.channelHint}>
|
|
<LibraryRegular />
|
|
<Caption1 className={styles.channelHintText}>
|
|
This looks like a channel or playlist. Add it in the Library to download many videos at
|
|
once.
|
|
</Caption1>
|
|
<Button size="small" appearance="primary" onClick={bar.openInLibrary}>
|
|
Open in Library
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<DismissRegular />}
|
|
onClick={bar.dismissChannelHint}
|
|
aria-label="Dismiss Library suggestion"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{dup && (
|
|
<div className={styles.dupRow}>
|
|
<WarningRegular />
|
|
<Caption1 className={styles.suggestionText}>Already in your queue: "{dup}".</Caption1>
|
|
<Button size="small" appearance="primary" onClick={bar.downloadAnyway}>
|
|
Download anyway
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<DismissRegular />}
|
|
onClick={() => bar.setDup(null)}
|
|
aria-label="Dismiss duplicate warning"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{probing && (
|
|
<div className={styles.statusRow}>
|
|
<Spinner size="tiny" />
|
|
<Caption1>Fetching…</Caption1>
|
|
</div>
|
|
)}
|
|
|
|
{probeError && (
|
|
<div className={mergeClasses(styles.statusRow, styles.errorRow)}>
|
|
<ErrorCircleRegular />
|
|
<Caption1>{probeError} -- you can still download with the presets below.</Caption1>
|
|
</div>
|
|
)}
|
|
|
|
{info && (
|
|
<div className={styles.preview}>
|
|
{info.thumbnail && thumbFailed !== info.thumbnail ? (
|
|
<img
|
|
className={styles.previewThumb}
|
|
src={info.thumbnail}
|
|
alt=""
|
|
onError={() => bar.setThumbFailed(info.thumbnail ?? null)}
|
|
/>
|
|
) : (
|
|
<div className={styles.previewThumb}>
|
|
{kind === 'audio' ? (
|
|
<MusicNote2Regular fontSize={28} />
|
|
) : (
|
|
<VideoClipRegular fontSize={28} />
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className={styles.previewBody}>
|
|
<Text className={styles.previewTitle}>{info.title}</Text>
|
|
<Caption1 className={styles.previewMeta}>
|
|
{[info.channel, info.durationLabel].filter(Boolean).join(' • ')}
|
|
</Caption1>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{playlist && (
|
|
<PlaylistPanel
|
|
playlist={playlist}
|
|
selected={selected}
|
|
allSelected={allSelected}
|
|
onToggleAll={bar.toggleAll}
|
|
onSetAllKinds={bar.setAllKinds}
|
|
onToggleEntry={bar.toggleEntry}
|
|
effKind={bar.effKind}
|
|
onToggleItemKind={bar.toggleItemKind}
|
|
/>
|
|
)}
|
|
|
|
{!playlist && (
|
|
<div className={styles.trimBlock}>
|
|
<div className={styles.optButtons}>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<CutRegular />}
|
|
onClick={() => bar.setShowTrim((v) => !v)}
|
|
>
|
|
{trim.trim() ? 'Trim · on' : 'Trim'}
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<CalendarClockRegular />}
|
|
onClick={() => bar.setShowSchedule((v) => !v)}
|
|
>
|
|
{scheduleAt ? 'Scheduled' : 'Schedule'}
|
|
</Button>
|
|
</div>
|
|
{showTrim && (
|
|
<div className={styles.trimPanel}>
|
|
<Field
|
|
label="Sections to keep"
|
|
hint="Time ranges like 1:30-2:00 -- one per line or comma-separated. Leave empty to download the whole thing."
|
|
>
|
|
<Textarea
|
|
value={trim}
|
|
onChange={(_, d) => bar.onTrimChange(d.value)}
|
|
placeholder={'0:30-1:45\n3:00-3:30'}
|
|
resize="vertical"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
{showSchedule && (
|
|
<div className={styles.trimPanel}>
|
|
<Field
|
|
label="Start at"
|
|
hint="Pick a date and time to start this download. Leave empty to start now. Scheduled downloads only fire while AeroFetch is running."
|
|
>
|
|
<input
|
|
type="datetime-local"
|
|
className={styles.dtInput}
|
|
value={scheduleAt}
|
|
min={toLocalDatetimeValue(new Date())}
|
|
onChange={(e) => bar.setScheduleAt(e.target.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!playlist && (
|
|
<div className={styles.commandBlock}>
|
|
<div className={styles.optButtons}>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<SettingsRegular />}
|
|
onClick={() => bar.setShowAdvanced((v) => !v)}
|
|
>
|
|
{showAdvanced ? 'Advanced · on' : 'Advanced'}
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={previewLoading ? <Spinner size="tiny" /> : <WindowConsoleRegular />}
|
|
onClick={bar.toggleCommandPreview}
|
|
disabled={!url.trim() || previewLoading}
|
|
>
|
|
{commandPreview ? 'Hide command' : 'Show command'}
|
|
</Button>
|
|
</div>
|
|
{commandPreview && (
|
|
<div className={styles.commandPreviewPanel}>
|
|
{commandPreview.ok ? (
|
|
commandPreview.command
|
|
) : (
|
|
<span className={styles.commandError}>Error: {commandPreview.error}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{showAdvanced && (
|
|
<div className={styles.optionsPanel}>
|
|
<Checkbox
|
|
checked={incognito}
|
|
onChange={(_, d) => bar.setIncognito(!!d.checked)}
|
|
label="Incognito mode (no logging, no history, no cookies)"
|
|
/>
|
|
<DownloadOptionsForm
|
|
value={downloadOptions}
|
|
onChange={bar.onDownloadOptionsChange}
|
|
kind={kind}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.controls}>
|
|
<div className={styles.control}>
|
|
<Caption1>Format</Caption1>
|
|
<SegmentedControl<MediaKind>
|
|
value={kind}
|
|
options={[
|
|
{ value: 'video', label: 'Video' },
|
|
{ value: 'audio', label: 'Audio' }
|
|
]}
|
|
onChange={bar.onKindChange}
|
|
ariaLabel="Format"
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.control}>
|
|
<Caption1>{usingFormats ? 'Quality / format' : 'Quality'}</Caption1>
|
|
{usingFormats ? (
|
|
<Select
|
|
className={styles.quality}
|
|
aria-label="Quality / format"
|
|
size="large"
|
|
value={selectedFormat?.id ?? ''}
|
|
options={(info?.formats ?? []).map((f) => ({ value: f.id, label: f.label }))}
|
|
onChange={bar.onFormatIdChange}
|
|
/>
|
|
) : (
|
|
<Select
|
|
className={styles.quality}
|
|
aria-label="Quality"
|
|
size="large"
|
|
value={quality}
|
|
options={QUALITY_OPTIONS[kind].map((q) => ({ value: q, label: q }))}
|
|
onChange={bar.onQualityChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.spacer} />
|
|
|
|
{playlist ? (
|
|
<Button
|
|
appearance="primary"
|
|
size="large"
|
|
icon={<ArrowDownloadRegular />}
|
|
onClick={bar.addPlaylist}
|
|
disabled={selected.size === 0}
|
|
>
|
|
Add {selected.size} to queue
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
appearance="primary"
|
|
size="large"
|
|
icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
|
|
onClick={bar.download}
|
|
disabled={!url.trim()}
|
|
>
|
|
{scheduleAt ? 'Schedule' : 'Download'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|