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",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "A yt-dlp frontend for Windows",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "AeroFetch",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
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 { getSettings, getDownloadArchivePath } from './settings'
|
||||
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
||||
import { getCookiesFilePath } from './cookies'
|
||||
import { listTemplates } from './templates'
|
||||
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. */
|
||||
export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||
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>/
|
||||
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
|
||||
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||
|
||||
+5
-1
@@ -13,7 +13,7 @@ import {
|
||||
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||
import { probeMedia } from './probe'
|
||||
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 { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||
import { setupPortableData } from './portable'
|
||||
@@ -303,6 +303,10 @@ if (isPrimaryInstance) {
|
||||
app.whenReady().then(() => {
|
||||
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) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
+30
-5
@@ -1,5 +1,6 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
import Store from 'electron-store'
|
||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
} from '@shared/ipc'
|
||||
|
||||
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',
|
||||
defaultVideoQuality: 'Best available',
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
@@ -45,6 +46,31 @@ export function getDownloadArchivePath(): string {
|
||||
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
|
||||
// defaults for any missing/invalid field. Used both to migrate older settings
|
||||
// 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
|
||||
// the settings file on every read. (audit P1)
|
||||
const cur = s.store
|
||||
if (!cur.outputDir) {
|
||||
// Fill in the real Downloads path the first time, and persist it once.
|
||||
s.set('outputDir', app.getPath('downloads'))
|
||||
}
|
||||
// outputDir is intentionally left blank by default — an empty value routes each
|
||||
// download into Documents\Video / Documents\Audio by kind (see buildCommand).
|
||||
// Only an explicit user choice (Settings → Download folder) overrides that.
|
||||
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
||||
// but only persist when sanitizing actually altered the stored value.
|
||||
const sanitized = sanitizeOptions(cur.downloadOptions)
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
Input,
|
||||
Button,
|
||||
Checkbox,
|
||||
Switch,
|
||||
Field,
|
||||
Spinner,
|
||||
Text,
|
||||
Caption1,
|
||||
@@ -16,36 +14,19 @@ import {
|
||||
import {
|
||||
ArrowDownloadRegular,
|
||||
ClipboardPasteRegular,
|
||||
FolderRegular,
|
||||
SearchRegular,
|
||||
VideoClipRegular,
|
||||
MusicNote2Regular,
|
||||
ErrorCircleRegular,
|
||||
LinkRegular,
|
||||
DismissRegular,
|
||||
OptionsRegular,
|
||||
ChevronDownRegular,
|
||||
ChevronUpRegular,
|
||||
AppsListRegular,
|
||||
CodeRegular,
|
||||
EyeRegular,
|
||||
EyeOffRegular,
|
||||
CopyRegular
|
||||
AppsListRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type {
|
||||
MediaInfo,
|
||||
FormatOption,
|
||||
PlaylistInfo,
|
||||
DownloadOptions,
|
||||
CommandPreviewResult,
|
||||
StartDownloadOptions
|
||||
} from '@shared/ipc'
|
||||
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
|
||||
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { useTemplates } from '../store/templates'
|
||||
import { Select } from './Select'
|
||||
import { Hint } from './Hint'
|
||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||||
|
||||
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
||||
function looksLikeUrl(text: string): boolean {
|
||||
@@ -184,33 +165,6 @@ const useStyles = makeStyles({
|
||||
spacer: {
|
||||
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 ---
|
||||
plPanel: {
|
||||
display: 'flex',
|
||||
@@ -258,63 +212,21 @@ const useStyles = makeStyles({
|
||||
},
|
||||
plItemMeta: {
|
||||
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 {
|
||||
const styles = useStyles()
|
||||
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 defaultKind = useSettings((s) => s.defaultKind)
|
||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||
const downloadOptions = useSettings((s) => s.downloadOptions)
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [kind, setKind] = useState<MediaKind>('video')
|
||||
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.
|
||||
const appliedDefaults = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -393,7 +305,6 @@ export function DownloadBar(): React.JSX.Element {
|
||||
setSuggestion(null)
|
||||
}
|
||||
|
||||
const folder = outputDir || 'your Downloads folder'
|
||||
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]
|
||||
@@ -468,61 +379,6 @@ export function DownloadBar(): React.JSX.Element {
|
||||
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 {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
@@ -543,18 +399,10 @@ export function DownloadBar(): React.JSX.Element {
|
||||
id: selectedFormat.id,
|
||||
hasAudio: selectedFormat.hasAudio,
|
||||
label: selectedFormat.label
|
||||
},
|
||||
options: override ?? undefined,
|
||||
extraArgs: resolveExtraArgs(),
|
||||
incognito
|
||||
}
|
||||
})
|
||||
} else {
|
||||
addFromUrl(trimmed, kind, quality, {
|
||||
...meta,
|
||||
options: override ?? undefined,
|
||||
extraArgs: resolveExtraArgs(),
|
||||
incognito
|
||||
})
|
||||
addFromUrl(trimmed, kind, quality, meta)
|
||||
}
|
||||
|
||||
setUrl('')
|
||||
@@ -568,10 +416,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
addFromUrl(e.url, kind, quality, {
|
||||
title: e.title,
|
||||
channel: e.uploader,
|
||||
durationLabel: e.durationLabel,
|
||||
options: override ?? undefined,
|
||||
extraArgs: resolveExtraArgs(),
|
||||
incognito
|
||||
durationLabel: e.durationLabel
|
||||
})
|
||||
}
|
||||
setUrl('')
|
||||
@@ -769,136 +614,6 @@ export function DownloadBar(): React.JSX.Element {
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,12 +118,16 @@ export function Onboarding(): React.JSX.Element {
|
||||
audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install.
|
||||
</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}>
|
||||
<Input
|
||||
readOnly
|
||||
className={styles.folderInput}
|
||||
value={outputDir}
|
||||
placeholder="Documents\Video and Documents\Audio (by type)"
|
||||
contentBefore={<FolderRegular />}
|
||||
/>
|
||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
||||
|
||||
@@ -328,12 +328,16 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Subtitle2>Downloads</Subtitle2>
|
||||
</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}>
|
||||
<Input
|
||||
readOnly
|
||||
className={styles.folderInput}
|
||||
value={outputDir}
|
||||
placeholder="Documents\Video and Documents\Audio (by type)"
|
||||
contentBefore={<FolderRegular />}
|
||||
/>
|
||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
||||
|
||||
Reference in New Issue
Block a user