Route downloads into Documents\Video / Documents\Audio and simplify the home page
- Create Documents\Video and Documents\Audio on startup (ensureMediaDirs), and route each download into them by kind when no explicit output folder is set. An empty outputDir now means "sort by type"; a chosen folder still overrides for both kinds. - Settings/Onboarding "Download folder" gains a placeholder + hint describing the blank = route-by-type behaviour. - Trim the home download bar to the essentials: URL box, Search and Paste buttons, format/quality, and the Download button. Removed the private-mode toggle, per-download Options panel, custom-command panel, command preview, and the saving-to-folder line. - Bump version to 0.3.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "A yt-dlp frontend for Windows",
|
"description": "A yt-dlp frontend for Windows",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "AeroFetch",
|
"author": "AeroFetch",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { spawn, execFile, type ChildProcess } from 'child_process'
|
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { app, BrowserWindow, Notification, type WebContents } from 'electron'
|
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
||||||
import { getYtdlpPath, getBinDir, getAria2cPath, getFfmpegPath, getFfprobePath } from './binaries'
|
import { getYtdlpPath, getBinDir, getAria2cPath, getFfmpegPath, getFfprobePath } from './binaries'
|
||||||
import { getSettings, getDownloadArchivePath } from './settings'
|
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
||||||
import { getCookiesFilePath } from './cookies'
|
import { getCookiesFilePath } from './cookies'
|
||||||
import { listTemplates } from './templates'
|
import { listTemplates } from './templates'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
@@ -165,7 +165,11 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
|
|||||||
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
||||||
export function buildCommand(opts: StartDownloadOptions): string[] {
|
export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||||
const settings = getSettings()
|
const settings = getSettings()
|
||||||
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
|
// Output dir resolution: a per-download override wins, then the user's explicit
|
||||||
|
// global folder (Settings → Download folder), and finally — when both are blank —
|
||||||
|
// the per-kind default (Documents\Video for video, Documents\Audio for audio).
|
||||||
|
const outDir =
|
||||||
|
opts.outputDir?.trim() || settings.outputDir?.trim() || getDefaultMediaDir(opts.kind)
|
||||||
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
||||||
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
|
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
|
||||||
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||||
|
|||||||
+5
-1
@@ -13,7 +13,7 @@ import {
|
|||||||
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||||
import { probeMedia } from './probe'
|
import { probeMedia } from './probe'
|
||||||
import { startDownload, cancelDownload, previewCommand } from './download'
|
import { startDownload, cancelDownload, previewCommand } from './download'
|
||||||
import { getSettings, setSettings } from './settings'
|
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||||
import { setupPortableData } from './portable'
|
import { setupPortableData } from './portable'
|
||||||
@@ -303,6 +303,10 @@ if (isPrimaryInstance) {
|
|||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
electronApp.setAppUserModelId('com.aerofetch.app')
|
electronApp.setAppUserModelId('com.aerofetch.app')
|
||||||
|
|
||||||
|
// Create the default Documents\Video and Documents\Audio destinations so they
|
||||||
|
// exist from first launch (downloads are routed into them by kind).
|
||||||
|
ensureMediaDirs()
|
||||||
|
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|||||||
+30
-5
@@ -1,5 +1,6 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
const DEFAULTS: Settings = {
|
const DEFAULTS: Settings = {
|
||||||
outputDir: '', // resolved to the OS Downloads folder on first read
|
outputDir: '', // blank = route by kind into Documents\Video / Documents\Audio (see getDefaultMediaDir)
|
||||||
defaultKind: 'video',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -45,6 +46,31 @@ export function getDownloadArchivePath(): string {
|
|||||||
return join(app.getPath('userData'), 'download-archive.txt')
|
return join(app.getPath('userData'), 'download-archive.txt')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default per-kind download destination: video → Documents\Video,
|
||||||
|
* audio → Documents\Audio. Used when the user hasn't set an explicit output
|
||||||
|
* folder (Settings → Download folder), so downloads are sorted by type.
|
||||||
|
*/
|
||||||
|
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
|
||||||
|
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Documents\Video and Documents\Audio folders up front (called once
|
||||||
|
* at startup) so they exist the moment the app opens, not just after the first
|
||||||
|
* download. Best-effort: yt-dlp also creates the output dir at download time, so
|
||||||
|
* a failure here (read-only Documents, redirected folder) is non-fatal.
|
||||||
|
*/
|
||||||
|
export function ensureMediaDirs(): void {
|
||||||
|
for (const kind of ['video', 'audio'] as const) {
|
||||||
|
try {
|
||||||
|
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — the download path will be created on demand instead */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
||||||
// defaults for any missing/invalid field. Used both to migrate older settings
|
// defaults for any missing/invalid field. Used both to migrate older settings
|
||||||
// files (which predate downloadOptions) and to validate renderer writes.
|
// files (which predate downloadOptions) and to validate renderer writes.
|
||||||
@@ -100,10 +126,9 @@ export function getSettings(): Settings {
|
|||||||
// `set`, so only write when something actually changed — otherwise this churns
|
// `set`, so only write when something actually changed — otherwise this churns
|
||||||
// the settings file on every read. (audit P1)
|
// the settings file on every read. (audit P1)
|
||||||
const cur = s.store
|
const cur = s.store
|
||||||
if (!cur.outputDir) {
|
// outputDir is intentionally left blank by default — an empty value routes each
|
||||||
// Fill in the real Downloads path the first time, and persist it once.
|
// download into Documents\Video / Documents\Audio by kind (see buildCommand).
|
||||||
s.set('outputDir', app.getPath('downloads'))
|
// Only an explicit user choice (Settings → Download folder) overrides that.
|
||||||
}
|
|
||||||
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
||||||
// but only persist when sanitizing actually altered the stored value.
|
// but only persist when sanitizing actually altered the stored value.
|
||||||
const sanitized = sanitizeOptions(cur.downloadOptions)
|
const sanitized = sanitizeOptions(cur.downloadOptions)
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Switch,
|
|
||||||
Field,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
@@ -16,36 +14,19 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
ClipboardPasteRegular,
|
ClipboardPasteRegular,
|
||||||
FolderRegular,
|
|
||||||
SearchRegular,
|
SearchRegular,
|
||||||
VideoClipRegular,
|
VideoClipRegular,
|
||||||
MusicNote2Regular,
|
MusicNote2Regular,
|
||||||
ErrorCircleRegular,
|
ErrorCircleRegular,
|
||||||
LinkRegular,
|
LinkRegular,
|
||||||
DismissRegular,
|
DismissRegular,
|
||||||
OptionsRegular,
|
AppsListRegular
|
||||||
ChevronDownRegular,
|
|
||||||
ChevronUpRegular,
|
|
||||||
AppsListRegular,
|
|
||||||
CodeRegular,
|
|
||||||
EyeRegular,
|
|
||||||
EyeOffRegular,
|
|
||||||
CopyRegular
|
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type {
|
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
|
||||||
MediaInfo,
|
|
||||||
FormatOption,
|
|
||||||
PlaylistInfo,
|
|
||||||
DownloadOptions,
|
|
||||||
CommandPreviewResult,
|
|
||||||
StartDownloadOptions
|
|
||||||
} from '@shared/ipc'
|
|
||||||
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
import { useTemplates } from '../store/templates'
|
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
|
||||||
|
|
||||||
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
||||||
function looksLikeUrl(text: string): boolean {
|
function looksLikeUrl(text: string): boolean {
|
||||||
@@ -184,33 +165,6 @@ const useStyles = makeStyles({
|
|||||||
spacer: {
|
spacer: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
},
|
},
|
||||||
// --- per-download options panel ---
|
|
||||||
optionsBar: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
},
|
|
||||||
optionsPanel: {
|
|
||||||
padding: '14px',
|
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
|
||||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
|
||||||
},
|
|
||||||
// --- command preview ---
|
|
||||||
previewPanel: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '14px',
|
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
|
||||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
|
||||||
},
|
|
||||||
previewCommandText: {
|
|
||||||
fontFamily: tokens.fontFamilyMonospace,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all'
|
|
||||||
},
|
|
||||||
// --- playlist selection ---
|
// --- playlist selection ---
|
||||||
plPanel: {
|
plPanel: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -258,63 +212,21 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
plItemMeta: {
|
plItemMeta: {
|
||||||
color: tokens.colorNeutralForeground3
|
color: tokens.colorNeutralForeground3
|
||||||
},
|
|
||||||
folder: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
color: tokens.colorNeutralForeground3,
|
|
||||||
maxWidth: '360px'
|
|
||||||
},
|
|
||||||
folderPath: {
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export function DownloadBar(): React.JSX.Element {
|
export function DownloadBar(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||||
const outputDir = useSettings((s) => s.outputDir)
|
|
||||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
|
||||||
const settingsLoaded = useSettings((s) => s.loaded)
|
const settingsLoaded = useSettings((s) => s.loaded)
|
||||||
const defaultKind = useSettings((s) => s.defaultKind)
|
const defaultKind = useSettings((s) => s.defaultKind)
|
||||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||||
const downloadOptions = useSettings((s) => s.downloadOptions)
|
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [kind, setKind] = useState<MediaKind>('video')
|
const [kind, setKind] = useState<MediaKind>('video')
|
||||||
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
||||||
|
|
||||||
// Per-download options override (null = use the persisted defaults).
|
|
||||||
const [override, setOverride] = useState<DownloadOptions | null>(null)
|
|
||||||
const [showOptions, setShowOptions] = useState(false)
|
|
||||||
const effectiveOptions = override ?? downloadOptions
|
|
||||||
|
|
||||||
// Private mode — sticky like an incognito tab; the download still runs and
|
|
||||||
// appears in the queue, but its completion is never recorded to history.
|
|
||||||
const [incognito, setIncognito] = useState(false)
|
|
||||||
|
|
||||||
// Per-download custom-command override: undefined = defer to the settings
|
|
||||||
// default (customCommandEnabled + defaultTemplateId); null = explicit
|
|
||||||
// "None" for just this download; a string = a chosen template id override.
|
|
||||||
const [templateOverride, setTemplateOverride] = useState<string | null | undefined>(undefined)
|
|
||||||
const [showCustomCommand, setShowCustomCommand] = useState(false)
|
|
||||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
|
||||||
const settingsDefaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
|
||||||
const templates = useTemplates((s) => s.templates)
|
|
||||||
const effectiveTemplateId =
|
|
||||||
templateOverride !== undefined
|
|
||||||
? templateOverride
|
|
||||||
: customCommandEnabled
|
|
||||||
? settingsDefaultTemplateId
|
|
||||||
: null
|
|
||||||
|
|
||||||
const [previewResult, setPreviewResult] = useState<CommandPreviewResult | null>(null)
|
|
||||||
const [previewing, setPreviewing] = useState(false)
|
|
||||||
|
|
||||||
// Apply the saved default format once, when persisted settings first arrive.
|
// Apply the saved default format once, when persisted settings first arrive.
|
||||||
const appliedDefaults = useRef(false)
|
const appliedDefaults = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -393,7 +305,6 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
setSuggestion(null)
|
setSuggestion(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const folder = outputDir || 'your Downloads folder'
|
|
||||||
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
||||||
const selectedFormat: FormatOption | undefined = usingFormats
|
const selectedFormat: FormatOption | undefined = usingFormats
|
||||||
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
|
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
|
||||||
@@ -468,61 +379,6 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolves the per-download custom-command override to the raw extra-args
|
|
||||||
// string sent to main; undefined defers to the settings default there.
|
|
||||||
function resolveExtraArgs(): string | undefined {
|
|
||||||
if (templateOverride === undefined) return undefined
|
|
||||||
if (templateOverride === null) return ''
|
|
||||||
return templates.find((t) => t.id === templateOverride)?.args ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// The StartDownloadOptions the current form state would produce — shared by
|
|
||||||
// the real download() call and the command-preview button so they can never
|
|
||||||
// drift apart.
|
|
||||||
function currentStartOptions(): StartDownloadOptions {
|
|
||||||
const base: StartDownloadOptions = {
|
|
||||||
id: 'preview',
|
|
||||||
url: url.trim(),
|
|
||||||
kind,
|
|
||||||
quality,
|
|
||||||
outputDir: outputDir || undefined,
|
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs()
|
|
||||||
}
|
|
||||||
if (usingFormats && selectedFormat) {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: 'video',
|
|
||||||
quality: selectedFormat.label,
|
|
||||||
formatId: selectedFormat.id,
|
|
||||||
formatHasAudio: selectedFormat.hasAudio
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewCommand(): Promise<void> {
|
|
||||||
if (!url.trim() || previewing) return
|
|
||||||
setPreviewing(true)
|
|
||||||
setPreviewResult(null)
|
|
||||||
try {
|
|
||||||
setPreviewResult(await window.api.previewCommand(currentStartOptions()))
|
|
||||||
} catch (e) {
|
|
||||||
setPreviewResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
||||||
} finally {
|
|
||||||
setPreviewing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyPreview(): Promise<void> {
|
|
||||||
if (!previewResult?.command) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(previewResult.command)
|
|
||||||
} catch {
|
|
||||||
/* clipboard blocked — ignore in preview */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function download(): void {
|
function download(): void {
|
||||||
const trimmed = url.trim()
|
const trimmed = url.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
@@ -543,18 +399,10 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
id: selectedFormat.id,
|
id: selectedFormat.id,
|
||||||
hasAudio: selectedFormat.hasAudio,
|
hasAudio: selectedFormat.hasAudio,
|
||||||
label: selectedFormat.label
|
label: selectedFormat.label
|
||||||
},
|
}
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs(),
|
|
||||||
incognito
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
addFromUrl(trimmed, kind, quality, {
|
addFromUrl(trimmed, kind, quality, meta)
|
||||||
...meta,
|
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs(),
|
|
||||||
incognito
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setUrl('')
|
setUrl('')
|
||||||
@@ -568,10 +416,7 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
addFromUrl(e.url, kind, quality, {
|
addFromUrl(e.url, kind, quality, {
|
||||||
title: e.title,
|
title: e.title,
|
||||||
channel: e.uploader,
|
channel: e.uploader,
|
||||||
durationLabel: e.durationLabel,
|
durationLabel: e.durationLabel
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs(),
|
|
||||||
incognito
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setUrl('')
|
setUrl('')
|
||||||
@@ -769,136 +614,6 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
|
||||||
{incognito ? <EyeOffRegular /> : <EyeRegular />}
|
|
||||||
<Switch
|
|
||||||
checked={incognito}
|
|
||||||
onChange={(_, d) => setIncognito(d.checked)}
|
|
||||||
label={incognito ? 'Private — won’t be saved to history' : 'Private mode'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
icon={<OptionsRegular />}
|
|
||||||
iconPosition="before"
|
|
||||||
onClick={() => setShowOptions((v) => !v)}
|
|
||||||
>
|
|
||||||
Options {showOptions ? <ChevronUpRegular /> : <ChevronDownRegular />}
|
|
||||||
</Button>
|
|
||||||
{override && (
|
|
||||||
<>
|
|
||||||
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
|
|
||||||
<Button appearance="subtle" size="small" onClick={() => setOverride(null)}>
|
|
||||||
Reset to defaults
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showOptions && (
|
|
||||||
<div className={styles.optionsPanel}>
|
|
||||||
<DownloadOptionsForm value={effectiveOptions} onChange={(o) => setOverride(o)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
icon={<CodeRegular />}
|
|
||||||
iconPosition="before"
|
|
||||||
onClick={() => setShowCustomCommand((v) => !v)}
|
|
||||||
>
|
|
||||||
Custom command {showCustomCommand ? <ChevronUpRegular /> : <ChevronDownRegular />}
|
|
||||||
</Button>
|
|
||||||
{templateOverride !== undefined && (
|
|
||||||
<>
|
|
||||||
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
|
|
||||||
<Button appearance="subtle" size="small" onClick={() => setTemplateOverride(undefined)}>
|
|
||||||
Reset to default
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className={styles.spacer} />
|
|
||||||
<Hint label="Build and show the exact yt-dlp command line" placement="top" align="end">
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
icon={previewing ? <Spinner size="tiny" /> : <EyeRegular />}
|
|
||||||
iconPosition="before"
|
|
||||||
onClick={previewCommand}
|
|
||||||
disabled={!url.trim() || previewing}
|
|
||||||
>
|
|
||||||
Preview command
|
|
||||||
</Button>
|
|
||||||
</Hint>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCustomCommand && (
|
|
||||||
<div className={styles.optionsPanel}>
|
|
||||||
<Field label="Template" hint="Extra yt-dlp flags layered onto this download, after every other option.">
|
|
||||||
<Select
|
|
||||||
aria-label="Custom command template"
|
|
||||||
value={effectiveTemplateId ?? 'none'}
|
|
||||||
options={[
|
|
||||||
{ value: 'none', label: 'None' },
|
|
||||||
...templates.map((t) => ({ value: t.id, label: t.name }))
|
|
||||||
]}
|
|
||||||
onChange={(v) => setTemplateOverride(v === 'none' ? null : v)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{templates.length === 0 && (
|
|
||||||
<Caption1 className={styles.previewMeta}>
|
|
||||||
No templates yet — add one in Settings → Custom commands.
|
|
||||||
</Caption1>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{previewResult && (
|
|
||||||
<div className={styles.previewPanel}>
|
|
||||||
<div className={styles.plHeader}>
|
|
||||||
<Caption1 className={styles.plHeaderText}>
|
|
||||||
{previewResult.ok ? 'Command preview' : 'Could not build the command'}
|
|
||||||
</Caption1>
|
|
||||||
{previewResult.ok && (
|
|
||||||
<Button size="small" icon={<CopyRegular />} onClick={copyPreview}>
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<DismissRegular />}
|
|
||||||
onClick={() => setPreviewResult(null)}
|
|
||||||
aria-label="Dismiss preview"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{previewResult.ok ? (
|
|
||||||
<Text className={styles.previewCommandText}>{previewResult.command}</Text>
|
|
||||||
) : (
|
|
||||||
<Caption1 className={mergeClasses(styles.previewMeta, styles.errorRow)}>
|
|
||||||
{previewResult.error}
|
|
||||||
</Caption1>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.folder}>
|
|
||||||
<FolderRegular />
|
|
||||||
<Caption1 className={styles.folderPath} title={folder}>
|
|
||||||
Saving to {folder}
|
|
||||||
</Caption1>
|
|
||||||
<Hint label="Change download folder" placement="top" align="start">
|
|
||||||
<Button size="small" appearance="transparent" onClick={chooseOutputDir}>
|
|
||||||
Change
|
|
||||||
</Button>
|
|
||||||
</Hint>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,12 +118,16 @@ export function Onboarding(): React.JSX.Element {
|
|||||||
audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install.
|
audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install.
|
||||||
</Body1>
|
</Body1>
|
||||||
|
|
||||||
<Field label="Download folder">
|
<Field
|
||||||
|
label="Download folder"
|
||||||
|
hint="Leave blank to sort downloads into Documents\Video and Documents\Audio by type, or pick a folder for everything."
|
||||||
|
>
|
||||||
<div className={styles.folderRow}>
|
<div className={styles.folderRow}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
className={styles.folderInput}
|
className={styles.folderInput}
|
||||||
value={outputDir}
|
value={outputDir}
|
||||||
|
placeholder="Documents\Video and Documents\Audio (by type)"
|
||||||
contentBefore={<FolderRegular />}
|
contentBefore={<FolderRegular />}
|
||||||
/>
|
/>
|
||||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
||||||
|
|||||||
@@ -328,12 +328,16 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
<Subtitle2>Downloads</Subtitle2>
|
<Subtitle2>Downloads</Subtitle2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Download folder">
|
<Field
|
||||||
|
label="Download folder"
|
||||||
|
hint="Leave blank to sort downloads by type into Documents\Video and Documents\Audio. Choose a folder to send everything there instead."
|
||||||
|
>
|
||||||
<div className={styles.folderRow}>
|
<div className={styles.folderRow}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
className={styles.folderInput}
|
className={styles.folderInput}
|
||||||
value={outputDir}
|
value={outputDir}
|
||||||
|
placeholder="Documents\Video and Documents\Audio (by type)"
|
||||||
contentBefore={<FolderRegular />}
|
contentBefore={<FolderRegular />}
|
||||||
/>
|
/>
|
||||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
||||||
|
|||||||
Reference in New Issue
Block a user