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",
|
"name": "aerofetch",
|
||||||
"version": "0.2.0",
|
"version": "0.3.2",
|
||||||
"description": "A yt-dlp frontend for Windows",
|
"description": "A yt-dlp frontend for Windows",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "AeroFetch",
|
"author": "AeroFetch",
|
||||||
|
|||||||
+13
-11
@@ -1,7 +1,7 @@
|
|||||||
import { spawn, execFile, type ChildProcess } from 'child_process'
|
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { app, BrowserWindow, Notification, type WebContents } from 'electron'
|
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
||||||
import {
|
import {
|
||||||
getYtdlpPath,
|
getYtdlpPath,
|
||||||
getBinDir,
|
getBinDir,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
getFfprobePath,
|
getFfprobePath,
|
||||||
getSystem32Path
|
getSystem32Path
|
||||||
} from './binaries'
|
} from './binaries'
|
||||||
import { getSettings, getDownloadArchivePath } from './settings'
|
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
||||||
import { getCookiesFilePath } from './cookies'
|
import { getCookiesFilePath } from './cookies'
|
||||||
import { listTemplates } from './templates'
|
import { listTemplates } from './templates'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
@@ -175,16 +175,18 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
|
|||||||
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
||||||
export function buildCommand(opts: StartDownloadOptions): string[] {
|
export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||||
const settings = getSettings()
|
const settings = getSettings()
|
||||||
// A per-download outputDir override must clear the same safety check the
|
// Output dir resolution: a per-download override wins, then the user's explicit
|
||||||
// persisted setting does (absolute path only); a renderer-supplied override is
|
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
|
||||||
// otherwise untrusted — it dictates where downloaded files get written. An
|
// blank — the per-kind default (Documents\Video for video, Documents\Audio for audio).
|
||||||
// unsafe override is ignored in favour of the validated setting, then the OS
|
//
|
||||||
// Downloads folder. (audit F4)
|
// 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 override = opts.outputDir?.trim()
|
||||||
const outDir =
|
const safeOverride = override && isSafeOutputDir(override) ? override : ''
|
||||||
(override && isSafeOutputDir(override) ? override : '') ||
|
const perKindDir = (opts.kind === 'audio' ? settings.audioDir : settings.videoDir)?.trim()
|
||||||
settings.outputDir ||
|
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
|
||||||
app.getPath('downloads')
|
|
||||||
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
||||||
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
|
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
|
||||||
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||||
|
|||||||
+7
-1
@@ -13,7 +13,7 @@ import {
|
|||||||
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||||
import { probeMedia } from './probe'
|
import { probeMedia } from './probe'
|
||||||
import { startDownload, cancelDownload, previewCommand } from './download'
|
import { startDownload, cancelDownload, previewCommand } from './download'
|
||||||
import { getSettings, setSettings } from './settings'
|
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||||
import { setupPortableData } from './portable'
|
import { setupPortableData } from './portable'
|
||||||
@@ -179,6 +179,8 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerIpcHandlers(): void {
|
function registerIpcHandlers(): void {
|
||||||
|
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||||
@@ -331,6 +333,10 @@ if (isPrimaryInstance) {
|
|||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
electronApp.setAppUserModelId('com.aerofetch.app')
|
electronApp.setAppUserModelId('com.aerofetch.app')
|
||||||
|
|
||||||
|
// Create the default Documents\Video and Documents\Audio destinations so they
|
||||||
|
// exist from first launch (downloads are routed into them by kind).
|
||||||
|
ensureMediaDirs()
|
||||||
|
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|||||||
+40
-7
@@ -1,5 +1,6 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +17,10 @@ import {
|
|||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
const DEFAULTS: Settings = {
|
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',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -45,6 +49,31 @@ export function getDownloadArchivePath(): string {
|
|||||||
return join(app.getPath('userData'), 'download-archive.txt')
|
return join(app.getPath('userData'), 'download-archive.txt')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default per-kind download destination: video → Documents\Video,
|
||||||
|
* audio → Documents\Audio. Used when the user hasn't set an explicit output
|
||||||
|
* folder (Settings → Download folder), so downloads are sorted by type.
|
||||||
|
*/
|
||||||
|
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
|
||||||
|
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Documents\Video and Documents\Audio folders up front (called once
|
||||||
|
* at startup) so they exist the moment the app opens, not just after the first
|
||||||
|
* download. Best-effort: yt-dlp also creates the output dir at download time, so
|
||||||
|
* a failure here (read-only Documents, redirected folder) is non-fatal.
|
||||||
|
*/
|
||||||
|
export function ensureMediaDirs(): void {
|
||||||
|
for (const kind of ['video', 'audio'] as const) {
|
||||||
|
try {
|
||||||
|
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — the download path will be created on demand instead */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
||||||
// defaults for any missing/invalid field. Used both to migrate older settings
|
// defaults for any missing/invalid field. Used both to migrate older settings
|
||||||
// files (which predate downloadOptions) and to validate renderer writes.
|
// files (which predate downloadOptions) and to validate renderer writes.
|
||||||
@@ -100,10 +129,9 @@ export function getSettings(): Settings {
|
|||||||
// `set`, so only write when something actually changed — otherwise this churns
|
// `set`, so only write when something actually changed — otherwise this churns
|
||||||
// the settings file on every read. (audit P1)
|
// the settings file on every read. (audit P1)
|
||||||
const cur = s.store
|
const cur = s.store
|
||||||
if (!cur.outputDir) {
|
// videoDir/audioDir are intentionally left blank by default — an empty value
|
||||||
// Fill in the real Downloads path the first time, and persist it once.
|
// routes that kind into Documents\Video / Documents\Audio (see buildCommand).
|
||||||
s.set('outputDir', app.getPath('downloads'))
|
// Only an explicit user choice (Settings → folders) overrides that.
|
||||||
}
|
|
||||||
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
// Migrate settings files that predate downloadOptions (or hold a partial one),
|
||||||
// but only persist when sanitizing actually altered the stored value.
|
// but only persist when sanitizing actually altered the stored value.
|
||||||
const sanitized = sanitizeOptions(cur.downloadOptions)
|
const sanitized = sanitizeOptions(cur.downloadOptions)
|
||||||
@@ -187,8 +215,13 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
// group (it merges field changes locally before calling setSettings).
|
// group (it merges field changes locally before calling setSettings).
|
||||||
s.set('downloadOptions', sanitizeOptions(value))
|
s.set('downloadOptions', sanitizeOptions(value))
|
||||||
break
|
break
|
||||||
case 'outputDir':
|
case 'videoDir':
|
||||||
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
|
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
|
break
|
||||||
case 'filenameTemplate':
|
case 'filenameTemplate':
|
||||||
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
|
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.
|
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
||||||
const api = {
|
const api = {
|
||||||
|
/** AeroFetch's own version string (e.g. '0.3.1'). */
|
||||||
|
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
|
||||||
|
|
||||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
||||||
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
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 { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
||||||
import { Sidebar, type TabValue } from './components/Sidebar'
|
import { Sidebar, type TabValue } from './components/Sidebar'
|
||||||
import { DownloadsView } from './components/DownloadsView'
|
import { DownloadsView } from './components/DownloadsView'
|
||||||
@@ -35,6 +35,24 @@ function App(): React.JSX.Element {
|
|||||||
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
|
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
|
||||||
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
|
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
|
||||||
const [tab, setTab] = useState<TabValue>('downloads')
|
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
|
// Gate on `loaded` so a returning user's real settings never get clobbered
|
||||||
// by a one-frame flash of the (default-false) onboarding state.
|
// by a one-frame flash of the (default-false) onboarding state.
|
||||||
const loaded = useSettings((s) => s.loaded)
|
const loaded = useSettings((s) => s.loaded)
|
||||||
@@ -60,9 +78,12 @@ function App(): React.JSX.Element {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
tab={tab}
|
tab={tab}
|
||||||
onTabChange={setTab}
|
onTabChange={setTab}
|
||||||
|
theme={theme}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
followingSystem={theme === 'system'}
|
onSetTheme={(mode) => updateSettings({ theme: mode })}
|
||||||
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
|
version={version}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleCollapsed={toggleCollapsed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className={styles.content}>
|
<main className={styles.content}>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Switch,
|
|
||||||
Field,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
@@ -16,36 +14,19 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
ClipboardPasteRegular,
|
ClipboardPasteRegular,
|
||||||
FolderRegular,
|
|
||||||
SearchRegular,
|
SearchRegular,
|
||||||
VideoClipRegular,
|
VideoClipRegular,
|
||||||
MusicNote2Regular,
|
MusicNote2Regular,
|
||||||
ErrorCircleRegular,
|
ErrorCircleRegular,
|
||||||
LinkRegular,
|
LinkRegular,
|
||||||
DismissRegular,
|
DismissRegular,
|
||||||
OptionsRegular,
|
AppsListRegular
|
||||||
ChevronDownRegular,
|
|
||||||
ChevronUpRegular,
|
|
||||||
AppsListRegular,
|
|
||||||
CodeRegular,
|
|
||||||
EyeRegular,
|
|
||||||
EyeOffRegular,
|
|
||||||
CopyRegular
|
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type {
|
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
|
||||||
MediaInfo,
|
|
||||||
FormatOption,
|
|
||||||
PlaylistInfo,
|
|
||||||
DownloadOptions,
|
|
||||||
CommandPreviewResult,
|
|
||||||
StartDownloadOptions
|
|
||||||
} from '@shared/ipc'
|
|
||||||
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
import { useTemplates } from '../store/templates'
|
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
|
||||||
|
|
||||||
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
/** A quick heuristic for "this clipboard text is a link worth offering". */
|
||||||
function looksLikeUrl(text: string): boolean {
|
function looksLikeUrl(text: string): boolean {
|
||||||
@@ -184,33 +165,6 @@ const useStyles = makeStyles({
|
|||||||
spacer: {
|
spacer: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
},
|
},
|
||||||
// --- per-download options panel ---
|
|
||||||
optionsBar: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px'
|
|
||||||
},
|
|
||||||
optionsPanel: {
|
|
||||||
padding: '14px',
|
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
|
||||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
|
||||||
},
|
|
||||||
// --- command preview ---
|
|
||||||
previewPanel: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '14px',
|
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
|
||||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
|
||||||
},
|
|
||||||
previewCommandText: {
|
|
||||||
fontFamily: tokens.fontFamilyMonospace,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all'
|
|
||||||
},
|
|
||||||
// --- playlist selection ---
|
// --- playlist selection ---
|
||||||
plPanel: {
|
plPanel: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -258,63 +212,21 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
plItemMeta: {
|
plItemMeta: {
|
||||||
color: tokens.colorNeutralForeground3
|
color: tokens.colorNeutralForeground3
|
||||||
},
|
|
||||||
folder: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
color: tokens.colorNeutralForeground3,
|
|
||||||
maxWidth: '360px'
|
|
||||||
},
|
|
||||||
folderPath: {
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export function DownloadBar(): React.JSX.Element {
|
export function DownloadBar(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||||
const outputDir = useSettings((s) => s.outputDir)
|
|
||||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
|
||||||
const settingsLoaded = useSettings((s) => s.loaded)
|
const settingsLoaded = useSettings((s) => s.loaded)
|
||||||
const defaultKind = useSettings((s) => s.defaultKind)
|
const defaultKind = useSettings((s) => s.defaultKind)
|
||||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||||
const downloadOptions = useSettings((s) => s.downloadOptions)
|
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [kind, setKind] = useState<MediaKind>('video')
|
const [kind, setKind] = useState<MediaKind>('video')
|
||||||
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
||||||
|
|
||||||
// Per-download options override (null = use the persisted defaults).
|
|
||||||
const [override, setOverride] = useState<DownloadOptions | null>(null)
|
|
||||||
const [showOptions, setShowOptions] = useState(false)
|
|
||||||
const effectiveOptions = override ?? downloadOptions
|
|
||||||
|
|
||||||
// Private mode — sticky like an incognito tab; the download still runs and
|
|
||||||
// appears in the queue, but its completion is never recorded to history.
|
|
||||||
const [incognito, setIncognito] = useState(false)
|
|
||||||
|
|
||||||
// Per-download custom-command override: undefined = defer to the settings
|
|
||||||
// default (customCommandEnabled + defaultTemplateId); null = explicit
|
|
||||||
// "None" for just this download; a string = a chosen template id override.
|
|
||||||
const [templateOverride, setTemplateOverride] = useState<string | null | undefined>(undefined)
|
|
||||||
const [showCustomCommand, setShowCustomCommand] = useState(false)
|
|
||||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
|
||||||
const settingsDefaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
|
||||||
const templates = useTemplates((s) => s.templates)
|
|
||||||
const effectiveTemplateId =
|
|
||||||
templateOverride !== undefined
|
|
||||||
? templateOverride
|
|
||||||
: customCommandEnabled
|
|
||||||
? settingsDefaultTemplateId
|
|
||||||
: null
|
|
||||||
|
|
||||||
const [previewResult, setPreviewResult] = useState<CommandPreviewResult | null>(null)
|
|
||||||
const [previewing, setPreviewing] = useState(false)
|
|
||||||
|
|
||||||
// Apply the saved default format once, when persisted settings first arrive.
|
// Apply the saved default format once, when persisted settings first arrive.
|
||||||
const appliedDefaults = useRef(false)
|
const appliedDefaults = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -393,7 +305,6 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
setSuggestion(null)
|
setSuggestion(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const folder = outputDir || 'your Downloads folder'
|
|
||||||
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
||||||
const selectedFormat: FormatOption | undefined = usingFormats
|
const selectedFormat: FormatOption | undefined = usingFormats
|
||||||
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
|
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
|
||||||
@@ -468,61 +379,6 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolves the per-download custom-command override to the raw extra-args
|
|
||||||
// string sent to main; undefined defers to the settings default there.
|
|
||||||
function resolveExtraArgs(): string | undefined {
|
|
||||||
if (templateOverride === undefined) return undefined
|
|
||||||
if (templateOverride === null) return ''
|
|
||||||
return templates.find((t) => t.id === templateOverride)?.args ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// The StartDownloadOptions the current form state would produce — shared by
|
|
||||||
// the real download() call and the command-preview button so they can never
|
|
||||||
// drift apart.
|
|
||||||
function currentStartOptions(): StartDownloadOptions {
|
|
||||||
const base: StartDownloadOptions = {
|
|
||||||
id: 'preview',
|
|
||||||
url: url.trim(),
|
|
||||||
kind,
|
|
||||||
quality,
|
|
||||||
outputDir: outputDir || undefined,
|
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs()
|
|
||||||
}
|
|
||||||
if (usingFormats && selectedFormat) {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
kind: 'video',
|
|
||||||
quality: selectedFormat.label,
|
|
||||||
formatId: selectedFormat.id,
|
|
||||||
formatHasAudio: selectedFormat.hasAudio
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewCommand(): Promise<void> {
|
|
||||||
if (!url.trim() || previewing) return
|
|
||||||
setPreviewing(true)
|
|
||||||
setPreviewResult(null)
|
|
||||||
try {
|
|
||||||
setPreviewResult(await window.api.previewCommand(currentStartOptions()))
|
|
||||||
} catch (e) {
|
|
||||||
setPreviewResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
||||||
} finally {
|
|
||||||
setPreviewing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyPreview(): Promise<void> {
|
|
||||||
if (!previewResult?.command) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(previewResult.command)
|
|
||||||
} catch {
|
|
||||||
/* clipboard blocked — ignore in preview */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function download(): void {
|
function download(): void {
|
||||||
const trimmed = url.trim()
|
const trimmed = url.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
@@ -543,18 +399,10 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
id: selectedFormat.id,
|
id: selectedFormat.id,
|
||||||
hasAudio: selectedFormat.hasAudio,
|
hasAudio: selectedFormat.hasAudio,
|
||||||
label: selectedFormat.label
|
label: selectedFormat.label
|
||||||
},
|
}
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs(),
|
|
||||||
incognito
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
addFromUrl(trimmed, kind, quality, {
|
addFromUrl(trimmed, kind, quality, meta)
|
||||||
...meta,
|
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs(),
|
|
||||||
incognito
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setUrl('')
|
setUrl('')
|
||||||
@@ -568,10 +416,7 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
addFromUrl(e.url, kind, quality, {
|
addFromUrl(e.url, kind, quality, {
|
||||||
title: e.title,
|
title: e.title,
|
||||||
channel: e.uploader,
|
channel: e.uploader,
|
||||||
durationLabel: e.durationLabel,
|
durationLabel: e.durationLabel
|
||||||
options: override ?? undefined,
|
|
||||||
extraArgs: resolveExtraArgs(),
|
|
||||||
incognito
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setUrl('')
|
setUrl('')
|
||||||
@@ -769,148 +614,6 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
|
||||||
{incognito ? <EyeOffRegular /> : <EyeRegular />}
|
|
||||||
<Switch
|
|
||||||
checked={incognito}
|
|
||||||
onChange={(_, d) => setIncognito(d.checked)}
|
|
||||||
label={incognito ? 'Private — won’t be saved to history' : 'Private mode'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
icon={<OptionsRegular />}
|
|
||||||
iconPosition="before"
|
|
||||||
onClick={() => setShowOptions((v) => !v)}
|
|
||||||
>
|
|
||||||
Options {showOptions ? <ChevronUpRegular /> : <ChevronDownRegular />}
|
|
||||||
</Button>
|
|
||||||
{override && (
|
|
||||||
<>
|
|
||||||
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
|
|
||||||
<Button appearance="subtle" size="small" onClick={() => setOverride(null)}>
|
|
||||||
Reset to defaults
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showOptions && (
|
|
||||||
<div className={styles.optionsPanel}>
|
|
||||||
<DownloadOptionsForm value={effectiveOptions} onChange={(o) => setOverride(o)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
icon={<CodeRegular />}
|
|
||||||
iconPosition="before"
|
|
||||||
onClick={() => setShowCustomCommand((v) => !v)}
|
|
||||||
>
|
|
||||||
Custom command {showCustomCommand ? <ChevronUpRegular /> : <ChevronDownRegular />}
|
|
||||||
</Button>
|
|
||||||
{templateOverride !== undefined && (
|
|
||||||
<>
|
|
||||||
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
|
|
||||||
<Button appearance="subtle" size="small" onClick={() => setTemplateOverride(undefined)}>
|
|
||||||
Reset to default
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className={styles.spacer} />
|
|
||||||
<Hint label="Build and show the exact yt-dlp command line" placement="top" align="end">
|
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
size="small"
|
|
||||||
icon={previewing ? <Spinner size="tiny" /> : <EyeRegular />}
|
|
||||||
iconPosition="before"
|
|
||||||
onClick={previewCommand}
|
|
||||||
disabled={!url.trim() || previewing}
|
|
||||||
>
|
|
||||||
Preview command
|
|
||||||
</Button>
|
|
||||||
</Hint>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCustomCommand && (
|
|
||||||
<div className={styles.optionsPanel}>
|
|
||||||
{!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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,15 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
top: { bottom: 'calc(100% + 6px)' },
|
top: { bottom: 'calc(100% + 6px)' },
|
||||||
bottom: { top: '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 },
|
alignStart: { left: 0 },
|
||||||
alignEnd: { right: 0 }
|
alignEnd: { right: 0 }
|
||||||
})
|
})
|
||||||
|
|
||||||
interface HintProps {
|
interface HintProps {
|
||||||
label: string
|
label: string
|
||||||
placement?: 'top' | 'bottom'
|
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||||
align?: 'start' | 'end'
|
align?: 'start' | 'end'
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
@@ -56,8 +58,16 @@ export function Hint({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
className={mergeClasses(
|
className={mergeClasses(
|
||||||
styles.bubble,
|
styles.bubble,
|
||||||
placement === 'bottom' ? styles.bottom : styles.top,
|
placement === 'bottom'
|
||||||
align === 'end' ? styles.alignEnd : styles.alignStart
|
? 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}
|
{label}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Body1,
|
Body1,
|
||||||
Caption1,
|
Caption1,
|
||||||
Field,
|
Field,
|
||||||
Input,
|
|
||||||
Button,
|
Button,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -12,7 +11,6 @@ import {
|
|||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import {
|
import {
|
||||||
ArrowDownloadFilled,
|
ArrowDownloadFilled,
|
||||||
FolderRegular,
|
|
||||||
ClipboardPasteRegular,
|
ClipboardPasteRegular,
|
||||||
HistoryRegular,
|
HistoryRegular,
|
||||||
OptionsRegular,
|
OptionsRegular,
|
||||||
@@ -56,13 +54,8 @@ const useStyles = makeStyles({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '26px'
|
fontSize: '26px'
|
||||||
},
|
},
|
||||||
folderRow: {
|
folderNote: {
|
||||||
display: 'flex',
|
color: tokens.colorNeutralForeground3
|
||||||
gap: '8px',
|
|
||||||
alignItems: 'flex-end'
|
|
||||||
},
|
|
||||||
folderInput: {
|
|
||||||
flexGrow: 1
|
|
||||||
},
|
},
|
||||||
tips: {
|
tips: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -99,8 +92,6 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
|||||||
|
|
||||||
export function Onboarding(): React.JSX.Element {
|
export function Onboarding(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const outputDir = useSettings((s) => s.outputDir)
|
|
||||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
return (
|
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.
|
audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install.
|
||||||
</Body1>
|
</Body1>
|
||||||
|
|
||||||
<Field label="Download folder">
|
<Field label="Where downloads go">
|
||||||
<div className={styles.folderRow}>
|
<Caption1 className={styles.folderNote}>
|
||||||
<Input
|
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
|
||||||
readOnly
|
<strong>Documents\Audio</strong>. You can point each to a different folder any time
|
||||||
className={styles.folderInput}
|
in Settings.
|
||||||
value={outputDir}
|
</Caption1>
|
||||||
contentBefore={<FolderRegular />}
|
|
||||||
/>
|
|
||||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className={styles.tips}>
|
<div className={styles.tips}>
|
||||||
|
|||||||
@@ -170,8 +170,10 @@ const UPDATE_CHANNEL_OPTIONS = [
|
|||||||
export function SettingsView(): React.JSX.Element {
|
export function SettingsView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
const outputDir = useSettings((s) => s.outputDir)
|
const videoDir = useSettings((s) => s.videoDir)
|
||||||
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
|
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 defaultKind = useSettings((s) => s.defaultKind)
|
||||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||||
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
||||||
@@ -328,17 +330,49 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
<Subtitle2>Downloads</Subtitle2>
|
<Subtitle2>Downloads</Subtitle2>
|
||||||
</div>
|
</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}>
|
<div className={styles.folderRow}>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
className={styles.folderInput}
|
className={styles.folderInput}
|
||||||
value={outputDir}
|
value={videoDir}
|
||||||
|
placeholder="Documents\Video (default)"
|
||||||
contentBefore={<FolderRegular />}
|
contentBefore={<FolderRegular />}
|
||||||
/>
|
/>
|
||||||
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
|
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||||
Browse
|
Browse
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||||
Caption1,
|
|
||||||
Switch,
|
|
||||||
makeStyles,
|
|
||||||
mergeClasses,
|
|
||||||
tokens,
|
|
||||||
shorthands
|
|
||||||
} from '@fluentui/react-components'
|
|
||||||
import {
|
import {
|
||||||
ArrowDownloadFilled,
|
ArrowDownloadFilled,
|
||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
@@ -13,8 +6,13 @@ import {
|
|||||||
LibraryRegular,
|
LibraryRegular,
|
||||||
SettingsRegular,
|
SettingsRegular,
|
||||||
WeatherMoonRegular,
|
WeatherMoonRegular,
|
||||||
WeatherSunnyRegular
|
WeatherSunnyRegular,
|
||||||
|
DesktopRegular,
|
||||||
|
PanelLeftContractRegular,
|
||||||
|
PanelLeftExpandRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
|
import type { ThemeMode } from '@shared/ipc'
|
||||||
|
import { Hint } from './Hint'
|
||||||
|
|
||||||
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
|
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
|
||||||
|
|
||||||
@@ -27,13 +25,48 @@ const useStyles = makeStyles({
|
|||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '16px 12px',
|
padding: '16px 12px',
|
||||||
backgroundColor: tokens.colorNeutralBackground1,
|
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: {
|
brand: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '11px',
|
gap: '11px',
|
||||||
padding: '6px 10px 14px'
|
padding: '0 10px 14px'
|
||||||
|
},
|
||||||
|
brandCollapsed: {
|
||||||
|
padding: '0 0 12px',
|
||||||
|
justifyContent: 'center'
|
||||||
},
|
},
|
||||||
mark: {
|
mark: {
|
||||||
width: '36px',
|
width: '36px',
|
||||||
@@ -64,7 +97,8 @@ const useStyles = makeStyles({
|
|||||||
nav: {
|
nav: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '3px'
|
gap: '3px',
|
||||||
|
alignSelf: 'stretch'
|
||||||
},
|
},
|
||||||
navItem: {
|
navItem: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -84,6 +118,10 @@ const useStyles = makeStyles({
|
|||||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
navItemCollapsed: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '9px 0'
|
||||||
|
},
|
||||||
navItemActive: {
|
navItemActive: {
|
||||||
backgroundColor: tokens.colorBrandBackground2,
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
color: tokens.colorBrandForeground2,
|
color: tokens.colorBrandForeground2,
|
||||||
@@ -94,22 +132,46 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
navIcon: {
|
navIcon: {
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
flexShrink: 0
|
flexShrink: 0,
|
||||||
|
display: 'flex'
|
||||||
},
|
},
|
||||||
spacer: {
|
spacer: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
},
|
},
|
||||||
themeRow: {
|
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
|
||||||
|
themeGroup: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
width: '100%',
|
||||||
justifyContent: 'space-between',
|
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||||
padding: '8px 12px',
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
color: tokens.colorNeutralForeground3
|
overflow: 'hidden'
|
||||||
},
|
},
|
||||||
themeLabel: {
|
themeSeg: {
|
||||||
|
flex: 1,
|
||||||
|
appearance: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: tokens.colorNeutralForeground2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
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 /> }
|
{ 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 {
|
interface SidebarProps {
|
||||||
tab: TabValue
|
tab: TabValue
|
||||||
onTabChange: (t: TabValue) => void
|
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
|
isDark: boolean
|
||||||
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
|
onSetTheme: (mode: ThemeMode) => void
|
||||||
followingSystem: boolean
|
/** AeroFetch version string, e.g. '0.3.2' ('' until loaded) */
|
||||||
onToggleTheme: () => void
|
version: string
|
||||||
|
collapsed: boolean
|
||||||
|
onToggleCollapsed: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
tab,
|
tab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
|
theme,
|
||||||
isDark,
|
isDark,
|
||||||
followingSystem,
|
onSetTheme,
|
||||||
onToggleTheme
|
version,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapsed
|
||||||
}: SidebarProps): React.JSX.Element {
|
}: SidebarProps): React.JSX.Element {
|
||||||
const styles = useStyles()
|
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 (
|
return (
|
||||||
<nav className={styles.root}>
|
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
|
||||||
<div className={styles.brand}>
|
<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}>
|
<div className={styles.mark}>
|
||||||
<ArrowDownloadFilled />
|
<ArrowDownloadFilled />
|
||||||
</div>
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
<div className={styles.brandText}>
|
<div className={styles.brandText}>
|
||||||
<span className={styles.brandName}>AeroFetch</span>
|
<span className={styles.brandName}>AeroFetch</span>
|
||||||
<Caption1 className={styles.caption}>yt-dlp frontend</Caption1>
|
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.nav}>
|
<div className={styles.nav}>
|
||||||
{NAV.map((n) => {
|
{NAV.map((n) => {
|
||||||
const active = tab === n.value
|
const active = tab === n.value
|
||||||
return (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
key={n.value}
|
key={n.value}
|
||||||
type="button"
|
type="button"
|
||||||
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
|
className={mergeClasses(
|
||||||
|
styles.navItem,
|
||||||
|
collapsed && styles.navItemCollapsed,
|
||||||
|
active && styles.navItemActive
|
||||||
|
)}
|
||||||
style={
|
style={
|
||||||
active
|
active && !collapsed
|
||||||
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
|
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={() => onTabChange(n.value)}
|
onClick={() => onTabChange(n.value)}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
aria-label={n.label}
|
||||||
>
|
>
|
||||||
<span className={styles.navIcon}>{n.icon}</span>
|
<span className={styles.navIcon}>{n.icon}</span>
|
||||||
{n.label}
|
{!collapsed && n.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
return collapsed ? (
|
||||||
|
<Hint key={n.value} label={n.label} placement="right">
|
||||||
|
{btn}
|
||||||
|
</Hint>
|
||||||
|
) : (
|
||||||
|
btn
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
|
|
||||||
<div className={styles.themeRow}>
|
{collapsed ? (
|
||||||
<span className={styles.themeLabel}>
|
<Hint label={`Theme: ${themeLabel}`} placement="right">
|
||||||
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
|
<button
|
||||||
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
|
type="button"
|
||||||
</span>
|
className={styles.iconBtn}
|
||||||
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
|
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>
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import App from './App'
|
|||||||
// In the real Electron app this branch is skipped — preload provides window.api.
|
// In the real Electron app this branch is skipped — preload provides window.api.
|
||||||
if (import.meta.env.DEV && !window.api) {
|
if (import.meta.env.DEV && !window.api) {
|
||||||
const MOCK_SETTINGS: Settings = {
|
const MOCK_SETTINGS: Settings = {
|
||||||
outputDir: 'C:\\Users\\you\\Downloads',
|
videoDir: 'C:\\Users\\you\\Documents\\Video',
|
||||||
|
audioDir: 'C:\\Users\\you\\Documents\\Audio',
|
||||||
defaultKind: 'video',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -44,6 +45,7 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
window.api = {
|
window.api = {
|
||||||
|
getAppVersion: async () => '0.3.2-preview',
|
||||||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||||
probe: async (url: string) => {
|
probe: async (url: string) => {
|
||||||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||||||
@@ -121,11 +123,11 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
previewCommand: async (opts) => {
|
previewCommand: async (opts) => {
|
||||||
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
|
||||||
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
|
||||||
|
const dir = opts.kind === 'audio' ? MOCK_SETTINGS.audioDir : MOCK_SETTINGS.videoDir
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
command:
|
command:
|
||||||
`yt-dlp.exe -f "bv*+ba/b" -o "${MOCK_SETTINGS.outputDir}\\%(title)s.%(ext)s"` +
|
`yt-dlp.exe -f "bv*+ba/b" -o "${dir}\\%(title)s.%(ext)s"` + `${extra} -- ${opts.url}`
|
||||||
`${extra} -- ${opts.url}`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateYtdlp: async (channel) => {
|
updateYtdlp: async (channel) => {
|
||||||
|
|||||||
@@ -247,7 +247,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
url: item.url,
|
url: item.url,
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
quality: item.quality,
|
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,
|
formatId: item.formatId,
|
||||||
formatHasAudio: item.formatHasAudio,
|
formatHasAudio: item.formatHasAudio,
|
||||||
options: item.options,
|
options: item.options,
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
|
|||||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||||
|
|
||||||
const FALLBACK: Settings = {
|
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',
|
defaultKind: 'video',
|
||||||
defaultVideoQuality: 'Best available',
|
defaultVideoQuality: 'Best available',
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
defaultAudioQuality: 'Best (MP3)',
|
||||||
@@ -35,7 +36,10 @@ interface SettingsState extends Settings {
|
|||||||
/** true once persisted settings have loaded (always true in preview) */
|
/** true once persisted settings have loaded (always true in preview) */
|
||||||
loaded: boolean
|
loaded: boolean
|
||||||
update: (partial: Partial<Settings>) => void
|
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) => ({
|
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(() => {})
|
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
|
||||||
},
|
},
|
||||||
|
|
||||||
chooseOutputDir: () => {
|
chooseDir: (target) => {
|
||||||
if (PREVIEW) return
|
if (PREVIEW) return
|
||||||
window.api.chooseFolder().then((dir) => {
|
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.
|
// Load persisted settings on startup.
|
||||||
|
|||||||
+6
-2
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const IpcChannels = {
|
export const IpcChannels = {
|
||||||
|
/** the AeroFetch app version (package.json / app.getVersion) */
|
||||||
|
appVersion: 'app:version',
|
||||||
ytdlpVersion: 'ytdlp:version',
|
ytdlpVersion: 'ytdlp:version',
|
||||||
probe: 'media:probe',
|
probe: 'media:probe',
|
||||||
downloadStart: 'download:start',
|
downloadStart: 'download:start',
|
||||||
@@ -355,8 +357,10 @@ export type DownloadEvent =
|
|||||||
|
|
||||||
/** Persisted user settings (electron-store). */
|
/** Persisted user settings (electron-store). */
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
/** absolute output directory (empty string resolves to the OS Downloads folder) */
|
/** where video downloads are saved; empty string = the default Documents\Video folder */
|
||||||
outputDir: string
|
videoDir: string
|
||||||
|
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
|
||||||
|
audioDir: string
|
||||||
defaultKind: MediaKind
|
defaultKind: MediaKind
|
||||||
defaultVideoQuality: string
|
defaultVideoQuality: string
|
||||||
defaultAudioQuality: string
|
defaultAudioQuality: string
|
||||||
|
|||||||
Reference in New Issue
Block a user