From fa78b13cac0dd4b170be96082f35c722766d85d0 Mon Sep 17 00:00:00 2001 From: debont80 Date: Tue, 23 Jun 2026 08:45:09 -0400 Subject: [PATCH] Complete Phase E theming, onboarding, and Windows share integration Adds theme presets (Toffee/Slate/Evergreen/Lavender) with a "follow system" mode and high-contrast awareness, a first-run welcome screen, and the Windows analog of Android's share sheet for a Win32 app: an aerofetch:// protocol handler plus an Explorer "Send to AeroFetch" entry, both routed through a single-instance lock so a second launch hands its link to the already-running window instead of opening a duplicate. Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 68 +++++++- electron-builder.yml | 9 ++ src/main/deeplink.ts | 85 ++++++++++ src/main/index.ts | 140 +++++++++++++---- src/main/settings.ts | 13 +- src/preload/index.ts | 24 ++- src/renderer/src/App.tsx | 47 ++++-- src/renderer/src/components/DownloadBar.tsx | 24 ++- src/renderer/src/components/Onboarding.tsx | 154 +++++++++++++++++++ src/renderer/src/components/SettingsView.tsx | 90 ++++++++++- src/renderer/src/components/Sidebar.tsx | 10 +- src/renderer/src/main.tsx | 10 +- src/renderer/src/store/settings.ts | 6 +- src/renderer/src/store/systemTheme.ts | 26 ++++ src/renderer/src/theme.ts | 150 ++++++++++++++++-- src/shared/ipc.ts | 32 +++- test/deeplink.test.ts | 72 +++++++++ 17 files changed, 883 insertions(+), 77 deletions(-) create mode 100644 src/main/deeplink.ts create mode 100644 src/renderer/src/components/Onboarding.tsx create mode 100644 src/renderer/src/store/systemTheme.ts create mode 100644 test/deeplink.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index eb49c52..3812cf2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -155,18 +155,70 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes Some items are Android-specific in Seal and adapted to Windows here. -- [ ] **Theme presets + "follow system"** and high-contrast (toffee light/dark already exists; - add system-follow + a few accent presets). *Material-You dynamic color is Android-only; - closest Windows analog is accent presets / system accent.* -- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional. -- [ ] **Windows "open with" / share integration** — register a URL-protocol handler or Explorer - "Send to" entry (analog of Android's share sheet). *Lower priority, platform-specific.* -- [ ] **Onboarding / welcome screen** on first run. +- [x] **Theme presets + "follow system" and high-contrast.** Settings → Appearance has a + Theme select (Light / Dark / **Follow system**) and four accent presets — Toffee + (original), Slate, Evergreen, Lavender — each a 16-stop `BrandVariants` ramp sharing + toffee's lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow + system" reads Electron's `nativeTheme.shouldUseDarkColors` in the main process + (`src/main/index.ts`'s `resolveBackgroundMode`/`registerSystemThemeBridge`, pushed to + the renderer over a new `system-theme:update` channel into + `src/renderer/src/store/systemTheme.ts`) so the native window background stays in sync + too, not just the in-page theme. High contrast: `nativeTheme.shouldUseHighContrastColors` + surfaces as a status row in Settings → Appearance with a shortcut to Windows' contrast + settings (`ms-settings:easeofaccess-highcontrast`); actual color overrides are left to + Chromium's native `forced-colors` handling since nothing in the app sets + `forced-color-adjust: none`. The sidebar's quick toggle still works under "system" — it + sets an explicit light/dark away from whatever's currently resolved, breaking out of + auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee, + switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar + toggle's break-out-of-system behavior all checked visually. +- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional — deferred, + since shipping ~40 real translations isn't something to fake; doing this properly means + wiring up an i18n library + extracting every string now, with translation content + arriving incrementally from contributors. +- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32 + (non-MSIX) app can't register as an actual Share Target: + - **`aerofetch://` protocol** (`aerofetch://download?url=`) — registered at + install time via `electron-builder.yml`'s `protocols` field (NSIS), and at runtime via + `app.setAsDefaultProtocolClient` in `src/main/index.ts` (covers the portable build and + dev, which have no installer step to do it for them). + - **Explorer "Send to AeroFetch"** — `src/main/deeplink.ts`'s `registerSendToShortcut()` + writes a `.lnk` into `%APPDATA%\Microsoft\Windows\SendTo` via Electron's + `shell.writeShortcutLink` (no native deps). Sending a `.url` Internet Shortcut file + (what Explorer/browsers create from "Create shortcut"/dragging a tab to the desktop) + passes its path as argv; `extractIncomingUrl()` parses the `URL=` line. + + Both paths funnel through the same `extractIncomingUrl()` (only ever forwards http(s) + targets — same restriction as the existing external-link window-open handler) and a new + `external-url` IPC push channel. The renderer surfaces the incoming link via + `DownloadBar.tsx`'s existing clipboard-suggestion banner (now labeled "Link received:" + for this source instead of "Use copied link?") rather than a new UI element. Added + `app.requestSingleInstanceLock()` + a `second-instance` handler so a second invocation + (the OS relaunching us for the protocol/SendTo case) hands its link to the already-running + window instead of opening a duplicate. `extractIncomingUrl()`'s parsing/validation logic + has 10 new unit tests (`test/deeplink.test.ts`); typecheck and the full suite are clean. + *Caveat: the OS-level wiring (protocol registration, second-instance routing, the SendTo + shortcut) is main-process/Electron-only and can't be exercised in the Vite browser + preview used elsewhere in this phase — worth a real install + a manual + `aerofetch://download?url=...` / "Send to" smoke test, same spirit as Phases A–C's + real-download caveats.* +- [x] **Onboarding / welcome screen** on first run. `Settings.hasCompletedOnboarding` + (default `false`, persisted) gates a full-screen `Onboarding.tsx` rendered in place of + the sidebar + main content in `App.tsx` — not a Fluent `Dialog`, since this app avoids + Fluent's portal-based overlays entirely (GPU/driver blank-overlay issue noted in + `src/main/index.ts` and `Select.tsx`). Shows the brand mark, a one-line pitch, the + download-folder picker (same field as Settings → Downloads), three tip bullets, and a + "Get started" button that flips the flag. Gated on the settings store's `loaded` flag + so a returning user's real settings can never get clobbered by a one-frame flash of the + default-false state. Verified in the UI preview (typecheck + `npm run test` clean): + screen renders, folder field/browse work, "Get started" dismisses into the normal app. --- ## Not portable from Seal (noted for completeness) - SAF directory picker (Android storage) → already covered by the native folder dialog. -- Android share-sheet / quick-download-on-share → partially mapped to Phase E. +- Android share-sheet / quick-download-on-share → mapped to Phase E's `aerofetch://` protocol + + Explorer "Send to" integration (a true Share Target needs an MSIX package, out of scope + for this app's NSIS/portable distribution). - F-Droid distribution → N/A (AeroFetch ships NSIS + portable). diff --git a/electron-builder.yml b/electron-builder.yml index 47beda7..02d7589 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -2,6 +2,15 @@ appId: com.aerofetch.app productName: AeroFetch copyright: yt-dlp frontend +# Lets a browser/another app hand AeroFetch a link via aerofetch://download?url= +# (src/main/deeplink.ts) — the closest Windows analog to Android's share-to-app intent. +# The NSIS installer registers this at install time; main/index.ts's +# app.setAsDefaultProtocolClient call additionally covers the portable build and dev. +protocols: + - name: AeroFetch + schemes: + - aerofetch + directories: buildResources: build output: dist diff --git a/src/main/deeplink.ts b/src/main/deeplink.ts new file mode 100644 index 0000000..ac08482 --- /dev/null +++ b/src/main/deeplink.ts @@ -0,0 +1,85 @@ +import { app, shell, type BrowserWindow } from 'electron' +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' + +/** Only ever forward http(s) targets into the app — same restriction the + * external-link window-open handler in index.ts applies to in-page links. */ +function asHttpUrl(candidate: string): string | null { + try { + const u = new URL(candidate) + return u.protocol === 'http:' || u.protocol === 'https:' ? candidate : null + } catch { + return null + } +} + +/** Reads a Windows Internet Shortcut (.url) file's target — what Explorer's + * "Send to" menu hands us when the user sends a saved link to AeroFetch. */ +function readUrlShortcut(path: string): string | null { + try { + const text = readFileSync(path, 'utf8') + const match = /^URL=(.+)$/im.exec(text) + return match ? asHttpUrl(match[1].trim()) : null + } catch { + return null + } +} + +/** + * Pulls a download target out of process argv — either the `aerofetch://` + * custom protocol (`aerofetch://download?url=`) or a `.url` Internet + * Shortcut path Explorer's "Send to" menu passes us (see registerSendToShortcut). + */ +export function extractIncomingUrl(argv: string[]): string | null { + for (const arg of argv) { + if (arg.startsWith('aerofetch://')) { + try { + const target = new URL(arg).searchParams.get('url') + const valid = target && asHttpUrl(target) + if (valid) return valid + } catch { + /* malformed protocol invocation — ignore */ + } + } else if (/\.url$/i.test(arg) && existsSync(arg)) { + const target = readUrlShortcut(arg) + if (target) return target + } + } + return null +} + +/** + * Adds/refreshes a "Send to AeroFetch" entry in Explorer's SendTo menu — the + * closest Windows analog to Android's share sheet a Win32 (non-MSIX) app can + * offer; registering as an actual Share Target requires an MSIX package. + * Best-effort and idempotent (re-running just overwrites the same shortcut), + * so a failure here should never block startup. + */ +export function registerSendToShortcut(): void { + if (process.platform !== 'win32') return + try { + const shortcutPath = join( + app.getPath('appData'), + 'Microsoft', + 'Windows', + 'SendTo', + 'AeroFetch.lnk' + ) + shell.writeShortcutLink(shortcutPath, { + target: process.execPath, + description: 'Send to AeroFetch', + icon: process.execPath, + iconIndex: 0 + }) + } catch { + /* SendTo integration is a nice-to-have */ + } +} + +/** Focuses (and un-minimizes) an existing window — used when a second launch + * (protocol or SendTo) should hand its URL to the already-running instance. */ +export function focusWindow(win: BrowserWindow): void { + if (win.isMinimized()) win.restore() + win.show() + win.focus() +} diff --git a/src/main/index.ts b/src/main/index.ts index 992ffd7..29dda86 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ -import { app, shell, BrowserWindow, ipcMain, dialog, clipboard } from 'electron' -import { join } from 'path' +import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron' +import { join, resolve } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { IpcChannels, @@ -7,7 +7,8 @@ import { type Settings, type HistoryEntry, type CommandTemplate, - type YtdlpUpdateChannel + type YtdlpUpdateChannel, + type SystemThemeInfo } from '@shared/ipc' import { getYtdlpVersion, updateYtdlp } from './ytdlp' import { probeMedia } from './probe' @@ -20,6 +21,13 @@ import { safeOpenPath, safeShowInFolder } from './reveal' import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies' import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog' import { exportBackup, importBackup } from './backup' +import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink' + +// Only one instance ever runs. A second launch — e.g. the OS invoking us again +// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to +// this instance via 'second-instance' below, instead of opening a duplicate window. +const isPrimaryInstance = app.requestSingleInstanceLock() +if (!isPrimaryInstance) app.quit() // Force software compositing (GPU acceleration off). On this dev machine's old GeForce // GT 625 — and plausibly on the old / locked-down public PCs this app targets — @@ -38,18 +46,45 @@ app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion') // before the app is ready and before any user path is read. setupPortableData() +// Register aerofetch:// so a browser/another app can hand AeroFetch a link +// (?url=) the way Android's share sheet hands Seal one. The NSIS +// installer also declares this scheme (electron-builder.yml's `protocols`) +// so it's registered even before first launch; this call additionally covers +// the portable build and dev, which have no installer step to do it for us. +if (is.dev && process.argv.length >= 2) { + app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(process.argv[1])]) +} else { + app.setAsDefaultProtocolClient('aerofetch') +} + +let mainWindow: BrowserWindow | null = null + // Page background per theme — keep in sync with `pageBackground` in // src/renderer/src/theme.ts. Set as the window's NATIVE background so the // one-frame compositor repaint (when a tooltip/dropdown overlay first paints on // Windows) shows the app's current color instead of a mismatched white flash. const THEME_BACKGROUND = { light: '#f6f1ef', dark: '#161618' } as const +// 'system' isn't a real background — resolve it against the OS's current +// preference (nativeTheme.themeSource defaults to 'system', so this tracks it +// without AeroFetch ever touching themeSource itself). +function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' { + return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme +} + +function getSystemThemeInfo(): SystemThemeInfo { + return { + shouldUseDarkColors: nativeTheme.shouldUseDarkColors, + shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors + } +} + function createWindow(): void { - const mainWindow = new BrowserWindow({ + const win = new BrowserWindow({ width: 920, height: 700, show: false, - backgroundColor: THEME_BACKGROUND[getSettings().theme], + backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)], autoHideMenuBar: true, webPreferences: { preload: join(__dirname, '../preload/index.cjs'), @@ -58,14 +93,29 @@ function createWindow(): void { nodeIntegration: false } }) + mainWindow = win - mainWindow.on('ready-to-show', () => { - mainWindow.show() + win.on('ready-to-show', () => { + win.show() }) + win.on('closed', () => { + if (mainWindow === win) mainWindow = null + }) + + // The OS may have launched us with an aerofetch:// link or a "Send to" .url + // file on the command line — hand it to DownloadBar's link-suggestion banner + // once the page (and its IPC listener) is actually ready to receive it. + const incomingUrl = extractIncomingUrl(process.argv) + if (incomingUrl) { + win.webContents.once('did-finish-load', () => { + win.webContents.send(IpcChannels.externalUrl, incomingUrl) + }) + } + // Open external links in the OS browser, never in-app — and only http(s), so a // file:// or custom-protocol URL can't be used to launch a local handler. - mainWindow.webContents.setWindowOpenHandler((details) => { + win.webContents.setWindowOpenHandler((details) => { try { const { protocol } = new URL(details.url) if (protocol === 'http:' || protocol === 'https:') shell.openExternal(details.url) @@ -77,12 +127,12 @@ function createWindow(): void { // The app shell is local and self-contained — never let the renderer navigate // away from it (defence in depth; HMR uses websockets, not navigation). - mainWindow.webContents.on('will-navigate', (e) => e.preventDefault()) + win.webContents.on('will-navigate', (e) => e.preventDefault()) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + win.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + win.loadFile(join(__dirname, '../renderer/index.html')) } } @@ -130,11 +180,19 @@ function registerIpcHandlers(): void { // repaint never flashes a mismatched color behind an overlay. Use the // validated result, not the raw partial (which may hold a bogus value). if (partial.theme) { - BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[result.theme]) + BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor( + THEME_BACKGROUND[resolveBackgroundMode(result.theme)] + ) } return result }) + ipcMain.handle(IpcChannels.systemThemeGet, () => getSystemThemeInfo()) + + ipcMain.handle(IpcChannels.openHighContrastSettings, () => + shell.openExternal('ms-settings:easeofaccess-highcontrast') + ) + ipcMain.handle(IpcChannels.historyList, () => listHistory()) ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry)) ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id)) @@ -166,28 +224,56 @@ function registerIpcHandlers(): void { const result = await importBackup(win) // A restored backup may have changed the theme; keep the native window // background in sync the same way settingsSet does. - if (result.ok) win?.setBackgroundColor(THEME_BACKGROUND[getSettings().theme]) + if (result.ok) { + win?.setBackgroundColor(THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)]) + } return result }) } -app.whenReady().then(() => { - electronApp.setAppUserModelId('com.aerofetch.app') +// Push OS theme/contrast changes to every window, and keep the native +// background in sync for windows currently following 'system'. +function registerSystemThemeBridge(): void { + nativeTheme.on('updated', () => { + const info = getSystemThemeInfo() + const bg = THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)] + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IpcChannels.systemThemeUpdate, info) + if (getSettings().theme === 'system') win.setBackgroundColor(bg) + } + }) +} - app.on('browser-window-created', (_, window) => { - optimizer.watchWindowShortcuts(window) +if (isPrimaryInstance) { + // A second launch arrives here as argv, not a real new process — extract any + // link it carried and hand it to the window we already have. + app.on('second-instance', (_e, argv) => { + if (!mainWindow) return + focusWindow(mainWindow) + const incomingUrl = extractIncomingUrl(argv) + if (incomingUrl) mainWindow.webContents.send(IpcChannels.externalUrl, incomingUrl) }) - registerIpcHandlers() - createWindow() + app.whenReady().then(() => { + electronApp.setAppUserModelId('com.aerofetch.app') - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() + app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + + registerIpcHandlers() + registerSystemThemeBridge() + registerSendToShortcut() + createWindow() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) }) -}) -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - } -}) + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } + }) +} diff --git a/src/main/settings.ts b/src/main/settings.ts index 29e3f06..c943339 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -7,6 +7,7 @@ import { VIDEO_CODECS, SPONSORBLOCK_CATEGORIES, COOKIE_BROWSERS, + ACCENT_COLORS, DEFAULT_DOWNLOAD_OPTIONS, type Settings, type DownloadOptions, @@ -21,6 +22,7 @@ const DEFAULTS: Settings = { maxConcurrent: 2, filenameTemplate: '%(title)s.%(ext)s', theme: 'light', + accentColor: 'toffee', clipboardWatch: true, downloadOptions: DEFAULT_DOWNLOAD_OPTIONS, proxy: '', @@ -32,7 +34,8 @@ const DEFAULTS: Settings = { downloadArchive: false, customCommandEnabled: false, defaultTemplateId: null, - notifyOnComplete: true + notifyOnComplete: true, + hasCompletedOnboarding: false } /** Fixed path for the --download-archive file; not user-configurable. */ @@ -104,7 +107,12 @@ export function setSettings(partial: Partial): Settings { if (value === undefined) continue switch (key) { case 'theme': - if (value === 'light' || value === 'dark') s.set('theme', value) + if (value === 'light' || value === 'dark' || value === 'system') s.set('theme', value) + break + case 'accentColor': + if ((ACCENT_COLORS as readonly string[]).includes(value as string)) { + s.set('accentColor', value as Settings['accentColor']) + } break case 'defaultKind': if (value === 'video' || value === 'audio') s.set('defaultKind', value) @@ -120,6 +128,7 @@ export function setSettings(partial: Partial): Settings { case 'downloadArchive': case 'customCommandEnabled': case 'notifyOnComplete': + case 'hasCompletedOnboarding': if (typeof value === 'boolean') s.set(key, value) break case 'defaultTemplateId': diff --git a/src/preload/index.ts b/src/preload/index.ts index 87e8ad0..158f33a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,7 +16,8 @@ import { type CommandPreviewResult, type ErrorLogEntry, type BackupExportResult, - type BackupImportResult + type BackupImportResult, + type SystemThemeInfo } from '@shared/ipc' // The surface exposed to the renderer. Keep this thin: it only forwards to IPC. @@ -99,6 +100,27 @@ const api = { const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev) ipcRenderer.on(IpcChannels.downloadEvent, listener) return () => ipcRenderer.removeListener(IpcChannels.downloadEvent, listener) + }, + + getSystemTheme: (): Promise => ipcRenderer.invoke(IpcChannels.systemThemeGet), + + /** Subscribe to OS theme/contrast changes. Returns an unsubscribe function. */ + onSystemThemeUpdate: (cb: (info: SystemThemeInfo) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, info: SystemThemeInfo): void => cb(info) + ipcRenderer.on(IpcChannels.systemThemeUpdate, listener) + return () => ipcRenderer.removeListener(IpcChannels.systemThemeUpdate, listener) + }, + + /** Opens Windows' "Contrast themes" accessibility settings page. */ + openHighContrastSettings: (): Promise => + ipcRenderer.invoke(IpcChannels.openHighContrastSettings), + + /** Subscribe to links handed to AeroFetch from outside (aerofetch:// or a + * "Send to" .url file). Returns an unsubscribe function. */ + onExternalUrl: (cb: (url: string) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, url: string): void => cb(url) + ipcRenderer.on(IpcChannels.externalUrl, listener) + return () => ipcRenderer.removeListener(IpcChannels.externalUrl, listener) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6b02f4b..1f56ce3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,8 +4,10 @@ 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 { Onboarding } from './components/Onboarding' +import { getTheme, pageBackground } from './theme' import { useSettings } from './store/settings' +import { useSystemTheme } from './store/systemTheme' const useStyles = makeStyles({ provider: { @@ -27,12 +29,22 @@ const useStyles = makeStyles({ function App(): React.JSX.Element { const styles = useStyles() const theme = useSettings((s) => s.theme) + const accentColor = useSettings((s) => s.accentColor) const updateSettings = useSettings((s) => s.update) - const isDark = theme === 'dark' + const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors) + const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark' const [tab, setTab] = useState('downloads') + // Gate on `loaded` so a returning user's real settings never get clobbered + // by a one-frame flash of the (default-false) onboarding state. + const loaded = useSettings((s) => s.loaded) + const hasCompletedOnboarding = useSettings((s) => s.hasCompletedOnboarding) + const showOnboarding = loaded && !hasCompletedOnboarding return ( - +
- updateSettings({ theme: isDark ? 'light' : 'dark' })} - /> + {showOnboarding ? ( + + ) : ( + <> + updateSettings({ theme: isDark ? 'light' : 'dark' })} + /> -
- {tab === 'downloads' && } - {tab === 'history' && } - {tab === 'settings' && } -
+
+ {tab === 'downloads' && } + {tab === 'history' && } + {tab === 'settings' && } +
+ + )}
) diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 510bd06..2bf4b8e 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -337,7 +337,10 @@ export function DownloadBar(): React.JSX.Element { // 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). + // The same banner also surfaces links handed to AeroFetch from outside — the + // aerofetch:// protocol or a "Send to" .url file (see onExternalUrl below). const [suggestion, setSuggestion] = useState(null) + const [suggestionSource, setSuggestionSource] = useState<'clipboard' | 'external'>('clipboard') const urlRef = useRef('') urlRef.current = url const lastSeen = useRef(null) @@ -354,7 +357,10 @@ export function DownloadBar(): React.JSX.Element { } if (!active) return text = text.trim() - if (looksLikeUrl(text) && text !== lastSeen.current) setSuggestion(text) + if (looksLikeUrl(text) && text !== lastSeen.current) { + setSuggestionSource('clipboard') + setSuggestion(text) + } } check() window.addEventListener('focus', check) @@ -364,6 +370,17 @@ export function DownloadBar(): React.JSX.Element { } }, []) + // A link handed in from outside always takes priority over whatever the + // clipboard banner was showing — it's a direct request, not a guess. + useEffect( + () => + window.api.onExternalUrl((incomingUrl) => { + setSuggestionSource('external') + setSuggestion(incomingUrl) + }), + [] + ) + function acceptSuggestion(): void { if (!suggestion) return lastSeen.current = suggestion @@ -595,7 +612,10 @@ export function DownloadBar(): React.JSX.Element { {suggestion && (
- Use copied link? {suggestion} + + {suggestionSource === 'external' ? 'Link received: ' : 'Use copied link? '} + {suggestion} + diff --git a/src/renderer/src/components/Onboarding.tsx b/src/renderer/src/components/Onboarding.tsx new file mode 100644 index 0000000..519ac20 --- /dev/null +++ b/src/renderer/src/components/Onboarding.tsx @@ -0,0 +1,154 @@ +import { + Card, + Title2, + Body1, + Caption1, + Field, + Input, + Button, + makeStyles, + tokens, + shorthands +} from '@fluentui/react-components' +import { + ArrowDownloadFilled, + FolderRegular, + ClipboardPasteRegular, + HistoryRegular, + OptionsRegular, + RocketRegular +} from '@fluentui/react-icons' +import { useSettings } from '../store/settings' + +const useStyles = makeStyles({ + root: { + height: '100%', + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflowY: 'auto', + padding: '24px' + }, + card: { + display: 'flex', + flexDirection: 'column', + gap: '20px', + padding: '32px', + maxWidth: '460px', + width: '100%', + ...shorthands.borderRadius(tokens.borderRadiusXLarge) + }, + brandRow: { + display: 'flex', + alignItems: 'center', + gap: '14px' + }, + mark: { + width: '48px', + height: '48px', + flexShrink: 0, + ...shorthands.borderRadius(tokens.borderRadiusLarge), + backgroundColor: tokens.colorBrandBackground, + color: tokens.colorNeutralForegroundOnBrand, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '26px' + }, + folderRow: { + display: 'flex', + gap: '8px', + alignItems: 'flex-end' + }, + folderInput: { + flexGrow: 1 + }, + tips: { + display: 'flex', + flexDirection: 'column', + gap: '10px' + }, + tip: { + display: 'flex', + alignItems: 'flex-start', + gap: '10px' + }, + tipIcon: { + fontSize: '18px', + flexShrink: 0, + marginTop: '2px', + color: tokens.colorCompoundBrandForeground1 + } +}) + +const TIPS: { icon: React.JSX.Element; text: string }[] = [ + { + icon: , + text: 'Copy a video link and AeroFetch offers to fetch it as soon as you switch back.' + }, + { + icon: , + text: 'Downloads queue with a concurrency cap, and every completed file lands in History.' + }, + { + icon: , + text: 'Subtitles, SponsorBlock, custom yt-dlp commands, and more live in Settings.' + } +] + +export function Onboarding(): React.JSX.Element { + const styles = useStyles() + const outputDir = useSettings((s) => s.outputDir) + const chooseOutputDir = useSettings((s) => s.chooseOutputDir) + const update = useSettings((s) => s.update) + + return ( +
+ +
+
+ +
+ Welcome to AeroFetch +
+ + + A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or + audio. yt-dlp and ffmpeg are bundled, so there's nothing else to install. + + + +
+ } + /> + +
+
+ +
+ {TIPS.map((tip) => ( +
+ {tip.icon} + {tip.text} +
+ ))} +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 3598f48..6558517 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -11,6 +11,7 @@ import { Spinner, Text, makeStyles, + mergeClasses, tokens, shorthands } from '@fluentui/react-components' @@ -28,7 +29,9 @@ import { DocumentArrowUpRegular, BugRegular, CopyRegular, - DeleteRegular + DeleteRegular, + PaintBucketRegular, + AccessibilityRegular } from '@fluentui/react-icons' import { COOKIE_BROWSERS, @@ -40,15 +43,19 @@ import { type CookieBrowser, type CookiesStatus, type BackupExportResult, - type BackupImportResult + type BackupImportResult, + type ThemeMode, + type AccentColor } from '@shared/ipc' import { useSettings } from '../store/settings' +import { useSystemTheme } from '../store/systemTheme' import { QUALITY_OPTIONS, useDownloads } from '../store/downloads' import { useTemplates } from '../store/templates' import { useErrorLog } from '../store/errorlog' import { Select } from './Select' import { DownloadOptionsForm } from './DownloadOptionsForm' import { TemplateManager } from './TemplateManager' +import { ACCENT_OPTIONS } from '../theme' const useStyles = makeStyles({ root: { @@ -84,6 +91,23 @@ const useStyles = makeStyles({ hint: { color: tokens.colorNeutralForeground3 }, + swatchRow: { + display: 'flex', + gap: '10px' + }, + swatch: { + boxSizing: 'border-box', + width: '28px', + height: '28px', + flexShrink: 0, + ...shorthands.borderRadius('50%'), + ...shorthands.border('2px', 'solid', 'transparent'), + padding: 0, + cursor: 'pointer' + }, + swatchActive: { + ...shorthands.borderColor(tokens.colorNeutralForeground1) + }, errorList: { display: 'flex', flexDirection: 'column', @@ -121,6 +145,12 @@ const FORMAT_OPTIONS = [ ...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` })) ] +const THEME_MODE_OPTIONS = [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'system', label: 'Follow system' } +] + const COOKIE_SOURCE_OPTIONS = [ { value: 'none', label: 'None' }, { value: 'browser', label: "From a browser's cookie store" }, @@ -147,6 +177,9 @@ export function SettingsView(): React.JSX.Element { const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality) const maxConcurrent = useSettings((s) => s.maxConcurrent) const filenameTemplate = useSettings((s) => s.filenameTemplate) + const theme = useSettings((s) => s.theme) + const accentColor = useSettings((s) => s.accentColor) + const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors) const clipboardWatch = useSettings((s) => s.clipboardWatch) const downloadOptions = useSettings((s) => s.downloadOptions) const proxy = useSettings((s) => s.proxy) @@ -360,6 +393,59 @@ export function SettingsView(): React.JSX.Element { + +
+ + Appearance +
+ + +