diff --git a/package.json b/package.json index cdf0e4b..3f93f07 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/download.ts b/src/main/download.ts index ffa4431..c4fe548 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -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 // // - folders; an ordinary download uses the flat filenameTemplate. const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s' diff --git a/src/main/index.ts b/src/main/index.ts index 377b594..6305fe0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) }) diff --git a/src/main/settings.ts b/src/main/settings.ts index 8b4add0..f99a049 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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())) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 9628b65..aa93a80 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1645e4b..0be8f38 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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}> diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 3f2dfe8..a76f075 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -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> ) } diff --git a/src/renderer/src/components/Hint.tsx b/src/renderer/src/components/Hint.tsx index 57f99f3..767d084 100644 --- a/src/renderer/src/components/Hint.tsx +++ b/src/renderer/src/components/Hint.tsx @@ -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} diff --git a/src/renderer/src/components/Onboarding.tsx b/src/renderer/src/components/Onboarding.tsx index 519ac20..82697da 100644 --- a/src/renderer/src/components/Onboarding.tsx +++ b/src/renderer/src/components/Onboarding.tsx @@ -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}> diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 6558517..889d490 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -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> diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 9473a62..550d66f 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -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> ) } diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 524de57..18700db 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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) => { diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts index d9e800c..14ce7ca 100644 --- a/src/renderer/src/store/downloads.ts +++ b/src/renderer/src/store/downloads.ts @@ -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, diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 22a3ad5..bd5f490 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -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. diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5d95c38..bc88370 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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