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:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "ui"],
|
||||
"port": 5174
|
||||
}
|
||||
]
|
||||
}
|
||||
+13
@@ -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
|
||||
@@ -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).
|
||||
@@ -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()]
|
||||
}
|
||||
})
|
||||
Generated
+7848
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
@@ -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.' })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() })
|
||||
})
|
||||
})
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { Api } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: Api
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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. 2–3 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
createLightTheme,
|
||||
createDarkTheme,
|
||||
type BrandVariants,
|
||||
type Theme
|
||||
} from '@fluentui/react-components'
|
||||
|
||||
// Toffee-brown brand ramp. The product palette (50–950) 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user