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,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
|
||||
Reference in New Issue
Block a user