Initial commit: AeroFetch — yt-dlp/YouTube downloader for Windows

Electron + React (Fluent UI) desktop frontend for yt-dlp:
- Download queue with live progress, concurrency cap, cancel/retry
- Format/quality picker via yt-dlp probe; audio extraction to MP3
- Settings + history persistence (electron-store / JSON)
- Clipboard link auto-detect; persisted light/dark theme
- NSIS + portable packaging (binaries bundled at build time, not in git)

UI: "Studio" sidebar layout with a toffee-brown theme (light + neutral dark).
Dropdowns use a native <select> and tooltips a CSS-only Hint, to avoid a
dev-machine GPU overlay flicker/blank issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 18:17:41 -04:00
commit 1a2270c95e
39 changed files with 11351 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "ui",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "ui"],
"port": 5174
}
]
}
+13
View File
@@ -0,0 +1,13 @@
node_modules
out
dist
*.log
.DS_Store
*.tsbuildinfo
# Local screen-capture frames (not source)
.frames/
# Bundled binaries are large and license-bound — keep them out of git.
# See resources/bin/README.md for how to obtain them.
resources/bin/*.exe
+49
View File
@@ -0,0 +1,49 @@
appId: com.aerofetch.app
productName: AeroFetch
copyright: yt-dlp frontend
directories:
buildResources: build
output: dist
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.eslintrc.js,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,package-lock.json}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json,tsconfig.tsbuildinfo}'
# yt-dlp.exe + ffmpeg.exe ship outside the asar archive so they can be spawned
# directly. They land in <install>/resources/bin, reachable via process.resourcesPath.
extraResources:
- from: resources/bin
to: bin
filter:
- '**/*'
win:
# Two artifacts:
# - nsis → regular installer (Start-menu shortcut + uninstaller). Per-user
# (installs to %LOCALAPPDATA%), so it needs NO admin rights.
# - portable → single self-contained .exe. No install, no admin; runs from a
# USB stick / Downloads folder on a locked-down public PC.
target:
- nsis
- portable
# Never request admin elevation.
requestedExecutionLevel: asInvoker
# icon: build/icon.ico # add before release
portable:
artifactName: ${productName}-${version}-portable.${ext}
# Fixed extraction dir name so repeated launches reuse it instead of re-extracting.
unpackDirName: AeroFetch
requestExecutionLevel: user
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: false
# Flip perMachine to true for an all-users install to Program Files (requires admin).
+31
View File
@@ -0,0 +1,31 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('src/shared')
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('src/shared')
}
}
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
},
plugins: [react()]
}
})
+7848
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "aerofetch",
"version": "0.1.0",
"description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js",
"author": "AeroFetch",
"homepage": "https://github.com/yt-dlp/yt-dlp",
"private": true,
"type": "module",
"scripts": {
"dev": "electron-vite dev",
"dev:watch": "electron-vite dev --watch",
"ui": "vite",
"ui:build": "vite build",
"ui:preview": "vite preview",
"build": "electron-vite build",
"build:win": "electron-vite build && electron-builder --win",
"start": "electron-vite preview",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330",
"electron-store": "^11.0.2",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"zustand": "^5.0.14"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/node": "^26.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"electron": "^42.4.1",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"typescript": "^6.0.3",
"vite": "^7.3.5"
}
}
+30
View File
@@ -0,0 +1,30 @@
# Bundled binaries
AeroFetch spawns these from the **main process** and bundles them via
electron-builder `extraResources` (they ship outside the asar archive). They are
**not** committed to git (see `.gitignore`).
Drop the two Windows executables here before running `npm run dev` or building:
```
resources/bin/
├── yt-dlp.exe
└── ffmpeg.exe
```
## yt-dlp.exe
- Download: https://github.com/yt-dlp/yt-dlp/releases/latest
- Grab `yt-dlp.exe`.
- Unlicense / public domain.
## ffmpeg.exe
- Use an **LGPL** build to keep licensing simple, e.g.
https://github.com/BtbN/FFmpeg-Builds/releases (pick a `*-lgpl-shared` or
`*-lgpl` win64 build) or https://www.gyan.dev/ffmpeg/builds/.
- Copy `ffmpeg.exe` here. Ship the LGPL license text alongside the installer
before release.
> Branding stays generic: AeroFetch is a "yt-dlp frontend." Keep upstream
> license texts with any distributed build.
+22
View File
@@ -0,0 +1,22 @@
import { app } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
/**
* Resolves the directory holding the bundled binaries (yt-dlp.exe, ffmpeg.exe).
*
* In dev they live in the repo at resources/bin/.
* In a packaged build, electron-builder's extraResources copies them to
* <resources>/bin (see electron-builder.yml), reachable via process.resourcesPath.
*/
export function getBinDir(): string {
return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin')
}
export function getYtdlpPath(): string {
return join(getBinDir(), 'yt-dlp.exe')
}
export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
}
+303
View File
@@ -0,0 +1,303 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { app, type WebContents } from 'electron'
import { getYtdlpPath, getBinDir } from './binaries'
import { getSettings } from './settings'
import {
IpcChannels,
BEST_FORMAT_ID,
type StartDownloadOptions,
type StartDownloadResult,
type DownloadEvent,
type DownloadMeta,
type DownloadProgress
} from '@shared/ipc'
interface ActiveDownload {
child: ChildProcess
canceled: boolean
}
const active = new Map<string, ActiveDownload>()
// --- Quality → yt-dlp selector mapping --------------------------------------
function videoFormat(quality: string): string {
switch (quality) {
case '1080p':
return 'bv*[height<=1080]+ba/b[height<=1080]'
case '720p':
return 'bv*[height<=720]+ba/b[height<=720]'
case '480p':
return 'bv*[height<=480]+ba/b[height<=480]'
case '360p':
return 'bv*[height<=360]+ba/b[height<=360]'
default:
return 'bv*+ba/b' // Best available
}
}
/**
* The video format selector. A specific probed format_id wins; otherwise fall
* back to the height-based preset. Video-only formats get +bestaudio so the
* merge produces a file with sound (with a video-only fallback if no audio).
*/
function videoSelector(opts: StartDownloadOptions): string {
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
return opts.formatHasAudio
? opts.formatId
: `${opts.formatId}+bestaudio/${opts.formatId}`
}
return videoFormat(opts.quality)
}
function audioQuality(quality: string): string {
switch (quality) {
case '320 kbps':
return '320K'
case '192 kbps':
return '192K'
case '128 kbps':
return '128K'
default:
return '0' // Best
}
}
// The progress line yt-dlp emits (one per --newline tick). Note the leading
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
// (it does NOT appear in the output). The literal `prog|` that follows is our
// own marker, so we can tell progress lines apart from the after-move filepath
// print on the same stdout stream.
const PROGRESS_TEMPLATE =
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
'%(progress.speed)s|%(progress.eta)s'
function buildArgs(opts: StartDownloadOptions, outputTemplate: string): string[] {
const args = [
'--newline',
'--no-color',
'--no-playlist',
// --print (below) implies --quiet, which would suppress progress; --progress
// forces the progress template to emit anyway.
'--progress',
'--ffmpeg-location',
getBinDir(),
'-o',
outputTemplate,
'--progress-template',
PROGRESS_TEMPLATE,
// Print the final path after post-processing/move so we can open it later.
'--print',
'after_move:path|%(filepath)s',
'--no-simulate'
]
if (opts.kind === 'audio') {
args.push(
'-x',
'--audio-format',
'mp3',
'--audio-quality',
audioQuality(opts.quality),
'--embed-thumbnail',
'--embed-metadata'
)
} else {
args.push('-f', videoSelector(opts), '--merge-output-format', 'mp4')
}
args.push(opts.url)
return args
}
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
function num(s?: string): number | undefined {
if (!s || s === 'NA') return undefined
const n = Number(s)
return Number.isFinite(n) ? n : undefined
}
export function fmtBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let v = bytes / 1024
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`
}
function fmtSpeed(bytesPerSec?: number): string | undefined {
if (bytesPerSec == null) return undefined
return `${fmtBytes(bytesPerSec)}/s`
}
function fmtEta(seconds?: number): string | undefined {
if (seconds == null) return undefined
const s = Math.max(0, Math.round(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
function parseProgress(rest: string): DownloadProgress | null {
const parts = rest.split('|')
if (parts.length < 6) return null
const [status, dl, total, totalEst, speed, eta] = parts
const downloaded = num(dl)
const totalBytes = num(total) ?? num(totalEst)
let progress = 0
if (totalBytes && downloaded != null) progress = Math.min(1, downloaded / totalBytes)
return {
status: status || 'downloading',
progress,
speed: fmtSpeed(num(speed)),
eta: fmtEta(num(eta)),
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined
}
}
/** Trim yt-dlp's noisy stderr down to the most useful trailing error line. */
function cleanError(stderr: string): string {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
}
function send(wc: WebContents, ev: DownloadEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
}
// --- Best-effort metadata probe (runs alongside the download) ---------------
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
return new Promise((resolve) => {
execFile(
ytdlp,
[
'--no-playlist',
'--no-warnings',
'--skip-download',
'--print',
'title',
'--print',
'uploader',
'--print',
'duration_string',
url
],
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
(err, stdout) => {
if (err) return resolve(null)
const [title, uploader, duration] = stdout.split('\n').map((l) => l.trim())
const clean = (v?: string): string | undefined =>
v && v !== 'NA' ? v : undefined
resolve({
title: clean(title),
channel: clean(uploader),
durationLabel: clean(duration)
})
}
)
})
}
// --- Public API -------------------------------------------------------------
export function startDownload(
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return {
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
}
}
if (active.has(opts.id)) {
return { ok: false, error: 'A download with this id is already running.' }
}
const settings = getSettings()
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
let child: ChildProcess
try {
child = spawn(ytdlp, buildArgs(opts, join(outDir, template)), { windowsHide: true })
} catch (e) {
return { ok: false, error: (e as Error).message }
}
const rec: ActiveDownload = { child, canceled: false }
active.set(opts.id, rec)
// Fetch title/channel/duration in parallel so the card fills in quickly.
probeMeta(ytdlp, opts.url).then((meta) => {
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
})
let stdoutBuf = ''
let stderrTail = ''
let filePath: string | undefined
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBuf += chunk.toString()
let nl: number
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line.startsWith('prog|')) {
const p = parseProgress(line.slice('prog|'.length))
if (p) send(wc, { type: 'progress', id: opts.id, progress: p })
} else if (line.startsWith('path|')) {
filePath = line.slice('path|'.length).trim()
}
}
})
child.stderr?.on('data', (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-4000)
})
child.on('error', (err) => {
active.delete(opts.id)
if (!rec.canceled) send(wc, { type: 'error', id: opts.id, error: err.message })
})
child.on('close', (code) => {
active.delete(opts.id)
if (rec.canceled) return // renderer already showed 'canceled' optimistically
if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath })
} else {
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg })
}
})
return { ok: true }
}
export function cancelDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.canceled = true
const pid = rec.child.pid
if (pid != null) {
// Kill the whole tree (/T) so the spawned ffmpeg child dies too.
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {})
} else {
rec.child.kill()
}
}
+48
View File
@@ -0,0 +1,48 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { HistoryEntry } from '@shared/ipc'
// Plain JSON in userData (portable build redirects userData next to the exe).
// Kept simple per the build plan; can migrate to better-sqlite3 later.
const MAX_ENTRIES = 500
function historyFile(): string {
return join(app.getPath('userData'), 'history.json')
}
export function listHistory(): HistoryEntry[] {
try {
if (!existsSync(historyFile())) return []
const data = JSON.parse(readFileSync(historyFile(), 'utf8'))
return Array.isArray(data) ? (data as HistoryEntry[]) : []
} catch {
return []
}
}
function save(entries: HistoryEntry[]): void {
try {
writeFileSync(historyFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted history */
}
}
export function addHistory(entry: HistoryEntry): HistoryEntry[] {
// De-dupe by id (a retry of the same item replaces its prior entry).
const entries = [entry, ...listHistory().filter((e) => e.id !== entry.id)]
save(entries)
return entries
}
export function removeHistory(id: string): HistoryEntry[] {
const entries = listHistory().filter((e) => e.id !== id)
save(entries)
return entries
}
export function clearHistory(): HistoryEntry[] {
save([])
return []
}
+133
View File
@@ -0,0 +1,133 @@
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import {
IpcChannels,
type StartDownloadOptions,
type Settings,
type HistoryEntry
} from '@shared/ipc'
import { getYtdlpVersion } from './ytdlp'
import { probeMedia } from './probe'
import { startDownload, cancelDownload } from './download'
import { getSettings, setSettings } from './settings'
import { listHistory, addHistory, removeHistory, clearHistory } from './history'
import { setupPortableData } from './portable'
// Force software compositing (GPU acceleration off). On this dev machine's old GeForce
// GT 625 — and plausibly on the old / locked-down public PCs this app targets —
// Chromium's GPU compositor renders blank: under hardware acceleration the WHOLE window
// paints white (popup overlays alone were blank earlier). Updating the NVIDIA driver to
// the last Fermi release did not fix it. Software compositing renders correctly and
// costs no meaningful performance for this lightweight UI, so it is the safe default for
// this app's target hardware. The only downside is a minor overlay-repaint flicker that
// appears on the most broken GPUs. The window backgroundColor is theme-matched (see
// createWindow); native window-occlusion tracking is disabled (a separate documented
// Windows blank/flicker cause). Must run before the app is ready.
app.disableHardwareAcceleration()
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
// Redirect user data next to the exe when possible (portable, no-admin). Must run
// before the app is ready and before any user path is read.
setupPortableData()
// Page background per theme — keep in sync with `pageBackground` in
// src/renderer/src/theme.ts. Set as the window's NATIVE background so the
// one-frame compositor repaint (when a tooltip/dropdown overlay first paints on
// Windows) shows the app's current color instead of a mismatched white flash.
const THEME_BACKGROUND = { light: '#f6f1ef', dark: '#161618' } as const
function createWindow(): void {
const mainWindow = new BrowserWindow({
width: 920,
height: 700,
show: false,
backgroundColor: THEME_BACKGROUND[getSettings().theme],
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.mjs'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
// Open external links in the OS browser, never in-app.
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) =>
startDownload(e.sender, opts)
)
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
ipcMain.handle(IpcChannels.chooseFolder, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
const res = await dialog.showOpenDialog(win!, {
properties: ['openDirectory', 'createDirectory']
})
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
})
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => shell.openPath(p))
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => shell.showItemInFolder(p))
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
const result = setSettings(partial)
// Keep the window's native background in sync with the theme so a compositor
// repaint never flashes a mismatched color behind an overlay.
if (partial.theme) {
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[partial.theme])
}
return result
})
ipcMain.handle(IpcChannels.historyList, () => listHistory())
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
+34
View File
@@ -0,0 +1,34 @@
import { app } from 'electron'
import { join } from 'path'
import { accessSync, mkdirSync, constants } from 'fs'
/**
* When running as the portable build, keep all per-user data (settings, history,
* caches — everything under `userData`) in an `AeroFetch-data` folder right next to
* the portable .exe. That makes the app fully self-contained on a USB stick and
* leaves nothing behind on a shared/library PC.
*
* Detection: electron-builder's `portable` target sets PORTABLE_EXECUTABLE_DIR at
* runtime to the folder the user launched the exe from. The installed (NSIS) build
* and dev don't set it, so they correctly use the default %APPDATA%\AeroFetch —
* which is per-user and never requires admin either.
*
* If the portable folder is read-only (locked-down machine, read-only media), we
* silently fall back to %APPDATA%. Either way the app needs no admin privileges.
*
* Must be called at startup, before the app is ready and before anything reads a
* user path (electron-store, history DB, etc.).
*/
export function setupPortableData(): void {
const portableExeDir = process.env.PORTABLE_EXECUTABLE_DIR
if (!portableExeDir) return
const portableDataDir = join(portableExeDir, 'AeroFetch-data')
try {
mkdirSync(portableDataDir, { recursive: true })
accessSync(portableDataDir, constants.W_OK)
app.setPath('userData', portableDataDir)
} catch {
// Read-only media — keep the default %APPDATA% userData (per-user, no admin).
}
}
+111
View File
@@ -0,0 +1,111 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import { fmtBytes } from './download'
import { BEST_FORMAT_ID, type ProbeResult, type MediaInfo, type FormatOption } from '@shared/ipc'
// The slice of `yt-dlp -J` output we actually read.
interface RawFormat {
format_id: string
ext?: string
vcodec?: string
acodec?: string
height?: number
fps?: number
tbr?: number
filesize?: number
filesize_approx?: number
}
interface RawInfo {
title?: string
uploader?: string
channel?: string
duration_string?: string
thumbnail?: string
formats?: RawFormat[]
}
/** Quality proxy for picking the best format at a given height. */
function score(f: RawFormat): number {
return (f.tbr ?? 0) + (f.ext === 'mp4' ? 0.5 : 0)
}
function toOption(f: RawFormat): FormatOption {
const size = f.filesize ?? f.filesize_approx
const fpsTag = f.fps && f.fps > 30 ? String(Math.round(f.fps)) : ''
const sizeLabel = size ? fmtBytes(size) : undefined
const parts = [`${f.height}p${fpsTag}`, f.ext, sizeLabel].filter(Boolean) as string[]
return {
id: f.format_id,
label: parts.join(' · '),
height: f.height,
ext: f.ext,
filesizeLabel: sizeLabel,
hasAudio: !!f.acodec && f.acodec !== 'none'
}
}
/** One option per distinct height (best at that height), best-first, plus auto. */
function buildVideoFormats(raw: RawFormat[]): FormatOption[] {
const bestByHeight = new Map<number, RawFormat>()
for (const f of raw) {
if (!f.vcodec || f.vcodec === 'none' || !f.height) continue
const cur = bestByHeight.get(f.height)
if (!cur || score(f) > score(cur)) bestByHeight.set(f.height, f)
}
const options = [...bestByHeight.values()]
.sort((a, b) => (b.height ?? 0) - (a.height ?? 0))
.map(toOption)
options.unshift({ id: BEST_FORMAT_ID, label: 'Best available', ext: 'mp4', hasAudio: true })
return options
}
function buildInfo(data: RawInfo): MediaInfo {
return {
title: data.title || 'Untitled',
channel: data.channel || data.uploader || undefined,
durationLabel: data.duration_string || undefined,
thumbnail: data.thumbnail || undefined,
formats: buildVideoFormats(data.formats ?? [])
}
}
function cleanError(stderr: string): string {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
}
/** Probe a URL with `yt-dlp -J` and return metadata + available video formats. */
export function probeMedia(url: string): Promise<ProbeResult> {
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return Promise.resolve({
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
})
}
return new Promise((resolve) => {
execFile(
ytdlp,
['-J', '--no-playlist', '--no-warnings', url],
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
resolve({ ok: false, error: cleanError(stderr) || err.message })
return
}
try {
resolve({ ok: true, info: buildInfo(JSON.parse(stdout) as RawInfo) })
} catch {
resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' })
}
}
)
})
}
+38
View File
@@ -0,0 +1,38 @@
import { app } from 'electron'
import Store from 'electron-store'
import type { Settings } from '@shared/ipc'
const DEFAULTS: Settings = {
outputDir: '', // resolved to the OS Downloads folder on first read
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true
}
// Constructed lazily — electron-store needs app paths, which exist only after
// the app is ready (all callers run post-ready).
let store: Store<Settings> | null = null
function getStore(): Store<Settings> {
if (!store) store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
return store
}
export function getSettings(): Settings {
const s = getStore()
// Fill in the real Downloads path the first time, and persist it.
if (!s.get('outputDir')) s.set('outputDir', app.getPath('downloads'))
return s.store
}
export function setSettings(partial: Partial<Settings>): Settings {
const s = getStore()
;(Object.keys(partial) as (keyof Settings)[]).forEach((key) => {
const value = partial[key]
if (value !== undefined) s.set(key, value)
})
return getSettings()
}
+29
View File
@@ -0,0 +1,29 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import type { YtdlpVersionResult } from '@shared/ipc'
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
* Proves the main-process → bundled-binary → IPC path end to end.
*/
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
return Promise.resolve({
ok: false,
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
})
}
return new Promise((resolve) => {
execFile(ytdlpPath, ['--version'], { windowsHide: true }, (err, stdout, stderr) => {
if (err) {
resolve({ ok: false, error: (stderr || err.message).trim() })
return
}
resolve({ ok: true, version: stdout.trim() })
})
})
}
+9
View File
@@ -0,0 +1,9 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type { Api } from './index'
declare global {
interface Window {
electron: ElectronAPI
api: Api
}
}
+75
View File
@@ -0,0 +1,75 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import {
IpcChannels,
type YtdlpVersionResult,
type ProbeResult,
type StartDownloadOptions,
type StartDownloadResult,
type DownloadEvent,
type Settings,
type HistoryEntry
} from '@shared/ipc'
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
const api = {
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
ipcRenderer.invoke(IpcChannels.downloadStart, opts),
cancelDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path),
showInFolder: (path: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.showInFolder, path),
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
getSettings: (): Promise<Settings> => ipcRenderer.invoke(IpcChannels.settingsGet),
setSettings: (partial: Partial<Settings>): Promise<Settings> =>
ipcRenderer.invoke(IpcChannels.settingsSet, partial),
listHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IpcChannels.historyList),
addHistory: (entry: HistoryEntry): Promise<HistoryEntry[]> =>
ipcRenderer.invoke(IpcChannels.historyAdd, entry),
removeHistory: (id: string): Promise<HistoryEntry[]> =>
ipcRenderer.invoke(IpcChannels.historyRemove, id),
clearHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IpcChannels.historyClear),
/** Subscribe to live download events. Returns an unsubscribe function. */
onDownloadEvent: (cb: (ev: DownloadEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev)
ipcRenderer.on(IpcChannels.downloadEvent, listener)
return () => ipcRenderer.removeListener(IpcChannels.downloadEvent, listener)
}
}
export type Api = typeof api
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (defined in index.d.ts)
window.electron = electronAPI
// @ts-ignore (defined in index.d.ts)
window.api = api
}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AeroFetch</title>
<!--
contextIsolation is on and the renderer never touches Node. Lock the CSP down:
scripts only from self; styles allow 'unsafe-inline' because Fluent UI (griffel)
injects runtime stylesheets.
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react'
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView'
import { HistoryView } from './components/HistoryView'
import { SettingsView } from './components/SettingsView'
import { lightTheme, darkTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
const useStyles = makeStyles({
provider: {
height: '100vh'
},
root: {
height: '100vh',
display: 'flex',
color: tokens.colorNeutralForeground1
},
content: {
flexGrow: 1,
minWidth: 0,
overflowY: 'auto',
padding: '24px 28px'
}
})
function App(): React.JSX.Element {
const styles = useStyles()
const theme = useSettings((s) => s.theme)
const updateSettings = useSettings((s) => s.update)
const isDark = theme === 'dark'
const [tab, setTab] = useState<TabValue>('downloads')
return (
<FluentProvider theme={isDark ? darkTheme : lightTheme} className={styles.provider}>
<div
className={styles.root}
style={{
backgroundColor: isDark ? pageBackground.dark : pageBackground.light,
colorScheme: isDark ? 'dark' : 'light'
}}
>
<Sidebar
tab={tab}
onTabChange={setTab}
isDark={isDark}
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
/>
<main className={styles.content}>
{tab === 'downloads' && <DownloadsView />}
{tab === 'history' && <HistoryView />}
{tab === 'settings' && <SettingsView />}
</main>
</div>
</FluentProvider>
)
}
export default App
+11
View File
@@ -0,0 +1,11 @@
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
#root {
height: 100vh;
}
+485
View File
@@ -0,0 +1,485 @@
import { useState, useEffect, useRef } from 'react'
import {
Input,
Button,
Spinner,
Text,
Caption1,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
ArrowDownloadRegular,
ClipboardPasteRegular,
FolderRegular,
SearchRegular,
VideoClipRegular,
MusicNote2Regular,
ErrorCircleRegular,
LinkRegular,
DismissRegular
} from '@fluentui/react-icons'
import type { MediaInfo, FormatOption } from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { useSettings } from '../store/settings'
import { Select } from './Select'
import { Hint } from './Hint'
/** A quick heuristic for "this clipboard text is a link worth offering". */
function looksLikeUrl(text: string): boolean {
const t = text.trim()
if (!/^https?:\/\//i.test(t)) return false
try {
new URL(t)
return true
} catch {
return false
}
}
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '16px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
boxShadow: tokens.shadow4
},
urlRow: {
display: 'flex',
gap: '8px'
},
suggestion: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
suggestionText: {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
url: {
flexGrow: 1
},
// --- metadata preview card ---
preview: {
display: 'flex',
gap: '12px',
padding: '10px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
previewThumb: {
flexShrink: 0,
width: '120px',
height: '68px',
objectFit: 'cover',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground1,
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
previewBody: {
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '2px'
},
previewTitle: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
previewMeta: {
color: tokens.colorNeutralForeground3
},
statusRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: tokens.colorNeutralForeground3
},
errorRow: {
color: tokens.colorPaletteRedForeground1
},
controls: {
display: 'flex',
alignItems: 'flex-end',
gap: '16px',
flexWrap: 'wrap'
},
control: {
display: 'flex',
flexDirection: 'column',
gap: '4px'
},
segmented: {
display: 'inline-flex',
width: 'fit-content',
border: `1px solid ${tokens.colorNeutralStroke1}`,
...shorthands.borderRadius(tokens.borderRadiusMedium),
overflow: 'hidden'
},
segment: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
padding: '7px 16px',
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
segmentActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
}
},
quality: {
minWidth: '220px'
},
spacer: {
flexGrow: 1
},
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 [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
useEffect(() => {
if (settingsLoaded && !appliedDefaults.current) {
appliedDefaults.current = true
setKind(defaultKind)
setQuality(defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality)
}
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality])
// Probe state for the format picker.
const [info, setInfo] = useState<MediaInfo | null>(null)
const [probing, setProbing] = useState(false)
const [probeError, setProbeError] = useState<string | null>(null)
const [formatId, setFormatId] = useState<string>('')
// Clipboard auto-detect: when the window gains focus and the clipboard holds a
// fresh link, offer it (without clobbering anything the user is already typing).
const [suggestion, setSuggestion] = useState<string | null>(null)
const urlRef = useRef('')
urlRef.current = url
const lastSeen = useRef<string | null>(null)
useEffect(() => {
let active = true
async function check(): Promise<void> {
if (!useSettings.getState().clipboardWatch || urlRef.current.trim()) return
let text = ''
try {
text = (await window.api.readClipboard()) ?? ''
} catch {
return
}
if (!active) return
text = text.trim()
if (looksLikeUrl(text) && text !== lastSeen.current) setSuggestion(text)
}
check()
window.addEventListener('focus', check)
return () => {
active = false
window.removeEventListener('focus', check)
}
}, [])
function acceptSuggestion(): void {
if (!suggestion) return
lastSeen.current = suggestion
onUrlChange(suggestion)
setSuggestion(null)
}
function dismissSuggestion(): void {
lastSeen.current = suggestion
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]
: undefined
function onUrlChange(next: string): void {
setUrl(next)
// Any probed info is stale once the URL changes.
if (info || probeError) {
setInfo(null)
setProbeError(null)
setFormatId('')
}
}
function onKindChange(next: MediaKind): void {
setKind(next)
setQuality(QUALITY_OPTIONS[next][0])
}
async function fetchFormats(): Promise<void> {
const trimmed = url.trim()
if (!trimmed || probing) return
setProbing(true)
setProbeError(null)
setInfo(null)
try {
const res = await window.api.probe(trimmed)
if (res.ok && res.info) {
setInfo(res.info)
setFormatId(res.info.formats[0]?.id ?? '')
} else {
setProbeError(res.error ?? 'Could not fetch video info.')
}
} catch (e) {
setProbeError(String(e))
} finally {
setProbing(false)
}
}
async function paste(): Promise<void> {
try {
const text = await navigator.clipboard.readText()
if (text) onUrlChange(text.trim())
} catch {
/* clipboard blocked — ignore in preview */
}
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
const meta = info
? {
title: info.title,
channel: info.channel,
durationLabel: info.durationLabel,
thumbnail: info.thumbnail
}
: {}
if (usingFormats && selectedFormat) {
addFromUrl(trimmed, 'video', selectedFormat.label, {
...meta,
format: {
id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
}
})
} else {
addFromUrl(trimmed, kind, quality, meta)
}
setUrl('')
setInfo(null)
setProbeError(null)
setFormatId('')
}
return (
<div className={styles.root}>
<div className={styles.urlRow}>
<Input
className={styles.url}
value={url}
onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
placeholder="Paste a video URL, then fetch formats…"
size="large"
contentBefore={<ArrowDownloadRegular />}
/>
<Hint label="Fetch available formats" placement="bottom" align="end">
<Button
size="large"
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
onClick={fetchFormats}
disabled={!url.trim() || probing}
aria-label="Fetch available formats"
/>
</Hint>
<Hint label="Paste from clipboard" placement="bottom" align="end">
<Button
size="large"
icon={<ClipboardPasteRegular />}
onClick={paste}
aria-label="Paste from clipboard"
/>
</Hint>
</div>
{suggestion && (
<div className={styles.suggestion}>
<LinkRegular />
<Caption1 className={styles.suggestionText}>Use copied link? {suggestion}</Caption1>
<Button size="small" appearance="primary" onClick={acceptSuggestion}>
Use
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={dismissSuggestion}
aria-label="Dismiss"
/>
</div>
)}
{probing && (
<div className={styles.statusRow}>
<Spinner size="tiny" />
<Caption1>Fetching available formats</Caption1>
</div>
)}
{probeError && (
<div className={mergeClasses(styles.statusRow, styles.errorRow)}>
<ErrorCircleRegular />
<Caption1>{probeError} you can still download with the presets below.</Caption1>
</div>
)}
{info && (
<div className={styles.preview}>
{info.thumbnail ? (
<img className={styles.previewThumb} src={info.thumbnail} alt="" />
) : (
<div className={styles.previewThumb}>
{kind === 'audio' ? (
<MusicNote2Regular fontSize={28} />
) : (
<VideoClipRegular fontSize={28} />
)}
</div>
)}
<div className={styles.previewBody}>
<Text className={styles.previewTitle}>{info.title}</Text>
<Caption1 className={styles.previewMeta}>
{[info.channel, info.durationLabel].filter(Boolean).join(' • ')}
</Caption1>
</div>
</div>
)}
<div className={styles.controls}>
<div className={styles.control}>
<Caption1>Format</Caption1>
<div className={styles.segmented} role="radiogroup" aria-label="Format">
{(['video', 'audio'] as MediaKind[]).map((k) => (
<button
key={k}
type="button"
role="radio"
aria-checked={kind === k}
className={mergeClasses(styles.segment, kind === k && styles.segmentActive)}
onClick={() => onKindChange(k)}
>
{k === 'video' ? 'Video' : 'Audio'}
</button>
))}
</div>
</div>
<div className={styles.control}>
<Caption1>{usingFormats ? 'Quality / format' : 'Quality'}</Caption1>
{usingFormats ? (
<Select
className={styles.quality}
aria-label="Quality / format"
value={selectedFormat?.id ?? ''}
options={info.formats.map((f) => ({ value: f.id, label: f.label }))}
onChange={setFormatId}
/>
) : (
<Select
className={styles.quality}
aria-label="Quality"
value={quality}
options={QUALITY_OPTIONS[kind].map((q) => ({ value: q, label: q }))}
onChange={setQuality}
/>
)}
</div>
<div className={styles.spacer} />
<Button
appearance="primary"
size="large"
icon={<ArrowDownloadRegular />}
onClick={download}
disabled={!url.trim()}
>
Download
</Button>
</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>
)
}
@@ -0,0 +1,76 @@
import {
Subtitle2,
Body1,
Button,
makeStyles,
tokens
} from '@fluentui/react-components'
import { ArrowDownloadRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '20px'
},
queueHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
list: {
display: 'flex',
flexDirection: 'column',
gap: '10px'
},
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '56px 16px',
color: tokens.colorNeutralForeground3,
textAlign: 'center'
}
})
export function DownloadsView(): React.JSX.Element {
const styles = useStyles()
const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished)
const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
)
return (
<div className={styles.root}>
<DownloadBar />
<div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2>
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
</Button>
)}
</div>
{items.length === 0 ? (
<div className={styles.empty}>
<ArrowDownloadRegular fontSize={40} />
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
</div>
) : (
<div className={styles.list}>
{items.map((item) => (
<QueueItem key={item.id} item={item} />
))}
</div>
)}
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
// An instant, theme-styled tooltip implemented entirely in CSS — shown via a
// `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child
// that lives inline in the DOM. We do NOT use Fluent's <Tooltip>, which renders
// a composited popover overlay that flickers on this machine's GPU/driver (see
// memory "aerofetch-gpu-flicker"). No portal, no JS repositioning, no animation,
// no transform/shadow — just a single static paint on hover, so nothing creates
// the overlay surface that flickers. Colors use Fluent's inverted-neutral tokens
// (dark bubble in light mode, light bubble in dark mode) so it matches the theme.
const useStyles = makeStyles({
wrap: {
position: 'relative',
display: 'inline-flex',
'&:hover [data-hint]': { visibility: 'visible' },
'&:focus-within [data-hint]': { visibility: 'visible' }
},
bubble: {
position: 'absolute',
zIndex: 1000,
visibility: 'hidden',
pointerEvents: 'none',
whiteSpace: 'nowrap',
padding: '4px 8px',
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase200,
color: tokens.colorNeutralForegroundInverted,
backgroundColor: tokens.colorNeutralBackgroundInverted,
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
top: { bottom: 'calc(100% + 6px)' },
bottom: { top: 'calc(100% + 6px)' },
alignStart: { left: 0 },
alignEnd: { right: 0 }
})
interface HintProps {
label: string
placement?: 'top' | 'bottom'
align?: 'start' | 'end'
children: React.ReactNode
}
export function Hint({
label,
placement = 'top',
align = 'start',
children
}: HintProps): React.JSX.Element {
const styles = useStyles()
return (
<span className={styles.wrap}>
{children}
<span
data-hint
aria-hidden
className={mergeClasses(
styles.bubble,
placement === 'bottom' ? styles.bottom : styles.top,
align === 'end' ? styles.alignEnd : styles.alignStart
)}
>
{label}
</span>
</span>
)
}
+200
View File
@@ -0,0 +1,200 @@
import {
Text,
Caption1,
Button,
Body1,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import {
OpenRegular,
FolderRegular,
DeleteRegular,
VideoClipRegular,
MusicNote2Regular,
HistoryRegular
} from '@fluentui/react-icons'
import type { HistoryEntry } from '@shared/ipc'
import { useHistory } from '../store/history'
import { useSettings } from '../store/settings'
import { thumbColors } from '../theme'
import { Hint } from './Hint'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '12px'
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
count: {
color: tokens.colorNeutralForeground3
},
list: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
row: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
thumb: {
flexShrink: 0,
width: '72px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
body: {
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column'
},
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
actions: {
display: 'flex',
gap: '4px',
flexShrink: 0
},
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '10px',
padding: '56px 16px',
textAlign: 'center'
},
emptyBadge: {
width: '56px',
height: '56px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
fontSize: '26px'
},
emptyHint: {
color: tokens.colorNeutralForeground3
}
})
function formatWhen(ts: number): string {
const d = new Date(ts)
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const dayMs = 1000 * 60 * 60 * 24
if (ts >= startOfToday) return `Today, ${time}`
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
export function HistoryView(): React.JSX.Element {
const styles = useStyles()
const isDark = useSettings((s) => s.theme === 'dark')
const tc = thumbColors[isDark ? 'dark' : 'light']
const entries = useHistory((s) => s.entries)
const openFile = useHistory((s) => s.openFile)
const showInFolder = useHistory((s) => s.showInFolder)
const remove = useHistory((s) => s.remove)
const clear = useHistory((s) => s.clear)
if (entries.length === 0) {
return (
<div className={styles.empty}>
<div
className={styles.emptyBadge}
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
>
<HistoryRegular />
</div>
<Body1>No downloads yet.</Body1>
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
</div>
)
}
return (
<div className={styles.root}>
<div className={styles.header}>
<Caption1 className={styles.count}>
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
</Caption1>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
Clear history
</Button>
</div>
<div className={styles.list}>
{entries.map((h: HistoryEntry) => {
const t = h.kind === 'audio' ? tc.audio : tc.video
return (
<div key={h.id} className={styles.row}>
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
{h.kind === 'audio' ? (
<MusicNote2Regular fontSize={22} />
) : (
<VideoClipRegular fontSize={22} />
)}
</div>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)].filter(Boolean).join(' • ')}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(h.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(h.id)}
aria-label="Show in folder"
/>
</Hint>
<Hint label="Remove from history" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(h.id)}
aria-label="Remove from history"
/>
</Hint>
</div>
</div>
)
})}
</div>
</div>
)
}
+236
View File
@@ -0,0 +1,236 @@
import {
Text,
Caption1,
Button,
ProgressBar,
Badge,
Spinner,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import {
DismissRegular,
DeleteRegular,
OpenRegular,
FolderRegular,
ArrowClockwiseRegular,
VideoClipRegular,
MusicNote2Regular,
CheckmarkCircleFilled,
ErrorCircleFilled
} from '@fluentui/react-icons'
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
import { useSettings } from '../store/settings'
import { thumbColors } from '../theme'
import { Hint } from './Hint'
const useStyles = makeStyles({
root: {
display: 'flex',
gap: '14px',
padding: '14px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
thumb: {
flexShrink: 0,
width: '108px',
height: '64px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
body: {
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: '4px'
},
titleRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
progressRow: {
display: 'flex',
alignItems: 'center',
gap: '10px',
marginTop: '4px'
},
progressBar: {
flexGrow: 1
},
stats: {
color: tokens.colorNeutralForeground3,
whiteSpace: 'nowrap'
},
error: {
color: tokens.colorPaletteRedForeground1
},
actions: {
display: 'flex',
alignItems: 'center',
gap: '4px',
flexShrink: 0
}
})
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
queued: { label: 'Queued', color: 'subtle' },
downloading: { label: 'Downloading', color: 'brand' },
completed: { label: 'Completed', color: 'success' },
error: { label: 'Failed', color: 'danger' },
canceled: { label: 'Canceled', color: 'warning' }
}
function pct(progress: number): string {
return `${Math.round(progress * 100)}%`
}
export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
const styles = useStyles()
const isDark = useSettings((s) => s.theme === 'dark')
const thumb = thumbColors[isDark ? 'dark' : 'light'][item.kind === 'audio' ? 'audio' : 'video']
const cancel = useDownloads((s) => s.cancel)
const remove = useDownloads((s) => s.remove)
const retry = useDownloads((s) => s.retry)
const openFile = useDownloads((s) => s.openFile)
const showInFolder = useDownloads((s) => s.showInFolder)
const badge = STATUS_BADGE[item.status]
const active = item.status === 'downloading' || item.status === 'queued'
const metaParts = [
item.channel,
item.durationLabel,
item.quality,
item.kind === 'audio' ? 'Audio' : 'Video',
item.sizeLabel
].filter(Boolean)
return (
<div className={styles.root}>
<div className={styles.thumb} style={{ backgroundColor: thumb.bg, color: thumb.fg }}>
{item.kind === 'audio' ? (
<MusicNote2Regular fontSize={28} />
) : (
<VideoClipRegular fontSize={28} />
)}
</div>
<div className={styles.body}>
<div className={styles.titleRow}>
{item.status === 'completed' && (
<CheckmarkCircleFilled
fontSize={16}
style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }}
/>
)}
{item.status === 'error' && (
<ErrorCircleFilled
fontSize={16}
style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }}
/>
)}
<Text className={styles.title}>{item.title}</Text>
<Badge appearance="tint" color={badge.color}>
{badge.label}
</Badge>
</div>
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
{item.status === 'downloading' && (
<div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
<Caption1 className={styles.stats}>
{pct(item.progress)}
{item.speed ? `${item.speed}` : ''}
{item.eta ? `${item.eta} left` : ''}
</Caption1>
</div>
)}
{item.status === 'queued' && (
<div className={styles.progressRow}>
<Spinner size="extra-tiny" />
<Caption1 className={styles.stats}>Waiting to start</Caption1>
</div>
)}
{item.status === 'error' && item.error && (
<Caption1 className={styles.error}>{item.error}</Caption1>
)}
</div>
<div className={styles.actions}>
{active && (
<Hint label="Cancel" placement="top" align="end">
<Button
appearance="subtle"
icon={<DismissRegular />}
onClick={() => cancel(item.id)}
aria-label="Cancel"
/>
</Hint>
)}
{item.status === 'completed' && (
<>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(item.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(item.id)}
aria-label="Show in folder"
/>
</Hint>
</>
)}
{(item.status === 'error' || item.status === 'canceled') && (
<Hint label="Retry" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={() => retry(item.id)}
aria-label="Retry"
/>
</Hint>
)}
{!active && (
<Hint label="Remove" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(item.id)}
aria-label="Remove"
/>
</Hint>
)}
</div>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
// A native <select> styled to match Fluent inputs. We use this instead of
// Fluent's <Dropdown> because Fluent's portal/popover menu is a composited
// overlay in the WebContents, and on this dev machine's GPU/driver that overlay
// paint blanks the whole window (same family as the documented flicker —
// hardware acceleration is already disabled; see memory "aerofetch-gpu-flicker").
// The native <select> popup is drawn by the OS, so it can't trigger the blank.
// The popup's light/dark styling follows the `color-scheme` set on the app root
// in App.tsx.
const useStyles = makeStyles({
select: {
width: '100%',
height: '32px',
padding: '0 8px',
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
color: tokens.colorNeutralForeground1,
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke1}`,
...shorthands.borderRadius(tokens.borderRadiusMedium),
cursor: 'pointer',
':hover': {
borderColor: tokens.colorNeutralStroke1Hover
},
':focus-visible': {
outline: 'none',
borderColor: tokens.colorCompoundBrandStroke
}
}
})
export interface SelectOption {
value: string
label: string
}
interface SelectProps {
value: string
options: SelectOption[]
onChange: (value: string) => void
className?: string
'aria-label'?: string
}
export function Select({
value,
options,
onChange,
className,
'aria-label': ariaLabel
}: SelectProps): React.JSX.Element {
const styles = useStyles()
return (
<select
className={mergeClasses(styles.select, className)}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={ariaLabel}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)
}
@@ -0,0 +1,216 @@
import { useState } from 'react'
import {
Field,
Input,
SpinButton,
Switch,
Button,
Card,
Subtitle2,
Caption1,
Spinner,
Text,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import {
FolderRegular,
ArrowDownloadRegular,
DocumentRegular,
InfoRegular
} from '@fluentui/react-icons'
import type { YtdlpVersionResult, MediaKind } from '@shared/ipc'
import { useSettings } from '../store/settings'
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
import { Select } from './Select'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
maxWidth: '640px'
},
card: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '20px',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
},
sectionHeader: {
display: 'flex',
alignItems: 'center',
gap: '10px'
},
sectionIcon: {
fontSize: '20px',
color: tokens.colorCompoundBrandForeground1
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
},
hint: {
color: tokens.colorNeutralForeground3
}
})
// Combined "Type — Quality" options for the default-format dropdown.
const FORMAT_OPTIONS = [
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video — ${q}` })),
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` }))
]
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const maxConcurrent = useSettings((s) => s.maxConcurrent)
const filenameTemplate = useSettings((s) => s.filenameTemplate)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const update = useSettings((s) => s.update)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
const formatValue = `${defaultKind}|${formatQuality}`
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
function onFormatSelect(value: string): void {
const [kind, quality] = value.split('|') as [MediaKind, string]
update(
kind === 'audio'
? { defaultKind: 'audio', defaultAudioQuality: quality }
: { defaultKind: 'video', defaultVideoQuality: quality }
)
}
async function checkVersion(): Promise<void> {
setChecking(true)
setVersion(null)
try {
setVersion(await window.api.getYtdlpVersion())
} catch (e) {
setVersion({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setChecking(false)
}
}
return (
<div className={styles.root}>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} />
<Subtitle2>Downloads</Subtitle2>
</div>
<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>
<Field label="Default format">
<Select
aria-label="Default format"
value={formatValue}
options={FORMAT_OPTIONS}
onChange={onFormatSelect}
/>
</Field>
<Field
label="Maximum simultaneous downloads"
hint="How many downloads run at once. 23 is a good balance."
>
<SpinButton
value={maxConcurrent}
min={1}
max={5}
onChange={(_, d) => {
const v = d.value ?? Number(d.displayValue)
if (typeof v === 'number' && Number.isFinite(v)) {
update({ maxConcurrent: Math.min(5, Math.max(1, Math.round(v))) })
// Raising the cap should start queued items right away.
useDownloads.getState().pump()
}
}}
/>
</Field>
<Field
label="Detect links from clipboard"
hint="When AeroFetch gains focus, offer a video link you've just copied."
>
<Switch
checked={clipboardWatch}
onChange={(_, d) => update({ clipboardWatch: d.checked })}
label={clipboardWatch ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} />
<Subtitle2>Filenames</Subtitle2>
</div>
<Field
label="Filename template"
hint="yt-dlp output template. Tokens like %(title)s and %(ext)s are filled in per video."
>
<Input
value={filenameTemplate}
onChange={(_, d) => update({ filenameTemplate: d.value })}
/>
</Field>
<Caption1 className={styles.hint}>
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
</Caption1>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
<Subtitle2>About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles yt-dlp and ffmpeg.
</Caption1>
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}>
Check yt-dlp version
</Button>
{checking && <Spinner size="tiny" />}
</div>
{version?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
)}
{version && !version.ok && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{version.error}
</Caption1>
)}
</Card>
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
import {
Caption1,
Switch,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
ArrowDownloadRegular,
HistoryRegular,
SettingsRegular,
WeatherMoonRegular,
WeatherSunnyRegular
} from '@fluentui/react-icons'
export type TabValue = 'downloads' | 'history' | 'settings'
const useStyles = makeStyles({
root: {
width: '212px',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: '4px',
padding: '16px 12px',
backgroundColor: tokens.colorNeutralBackground1,
borderRight: `1px solid ${tokens.colorNeutralStroke2}`
},
brand: {
display: 'flex',
alignItems: 'center',
gap: '11px',
padding: '6px 10px 14px'
},
mark: {
width: '36px',
height: '36px',
flexShrink: 0,
borderRadius: tokens.borderRadiusLarge,
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px'
},
brandText: {
display: 'flex',
flexDirection: 'column',
minWidth: 0
},
brandName: {
fontSize: tokens.fontSizeBase400,
fontWeight: tokens.fontWeightSemibold,
lineHeight: tokens.lineHeightBase400,
color: tokens.colorNeutralForeground1
},
caption: {
color: tokens.colorNeutralForeground3
},
nav: {
display: 'flex',
flexDirection: 'column',
gap: '3px'
},
navItem: {
display: 'flex',
alignItems: 'center',
gap: '11px',
width: '100%',
padding: '9px 12px',
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
navItemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackground2
}
},
navIcon: {
fontSize: '18px',
flexShrink: 0
},
spacer: {
flexGrow: 1
},
themeRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
color: tokens.colorNeutralForeground3
},
themeLabel: {
display: 'flex',
alignItems: 'center',
gap: '9px'
}
})
const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
]
interface SidebarProps {
tab: TabValue
onTabChange: (t: TabValue) => void
isDark: boolean
onToggleTheme: () => void
}
export function Sidebar({
tab,
onTabChange,
isDark,
onToggleTheme
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
return (
<nav className={styles.root}>
<div className={styles.brand}>
<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>
</div>
<div className={styles.nav}>
{NAV.map((n) => {
const active = tab === n.value
return (
<button
key={n.value}
type="button"
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
style={
active
? {
backgroundColor: isDark ? '#3b2f24' : '#efe5df',
boxShadow: `inset 3px 0 0 0 ${isDark ? '#c7ac9e' : '#825e4a'}`
}
: undefined
}
onClick={() => onTabChange(n.value)}
aria-current={active ? 'page' : undefined}
>
<span className={styles.navIcon}>{n.icon}</span>
{n.label}
</button>
)
})}
</div>
<div className={styles.spacer} />
<div className={styles.themeRow}>
<span className={styles.themeLabel}>
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
{isDark ? 'Dark' : 'Light'}
</span>
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
</div>
</nav>
)
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+73
View File
@@ -0,0 +1,73 @@
import './assets/base.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
// In the standalone UI preview (browser, no Electron preload) window.api isn't
// injected. Stub the full surface so the UI renders and stays interactive during
// design work. The store detects preview mode (no window.electron) and drives a
// fake progress ticker instead of calling these, so most are inert no-ops.
// In the real Electron app this branch is skipped — preload provides window.api.
if (import.meta.env.DEV && !window.api) {
window.api = {
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
return {
ok: true,
info: {
title: 'Building a Desktop App with Electron — Full Course',
channel: 'DevChannel',
durationLabel: '1:42:08',
thumbnail: undefined,
formats: [
{ id: '__best__', label: 'Best available', ext: 'mp4', hasAudio: true },
{ id: '299', label: '1080p60 · mp4 · 248 MB', height: 1080, ext: 'mp4', filesizeLabel: '248 MB', hasAudio: false },
{ id: '136', label: '720p · mp4 · 124 MB', height: 720, ext: 'mp4', filesizeLabel: '124 MB', hasAudio: false },
{ id: '135', label: '480p · mp4 · 72 MB', height: 480, ext: 'mp4', filesizeLabel: '72 MB', hasAudio: false },
{ id: '134', label: '360p · mp4 · 38 MB', height: 360, ext: 'mp4', filesizeLabel: '38 MB', hasAudio: false }
]
}
}
},
startDownload: async () => ({ ok: true }),
cancelDownload: async () => {},
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
chooseFolder: async () => null,
openPath: async () => '',
showInFolder: async () => {},
readClipboard: async () => '',
getSettings: async () => ({
outputDir: 'C:\\Users\\you\\Downloads',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true
}),
setSettings: async (partial) => ({
outputDir: 'C:\\Users\\you\\Downloads',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true,
...partial
}),
listHistory: async () => [],
addHistory: async () => [],
removeHistory: async () => [],
clearHistory: async () => [],
onDownloadEvent: () => () => {}
}
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+396
View File
@@ -0,0 +1,396 @@
import { create } from 'zustand'
import type { DownloadEvent } from '@shared/ipc'
import { useSettings } from './settings'
import { useHistory } from './history'
export type MediaKind = 'video' | 'audio'
export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'error' | 'canceled'
/** An exact probed format chosen for a download (omitted for the preset path). */
export interface ChosenFormat {
id: string
hasAudio: boolean
label: string
}
export interface DownloadItem {
id: string
url: string
title: string
channel?: string
durationLabel?: string
thumbnail?: string
kind: MediaKind
quality: string
status: DownloadStatus
/** 0..1 */
progress: number
speed?: string
eta?: string
sizeLabel?: string
filePath?: string
error?: string
/** exact format carried so retry re-downloads the same thing */
formatId?: string
formatHasAudio?: boolean
}
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
video: ['Best available', '1080p', '720p', '480p', '360p'],
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
}
// True when running in the standalone browser preview (no Electron preload).
// The real preload injects window.electron; the browser stub does not.
const PREVIEW = typeof window === 'undefined' || !window.electron
interface DownloadState {
items: DownloadItem[]
addFromUrl: (
url: string,
kind: MediaKind,
quality: string,
opts?: { format?: ChosenFormat; title?: string; channel?: string; durationLabel?: string; thumbnail?: string }
) => void
retry: (id: string) => void
cancel: (id: string) => void
remove: (id: string) => void
clearFinished: () => void
openFile: (id: string) => void
showInFolder: (id: string) => void
/** promote queued items into free download slots (respects MAX_CONCURRENT) */
pump: () => void
/** internal: apply a main-process download event */
applyEvent: (ev: DownloadEvent) => void
}
let idCounter = 0
function newId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `item-${++idCounter}`
}
function titleFromUrl(url: string): string {
try {
const u = new URL(url)
const v = u.searchParams.get('v')
if (v) return `YouTube video (${v})`
if (u.hostname) return `${u.hostname} download`
} catch {
/* not a URL */
}
return 'New download'
}
function fmtEta(seconds: number): string {
const s = Math.max(0, Math.round(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
// --- Mock seed data so every visual state is visible in the UI preview ------
const seed: DownloadItem[] = PREVIEW
? [
{
id: newId(),
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
title: 'Building a Desktop App with Electron — Full Course',
channel: 'DevChannel',
durationLabel: '1:42:08',
kind: 'video',
quality: '1080p',
status: 'downloading',
progress: 0.42,
speed: '4.7 MB/s',
eta: '1:12',
sizeLabel: '512 MB'
},
{
id: newId(),
url: 'https://youtube.com/watch?v=abcd1234',
title: 'Lo-fi beats to code to (1 hour mix)',
channel: 'ChillStudio',
durationLabel: '1:00:21',
kind: 'audio',
quality: 'Best (MP3)',
status: 'queued',
progress: 0
},
{
id: newId(),
url: 'https://youtube.com/watch?v=zzz9999',
title: 'How yt-dlp Works Under the Hood',
channel: 'Open Source Weekly',
durationLabel: '14:03',
kind: 'video',
quality: '720p',
status: 'completed',
progress: 1,
sizeLabel: '184 MB',
filePath: 'C:\\Users\\you\\Downloads\\How yt-dlp Works Under the Hood.mp4'
},
{
id: newId(),
url: 'https://youtube.com/watch?v=err0r',
title: 'Private video',
channel: undefined,
kind: 'video',
quality: 'Best available',
status: 'error',
progress: 0,
error: 'Video unavailable: this video is private.'
}
]
: []
// UI-preview only: animate fake progress so the queue feels alive without yt-dlp.
function startFakeTicker(
id: string,
get: () => DownloadState,
set: (fn: (s: DownloadState) => Partial<DownloadState>) => void
): void {
const timer = setInterval(() => {
const cur = get().items.find((i) => i.id === id)
if (!cur || cur.status !== 'downloading') {
clearInterval(timer)
return
}
const next = Math.min(1, cur.progress + 0.06 + Math.random() * 0.04)
const done = next >= 1
set((s) => ({
items: s.items.map((i) =>
i.id === id
? {
...i,
progress: next,
channel: 'Sample Channel',
durationLabel: '12:34',
sizeLabel: '96.4 MB',
speed: done ? undefined : `${(2 + Math.random() * 5).toFixed(1)} MB/s`,
eta: done ? undefined : fmtEta((1 - next) * 90),
status: done ? 'completed' : 'downloading',
filePath: done ? `C:\\Users\\you\\Downloads\\${cur.title}.mp4` : undefined
}
: i
)
}))
if (done) {
clearInterval(timer)
const finished = get().items.find((i) => i.id === id)
if (finished) {
useHistory.getState().add({
id: finished.id,
title: finished.title,
channel: finished.channel,
url: finished.url,
kind: finished.kind,
quality: finished.quality,
filePath: finished.filePath,
sizeLabel: finished.sizeLabel,
thumbnail: finished.thumbnail,
completedAt: Date.now()
})
}
get().pump() // a slot just freed — promote the next queued item
}
}, 650)
}
export const useDownloads = create<DownloadState>((set, get) => {
function markError(id: string, error?: string): void {
set((s) => ({
items: s.items.map((i) => (i.id === id ? { ...i, status: 'error', error } : i))
}))
pump() // a slot freed
}
// Actually start an item that pump() has just moved to 'downloading'.
function launchItem(item: DownloadItem): void {
if (PREVIEW) {
startFakeTicker(item.id, get, set)
return
}
window.api
.startDownload({
id: item.id,
url: item.url,
kind: item.kind,
quality: item.quality,
outputDir: useSettings.getState().outputDir || undefined,
formatId: item.formatId,
formatHasAudio: item.formatHasAudio
})
.then((res) => {
if (!res.ok) markError(item.id, res.error)
})
.catch((e: unknown) => markError(item.id, String(e)))
}
// Promote queued items (oldest first) into free slots, then launch them.
function pump(): void {
const items = get().items
const running = items.filter((i) => i.status === 'downloading').length
const slots = useSettings.getState().maxConcurrent - running
if (slots <= 0) return
// items are newest-first, so reverse the queued list to get oldest-first.
const toStart = items
.filter((i) => i.status === 'queued')
.reverse()
.slice(0, slots)
if (toStart.length === 0) return
const ids = new Set(toStart.map((i) => i.id))
set((s) => ({
items: s.items.map((i) => (ids.has(i.id) ? { ...i, status: 'downloading' } : i))
}))
for (const item of toStart) launchItem({ ...item, status: 'downloading' })
}
return {
items: seed,
pump,
addFromUrl: (url, kind, quality, opts) => {
const id = newId()
const item: DownloadItem = {
id,
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? 'Resolving…',
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
quality,
status: 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio
}
set((s) => ({ items: [item, ...s.items] }))
pump()
},
retry: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
: i
)
}))
pump()
},
cancel: (id) => {
const cur = get().items.find((i) => i.id === id)
if (!cur) return
// Only running items have a live process to kill; queued ones never started.
if (!PREVIEW && cur.status === 'downloading') window.api.cancelDownload(id).catch(() => {})
set((s) => ({
items: s.items.map((i) =>
i.id === id && (i.status === 'downloading' || i.status === 'queued')
? { ...i, status: 'canceled', speed: undefined, eta: undefined }
: i
)
}))
pump()
},
remove: (id) => {
set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
pump()
},
clearFinished: () =>
set((s) => ({
items: s.items.filter((i) => i.status === 'downloading' || i.status === 'queued')
})),
openFile: (id) => {
const item = get().items.find((i) => i.id === id)
if (!PREVIEW && item?.filePath) window.api.openPath(item.filePath)
},
showInFolder: (id) => {
const item = get().items.find((i) => i.id === id)
if (!PREVIEW && item?.filePath) window.api.showInFolder(item.filePath)
},
applyEvent: (ev) => {
set((s) => ({
items: s.items.map((i) => {
if (i.id !== ev.id) return i
switch (ev.type) {
case 'meta':
return {
...i,
title: ev.meta.title ?? i.title,
channel: ev.meta.channel ?? i.channel,
durationLabel: ev.meta.durationLabel ?? i.durationLabel
}
case 'progress':
// Ignore late events once the item has left the active state.
if (i.status !== 'downloading' && i.status !== 'queued') return i
return {
...i,
status: 'downloading',
progress: ev.progress.progress,
speed: ev.progress.speed,
eta: ev.progress.eta,
sizeLabel: ev.progress.sizeLabel ?? i.sizeLabel
}
case 'done':
if (i.status === 'canceled') return i
return {
...i,
status: 'completed',
progress: 1,
speed: undefined,
eta: undefined,
filePath: ev.filePath ?? i.filePath
}
case 'error':
if (i.status === 'canceled') return i
return { ...i, status: 'error', speed: undefined, eta: undefined, error: ev.error }
default:
return i
}
})
}))
// Record completed downloads to history.
if (ev.type === 'done') {
const item = get().items.find((i) => i.id === ev.id)
if (item && item.status === 'completed') {
useHistory.getState().add({
id: item.id,
title: item.title,
channel: item.channel,
url: item.url,
kind: item.kind,
quality: item.quality,
filePath: item.filePath,
sizeLabel: item.sizeLabel,
thumbnail: item.thumbnail,
completedAt: Date.now()
})
}
}
// A finished item frees a slot — promote whatever is queued next.
if (ev.type === 'done' || ev.type === 'error') pump()
}
}
})
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
if (!PREVIEW) {
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
} else {
// Browser preview: animate any seed item that starts out 'downloading' so the
// queue is live (and its completion promotes the queued seed item — a real
// demo of the concurrency cap).
useDownloads
.getState()
.items.filter((i) => i.status === 'downloading')
.forEach((i) => startFakeTicker(i.id, useDownloads.getState, (fn) => useDownloads.setState(fn)))
}
+87
View File
@@ -0,0 +1,87 @@
import { create } from 'zustand'
import type { HistoryEntry } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Mock history so the History tab has content during UI design.
const seed: HistoryEntry[] = PREVIEW
? [
{
id: 'h1',
title: 'How yt-dlp Works Under the Hood',
channel: 'Open Source Weekly',
url: 'https://youtube.com/watch?v=zzz9999',
kind: 'video',
quality: '720p · mp4 · 184 MB',
sizeLabel: '184 MB',
filePath: 'C:\\Users\\you\\Downloads\\How yt-dlp Works Under the Hood.mp4',
completedAt: Date.now() - 1000 * 60 * 42
},
{
id: 'h2',
title: 'Deep Focus — Ambient Mix',
channel: 'ChillStudio',
url: 'https://youtube.com/watch?v=aaa1111',
kind: 'audio',
quality: 'Best (MP3)',
sizeLabel: '142 MB',
filePath: 'C:\\Users\\you\\Downloads\\Deep Focus — Ambient Mix.mp3',
completedAt: Date.now() - 1000 * 60 * 60 * 26
},
{
id: 'h3',
title: 'Conference Keynote 2026 (Full)',
channel: 'TechConf',
url: 'https://youtube.com/watch?v=bbb2222',
kind: 'video',
quality: '1080p · mp4 · 1.2 GB',
sizeLabel: '1.2 GB',
filePath: 'C:\\Users\\you\\Downloads\\Conference Keynote 2026 (Full).mp4',
completedAt: Date.now() - 1000 * 60 * 60 * 24 * 3
}
]
: []
interface HistoryState {
entries: HistoryEntry[]
add: (entry: HistoryEntry) => void
remove: (id: string) => void
clear: () => void
openFile: (id: string) => void
showInFolder: (id: string) => void
}
export const useHistory = create<HistoryState>((set, get) => ({
entries: seed,
add: (entry) => {
set((s) => ({ entries: [entry, ...s.entries.filter((e) => e.id !== entry.id)] }))
if (!PREVIEW) window.api.addHistory(entry).catch(() => {})
},
remove: (id) => {
set((s) => ({ entries: s.entries.filter((e) => e.id !== id) }))
if (!PREVIEW) window.api.removeHistory(id).catch(() => {})
},
clear: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearHistory().catch(() => {})
},
openFile: (id) => {
const e = get().entries.find((x) => x.id === id)
if (!PREVIEW && e?.filePath) window.api.openPath(e.filePath)
},
showInFolder: (id) => {
const e = get().entries.find((x) => x.id === id)
if (!PREVIEW && e?.filePath) window.api.showInFolder(e.filePath)
}
}))
// Load persisted history on startup.
if (!PREVIEW) {
window.api.listHistory().then((entries) => useHistory.setState({ entries }))
}
+45
View File
@@ -0,0 +1,45 @@
import { create } from 'zustand'
import type { Settings } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
const FALLBACK: Settings = {
outputDir: PREVIEW ? 'C:\\Users\\you\\Downloads' : '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true
}
interface SettingsState extends Settings {
/** true once persisted settings have loaded (always true in preview) */
loaded: boolean
update: (partial: Partial<Settings>) => void
chooseOutputDir: () => void
}
export const useSettings = create<SettingsState>((set, get) => ({
...FALLBACK,
loaded: PREVIEW,
update: (partial) => {
set(partial) // optimistic local update
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
},
chooseOutputDir: () => {
if (PREVIEW) return
window.api.chooseFolder().then((dir) => {
if (dir) get().update({ outputDir: dir })
})
}
}))
// Load persisted settings on startup.
if (!PREVIEW) {
window.api.getSettings().then((s) => useSettings.setState({ ...s, loaded: true }))
}
+87
View File
@@ -0,0 +1,87 @@
import {
createLightTheme,
createDarkTheme,
type BrandVariants,
type Theme
} from '@fluentui/react-components'
// Toffee-brown brand ramp. The product palette (50950) is remapped onto
// Fluent's 16-stop BrandVariants (10 = darkest … 160 = lightest) so the LIGHT
// primary lands on toffee-600 (#825e4a) at index 80, and the DARK primary on a
// lightened toffee-400 (#b5917d) — applied via darkBrandOverrides below.
const toffee: BrandVariants = {
10: '#17100d',
20: '#201713',
30: '#2e211a',
40: '#412f25',
50: '#4f3a2d',
60: '#614638',
70: '#735140',
80: '#825e4a',
90: '#946b54',
100: '#a2755d',
110: '#b5917d',
120: '#c7ac9e',
130: '#d3bcae',
140: '#dac8be',
150: '#ece3df',
160: '#f6f1ef'
}
// Rounder corners — buttons/inputs ~10px, cards ~16px. NOT pills.
const friendlyRadii = {
borderRadiusSmall: '5px',
borderRadiusMedium: '10px',
borderRadiusLarge: '12px',
borderRadiusXLarge: '16px'
} as const
// Dark mode keeps Fluent's NEUTRAL (charcoal) surfaces and uses toffee only as
// the accent. The default dark brand is too low-contrast on near-black, so lift
// the brand to toffee-400 and flip on-brand text to dark for legible buttons.
const darkBrandOverrides = {
colorBrandBackground: '#b5917d',
colorBrandBackgroundHover: '#c19f8c',
colorBrandBackgroundPressed: '#a2755d',
colorBrandBackgroundSelected: '#b5917d',
colorNeutralForegroundOnBrand: '#1c1611',
colorCompoundBrandBackground: '#b5917d',
colorCompoundBrandBackgroundHover: '#c19f8c',
colorCompoundBrandBackgroundPressed: '#a2755d',
colorCompoundBrandForeground1: '#c7ac9e',
colorCompoundBrandForeground1Hover: '#d3bcae',
colorCompoundBrandForeground1Pressed: '#b5917d',
colorCompoundBrandStroke: '#b5917d',
colorCompoundBrandStrokeHover: '#c19f8c',
colorBrandForeground1: '#c7ac9e',
colorBrandForeground2: '#d3bcae',
colorBrandForegroundLink: '#c7ac9e',
colorBrandForegroundLinkHover: '#d3bcae',
colorBrandForegroundLinkPressed: '#b5917d',
colorBrandStroke1: '#b5917d',
colorBrandStroke2: '#5f4636'
} as const
export const lightTheme: Theme = { ...createLightTheme(toffee), ...friendlyRadii }
export const darkTheme: Theme = { ...createDarkTheme(toffee), ...friendlyRadii, ...darkBrandOverrides }
// Page background behind the app shell. Light: warm toffee paper. Dark: neutral
// charcoal (NOT brown) — toffee shows up only on accents.
export const pageBackground = {
light: '#f6f1ef',
dark: '#161618'
}
// Per-kind thumbnail tints. Video vs audio differ by ramp DEPTH rather than a
// competing hue, keeping the monochrome-warm Studio look. Theme-aware because
// these sit on the neutral card surface.
export const thumbColors = {
light: {
video: { bg: '#ece3df', fg: '#825e4a' },
audio: { bg: '#dac8be', fg: '#5f4636' }
},
dark: {
video: { bg: '#252025', fg: '#c7ac9e' },
audio: { bg: '#2b2620', fg: '#b5917d' }
}
} as const
+144
View File
@@ -0,0 +1,144 @@
/**
* Shared contract between main and renderer.
* IPC channel names + payload types live here so both sides stay in sync.
*/
export const IpcChannels = {
ytdlpVersion: 'ytdlp:version',
probe: 'media:probe',
downloadStart: 'download:start',
downloadCancel: 'download:cancel',
defaultFolder: 'download:default-folder',
chooseFolder: 'download:choose-folder',
openPath: 'shell:open-path',
showInFolder: 'shell:show-in-folder',
clipboardRead: 'clipboard:read',
settingsGet: 'settings:get',
settingsSet: 'settings:set',
historyList: 'history:list',
historyAdd: 'history:add',
historyRemove: 'history:remove',
historyClear: 'history:clear',
/** main → renderer push channel for live download events */
downloadEvent: 'download:event'
} as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
export const BEST_FORMAT_ID = '__best__'
export type MediaKind = 'video' | 'audio'
export interface YtdlpVersionResult {
ok: boolean
/** yt-dlp version string, present when ok is true */
version?: string
/** human-readable error, present when ok is false */
error?: string
}
/** A single selectable output format, derived from `yt-dlp -J`. */
export interface FormatOption {
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
id: string
/** human label, e.g. '1080p60 · mp4 · 124 MB' */
label: string
height?: number
ext?: string
filesizeLabel?: string
/** true when this format already carries an audio track */
hasAudio: boolean
}
/** Metadata + available formats returned by a probe. */
export interface MediaInfo {
title: string
channel?: string
durationLabel?: string
thumbnail?: string
/** video format options, best-first (audio is transcoded, so not listed) */
formats: FormatOption[]
}
export interface ProbeResult {
ok: boolean
info?: MediaInfo
error?: string
}
/** Sent renderer → main to kick off a download. The renderer owns the id. */
export interface StartDownloadOptions {
id: string
url: string
kind: MediaKind
/** one of the UI quality labels (e.g. '1080p', 'Best (MP3)') */
quality: string
/** absolute output directory; main falls back to the OS Downloads folder */
outputDir?: string
/** exact yt-dlp format_id chosen from a probe; omit for the preset path */
formatId?: string
/** whether the chosen format already includes audio (skips +bestaudio) */
formatHasAudio?: boolean
}
export interface StartDownloadResult {
ok: boolean
error?: string
}
export interface DownloadMeta {
title?: string
channel?: string
durationLabel?: string
}
export interface DownloadProgress {
/** yt-dlp progress status: 'downloading' | 'finished' | … */
status: string
/** 0..1 (0 when total size is unknown) */
progress: number
/** human-readable, e.g. '4.7 MB/s' */
speed?: string
/** human-readable, e.g. '1:12' */
eta?: string
/** human-readable total size, e.g. '512 MB' */
sizeLabel?: string
}
/** Discriminated union pushed on IpcChannels.downloadEvent. */
export type DownloadEvent =
| { type: 'meta'; id: string; meta: DownloadMeta }
| { type: 'progress'; id: string; progress: DownloadProgress }
| { type: 'done'; id: string; filePath?: string }
| { type: 'error'; id: string; error: string }
/** Persisted user settings (electron-store). */
export interface Settings {
/** absolute output directory (empty string resolves to the OS Downloads folder) */
outputDir: string
defaultKind: MediaKind
defaultVideoQuality: string
defaultAudioQuality: string
/** how many downloads run at once */
maxConcurrent: number
/** yt-dlp output template, e.g. '%(title)s.%(ext)s' */
filenameTemplate: string
/** UI color theme */
theme: 'light' | 'dark'
/** auto-detect video links from the clipboard on window focus */
clipboardWatch: boolean
}
/** A completed download, persisted to history.json (newest-first). */
export interface HistoryEntry {
id: string
title: string
channel?: string
url: string
kind: MediaKind
quality: string
filePath?: string
sizeLabel?: string
thumbnail?: string
/** epoch milliseconds */
completedAt: number
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*",
"src/shared/**/*"
],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node", "node"],
"paths": {
"@shared/*": ["./src/shared/*"]
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/**/*",
"src/renderer/src/env.d.ts",
"src/preload/*.d.ts",
"src/shared/**/*"
],
"compilerOptions": {
"composite": true,
"paths": {
"@renderer/*": ["./src/renderer/src/*"],
"@shared/*": ["./src/shared/*"]
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import { resolve } from 'path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
/**
* Standalone Vite server for fast UI iteration in a browser no Electron window.
* Run with `npm run ui`. Since there's no preload here, `window.api` is stubbed in
* dev (see src/renderer/src/main.tsx) so the UI stays interactive for design work.
*
* The full app still runs via `npm run dev` (electron-vite); this is purely for UI.
*/
export default defineConfig({
root: resolve('src/renderer'),
base: './',
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
},
plugins: [react()],
server: {
port: 5174,
open: false
}
})