From 1a2270c95e82bfffdc6ab55ca69e61f870c53cd6 Mon Sep 17 00:00:00 2001 From: debont80 Date: Mon, 22 Jun 2026 18:17:41 -0400 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20AeroFetch=20=E2=80=94=20yt-?= =?UTF-8?q?dlp/YouTube=20downloader=20for=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 onUrlChange(d.value)} + onKeyDown={(e) => e.key === 'Enter' && fetchFormats()} + placeholder="Paste a video URL, then fetch formats…" + size="large" + contentBefore={} + /> + + + + ))} + + + +
+ {usingFormats ? 'Quality / format' : 'Quality'} + {usingFormats ? ( + ({ value: q, label: q }))} + onChange={setQuality} + /> + )} +
+ +
+ + +
+ +
+ + + Saving to {folder} + + + + +
+ + ) +} diff --git a/src/renderer/src/components/DownloadsView.tsx b/src/renderer/src/components/DownloadsView.tsx new file mode 100644 index 0000000..efd0e55 --- /dev/null +++ b/src/renderer/src/components/DownloadsView.tsx @@ -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 ( +
+ + +
+ Queue ({items.length}) + {hasFinished && ( + + )} +
+ + {items.length === 0 ? ( +
+ + Nothing queued yet. Paste a URL above to get started. +
+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/Hint.tsx b/src/renderer/src/components/Hint.tsx new file mode 100644 index 0000000..57f99f3 --- /dev/null +++ b/src/renderer/src/components/Hint.tsx @@ -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 , 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 ( + + {children} + + {label} + + + ) +} diff --git a/src/renderer/src/components/HistoryView.tsx b/src/renderer/src/components/HistoryView.tsx new file mode 100644 index 0000000..9b6d38a --- /dev/null +++ b/src/renderer/src/components/HistoryView.tsx @@ -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 ( +
+
+ +
+ No downloads yet. + Finished downloads will show up here. +
+ ) + } + + return ( +
+
+ + {entries.length} {entries.length === 1 ? 'download' : 'downloads'} + + +
+ +
+ {entries.map((h: HistoryEntry) => { + const t = h.kind === 'audio' ? tc.audio : tc.video + return ( +
+
+ {h.kind === 'audio' ? ( + + ) : ( + + )} +
+
+ {h.title} + + {[h.quality, h.sizeLabel, formatWhen(h.completedAt)].filter(Boolean).join(' • ')} + +
+
+ +
+
+ ) + })} +
+
+ ) +} diff --git a/src/renderer/src/components/QueueItem.tsx b/src/renderer/src/components/QueueItem.tsx new file mode 100644 index 0000000..88e1255 --- /dev/null +++ b/src/renderer/src/components/QueueItem.tsx @@ -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 = { + 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 ( +
+
+ {item.kind === 'audio' ? ( + + ) : ( + + )} +
+ +
+
+ {item.status === 'completed' && ( + + )} + {item.status === 'error' && ( + + )} + {item.title} + + {badge.label} + +
+ + {metaParts.join(' • ')} + + {item.status === 'downloading' && ( +
+ + + {pct(item.progress)} + {item.speed ? ` • ${item.speed}` : ''} + {item.eta ? ` • ${item.eta} left` : ''} + +
+ )} + + {item.status === 'queued' && ( +
+ + Waiting to start… +
+ )} + + {item.status === 'error' && item.error && ( + {item.error} + )} +
+ +
+ {active && ( + +
+
+ ) +} diff --git a/src/renderer/src/components/Select.tsx b/src/renderer/src/components/Select.tsx new file mode 100644 index 0000000..2d79429 --- /dev/null +++ b/src/renderer/src/components/Select.tsx @@ -0,0 +1,68 @@ +import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components' + +// A native 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 ( + + ) +} diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx new file mode 100644 index 0000000..322954f --- /dev/null +++ b/src/renderer/src/components/SettingsView.tsx @@ -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(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 { + 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 ( +
+ +
+ + Downloads +
+ + +
+ } + /> + +
+
+ + + update({ filenameTemplate: d.value })} + /> + + + Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')} + +
+ + +
+ + About +
+ + AeroFetch is a generic frontend for yt-dlp. It bundles yt-dlp and ffmpeg. + +
+ + {checking && } +
+ {version?.ok && ( + yt-dlp {version.version} + )} + {version && !version.ok && ( + + {version.error} + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx new file mode 100644 index 0000000..b9459db --- /dev/null +++ b/src/renderer/src/components/Sidebar.tsx @@ -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: }, + { value: 'history', label: 'History', icon: }, + { value: 'settings', label: 'Settings', icon: } +] + +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 ( + + ) +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/renderer/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx new file mode 100644 index 0000000..1c71123 --- /dev/null +++ b/src/renderer/src/main.tsx @@ -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( + + + +) diff --git a/src/renderer/src/store/downloads.ts b/src/renderer/src/store/downloads.ts new file mode 100644 index 0000000..957147f --- /dev/null +++ b/src/renderer/src/store/downloads.ts @@ -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 = { + 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) => 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((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))) +} diff --git a/src/renderer/src/store/history.ts b/src/renderer/src/store/history.ts new file mode 100644 index 0000000..58249e9 --- /dev/null +++ b/src/renderer/src/store/history.ts @@ -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((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 })) +} diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts new file mode 100644 index 0000000..1602cd5 --- /dev/null +++ b/src/renderer/src/store/settings.ts @@ -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) => void + chooseOutputDir: () => void +} + +export const useSettings = create((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 })) +} diff --git a/src/renderer/src/theme.ts b/src/renderer/src/theme.ts new file mode 100644 index 0000000..2621968 --- /dev/null +++ b/src/renderer/src/theme.ts @@ -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 diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts new file mode 100644 index 0000000..b1173d5 --- /dev/null +++ b/src/shared/ipc.ts @@ -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 +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..155ebaa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.web.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..f1d4bc4 --- /dev/null +++ b/tsconfig.node.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/*"] + } + } +} diff --git a/tsconfig.web.json b/tsconfig.web.json new file mode 100644 index 0000000..29b549d --- /dev/null +++ b/tsconfig.web.json @@ -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/*"] + } + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..34f59a7 --- /dev/null +++ b/vite.config.ts @@ -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 + } +})