Merge feat/documents-folders-and-ui: Documents folders, collapsible sidebar, theme switch
# Conflicts: # src/main/download.ts # src/renderer/src/components/DownloadBar.tsx
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aerofetch",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.2",
|
||||
"description": "A yt-dlp frontend for Windows",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "AeroFetch",
|
||||
|
||||
+13
-11
@@ -1,7 +1,7 @@
|
||||
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,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getFfprobePath,
|
||||
getSystem32Path
|
||||
} 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'
|
||||
@@ -175,16 +175,18 @@ 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()
|
||||
// A per-download outputDir override must clear the same safety check the
|
||||
// persisted setting does (absolute path only); a renderer-supplied override is
|
||||
// otherwise untrusted — it dictates where downloaded files get written. An
|
||||
// unsafe override is ignored in favour of the validated setting, then the OS
|
||||
// Downloads folder. (audit F4)
|
||||
// Output dir resolution: a per-download override wins, then the user's explicit
|
||||
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
|
||||
// blank — the per-kind default (Documents\Video for video, Documents\Audio for audio).
|
||||
//
|
||||
// A per-download outputDir override must clear the same safety check the persisted
|
||||
// setting does (absolute path only); a renderer-supplied override is otherwise
|
||||
// untrusted — it dictates where downloaded files get written. An unsafe override is
|
||||
// ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
|
||||
const override = opts.outputDir?.trim()
|
||||
const outDir =
|
||||
(override && isSafeOutputDir(override) ? override : '') ||
|
||||
settings.outputDir ||
|
||||
app.getPath('downloads')
|
||||
const safeOverride = override && isSafeOutputDir(override) ? override : ''
|
||||
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
|
||||
const outDir = safeOverride || perKindDir || 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'
|
||||
|
||||
+7
-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'
|
||||
@@ -179,6 +179,8 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||
|
||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||
@@ -331,6 +333,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)
|
||||
})
|
||||
|
||||
+40
-7
@@ -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,10 @@ import {
|
||||
} from '@shared/ipc'
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
outputDir: '', // resolved to the OS Downloads folder on first read
|
||||
// Both blank by default → downloads land in Documents\Video / Documents\Audio
|
||||
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
|
||||
videoDir: '',
|
||||
audioDir: '',
|
||||
defaultKind: 'video',
|
||||
defaultVideoQuality: 'Best available',
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
@@ -45,6 +49,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 +129,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'))
|
||||
}
|
||||
// videoDir/audioDir are intentionally left blank by default — an empty value
|
||||
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
|
||||
// Only an explicit user choice (Settings → folders) 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)
|
||||
@@ -187,8 +215,13 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
// group (it merges field changes locally before calling setSettings).
|
||||
s.set('downloadOptions', sanitizeOptions(value))
|
||||
break
|
||||
case 'outputDir':
|
||||
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
|
||||
case 'videoDir':
|
||||
case 'audioDir':
|
||||
// An empty string is allowed — it clears the override and restores the
|
||||
// Documents\Video / Documents\Audio default for that kind.
|
||||
if (typeof value === 'string' && (value.trim() === '' || isSafeOutputDir(value.trim()))) {
|
||||
s.set(key, value.trim())
|
||||
}
|
||||
break
|
||||
case 'filenameTemplate':
|
||||
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
|
||||
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
|
||||
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
||||
const api = {
|
||||
/** AeroFetch's own version string (e.g. '0.3.1'). */
|
||||
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
|
||||
|
||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
||||
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
||||
import { Sidebar, type TabValue } from './components/Sidebar'
|
||||
import { DownloadsView } from './components/DownloadsView'
|
||||
@@ -35,6 +35,24 @@ function App(): React.JSX.Element {
|
||||
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
|
||||
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
|
||||
const [tab, setTab] = useState<TabValue>('downloads')
|
||||
|
||||
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
|
||||
const [version, setVersion] = useState('')
|
||||
useEffect(() => {
|
||||
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Sidebar collapse, persisted across launches in localStorage.
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem('aerofetch.sidebarCollapsed') === '1'
|
||||
)
|
||||
function toggleCollapsed(): void {
|
||||
setCollapsed((c) => {
|
||||
const next = !c
|
||||
localStorage.setItem('aerofetch.sidebarCollapsed', next ? '1' : '0')
|
||||
return next
|
||||
})
|
||||
}
|
||||
// Gate on `loaded` so a returning user's real settings never get clobbered
|
||||
// by a one-frame flash of the (default-false) onboarding state.
|
||||
const loaded = useSettings((s) => s.loaded)
|
||||
@@ -60,9 +78,12 @@ function App(): React.JSX.Element {
|
||||
<Sidebar
|
||||
tab={tab}
|
||||
onTabChange={setTab}
|
||||
theme={theme}
|
||||
isDark={isDark}
|
||||
followingSystem={theme === 'system'}
|
||||
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
|
||||
onSetTheme={(mode) => updateSettings({ theme: mode })}
|
||||
version={version}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={toggleCollapsed}
|
||||
/>
|
||||
|
||||
<main className={styles.content}>
|
||||
|
||||
@@ -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,148 +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}>
|
||||
{!customCommandEnabled ? (
|
||||
// Custom commands run arbitrary yt-dlp flags, so the main process
|
||||
// ignores per-download extra args unless this consent flag is on
|
||||
// (audit F2). Reflect that here rather than silently dropping a pick.
|
||||
<Caption1 className={styles.previewMeta}>
|
||||
Custom commands are off. Turn them on in Settings → Custom commands to
|
||||
layer extra yt-dlp flags onto a download.
|
||||
</Caption1>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,13 +30,15 @@ const useStyles = makeStyles({
|
||||
},
|
||||
top: { bottom: 'calc(100% + 6px)' },
|
||||
bottom: { top: 'calc(100% + 6px)' },
|
||||
right: { left: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
|
||||
left: { right: 'calc(100% + 6px)', top: '50%', transform: 'translateY(-50%)' },
|
||||
alignStart: { left: 0 },
|
||||
alignEnd: { right: 0 }
|
||||
})
|
||||
|
||||
interface HintProps {
|
||||
label: string
|
||||
placement?: 'top' | 'bottom'
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
align?: 'start' | 'end'
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -56,8 +58,16 @@ export function Hint({
|
||||
aria-hidden
|
||||
className={mergeClasses(
|
||||
styles.bubble,
|
||||
placement === 'bottom' ? styles.bottom : styles.top,
|
||||
align === 'end' ? styles.alignEnd : styles.alignStart
|
||||
placement === 'bottom'
|
||||
? styles.bottom
|
||||
: placement === 'right'
|
||||
? styles.right
|
||||
: placement === 'left'
|
||||
? styles.left
|
||||
: styles.top,
|
||||
// start/end alignment only applies to vertical (top/bottom) placements
|
||||
(placement === 'top' || placement === 'bottom') &&
|
||||
(align === 'end' ? styles.alignEnd : styles.alignStart)
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Body1,
|
||||
Caption1,
|
||||
Field,
|
||||
Input,
|
||||
Button,
|
||||
makeStyles,
|
||||
tokens,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
} from '@fluentui/react-components'
|
||||
import {
|
||||
ArrowDownloadFilled,
|
||||
FolderRegular,
|
||||
ClipboardPasteRegular,
|
||||
HistoryRegular,
|
||||
OptionsRegular,
|
||||
@@ -56,13 +54,8 @@ const useStyles = makeStyles({
|
||||
justifyContent: 'center',
|
||||
fontSize: '26px'
|
||||
},
|
||||
folderRow: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-end'
|
||||
},
|
||||
folderInput: {
|
||||
flexGrow: 1
|
||||
folderNote: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
tips: {
|
||||
display: 'flex',
|
||||
@@ -99,8 +92,6 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
||||
|
||||
export function Onboarding(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const outputDir = useSettings((s) => s.outputDir)
|
||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
return (
|
||||
@@ -118,18 +109,12 @@ 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">
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
readOnly
|
||||
className={styles.folderInput}
|
||||
value={outputDir}
|
||||
contentBefore={<FolderRegular />}
|
||||
/>
|
||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
<Field label="Where downloads go">
|
||||
<Caption1 className={styles.folderNote}>
|
||||
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
|
||||
<strong>Documents\Audio</strong>. You can point each to a different folder any time
|
||||
in Settings.
|
||||
</Caption1>
|
||||
</Field>
|
||||
|
||||
<div className={styles.tips}>
|
||||
|
||||
@@ -170,8 +170,10 @@ const UPDATE_CHANNEL_OPTIONS = [
|
||||
export function SettingsView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
|
||||
const outputDir = useSettings((s) => s.outputDir)
|
||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
||||
const videoDir = useSettings((s) => s.videoDir)
|
||||
const audioDir = useSettings((s) => s.audioDir)
|
||||
const chooseDir = useSettings((s) => s.chooseDir)
|
||||
const clearDir = useSettings((s) => s.clearDir)
|
||||
const defaultKind = useSettings((s) => s.defaultKind)
|
||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||
@@ -328,17 +330,49 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Subtitle2>Downloads</Subtitle2>
|
||||
</div>
|
||||
|
||||
<Field label="Download folder">
|
||||
<Field
|
||||
label="Video folder"
|
||||
hint="Where video downloads are saved. Leave blank to use Documents\Video."
|
||||
>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
readOnly
|
||||
className={styles.folderInput}
|
||||
value={outputDir}
|
||||
value={videoDir}
|
||||
placeholder="Documents\Video (default)"
|
||||
contentBefore={<FolderRegular />}
|
||||
/>
|
||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
||||
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||
Browse
|
||||
</Button>
|
||||
{videoDir && (
|
||||
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Audio folder"
|
||||
hint="Where audio downloads are saved. Leave blank to use Documents\Audio."
|
||||
>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
readOnly
|
||||
className={styles.folderInput}
|
||||
value={audioDir}
|
||||
placeholder="Documents\Audio (default)"
|
||||
contentBefore={<FolderRegular />}
|
||||
/>
|
||||
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
|
||||
Browse
|
||||
</Button>
|
||||
{audioDir && (
|
||||
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Caption1,
|
||||
Switch,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import {
|
||||
ArrowDownloadFilled,
|
||||
ArrowDownloadRegular,
|
||||
@@ -13,8 +6,13 @@ import {
|
||||
LibraryRegular,
|
||||
SettingsRegular,
|
||||
WeatherMoonRegular,
|
||||
WeatherSunnyRegular
|
||||
WeatherSunnyRegular,
|
||||
DesktopRegular,
|
||||
PanelLeftContractRegular,
|
||||
PanelLeftExpandRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { ThemeMode } from '@shared/ipc'
|
||||
import { Hint } from './Hint'
|
||||
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
|
||||
|
||||
@@ -27,13 +25,48 @@ const useStyles = makeStyles({
|
||||
gap: '4px',
|
||||
padding: '16px 12px',
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRight: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
transition: 'width 0.15s ease'
|
||||
},
|
||||
rootCollapsed: {
|
||||
width: '60px',
|
||||
alignItems: 'center'
|
||||
},
|
||||
topBar: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: '2px'
|
||||
},
|
||||
topBarCollapsed: {
|
||||
justifyContent: 'center'
|
||||
},
|
||||
iconBtn: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '18px',
|
||||
cursor: 'pointer',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground2
|
||||
}
|
||||
},
|
||||
brand: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '11px',
|
||||
padding: '6px 10px 14px'
|
||||
padding: '0 10px 14px'
|
||||
},
|
||||
brandCollapsed: {
|
||||
padding: '0 0 12px',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
mark: {
|
||||
width: '36px',
|
||||
@@ -64,7 +97,8 @@ const useStyles = makeStyles({
|
||||
nav: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '3px'
|
||||
gap: '3px',
|
||||
alignSelf: 'stretch'
|
||||
},
|
||||
navItem: {
|
||||
display: 'flex',
|
||||
@@ -84,6 +118,10 @@ const useStyles = makeStyles({
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
navItemCollapsed: {
|
||||
justifyContent: 'center',
|
||||
padding: '9px 0'
|
||||
},
|
||||
navItemActive: {
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2,
|
||||
@@ -94,22 +132,46 @@ const useStyles = makeStyles({
|
||||
},
|
||||
navIcon: {
|
||||
fontSize: '18px',
|
||||
flexShrink: 0
|
||||
flexShrink: 0,
|
||||
display: 'flex'
|
||||
},
|
||||
spacer: {
|
||||
flexGrow: 1
|
||||
},
|
||||
themeRow: {
|
||||
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
|
||||
themeGroup: {
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
color: tokens.colorNeutralForeground3
|
||||
width: '100%',
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
overflow: 'hidden'
|
||||
},
|
||||
themeLabel: {
|
||||
themeSeg: {
|
||||
flex: 1,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '9px'
|
||||
justifyContent: 'center',
|
||||
gap: '5px',
|
||||
padding: '7px 4px',
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
themeSegActive: {
|
||||
backgroundColor: tokens.colorBrandBackground,
|
||||
color: tokens.colorNeutralForegroundOnBrand,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorBrandBackgroundHover
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -120,67 +182,149 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
|
||||
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
|
||||
]
|
||||
|
||||
const THEMES: { value: ThemeMode; label: string; icon: React.JSX.Element }[] = [
|
||||
{ value: 'light', label: 'Light', icon: <WeatherSunnyRegular /> },
|
||||
{ value: 'dark', label: 'Dark', icon: <WeatherMoonRegular /> },
|
||||
{ value: 'system', label: 'Auto', icon: <DesktopRegular /> }
|
||||
]
|
||||
|
||||
interface SidebarProps {
|
||||
tab: TabValue
|
||||
onTabChange: (t: TabValue) => void
|
||||
/** the current theme preference: explicit light/dark, or 'system' to follow the OS */
|
||||
theme: ThemeMode
|
||||
/** whether the resolved theme is currently dark (drives the collapsed toggle icon) */
|
||||
isDark: boolean
|
||||
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
|
||||
followingSystem: boolean
|
||||
onToggleTheme: () => void
|
||||
onSetTheme: (mode: ThemeMode) => void
|
||||
/** AeroFetch version string, e.g. '0.3.2' ('' until loaded) */
|
||||
version: string
|
||||
collapsed: boolean
|
||||
onToggleCollapsed: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
tab,
|
||||
onTabChange,
|
||||
theme,
|
||||
isDark,
|
||||
followingSystem,
|
||||
onToggleTheme
|
||||
onSetTheme,
|
||||
version,
|
||||
collapsed,
|
||||
onToggleCollapsed
|
||||
}: SidebarProps): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
|
||||
// Collapsed view shows one button that cycles Light → Dark → Auto.
|
||||
const order: ThemeMode[] = ['light', 'dark', 'system']
|
||||
function cycleTheme(): void {
|
||||
onSetTheme(order[(order.indexOf(theme) + 1) % order.length])
|
||||
}
|
||||
const themeIcon =
|
||||
theme === 'system' ? (
|
||||
<DesktopRegular fontSize={18} />
|
||||
) : isDark ? (
|
||||
<WeatherMoonRegular fontSize={18} />
|
||||
) : (
|
||||
<WeatherSunnyRegular fontSize={18} />
|
||||
)
|
||||
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
|
||||
|
||||
return (
|
||||
<nav className={styles.root}>
|
||||
<div className={styles.brand}>
|
||||
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
|
||||
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
|
||||
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-pressed={collapsed}
|
||||
>
|
||||
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
|
||||
</button>
|
||||
</Hint>
|
||||
</div>
|
||||
|
||||
<div className={mergeClasses(styles.brand, collapsed && styles.brandCollapsed)}>
|
||||
<div className={styles.mark}>
|
||||
<ArrowDownloadFilled />
|
||||
</div>
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.brandName}>AeroFetch</span>
|
||||
<Caption1 className={styles.caption}>yt-dlp frontend</Caption1>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.brandName}>AeroFetch</span>
|
||||
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.nav}>
|
||||
{NAV.map((n) => {
|
||||
const active = tab === n.value
|
||||
return (
|
||||
const btn = (
|
||||
<button
|
||||
key={n.value}
|
||||
type="button"
|
||||
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
|
||||
className={mergeClasses(
|
||||
styles.navItem,
|
||||
collapsed && styles.navItemCollapsed,
|
||||
active && styles.navItemActive
|
||||
)}
|
||||
style={
|
||||
active
|
||||
active && !collapsed
|
||||
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onTabChange(n.value)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-label={n.label}
|
||||
>
|
||||
<span className={styles.navIcon}>{n.icon}</span>
|
||||
{n.label}
|
||||
{!collapsed && n.label}
|
||||
</button>
|
||||
)
|
||||
return collapsed ? (
|
||||
<Hint key={n.value} label={n.label} placement="right">
|
||||
{btn}
|
||||
</Hint>
|
||||
) : (
|
||||
btn
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.spacer} />
|
||||
|
||||
<div className={styles.themeRow}>
|
||||
<span className={styles.themeLabel}>
|
||||
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
|
||||
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
|
||||
</span>
|
||||
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<Hint label={`Theme: ${themeLabel}`} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
onClick={cycleTheme}
|
||||
aria-label={`Theme: ${themeLabel}. Click to change.`}
|
||||
>
|
||||
{themeIcon}
|
||||
</button>
|
||||
</Hint>
|
||||
) : (
|
||||
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
|
||||
{THEMES.map((t) => {
|
||||
const on = theme === t.value
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
className={mergeClasses(styles.themeSeg, on && styles.themeSegActive)}
|
||||
onClick={() => onSetTheme(t.value)}
|
||||
>
|
||||
<span className={styles.navIcon}>{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import App from './App'
|
||||
// In the real Electron app this branch is skipped — preload provides window.api.
|
||||
if (import.meta.env.DEV && !window.api) {
|
||||
const MOCK_SETTINGS: Settings = {
|
||||
outputDir: 'C:\\Users\\you\\Downloads',
|
||||
videoDir: 'C:\\Users\\you\\Documents\\Video',
|
||||
audioDir: 'C:\\Users\\you\\Documents\\Audio',
|
||||
defaultKind: 'video',
|
||||
defaultVideoQuality: 'Best available',
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
@@ -44,6 +45,7 @@ if (import.meta.env.DEV && !window.api) {
|
||||
]
|
||||
|
||||
window.api = {
|
||||
getAppVersion: async () => '0.3.2-preview',
|
||||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||
probe: async (url: string) => {
|
||||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||||
@@ -121,11 +123,11 @@ if (import.meta.env.DEV && !window.api) {
|
||||
previewCommand: async (opts) => {
|
||||
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
||||
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
||||
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
|
||||
return {
|
||||
ok: true,
|
||||
command:
|
||||
`yt-dlp.exe -f "bv*+ba/b" -o "${MOCK_SETTINGS.outputDir}\\%(title)s.%(ext)s"` +
|
||||
`${extra} -- ${opts.url}`
|
||||
`yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
|
||||
}
|
||||
},
|
||||
updateYtdlp: async (channel) => {
|
||||
|
||||
@@ -247,7 +247,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
url: item.url,
|
||||
kind: item.kind,
|
||||
quality: item.quality,
|
||||
outputDir: useSettings.getState().outputDir || undefined,
|
||||
// No outputDir here: main routes each download into the user's per-kind
|
||||
// folder (Settings → Video/Audio folder), or the Documents\… default.
|
||||
formatId: item.formatId,
|
||||
formatHasAudio: item.formatHasAudio,
|
||||
options: item.options,
|
||||
|
||||
@@ -5,7 +5,8 @@ import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
|
||||
const FALLBACK: Settings = {
|
||||
outputDir: PREVIEW ? 'C:\\Users\\you\\Downloads' : '',
|
||||
videoDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Video' : '',
|
||||
audioDir: PREVIEW ? 'C:\\Users\\you\\Documents\\Audio' : '',
|
||||
defaultKind: 'video',
|
||||
defaultVideoQuality: 'Best available',
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
@@ -35,7 +36,10 @@ interface SettingsState extends Settings {
|
||||
/** true once persisted settings have loaded (always true in preview) */
|
||||
loaded: boolean
|
||||
update: (partial: Partial<Settings>) => void
|
||||
chooseOutputDir: () => void
|
||||
/** open the OS folder picker and store the result as the video or audio folder */
|
||||
chooseDir: (target: 'videoDir' | 'audioDir') => void
|
||||
/** clear a per-kind folder override, restoring its Documents\… default */
|
||||
clearDir: (target: 'videoDir' | 'audioDir') => void
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsState>((set, get) => ({
|
||||
@@ -47,12 +51,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
|
||||
},
|
||||
|
||||
chooseOutputDir: () => {
|
||||
chooseDir: (target) => {
|
||||
if (PREVIEW) return
|
||||
window.api.chooseFolder().then((dir) => {
|
||||
if (dir) get().update({ outputDir: dir })
|
||||
if (dir) get().update({ [target]: dir })
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
clearDir: (target) => get().update({ [target]: '' })
|
||||
}))
|
||||
|
||||
// Load persisted settings on startup.
|
||||
|
||||
+6
-2
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
export const IpcChannels = {
|
||||
/** the AeroFetch app version (package.json / app.getVersion) */
|
||||
appVersion: 'app:version',
|
||||
ytdlpVersion: 'ytdlp:version',
|
||||
probe: 'media:probe',
|
||||
downloadStart: 'download:start',
|
||||
@@ -355,8 +357,10 @@ export type DownloadEvent =
|
||||
|
||||
/** Persisted user settings (electron-store). */
|
||||
export interface Settings {
|
||||
/** absolute output directory (empty string resolves to the OS Downloads folder) */
|
||||
outputDir: string
|
||||
/** where video downloads are saved; empty string = the default Documents\Video folder */
|
||||
videoDir: string
|
||||
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
|
||||
audioDir: string
|
||||
defaultKind: MediaKind
|
||||
defaultVideoQuality: string
|
||||
defaultAudioQuality: string
|
||||
|
||||
Reference in New Issue
Block a user