Merge audit chain (Batches 6-15) into main
Reconciles two lines of history that both descended from the same feat/tray-background-clipboard branch (background running, clipboard-link detection, updater auth): main picked it up as a single squash commit (PR #7,d112bc4), while the audit chain (p1-docs..batch-15) branched off that feature branch's pre-squash tip and continued with its own granular history, including the H1 decomposition of DownloadBar.tsx/SettingsView.tsx. 12 files conflicted, all resolved by verifying the audit chain already carries PR #7's functionality (confirmed per-file against the originald112bc4diff) and keeping the chain's newer/relocated version: - shared/ipc.ts, main/settings.ts: DEFAULT_SETTINGS single-sourced (C1) - main/download.ts, main/tray.ts: trivial, chain is a superset - main/index.ts: ipcMain.handle wiring extracted to main/ipc.ts (L2); removed a silent duplicate notifyBackgroundOnce that 3-way merge produced with no conflict markers - renderer/main.tsx, store/settings.ts: mock/fallback settings single-sourced (C1/mockApi.ts) - LibraryView.tsx: old inline clipboard-suggestion banner replaced by the shared LinkSuggestion primitive (Batch 9/UI19); removed dead icon imports + styles left behind by the auto-merge - DownloadBar.tsx, SettingsView.tsx: taken wholesale from the chain (H1-decomposed shells) after confirming their extracted hook/cards already contain PR #7's additions (useClipboardLink.offer(), launchAtStartup, updateToken) - useClipboardLink.ts, test/clipboardLink.test.ts (add/add): chain's version is a strict refinement of the same code (L40/L138/L145) Verified post-merge: typecheck (node+web), 279 tests, eslint, and the production build all pass; touched files are prettier-clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,3 +10,6 @@ dist
|
|||||||
|
|
||||||
# Secrets — never commit
|
# Secrets — never commit
|
||||||
.gitea-token
|
.gitea-token
|
||||||
|
|
||||||
|
# VS Code user settings (workspace settings/.vscode/launch.json are committed)
|
||||||
|
.vscode/settings.json
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "none"
|
||||||
|
}
|
||||||
+2364
-281
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -109,19 +109,26 @@ yet. This is the prerequisite for every later phase.
|
|||||||
channel?: string
|
channel?: string
|
||||||
addedAt: number
|
addedAt: number
|
||||||
lastIndexedAt?: number
|
lastIndexedAt?: number
|
||||||
|
itemCount: number // cached MediaItem count for list display
|
||||||
|
watched?: boolean // included in scheduled / startup sync (Phase J)
|
||||||
|
feedUrl?: string // YouTube RSS feed for cheap "anything new?" checks
|
||||||
}
|
}
|
||||||
interface MediaItem {
|
interface MediaItem {
|
||||||
id: string // yt-dlp video id — the dedup key
|
id: string // globally unique, `${sourceId}:${videoId}`
|
||||||
sourceId: string
|
sourceId: string
|
||||||
|
videoId: string // yt-dlp video id — the dedup key within a source
|
||||||
title: string
|
title: string
|
||||||
playlistTitle?: string // for the folder path; 'Uploads' fallback
|
url: string
|
||||||
playlistIndex?: number // 1-based, for NNN numbering
|
playlistTitle: string // for the folder path; 'Uploads' fallback
|
||||||
|
playlistIndex: number // 1-based, for NNN numbering
|
||||||
durationLabel?: string
|
durationLabel?: string
|
||||||
downloaded: boolean
|
downloaded: boolean
|
||||||
downloadedAt?: number
|
downloadedAt?: number
|
||||||
filePath?: string
|
filePath?: string
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
*(This block reflects the shipped `src/shared/ipc.ts` shape; the original sketch
|
||||||
|
predated the `videoId`/`itemCount`/`watched`/`feedUrl` fields.)*
|
||||||
- [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
|
- [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
|
||||||
(keyed by video id); first playlist seen wins the folder assignment (surface this choice
|
(keyed by video id); first playlist seen wins the folder assignment (surface this choice
|
||||||
in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
|
in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
|
||||||
|
|||||||
+45
-35
@@ -32,9 +32,10 @@ filename template · yt-dlp version check · NSIS installer + portable build.
|
|||||||
|
|
||||||
A shared `DownloadOptions` model (`src/shared/ipc.ts`) carries the post-processing
|
A shared `DownloadOptions` model (`src/shared/ipc.ts`) carries the post-processing
|
||||||
choices end-to-end. Persisted defaults are editable in **Settings → Format &
|
choices end-to-end. Persisted defaults are editable in **Settings → Format &
|
||||||
post-processing** via a reusable `DownloadOptionsForm` component, which the download
|
post-processing** via a reusable `DownloadOptionsForm` component. (The per-download
|
||||||
bar also hosts in a collapsible per-download **Options** panel. The main process emits
|
**Options** panel in the download bar is plumbed end-to-end — `StartDownloadOptions.options`/
|
||||||
the flags in `buildArgs` (`src/main/download.ts`). All items verified in the UI preview
|
`extraArgs` — but not yet surfaced in the bar UI; see audit UX1/M5.) The main process emits
|
||||||
|
the flags in `buildArgs` (`src/main/buildArgs.ts`). All items verified in the UI preview
|
||||||
(typecheck clean). **Real-download smoke test ✅ (2026-06-23):** crop-to-square cover,
|
(typecheck clean). **Real-download smoke test ✅ (2026-06-23):** crop-to-square cover,
|
||||||
audio re-encode (opus), video container + codec preference (mkv + vp9 merge), subtitle
|
audio re-encode (opus), video container + codec preference (mkv + vp9 merge), subtitle
|
||||||
embed (→ mov_text), chapter embed, and SponsorBlock-remove (output duration shrinks by the
|
embed (→ mov_text), chapter embed, and SponsorBlock-remove (output duration shrinks by the
|
||||||
@@ -55,7 +56,7 @@ found and fixed a real shipping bug — the bundled `ffprobe.exe` was missing (s
|
|||||||
- [x] **Video container + codec preference.** Container choice (mp4/mkv/webm) +
|
- [x] **Video container + codec preference.** Container choice (mp4/mkv/webm) +
|
||||||
preferred codec (`-S res,fps,vcodec:…`) to nudge toward av1 / vp9 / h264 without
|
preferred codec (`-S res,fps,vcodec:…`) to nudge toward av1 / vp9 / h264 without
|
||||||
overriding the requested resolution.
|
overriding the requested resolution.
|
||||||
- [x] **Embed chapters.** `--embed-chapters` toggle. (`--split-chapters` still TODO.)
|
- [x] **Embed chapters.** `--embed-chapters` toggle. (`--split-chapters` shipped in Phase L.)
|
||||||
- [x] **Embed thumbnail crop-to-square** for audio (Seal's "crop artwork") via thumbnail
|
- [x] **Embed thumbnail crop-to-square** for audio (Seal's "crop artwork") via thumbnail
|
||||||
post-processor args. *Smoke-tested ✅: the embedded cover comes out a perfect square
|
post-processor args. *Smoke-tested ✅: the embedded cover comes out a perfect square
|
||||||
(360×360) — the `--ppa` crop recipe survives yt-dlp's shlex split + ffmpeg's filtergraph.*
|
(360×360) — the `--ppa` crop recipe survives yt-dlp's shlex split + ffmpeg's filtergraph.*
|
||||||
@@ -126,10 +127,12 @@ extraArgs ordering). **Smoke-tested ✅ (2026-06-23):** `parseExtraArgs('--write
|
|||||||
the Custom commands card). The **"run custom command" mode** is
|
the Custom commands card). The **"run custom command" mode** is
|
||||||
`Settings.customCommandEnabled`, applied to every new download unless overridden
|
`Settings.customCommandEnabled`, applied to every new download unless overridden
|
||||||
per-download in the download bar's Custom command panel.
|
per-download in the download bar's Custom command panel.
|
||||||
- [x] **Command preview.** `IpcChannels.commandPreview` → `previewCommand()`
|
- [ ] **Command preview (plumbed, not surfaced).** `IpcChannels.commandPreview` →
|
||||||
(`src/main/download.ts`) builds the exact argv via the same `buildCommand()` path
|
`previewCommand()` (`src/main/download.ts`) builds the exact argv via the same
|
||||||
a real download would take, then renders it with `formatCommandLine`. Surfaced as
|
`buildCommand()` path a real download would take, then renders it with
|
||||||
a **Preview command** button in the download bar, with a Copy button.
|
`formatCommandLine`. The **Preview command** button is NOT yet in the download bar —
|
||||||
|
only the main/preload/buildArgs chain exists. Wire it into the per-download Options
|
||||||
|
panel or remove the chain (audit M5/UX1).
|
||||||
- [x] **In-app yt-dlp updater.** `updateYtdlp(channel)` (`src/main/ytdlp.ts`) runs
|
- [x] **In-app yt-dlp updater.** `updateYtdlp(channel)` (`src/main/ytdlp.ts`) runs
|
||||||
yt-dlp's own `--update-to stable|nightly`. Surfaced in **Settings → About**, right
|
yt-dlp's own `--update-to stable|nightly`. Surfaced in **Settings → About**, right
|
||||||
next to the existing version check, with a channel picker + result output.
|
next to the existing version check, with a channel picker + result output.
|
||||||
@@ -154,9 +157,12 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
|
|||||||
- [x] **Native OS notifications** on completion / failure (Electron `Notification`,
|
- [x] **Native OS notifications** on completion / failure (Electron `Notification`,
|
||||||
`src/main/download.ts`). Gated by a new **Notify when downloads finish** switch
|
`src/main/download.ts`). Gated by a new **Notify when downloads finish** switch
|
||||||
(`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window.
|
(`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window.
|
||||||
- [x] **Private / incognito mode** — a download bar toggle (`DownloadBar.tsx`, sticky like an
|
- [ ] **Private / incognito mode (plumbed, not surfaced).** `DownloadItem.incognito` flows
|
||||||
incognito tab) sets `DownloadItem.incognito`, which `store/downloads.ts` checks before
|
through `buildItem` → history-skip → the QueueItem "Private" badge, and
|
||||||
ever calling `useHistory().add(...)`. Queue items show an eye-off badge when private.
|
`store/downloads.ts` checks it before ever calling `useHistory().add(...)`. But no UI
|
||||||
|
control sets `incognito: true` yet — the download-bar toggle is not wired (audit M6/UX1).
|
||||||
|
Note `download.ts` would still log a failure / show a completion title for a private item,
|
||||||
|
so the "private" promise currently covers history only (audit L136).
|
||||||
- [x] **Backup / restore** settings + templates (export / import JSON). New
|
- [x] **Backup / restore** settings + templates (export / import JSON). New
|
||||||
`src/main/backup.ts` writes/reads `{ settings, templates }` via a save/open dialog;
|
`src/main/backup.ts` writes/reads `{ settings, templates }` via a save/open dialog;
|
||||||
`replaceTemplates()` (`src/main/templates.ts`) does a full restore rather than a
|
`replaceTemplates()` (`src/main/templates.ts`) does a full restore rather than a
|
||||||
@@ -171,9 +177,9 @@ 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.
|
Some items are Android-specific in Seal and adapted to Windows here.
|
||||||
|
|
||||||
- [x] **Theme presets + "follow system" and high-contrast.** Settings → Appearance has a
|
- [x] **Theme presets + "follow system" and high-contrast.** Settings → Appearance has a
|
||||||
Theme select (Light / Dark / **Follow system**) and four accent presets — Toffee
|
Theme select (Light / Dark / **Follow system**) and four accent presets — Rose,
|
||||||
(original), Slate, Evergreen, Lavender — each a 16-stop `BrandVariants` ramp sharing
|
Coral, Amber, Teal ("Sunset-to-sea", default Teal) — each a 16-stop `BrandVariants`
|
||||||
toffee's lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow
|
ramp sharing one lightness curve at a different hue (`src/renderer/src/theme.ts`). "Follow
|
||||||
system" reads Electron's `nativeTheme.shouldUseDarkColors` in the main process
|
system" reads Electron's `nativeTheme.shouldUseDarkColors` in the main process
|
||||||
(`src/main/index.ts`'s `resolveBackgroundMode`/`registerSystemThemeBridge`, pushed to
|
(`src/main/index.ts`'s `resolveBackgroundMode`/`registerSystemThemeBridge`, pushed to
|
||||||
the renderer over a new `system-theme:update` channel into
|
the renderer over a new `system-theme:update` channel into
|
||||||
@@ -184,8 +190,8 @@ Some items are Android-specific in Seal and adapted to Windows here.
|
|||||||
Chromium's native `forced-colors` handling since nothing in the app sets
|
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
|
`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
|
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,
|
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Teal,
|
||||||
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
|
switching to Rose/Coral/Amber, Light/Dark/Follow-system, and the sidebar
|
||||||
toggle's break-out-of-system behavior all checked visually.
|
toggle's break-out-of-system behavior all checked visually.
|
||||||
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
|
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
|
||||||
(non-MSIX) app can't register as an actual Share Target:
|
(non-MSIX) app can't register as an actual Share Target:
|
||||||
@@ -285,14 +291,11 @@ YTDLnis's signature differentiator. The keyframe plumbing already exists in Aero
|
|||||||
keeps the full file and writes per-chapter files via its default `chapter:` template.
|
keeps the full file and writes per-chapter files via its default `chapter:` template.
|
||||||
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
|
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
|
||||||
still pending.*
|
still pending.*
|
||||||
- [ ] **Metadata editing before download.** Change title / author / artist pre-download
|
- [x] **Metadata editing before download.** Change title / author / artist pre-download
|
||||||
(YTDLnis: "modify metadata such as title and author"). → `--parse-metadata` /
|
(YTDLnis: "modify metadata such as title and author"). Uses `--replace-in-metadata FIELD ^.*$ VALUE`
|
||||||
`--replace-in-metadata`; fits the audio path. Good for building a clean music library
|
(not `--parse-metadata FROM:TO` which splits on colons). Only backslashes need escaping in the
|
||||||
where the source title is messy. *Held: the exact set-a-literal recipe is fragile
|
replacement string. Title, Artist, Album inputs in `DownloadOptionsForm.tsx`; setting any override
|
||||||
(`--parse-metadata FROM:TO` splits on a colon that titles often contain;
|
implicitly enables `--embed-metadata`. Unit-tested in `buildArgs.test.ts`.
|
||||||
`--replace-in-metadata FIELD REGEX REPLACE` has empty-field + regex-replacement edge
|
|
||||||
cases) — confirm against a live yt-dlp run before wiring it, rather than guessing the
|
|
||||||
flag.*
|
|
||||||
|
|
||||||
## Phase M — Queue & daily-use UX ✅ COMPLETE
|
## Phase M — Queue & daily-use UX ✅ COMPLETE
|
||||||
|
|
||||||
@@ -339,8 +342,10 @@ Today `src/renderer/src/store/downloads.ts` has cancel + retry only.
|
|||||||
`datetime-local` input in `DownloadBar.tsx` (future time parks the new download as
|
`datetime-local` input in `DownloadBar.tsx` (future time parks the new download as
|
||||||
scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`;
|
scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`;
|
||||||
saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is
|
saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is
|
||||||
for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while
|
for *sources*). The queue is now persisted across restarts (audit M4 — `queue.json` via
|
||||||
AeroFetch is running** — noted in the UI hint and the code.*
|
`src/main/queue.ts`), so a parked/scheduled item survives a quit and its schedule fires on the
|
||||||
|
next launch once due. (The app must still be running at the scheduled moment for an immediate
|
||||||
|
fire; a schedule that came due while closed fires at the next launch.)*
|
||||||
|
|
||||||
## Phase N — Power-user surface ✅ COMPLETE
|
## Phase N — Power-user surface ✅ COMPLETE
|
||||||
|
|
||||||
@@ -384,9 +389,10 @@ All in the main process. typecheck + test + `npm run build` clean.
|
|||||||
- [x] **Jump list.** *Done: `app.setUserTasks` adds an "Open AeroFetch" task (focuses the
|
- [x] **Jump list.** *Done: `app.setUserTasks` adds an "Open AeroFetch" task (focuses the
|
||||||
single instance). **Thumbnail toolbar buttons deferred** — they need per-button `NativeImage`
|
single instance). **Thumbnail toolbar buttons deferred** — they need per-button `NativeImage`
|
||||||
assets this repo doesn't have tooling to generate here.*
|
assets this repo doesn't have tooling to generate here.*
|
||||||
- [ ] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state.
|
- [x] **Taskbar overlay badge** (`win.setOverlayIcon`) showing active-download count / error state.
|
||||||
*Deferred: a numeric/status badge needs a drawn `NativeImage` (no canvas in main); the
|
Green dot when downloading, red dot on error, cleared when idle. Pure-JS PNG generator in
|
||||||
taskbar progress bar's `error` mode already signals failures.*
|
`src/main/badge.ts` (Buffer + zlib, no new deps); badgeCount threaded through the existing
|
||||||
|
`taskbarProgress` IPC channel from `summarizeQueue`.
|
||||||
- [x] **Settings discoverability.** *Done: a search box atop `SettingsView` filters the ~11 cards
|
- [x] **Settings discoverability.** *Done: a search box atop `SettingsView` filters the ~11 cards
|
||||||
live by matching each card's text (DOM `display` toggle keyed off the root's children — no
|
live by matching each card's text (DOM `display` toggle keyed off the root's children — no
|
||||||
per-card refactor), with a "no settings match" hint.*
|
per-card refactor), with a "no settings match" hint.*
|
||||||
@@ -400,17 +406,21 @@ build clean); the parts that inherently need a human or a paid cert are delivere
|
|||||||
`youtubePoToken` → one `--extractor-args "youtube:player_client=…;po_token=…"` group
|
`youtubePoToken` → one `--extractor-args "youtube:player_client=…;po_token=…"` group
|
||||||
(`accessArgs` in `buildArgs.ts`, unit-tested), with "advanced" fields in Settings → Network.
|
(`accessArgs` in `buildArgs.ts`, unit-tested), with "advanced" fields in Settings → Network.
|
||||||
Lets a power user switch extraction client (`web_safari`/`tv`/`mweb`) or paste a token.*
|
Lets a power user switch extraction client (`web_safari`/`tv`/`mweb`) or paste a token.*
|
||||||
**Deferred: automatic WebView token minting** (the YTDLnis headline) — the hard part;
|
**Automatic WebView token minting shipped:** `src/main/poToken.ts` opens a YouTube video
|
||||||
this is the argv plumbing it would feed. Cookies remain the easier bot-check fix.
|
page in a hardened BrowserWindow (shared login session), injects an async poll for
|
||||||
|
`ytInitialPlayerResponse.serviceIntegrityDimensions.poToken`, saves the result encrypted
|
||||||
|
via `safeStorage`. "Fetch" button in Settings → Network triggers it via `youtube:po-token-mint`
|
||||||
|
IPC. Falls back gracefully (null) if YouTube's page structure changes.
|
||||||
- [x] **Verify the shipped-but-untested OS wiring.** *Can't be executed here (no real install).
|
- [x] **Verify the shipped-but-untested OS wiring.** *Can't be executed here (no real install).
|
||||||
Delivered as a release-gate checklist — [docs/SMOKE-TEST.md](docs/SMOKE-TEST.md) —
|
Delivered as a release-gate checklist — [docs/SMOKE-TEST.md](docs/SMOKE-TEST.md) —
|
||||||
enumerating every untested path (cookies window, `aerofetch://` + Send-to, scheduled
|
enumerating every untested path (cookies window, `aerofetch://` + Send-to, scheduled
|
||||||
`--sync` + RSS, aria2c, proxy, plus the new tray/taskbar/pause-resume/terminal) with exact
|
`--sync` + RSS, aria2c, proxy, plus the new tray/taskbar/pause-resume/terminal) with exact
|
||||||
steps + expected results. **Still needs a human to run it.**
|
steps + expected results. **Still needs a human to run it.**
|
||||||
- [x] **Code signing.** *Build is signing-ready: electron-builder picks the cert up from
|
- [x] **Code signing — non-goal.** *Decision: no certificate will be purchased, so builds ship
|
||||||
`CSC_LINK` / `CSC_KEY_PASSWORD` with no yml change (unset = unsigned, as today). Documented
|
unsigned and the SmartScreen first-run prompt is accepted. The build stays signing-ready —
|
||||||
in [docs/SIGNING.md](docs/SIGNING.md) (OV vs EV, local + CI, HSM/EV hook, signature
|
electron-builder picks the cert up from `CSC_LINK` / `CSC_KEY_PASSWORD` with no yml change
|
||||||
verification). **Actual signing needs a purchased certificate.***
|
(unset = unsigned, as today), documented in [docs/SIGNING.md](docs/SIGNING.md) (OV vs EV,
|
||||||
|
local + CI, HSM/EV hook, signature verification) — if that decision is ever reversed.*
|
||||||
- [ ] **i18n / language setting.** Still deferred — deliberately not faked. Shipping a language
|
- [ ] **i18n / language setting.** Still deferred — deliberately not faked. Shipping a language
|
||||||
picker that doesn't change anything would be misleading; doing it properly means wiring an
|
picker that doesn't change anything would be misleading; doing it properly means wiring an
|
||||||
i18n library + extracting every string, with translations arriving incrementally. Tracked,
|
i18n library + extracting every string, with translations arriving incrementally. Tracked,
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 361 KiB |
+23
-9
@@ -1,12 +1,26 @@
|
|||||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- Placeholder app icon (audit M3). Mirrors the in-app brand mark in
|
<!-- AeroFetch app icon (audit W14). A white download glyph — shaft + chevron over
|
||||||
Onboarding.tsx: a white download arrow on a rounded square in the default
|
a landing shelf — on the teal brand square, with a top-lit vertical gradient
|
||||||
"teal" accent (theme.ts teal[80] = colorBrandBackground in light mode).
|
drawn from the teal accent ramp (theme.ts teal 50→90, centred on teal[80]
|
||||||
Replace with a designed asset before v1.0; regenerate icon.ico from this. -->
|
#148185, the light-mode colorBrandBackground) and a soft upper sheen for depth.
|
||||||
<rect x="0" y="0" width="256" height="256" rx="52" ry="52" fill="#148185"/>
|
The bold rounded strokes stay legible down to 16px. Regenerate the multi-size
|
||||||
<g fill="#ffffff">
|
.ico after any edit:
|
||||||
<rect x="112" y="46" width="32" height="88" rx="12" ry="12"/>
|
magick -background none build/icon.svg -define icon:auto-resize=256,128,64,48,32,16 build/icon.ico -->
|
||||||
<polygon points="82,114 174,114 128,176"/>
|
<defs>
|
||||||
<rect x="66" y="190" width="124" height="20" rx="10" ry="10"/>
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1aa6ab"/>
|
||||||
|
<stop offset="1" stop-color="#0d595c"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ffffff" stop-opacity="0.16"/>
|
||||||
|
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="256" height="256" rx="56" ry="56" fill="url(#bg)"/>
|
||||||
|
<rect x="0" y="0" width="256" height="256" rx="56" ry="56" fill="url(#sheen)"/>
|
||||||
|
<g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M128 56 L128 150" stroke-width="30"/>
|
||||||
|
<path d="M84 116 L128 160 L172 116" stroke-width="30"/>
|
||||||
|
<path d="M80 200 L176 200" stroke-width="26"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 699 B After Width: | Height: | Size: 1.4 KiB |
+14
-6
@@ -1,6 +1,6 @@
|
|||||||
appId: com.aerofetch.app
|
appId: com.aerofetch.app
|
||||||
productName: AeroFetch
|
productName: AeroFetch
|
||||||
copyright: yt-dlp frontend
|
copyright: Copyright © 2026 AeroFetch
|
||||||
|
|
||||||
# Lets a browser/another app hand AeroFetch a link via aerofetch://download?url=<encoded>
|
# Lets a browser/another app hand AeroFetch a link via aerofetch://download?url=<encoded>
|
||||||
# (src/main/deeplink.ts) — the closest Windows analog to Android's share-to-app intent.
|
# (src/main/deeplink.ts) — the closest Windows analog to Android's share-to-app intent.
|
||||||
@@ -15,13 +15,18 @@ directories:
|
|||||||
buildResources: build
|
buildResources: build
|
||||||
output: dist
|
output: dist
|
||||||
|
|
||||||
|
# Generate a <installer>.exe.sha256 next to every built .exe (H8). The updater
|
||||||
|
# hard-requires this checksum asset or it refuses to install the update.
|
||||||
|
afterAllArtifactBuild: ./scripts/generate-checksums.cjs
|
||||||
|
|
||||||
files:
|
files:
|
||||||
- '!**/.vscode/*'
|
- '!**/.vscode/*'
|
||||||
- '!src/*'
|
- '!src/*'
|
||||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||||
- '!{.eslintignore,.eslintrc.cjs,.eslintrc.js,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
- '!{eslint.config.js,.prettierrc,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,package-lock.json}'
|
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml,package-lock.json}'
|
||||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json,tsconfig.tsbuildinfo}'
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
|
- '!*.tsbuildinfo'
|
||||||
|
|
||||||
# yt-dlp.exe + ffmpeg.exe ship outside the asar archive so they can be spawned
|
# yt-dlp.exe + ffmpeg.exe ship outside the asar archive so they can be spawned
|
||||||
# directly. They land in <install>/resources/bin, reachable via process.resourcesPath.
|
# directly. They land in <install>/resources/bin, reachable via process.resourcesPath.
|
||||||
@@ -46,9 +51,9 @@ win:
|
|||||||
- portable
|
- portable
|
||||||
# Never request admin elevation.
|
# Never request admin elevation.
|
||||||
requestedExecutionLevel: asInvoker
|
requestedExecutionLevel: asInvoker
|
||||||
# Placeholder icon (audit M3): white download glyph on the teal brand square,
|
# App icon (audit W14): white download glyph on the teal brand square with a
|
||||||
# mirroring the in-app brand mark. Generated from build/icon.svg as a multi-size
|
# top-lit gradient. Generated from build/icon.svg as a multi-size .ico
|
||||||
# .ico (256/128/64/48/32/16). Replace with a designed asset before v1.0 — re-run
|
# (256/128/64/48/32/16). After editing the SVG, regenerate with:
|
||||||
# magick -background none build/icon.svg -define icon:auto-resize=256,128,64,48,32,16 build/icon.ico
|
# magick -background none build/icon.svg -define icon:auto-resize=256,128,64,48,32,16 build/icon.ico
|
||||||
icon: build/icon.ico
|
icon: build/icon.ico
|
||||||
|
|
||||||
@@ -63,4 +68,7 @@ nsis:
|
|||||||
perMachine: false
|
perMachine: false
|
||||||
allowToChangeInstallationDirectory: true
|
allowToChangeInstallationDirectory: true
|
||||||
deleteAppDataOnUninstall: false
|
deleteAppDataOnUninstall: false
|
||||||
|
# The custom updater (src/main/updater.ts) does a full download and never consumes
|
||||||
|
# the differential .blockmap, so don't emit it (audit L83 — dead build artifact).
|
||||||
|
differentialPackage: false
|
||||||
# Flip perMachine to true for an all-users install to Program Files (requires admin).
|
# Flip perMachine to true for an all-users install to Program Files (requires admin).
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
'@shared': resolve('src/shared')
|
'@shared': resolve('src/shared')
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
// Hidden source maps: emit .map files so a field crash stack (the logger writes
|
||||||
|
// raw stacks, CC8) can be symbolicated offline, without referencing/exposing them
|
||||||
|
// in the shipped bundle (L170).
|
||||||
|
build: { sourcemap: 'hidden' }
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin()],
|
||||||
@@ -21,6 +25,7 @@ export default defineConfig({
|
|||||||
// Emit CommonJS (.cjs): sandboxed preload scripts must be CJS, and enabling
|
// Emit CommonJS (.cjs): sandboxed preload scripts must be CJS, and enabling
|
||||||
// the sandbox (webPreferences.sandbox) is part of the security hardening.
|
// the sandbox (webPreferences.sandbox) is part of the security hardening.
|
||||||
build: {
|
build: {
|
||||||
|
sourcemap: 'hidden',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
@@ -36,6 +41,7 @@ export default defineConfig({
|
|||||||
'@shared': resolve('src/shared')
|
'@shared': resolve('src/shared')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [react()]
|
plugins: [react()],
|
||||||
|
build: { sourcemap: 'hidden' }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import globals from 'globals'
|
||||||
|
import prettier from 'eslint-config-prettier'
|
||||||
|
|
||||||
|
// Flat config (ESLint 9 + typescript-eslint 8). Codifies the existing house style
|
||||||
|
// (CC2): typescript-eslint's recommended logic/type rules, with formatting left
|
||||||
|
// entirely to Prettier (eslint-config-prettier turns off any stylistic rules).
|
||||||
|
// Run with `npm run lint`; `npm run format` applies Prettier.
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['out/**', 'dist/**', 'node_modules/**', '**/*.cjs'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node, ...globals.browser }
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Empty catch blocks are a deliberate best-effort pattern across the app.
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
// Allow a documented @ts-ignore. The preload's window fallback needs one
|
||||||
|
// whose necessity is incremental-build-state-dependent, so @ts-expect-error
|
||||||
|
// (which errors when momentarily unused) is the wrong tool there.
|
||||||
|
'@typescript-eslint/ban-ts-comment': [
|
||||||
|
'error',
|
||||||
|
{ 'ts-ignore': 'allow-with-description', minimumDescriptionLength: 3 }
|
||||||
|
],
|
||||||
|
// Allow intentionally-unused names prefixed with `_` (e.g. IPC event args).
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// TypeScript already resolves identifiers/types, so core no-undef only
|
||||||
|
// false-positives on globals and type-only references here.
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
rules: { 'no-undef': 'off' }
|
||||||
|
},
|
||||||
|
// Keep Prettier last so it wins over any formatting-related rule.
|
||||||
|
prettier
|
||||||
|
)
|
||||||
Generated
+1218
-1
File diff suppressed because it is too large
Load Diff
+20
-3
@@ -4,7 +4,12 @@
|
|||||||
"description": "A yt-dlp frontend for Windows",
|
"description": "A yt-dlp frontend for Windows",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "AeroFetch",
|
"author": "AeroFetch",
|
||||||
"homepage": "https://github.com/yt-dlp/yt-dlp",
|
"license": "UNLICENSED",
|
||||||
|
"homepage": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch.git"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,12 +19,18 @@
|
|||||||
"ui:build": "vite build",
|
"ui:build": "vite build",
|
||||||
"ui:preview": "vite preview",
|
"ui:preview": "vite preview",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"build:win": "electron-vite build && electron-builder --win",
|
"clean:dist": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||||
|
"build:win": "npm run clean:dist && electron-vite build && electron-builder --win",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"test": "vitest run"
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:integration": "set AEROFETCH_REAL_DOWNLOAD=1&& vitest run test/real-download.integration.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
@@ -33,6 +44,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.0.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -40,7 +52,12 @@
|
|||||||
"electron": "^42.4.1",
|
"electron": "^42.4.1",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"globals": "^15.15.0",
|
||||||
|
"prettier": "^3.9.3",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.62.1",
|
||||||
"vite": "^7.3.5",
|
"vite": "^7.3.5",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// electron-builder `afterAllArtifactBuild` hook (H8).
|
||||||
|
//
|
||||||
|
// The in-app updater (src/main/updater.ts) sets REQUIRE_CHECKSUM = true and
|
||||||
|
// refuses any release whose installer lacks a sibling `<installer>.exe.sha256`
|
||||||
|
// asset — so a release published without one makes EVERY client's in-app update
|
||||||
|
// fail. `build:win` never generated these, so they had to be made by hand (and
|
||||||
|
// the 0.5.0 build shipped without any). This hook writes one next to every built
|
||||||
|
// `.exe`, in `sha256sum` format (`<lowercase-hex> <filename>`), which the
|
||||||
|
// updater's extractSha256 parses (it just needs a standalone 64-char hex token).
|
||||||
|
//
|
||||||
|
// Returning the paths tells electron-builder to also upload them as release
|
||||||
|
// assets when publishing.
|
||||||
|
|
||||||
|
const { createHash } = require('crypto')
|
||||||
|
const { readFileSync, writeFileSync } = require('fs')
|
||||||
|
const { basename } = require('path')
|
||||||
|
|
||||||
|
/** The `sha256sum`-format line for a file: "<lowercase-hex> <basename>". */
|
||||||
|
function checksumLine(filePath) {
|
||||||
|
const hex = createHash('sha256').update(readFileSync(filePath)).digest('hex')
|
||||||
|
return `${hex} ${basename(filePath)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.default = function generateChecksums(context) {
|
||||||
|
const written = []
|
||||||
|
for (const file of context.artifactPaths) {
|
||||||
|
if (!/\.exe$/i.test(file)) continue
|
||||||
|
const out = `${file}.sha256`
|
||||||
|
writeFileSync(out, checksumLine(file) + '\n')
|
||||||
|
written.push(out)
|
||||||
|
}
|
||||||
|
return written
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported for unit testing the hashing/format without running electron-builder.
|
||||||
|
exports.checksumLine = checksumLine
|
||||||
+30
-20
@@ -1,14 +1,9 @@
|
|||||||
import { dialog, type BrowserWindow } from 'electron'
|
import { dialog, type BrowserWindow } from 'electron'
|
||||||
import { readFileSync, writeFileSync } from 'fs'
|
import { readFileSync, writeFileSync } from 'fs'
|
||||||
import { getSettings, setSettings } from './settings'
|
import { getSettings, setSettings, SECRET_KEYS } from './settings'
|
||||||
import { listTemplates, replaceTemplates } from './templates'
|
import { listTemplates, replaceTemplates } from './templates'
|
||||||
import { isTemplateLike } from './validation'
|
import { isTemplateLike } from './validation'
|
||||||
import type {
|
import type { BackupExportResult, BackupImportResult, Settings, CommandTemplate } from '@shared/ipc'
|
||||||
BackupExportResult,
|
|
||||||
BackupImportResult,
|
|
||||||
Settings,
|
|
||||||
CommandTemplate
|
|
||||||
} from '@shared/ipc'
|
|
||||||
|
|
||||||
interface BackupFile {
|
interface BackupFile {
|
||||||
version: 1
|
version: 1
|
||||||
@@ -17,12 +12,18 @@ interface BackupFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> {
|
export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> {
|
||||||
const res = await dialog.showSaveDialog(win!, {
|
const opts = {
|
||||||
defaultPath: 'aerofetch-backup.json',
|
defaultPath: 'aerofetch-backup.json',
|
||||||
filters: [{ name: 'JSON', extensions: ['json'] }]
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
||||||
})
|
}
|
||||||
|
const res = await (win ? dialog.showSaveDialog(win, opts) : dialog.showSaveDialog(opts))
|
||||||
if (res.canceled || !res.filePath) return { ok: false }
|
if (res.canceled || !res.filePath) return { ok: false }
|
||||||
const payload: BackupFile = { version: 1, settings: getSettings(), templates: listTemplates() }
|
// Strip credentials (proxy creds, API tokens) so a backup file shared or synced
|
||||||
|
// to the cloud doesn't leak secrets in plaintext (M22). The user re-enters them
|
||||||
|
// after import. Shares SECRET_KEYS with settings.ts so the lists can't drift.
|
||||||
|
const settings = { ...getSettings() }
|
||||||
|
for (const key of SECRET_KEYS) (settings as Record<string, unknown>)[key] = ''
|
||||||
|
const payload: BackupFile = { version: 1, settings, templates: listTemplates() }
|
||||||
try {
|
try {
|
||||||
writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
|
writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
|
||||||
return { ok: true, path: res.filePath }
|
return { ok: true, path: res.filePath }
|
||||||
@@ -32,10 +33,11 @@ export async function exportBackup(win: BrowserWindow | undefined): Promise<Back
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> {
|
export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> {
|
||||||
const res = await dialog.showOpenDialog(win!, {
|
const openOpts = {
|
||||||
properties: ['openFile'],
|
properties: ['openFile' as const],
|
||||||
filters: [{ name: 'JSON', extensions: ['json'] }]
|
filters: [{ name: 'JSON', extensions: ['json'] }]
|
||||||
})
|
}
|
||||||
|
const res = await (win ? dialog.showOpenDialog(win, openOpts) : dialog.showOpenDialog(openOpts))
|
||||||
if (res.canceled || !res.filePaths[0]) return { ok: false }
|
if (res.canceled || !res.filePaths[0]) return { ok: false }
|
||||||
|
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
@@ -85,10 +87,9 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
|||||||
return `• ${name}: ${args}`
|
return `• ${name}: ${args}`
|
||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
const more =
|
const more = commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
|
||||||
commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
|
const msgOpts = {
|
||||||
const choice = await dialog.showMessageBox(win!, {
|
type: 'warning' as const,
|
||||||
type: 'warning',
|
|
||||||
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
|
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
|
||||||
defaultId: 1,
|
defaultId: 1,
|
||||||
cancelId: 2,
|
cancelId: 2,
|
||||||
@@ -100,17 +101,26 @@ export async function importBackup(win: BrowserWindow | undefined): Promise<Back
|
|||||||
'Custom commands run extra yt-dlp flags on every download. Only enable them if you trust this backup file.\n\n' +
|
'Custom commands run extra yt-dlp flags on every download. Only enable them if you trust this backup file.\n\n' +
|
||||||
preview +
|
preview +
|
||||||
more
|
more
|
||||||
})
|
}
|
||||||
|
const choice = await (win
|
||||||
|
? dialog.showMessageBox(win, msgOpts)
|
||||||
|
: dialog.showMessageBox(msgOpts))
|
||||||
if (choice.response === 2) return { ok: false } // Cancel — change nothing
|
if (choice.response === 2) return { ok: false } // Cancel — change nothing
|
||||||
applyCustomCommands = choice.response === 0
|
applyCustomCommands = choice.response === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incomingSettings) {
|
if (incomingSettings) {
|
||||||
|
// Never restore credential fields from a backup. Exports strip them (M22), so an
|
||||||
|
// imported '' would otherwise wipe a proxy/token already configured on this
|
||||||
|
// machine; honoring a hand-edited one would reintroduce the leak vector. Either
|
||||||
|
// way, import leaves the user's existing secrets untouched — they re-enter as needed.
|
||||||
|
const restored: Partial<Settings> = { ...incomingSettings }
|
||||||
|
for (const key of SECRET_KEYS) delete restored[key]
|
||||||
// When the user declined to enable custom commands (or there were none to
|
// When the user declined to enable custom commands (or there were none to
|
||||||
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
|
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
|
||||||
const safeSettings = applyCustomCommands
|
const safeSettings = applyCustomCommands
|
||||||
? incomingSettings
|
? restored
|
||||||
: { ...incomingSettings, customCommandEnabled: false }
|
: { ...restored, customCommandEnabled: false }
|
||||||
setSettings(safeSettings)
|
setSettings(safeSettings)
|
||||||
}
|
}
|
||||||
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
|
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { deflateSync } from 'zlib'
|
||||||
|
import { nativeImage, type NativeImage } from 'electron'
|
||||||
|
|
||||||
|
// CRC32 table polynomial used by PNG.
|
||||||
|
function crc32(buf: Buffer): number {
|
||||||
|
let crc = 0xffffffff
|
||||||
|
for (const b of buf) {
|
||||||
|
crc ^= b
|
||||||
|
for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngChunk(type: string, data: Buffer): Buffer {
|
||||||
|
const t = Buffer.from(type, 'ascii')
|
||||||
|
const len = Buffer.alloc(4)
|
||||||
|
len.writeUInt32BE(data.length)
|
||||||
|
const crc = Buffer.alloc(4)
|
||||||
|
crc.writeUInt32BE(crc32(Buffer.concat([t, data])))
|
||||||
|
return Buffer.concat([len, t, data, crc])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a 16×16 RGBA PNG containing a solid-colour filled circle. Used for the
|
||||||
|
* Windows taskbar overlay badge. No external deps — pure Node.js (Buffer + zlib).
|
||||||
|
*/
|
||||||
|
function makeDotPng(r: number, g: number, b: number): NativeImage {
|
||||||
|
const W = 16,
|
||||||
|
H = 16
|
||||||
|
const px = Buffer.alloc(W * H * 4, 0) // transparent bg
|
||||||
|
const cx = W / 2,
|
||||||
|
cy = H / 2,
|
||||||
|
rad = W / 2 - 1.5
|
||||||
|
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const dx = x + 0.5 - cx,
|
||||||
|
dy = y + 0.5 - cy
|
||||||
|
if (dx * dx + dy * dy <= rad * rad) {
|
||||||
|
const i = (y * W + x) * 4
|
||||||
|
px[i] = r
|
||||||
|
px[i + 1] = g
|
||||||
|
px[i + 2] = b
|
||||||
|
px[i + 3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNG: IHDR (8-bit RGBA, no interlace), filter-0 rows, deflated IDAT, IEND.
|
||||||
|
const ihdr = Buffer.alloc(13)
|
||||||
|
ihdr.writeUInt32BE(W, 0)
|
||||||
|
ihdr.writeUInt32BE(H, 4)
|
||||||
|
ihdr[8] = 8 // bit depth
|
||||||
|
ihdr[9] = 6 // colour type: RGBA
|
||||||
|
|
||||||
|
const rows = Buffer.alloc(H * (1 + W * 4))
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
rows[y * (1 + W * 4)] = 0 // filter type: None
|
||||||
|
px.copy(rows, y * (1 + W * 4) + 1, y * W * 4, (y + 1) * W * 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||||
|
const png = Buffer.concat([
|
||||||
|
sig,
|
||||||
|
pngChunk('IHDR', ihdr),
|
||||||
|
pngChunk('IDAT', deflateSync(rows)),
|
||||||
|
pngChunk('IEND', Buffer.alloc(0))
|
||||||
|
])
|
||||||
|
return nativeImage.createFromBuffer(png)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-initialised singletons — created on first use so this module is safe to
|
||||||
|
// import before the app is ready (nativeImage.createFromBuffer is fine post-ready).
|
||||||
|
let activeBadge: NativeImage | null = null
|
||||||
|
let errorBadge: NativeImage | null = null
|
||||||
|
|
||||||
|
/** Green dot badge for the taskbar overlay — shown when downloads are active. */
|
||||||
|
export function getActiveBadge(): NativeImage {
|
||||||
|
if (!activeBadge) activeBadge = makeDotPng(58, 185, 108) // #3ab96c
|
||||||
|
return activeBadge
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Red dot badge for the taskbar overlay — shown when a download has errored. */
|
||||||
|
export function getErrorBadge(): NativeImage {
|
||||||
|
if (!errorBadge) errorBadge = makeDotPng(217, 48, 37) // #d93025
|
||||||
|
return errorBadge
|
||||||
|
}
|
||||||
+23
-2
@@ -1,4 +1,4 @@
|
|||||||
import { app } from 'electron'
|
import { app, nativeImage, type NativeImage } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { is } from '@electron-toolkit/utils'
|
import { is } from '@electron-toolkit/utils'
|
||||||
|
|
||||||
@@ -24,6 +24,23 @@ export function getAppIconPath(): string {
|
|||||||
: join(process.resourcesPath, 'icon.ico')
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let appIconImage: NativeImage | null = null
|
||||||
|
/**
|
||||||
|
* The app icon as a cached NativeImage, for OS notifications (W13/L127). Loaded
|
||||||
|
* once from getAppIconPath(); a missing icon yields an empty image, which
|
||||||
|
* Notification treats as "no icon" (the OS default) rather than erroring. This
|
||||||
|
* gives completion/background toasts the real brand glyph — notably on the
|
||||||
|
* portable build, which has no installed AUMID shortcut icon for Windows to use.
|
||||||
|
*/
|
||||||
|
export function getAppIconImage(): NativeImage {
|
||||||
|
// Cache only a valid image so a transient read miss isn't latched forever; a
|
||||||
|
// genuinely-missing icon just re-reads (cheap — notifications are infrequent).
|
||||||
|
if (!appIconImage || appIconImage.isEmpty()) {
|
||||||
|
appIconImage = nativeImage.createFromPath(getAppIconPath())
|
||||||
|
}
|
||||||
|
return appIconImage
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
|
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
|
||||||
* from the read-only bundled seed in resources/bin. The managed copy is what
|
* from the read-only bundled seed in resources/bin. The managed copy is what
|
||||||
@@ -35,7 +52,7 @@ export function getAppIconPath(): string {
|
|||||||
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
|
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
|
||||||
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
|
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
|
||||||
*/
|
*/
|
||||||
export function getManagedBinDir(): string {
|
function getManagedBinDir(): string {
|
||||||
return join(app.getPath('userData'), 'bin')
|
return join(app.getPath('userData'), 'bin')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +80,10 @@ export function getFfprobePath(): string {
|
|||||||
return join(getBinDir(), 'ffprobe.exe')
|
return join(getBinDir(), 'ffprobe.exe')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-facing error shown by download/probe/index/update when yt-dlp.exe is missing. */
|
||||||
|
export const YTDLP_MISSING_MSG =
|
||||||
|
'yt-dlp.exe is missing. Open Settings → Software update to re-download it.'
|
||||||
|
|
||||||
/** Optional bundled external downloader; absent unless dropped into resources/bin. */
|
/** Optional bundled external downloader; absent unless dropped into resources/bin. */
|
||||||
export function getAria2cPath(): string {
|
export function getAria2cPath(): string {
|
||||||
return join(getBinDir(), 'aria2c.exe')
|
return join(getBinDir(), 'aria2c.exe')
|
||||||
|
|||||||
+79
-23
@@ -42,9 +42,7 @@ function videoFormat(quality: string): string {
|
|||||||
*/
|
*/
|
||||||
function videoSelector(opts: StartDownloadOptions): string {
|
function videoSelector(opts: StartDownloadOptions): string {
|
||||||
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
|
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
|
||||||
return opts.formatHasAudio
|
return opts.formatHasAudio ? opts.formatId : `${opts.formatId}+bestaudio/${opts.formatId}`
|
||||||
? opts.formatId
|
|
||||||
: `${opts.formatId}+bestaudio/${opts.formatId}`
|
|
||||||
}
|
}
|
||||||
return videoFormat(opts.quality)
|
return videoFormat(opts.quality)
|
||||||
}
|
}
|
||||||
@@ -57,18 +55,27 @@ function audioQuality(quality: string): string {
|
|||||||
return '192K'
|
return '192K'
|
||||||
case '128 kbps':
|
case '128 kbps':
|
||||||
return '128K'
|
return '128K'
|
||||||
|
case 'Best':
|
||||||
|
return '0'
|
||||||
default:
|
default:
|
||||||
return '0' // Best
|
// Unrecognised label — fall back to best (0) so the download still works.
|
||||||
|
return '0'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stdout line markers, shared by the emit side (the --print templates here) and
|
||||||
|
// the parse side (download.ts). Defined once so changing a marker can't silently
|
||||||
|
// break the parser that splits on it (CL1).
|
||||||
|
export const PROGRESS_MARKER = 'prog|'
|
||||||
|
export const FILEPATH_MARKER = 'path|'
|
||||||
|
|
||||||
// The progress line yt-dlp emits (one per --newline tick). Note the leading
|
// The progress line yt-dlp emits (one per --newline tick). Note the leading
|
||||||
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
|
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
|
||||||
// (it does NOT appear in the output). The literal `prog|` that follows is our
|
// (it does NOT appear in the output). The PROGRESS_MARKER that follows is our
|
||||||
// own marker, so we can tell progress lines apart from the after-move filepath
|
// own marker, so we can tell progress lines apart from the after-move filepath
|
||||||
// print on the same stdout stream.
|
// print on the same stdout stream.
|
||||||
export const PROGRESS_TEMPLATE =
|
const PROGRESS_TEMPLATE =
|
||||||
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
|
`download:${PROGRESS_MARKER}%(progress.status)s|%(progress.downloaded_bytes)s|` +
|
||||||
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
|
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
|
||||||
'%(progress.speed)s|%(progress.eta)s'
|
'%(progress.speed)s|%(progress.eta)s'
|
||||||
|
|
||||||
@@ -77,8 +84,7 @@ export const PROGRESS_TEMPLATE =
|
|||||||
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
|
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
|
||||||
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
|
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
|
||||||
export const CROP_SQUARE_PPA =
|
export const CROP_SQUARE_PPA =
|
||||||
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' + "crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
|
||||||
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase B "Access & networking" settings — not per-download options, always
|
* Phase B "Access & networking" settings — not per-download options, always
|
||||||
@@ -127,7 +133,9 @@ export function parseExtraArgs(raw: string): string[] {
|
|||||||
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
|
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
while ((m = re.exec(raw)) !== null) {
|
while ((m = re.exec(raw)) !== null) {
|
||||||
args.push(m[1] ?? m[2] ?? m[3])
|
// Exactly one of the three alternation groups matches; the '' fallback only
|
||||||
|
// satisfies the type checker (noUncheckedIndexedAccess) and never fires.
|
||||||
|
args.push(m[1] ?? m[2] ?? m[3] ?? '')
|
||||||
}
|
}
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
@@ -197,7 +205,10 @@ export function matchesUrl(pattern: string, url: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function parseTrimSections(raw: string | undefined): string[] {
|
export function parseTrimSections(raw: string | undefined): string[] {
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
|
// A time is SS, M:SS, or H:MM:SS — at most two colon-separated groups. The old
|
||||||
|
// `*` quantifier accepted nonsense like `1:2:3:4`, which then failed inside
|
||||||
|
// yt-dlp's --download-sections rather than being rejected up front (L146).
|
||||||
|
const TIME = String.raw`\d+(?::\d{1,2}){0,2}(?:\.\d+)?`
|
||||||
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
|
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
for (const piece of raw.split(/[,\n]/)) {
|
for (const piece of raw.split(/[,\n]/)) {
|
||||||
@@ -244,7 +255,11 @@ export function sanitizeDirSegment(name: string): string {
|
|||||||
// byte ever appears in this source file.
|
// byte ever appears in this source file.
|
||||||
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
|
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
|
||||||
s = s.replace(/[<>:"/\\|?*]/g, ' ')
|
s = s.replace(/[<>:"/\\|?*]/g, ' ')
|
||||||
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
|
s = s
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/[. ]+$/, '')
|
||||||
|
.replace(/^[. ]+/, '')
|
||||||
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
|
// Reserved device names are reserved even WITH an extension ('CON.txt' is still
|
||||||
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
|
// the CON device), so match an optional trailing '.<ext>' too (audit T3).
|
||||||
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
|
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(s)) s = `_${s}`
|
||||||
@@ -328,7 +343,23 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
|||||||
// Split into one file per chapter. yt-dlp also keeps the full file; the
|
// Split into one file per chapter. yt-dlp also keeps the full file; the
|
||||||
// per-chapter files use yt-dlp's default `chapter:` output template.
|
// per-chapter files use yt-dlp's default `chapter:` output template.
|
||||||
if (o.splitChapters) args.push('--split-chapters')
|
if (o.splitChapters) args.push('--split-chapters')
|
||||||
if (o.embedMetadata) args.push('--embed-metadata')
|
|
||||||
|
// Metadata embedding + optional per-field overrides.
|
||||||
|
// --replace-in-metadata FIELD REGEX REPLACE is used instead of --parse-metadata
|
||||||
|
// FROM:TO because the replacement arg is a separate element (no colon-splitting
|
||||||
|
// on values like "Foo: Bar"). ^.*$ matches any string including empty. The only
|
||||||
|
// escaping Python's re.sub needs in the replacement string is backslash.
|
||||||
|
const metaOverrides: [string, string | undefined][] = [
|
||||||
|
['title', o.metadataTitle],
|
||||||
|
['artist', o.metadataArtist],
|
||||||
|
['album', o.metadataAlbum]
|
||||||
|
]
|
||||||
|
const hasOverrides = metaOverrides.some(([, v]) => v?.trim())
|
||||||
|
if (o.embedMetadata || hasOverrides) args.push('--embed-metadata')
|
||||||
|
const escRepl = (s: string): string => s.replace(/\\/g, '\\\\')
|
||||||
|
for (const [field, val] of metaOverrides) {
|
||||||
|
if (val?.trim()) args.push('--replace-in-metadata', field, '^.*$', escRepl(val.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
// Media-server sidecar files (Phase K) — written next to the output file so
|
// Media-server sidecar files (Phase K) — written next to the output file so
|
||||||
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
|
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
|
||||||
@@ -339,18 +370,39 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildArgs(
|
/**
|
||||||
opts: StartDownloadOptions,
|
* Everything buildArgs needs to construct a yt-dlp argv. A single options object
|
||||||
outputTemplate: string,
|
* (rather than six positional params) so callers can't transpose `opts`/`options`
|
||||||
o: DownloadOptions,
|
* or the two path-like strings, and new inputs can be added without churning every
|
||||||
binDir: string,
|
* call site (CL2).
|
||||||
access: AccessOptions,
|
*/
|
||||||
extraArgs: string[] = []
|
export interface BuildArgsInput {
|
||||||
): string[] {
|
/** The per-download request (url, kind, quality, chosen format, trim, …). */
|
||||||
|
opts: StartDownloadOptions
|
||||||
|
/** The resolved `-o` output template (flat filename or collection folder tree). */
|
||||||
|
outputTemplate: string
|
||||||
|
/** The post-processing options group (per-download override or the persisted default). */
|
||||||
|
options: DownloadOptions
|
||||||
|
/** ffmpeg/yt-dlp bin dir, passed in so this module stays free of path resolution. */
|
||||||
|
binDir: string
|
||||||
|
/** Global access/networking settings (proxy, cookies, rate limit, …). */
|
||||||
|
access: AccessOptions
|
||||||
|
/** Custom-command extra args, already consent-gated by the caller. */
|
||||||
|
extraArgs?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArgs(input: BuildArgsInput): string[] {
|
||||||
|
const { opts, outputTemplate, options: o, binDir, access, extraArgs = [] } = input
|
||||||
const args = [
|
const args = [
|
||||||
'--newline',
|
'--newline',
|
||||||
'--no-color',
|
'--no-color',
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
|
// Bound a dead/hung connection so yt-dlp aborts (and retries, then exits) a
|
||||||
|
// stalled socket instead of hanging forever and pinning a concurrency slot
|
||||||
|
// with no recovery. The app-side idle watchdog in download.ts is the backstop
|
||||||
|
// for a fully-wedged process. (B1)
|
||||||
|
'--socket-timeout',
|
||||||
|
'30',
|
||||||
// --print (below) implies --quiet, which would suppress progress; --progress
|
// --print (below) implies --quiet, which would suppress progress; --progress
|
||||||
// forces the progress template to emit anyway.
|
// forces the progress template to emit anyway.
|
||||||
'--progress',
|
'--progress',
|
||||||
@@ -362,7 +414,7 @@ export function buildArgs(
|
|||||||
PROGRESS_TEMPLATE,
|
PROGRESS_TEMPLATE,
|
||||||
// Print the final path after post-processing/move so we can open it later.
|
// Print the final path after post-processing/move so we can open it later.
|
||||||
'--print',
|
'--print',
|
||||||
'after_move:path|%(filepath)s',
|
`after_move:${FILEPATH_MARKER}%(filepath)s`,
|
||||||
'--no-simulate'
|
'--no-simulate'
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -370,7 +422,11 @@ export function buildArgs(
|
|||||||
args.push(...postProcessArgs(opts, o))
|
args.push(...postProcessArgs(opts, o))
|
||||||
|
|
||||||
if (opts.kind === 'audio') {
|
if (opts.kind === 'audio') {
|
||||||
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
|
// --audio-quality is a bitrate selector meaningful only for lossy re-encodes.
|
||||||
|
// For lossless formats (flac/wav) it is silently ignored by yt-dlp (M21).
|
||||||
|
const lossless = o.audioFormat === 'flac' || o.audioFormat === 'wav'
|
||||||
|
args.push('-x', '--audio-format', o.audioFormat)
|
||||||
|
if (!lossless) args.push('--audio-quality', audioQuality(opts.quality))
|
||||||
if (o.embedThumbnail) {
|
if (o.embedThumbnail) {
|
||||||
args.push('--embed-thumbnail')
|
args.push('--embed-thumbnail')
|
||||||
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Central home for the main-process timeouts, buffer sizes, and store caps that
|
||||||
|
* were previously scattered as bare literals across the spawn/probe/index/store
|
||||||
|
* modules (L10). Collecting them here makes the operational envelope reviewable
|
||||||
|
* in one place and stops the same "how long / how big" decision drifting between
|
||||||
|
* modules. Values that are already single-sourced in the shared contract
|
||||||
|
* (e.g. HISTORY_MAX_ENTRIES) stay there; this file is for the main-only ones.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Child-process timeouts (ms) --------------------------------------------
|
||||||
|
|
||||||
|
/** `<binary> -version` probes (yt-dlp, ffmpeg, ffprobe) — a quick liveness call. */
|
||||||
|
export const VERSION_TIMEOUT_MS = 15_000
|
||||||
|
/** yt-dlp self-update run — can pull a fresh binary, so a touch longer. */
|
||||||
|
export const YTDLP_UPDATE_TIMEOUT_MS = 60_000
|
||||||
|
/** Best-effort metadata --print alongside a download (title/uploader/duration). */
|
||||||
|
export const META_PROBE_TIMEOUT_MS = 30_000
|
||||||
|
/** Full format/metadata probe (`-J`) for the download bar. */
|
||||||
|
export const PROBE_TIMEOUT_MS = 60_000
|
||||||
|
/** Channel/playlist indexing walk — a big channel legitimately takes minutes. */
|
||||||
|
export const INDEX_TIMEOUT_MS = 180_000
|
||||||
|
/** RSS feed fetch for a watched source's new-item check. */
|
||||||
|
export const FEED_FETCH_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idle watchdog for a running download: if a spawned yt-dlp emits no stdout or
|
||||||
|
* stderr for this long, treat it as wedged and kill it so the concurrency slot
|
||||||
|
* frees (B1). Generous on purpose so a long, output-less post-processing step
|
||||||
|
* (e.g. a large ffmpeg merge) isn't mistaken for a stall.
|
||||||
|
*/
|
||||||
|
export const STALL_TIMEOUT_MS = 5 * 60_000
|
||||||
|
|
||||||
|
// --- execFile maxBuffer sizes (bytes) ---------------------------------------
|
||||||
|
// yt-dlp JSON for a big channel/format list is large; cap generously so a valid
|
||||||
|
// response is never truncated (which would look like a parse failure).
|
||||||
|
|
||||||
|
/** Metadata --print output (three short fields). */
|
||||||
|
export const META_MAX_BUFFER = 4 * 1024 * 1024
|
||||||
|
/** Single-video `-J` probe JSON. */
|
||||||
|
export const PROBE_MAX_BUFFER = 64 * 1024 * 1024
|
||||||
|
/** Whole-channel index JSON. */
|
||||||
|
export const INDEX_MAX_BUFFER = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
// --- Misc ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** How much of a failed download's stderr to retain for the error message. */
|
||||||
|
export const STDERR_TAIL_BYTES = 4000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size cap for the diagnostic log file (logger.ts, CC8). Once the live
|
||||||
|
* `aerofetch.log` passes this, it's rotated to `aerofetch.log.1` (one generation
|
||||||
|
* kept) so app logging can't fill the disk.
|
||||||
|
*/
|
||||||
|
export const LOG_MAX_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
// --- JSON-store row caps ----------------------------------------------------
|
||||||
|
// Each hand-rolled JSON store trims to its cap on write so a store file can't
|
||||||
|
// grow without bound.
|
||||||
|
|
||||||
|
/** Diagnostics error log (errorlog.json). */
|
||||||
|
export const ERRORLOG_MAX_ENTRIES = 200
|
||||||
|
/** Saved custom-command templates (templates.json). */
|
||||||
|
export const TEMPLATES_MAX = 100
|
||||||
|
/** Indexed media items across all sources (media-items.json). */
|
||||||
|
export const MEDIA_ITEMS_MAX = 20_000
|
||||||
|
/** Persisted download-queue rows (queue.json, M4) — generous; a whole channel can be queued. */
|
||||||
|
export const QUEUE_MAX = 10_000
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Menu, type WebContents, type MenuItemConstructorOptions } from 'electron'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give editable fields (and any selected text) the standard Windows Cut / Copy /
|
||||||
|
* Paste / Select All right-click menu that Electron does not provide by default
|
||||||
|
* (W4) — plus spellcheck suggestions for text areas. The menu items use built-in
|
||||||
|
* roles, so the editing works directly in the sandboxed, context-isolated
|
||||||
|
* renderer without any extra IPC. Wired onto the main window only; the untrusted
|
||||||
|
* cookie sign-in window deliberately keeps its minimal chrome.
|
||||||
|
*/
|
||||||
|
export function attachEditContextMenu(wc: WebContents): void {
|
||||||
|
wc.on('context-menu', (_e, params) => {
|
||||||
|
const { isEditable, editFlags, selectionText, dictionarySuggestions } = params
|
||||||
|
// Only worth a menu over an editable field or a real text selection.
|
||||||
|
if (!isEditable && !selectionText.trim()) return
|
||||||
|
|
||||||
|
const template: MenuItemConstructorOptions[] = []
|
||||||
|
|
||||||
|
// Spellcheck suggestions first, when the caret sits on a misspelling.
|
||||||
|
if (isEditable && dictionarySuggestions.length > 0) {
|
||||||
|
for (const suggestion of dictionarySuggestions) {
|
||||||
|
template.push({ label: suggestion, click: () => wc.replaceMisspelling(suggestion) })
|
||||||
|
}
|
||||||
|
template.push({ type: 'separator' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditable) {
|
||||||
|
template.push(
|
||||||
|
{ role: 'cut', enabled: editFlags.canCut },
|
||||||
|
{ role: 'copy', enabled: editFlags.canCopy },
|
||||||
|
{ role: 'paste', enabled: editFlags.canPaste },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'selectAll', enabled: editFlags.canSelectAll }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Read-only text with a selection: offer Copy (and Select All).
|
||||||
|
template.push(
|
||||||
|
{ role: 'copy', enabled: editFlags.canCopy },
|
||||||
|
{ role: 'selectAll', enabled: editFlags.canSelectAll }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu.buildFromTemplate(template).popup()
|
||||||
|
})
|
||||||
|
}
|
||||||
+127
-18
@@ -1,15 +1,17 @@
|
|||||||
import { app, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
|
import { app, safeStorage, session, BrowserWindow, type Cookie, type WebContents } from 'electron'
|
||||||
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
import { existsSync, statSync, unlinkSync, writeFileSync, readFileSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
|
import { logger } from './logger'
|
||||||
import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
|
import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted session AeroFetch's built-in sign-in window uses. Kept separate
|
* Persisted session AeroFetch's built-in sign-in window uses. Kept separate
|
||||||
* from the main window's (default) session so a logged-in site can't see or
|
* from the main window's (default) session so a logged-in site can't see or
|
||||||
* touch anything the app itself loads.
|
* touch anything the app itself loads. Exported so the PO-token window can
|
||||||
|
* share the same session (and thus the user's YouTube sign-in state).
|
||||||
*/
|
*/
|
||||||
const PARTITION = 'persist:aerofetch-login'
|
export const LOGIN_PARTITION = 'persist:aerofetch-login'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sign-in window renders untrusted remote content, so every navigation and
|
* The sign-in window renders untrusted remote content, so every navigation and
|
||||||
@@ -43,7 +45,7 @@ function hardenLoginWebContents(wc: WebContents): void {
|
|||||||
overrideBrowserWindowOptions: {
|
overrideBrowserWindowOptions: {
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
partition: PARTITION,
|
partition: LOGIN_PARTITION,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
@@ -60,20 +62,99 @@ function hardenLoginWebContents(wc: WebContents): void {
|
|||||||
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
|
wc.on('did-create-window', (child) => hardenLoginWebContents(child.webContents))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCookiesFilePath(): string {
|
// The persisted cookie jar is encrypted at rest (H7). yt-dlp's `--cookies` needs
|
||||||
|
// a plaintext file, so the stored form is encrypted via Electron safeStorage
|
||||||
|
// (DPAPI on Windows — the same protection settings secrets get) and only ever
|
||||||
|
// decrypted to a short-lived temp file for the duration of a download. This
|
||||||
|
// matters for the portable build, where userData sits next to the exe (USB /
|
||||||
|
// shared Downloads folder) and a plaintext jar would hand over a logged-in session.
|
||||||
|
function cookieStorePath(): string {
|
||||||
|
return join(app.getPath('userData'), 'cookies.dat')
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyCookiePath(): string {
|
||||||
return join(app.getPath('userData'), 'cookies.txt')
|
return join(app.getPath('userData'), 'cookies.txt')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Marks ciphertext so a read can tell it from legacy/fallback plaintext (written
|
||||||
|
// before encryption, or where safeStorage was unavailable). Mirrors settings.ts.
|
||||||
|
const ENC_PREFIX = 'enc:v1:'
|
||||||
|
|
||||||
|
/** Persist the Netscape cookie text, encrypted where safeStorage is available. */
|
||||||
|
function storeCookies(netscape: string): void {
|
||||||
|
let out = netscape
|
||||||
|
try {
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
out = ENC_PREFIX + safeStorage.encryptString(netscape).toString('base64')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through — store plaintext, as it was before encryption existed */
|
||||||
|
}
|
||||||
|
writeFileSync(cookieStorePath(), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt the persisted cookie text, or null if none / undecryptable here. */
|
||||||
|
function loadCookies(): string | null {
|
||||||
|
const p = cookieStorePath()
|
||||||
|
if (!existsSync(p)) return null
|
||||||
|
const stored = readFileSync(p, 'utf8')
|
||||||
|
if (!stored.startsWith(ENC_PREFIX)) return stored // legacy / fallback plaintext
|
||||||
|
try {
|
||||||
|
return safeStorage.decryptString(Buffer.from(stored.slice(ENC_PREFIX.length), 'base64'))
|
||||||
|
} catch {
|
||||||
|
// A different Windows user/machine (portable jar copied elsewhere) or a
|
||||||
|
// corrupt blob — unusable; treat as no cookies rather than feed ciphertext.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration of a pre-encryption plaintext `cookies.txt` into the
|
||||||
|
* encrypted store, deleting the plaintext so a logged-in session no longer sits
|
||||||
|
* in the open after an update (H7). Best-effort; safe to call on every launch.
|
||||||
|
*/
|
||||||
|
export function migrateLegacyCookies(): void {
|
||||||
|
const legacy = legacyCookiePath()
|
||||||
|
try {
|
||||||
|
if (!existsSync(legacy)) return
|
||||||
|
if (!existsSync(cookieStorePath())) storeCookies(readFileSync(legacy, 'utf8'))
|
||||||
|
unlinkSync(legacy)
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a stored cookie jar exists (regardless of decryptability here). */
|
||||||
|
export function hasStoredCookies(): boolean {
|
||||||
|
return existsSync(cookieStorePath())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt the jar to a plaintext file at `dest` for yt-dlp's `--cookies`. Returns
|
||||||
|
* false when there's nothing usable to write. The caller deletes `dest` once the
|
||||||
|
* download settles, keeping the plaintext window as short as possible.
|
||||||
|
*/
|
||||||
|
export function materializeCookies(dest: string): boolean {
|
||||||
|
const text = loadCookies()
|
||||||
|
if (text == null) return false
|
||||||
|
try {
|
||||||
|
writeFileSync(dest, text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getCookiesStatus(): CookiesStatus {
|
export function getCookiesStatus(): CookiesStatus {
|
||||||
const p = getCookiesFilePath()
|
const p = cookieStorePath()
|
||||||
if (!existsSync(p)) return { exists: false }
|
if (!existsSync(p)) return { exists: false }
|
||||||
return { exists: true, savedAt: statSync(p).mtimeMs }
|
return { exists: true, savedAt: statSync(p).mtimeMs }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearCookies(): Promise<void> {
|
export async function clearCookies(): Promise<void> {
|
||||||
const p = getCookiesFilePath()
|
const p = cookieStorePath()
|
||||||
if (existsSync(p)) unlinkSync(p)
|
if (existsSync(p)) unlinkSync(p)
|
||||||
await session.fromPartition(PARTITION).clearStorageData()
|
await session.fromPartition(LOGIN_PARTITION).clearStorageData()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,9 +170,15 @@ function toNetscapeCookieFile(cookies: Cookie[]): string {
|
|||||||
const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE'
|
const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE'
|
||||||
const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate)
|
const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate)
|
||||||
lines.push(
|
lines.push(
|
||||||
[domain, includeSubdomains, c.path || '/', c.secure ? 'TRUE' : 'FALSE', expiry, c.name, c.value].join(
|
[
|
||||||
'\t'
|
domain,
|
||||||
)
|
includeSubdomains,
|
||||||
|
c.path || '/',
|
||||||
|
c.secure ? 'TRUE' : 'FALSE',
|
||||||
|
expiry,
|
||||||
|
c.name,
|
||||||
|
c.value
|
||||||
|
].join('\t')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return lines.join('\n') + '\n'
|
return lines.join('\n') + '\n'
|
||||||
@@ -102,10 +189,13 @@ let pendingResolvers: Array<(r: CookiesLoginResult) => void> = []
|
|||||||
|
|
||||||
function exportAndResolve(): void {
|
function exportAndResolve(): void {
|
||||||
session
|
session
|
||||||
.fromPartition(PARTITION)
|
.fromPartition(LOGIN_PARTITION)
|
||||||
.cookies.get({})
|
.cookies.get({})
|
||||||
.then((cookies) => {
|
.then((cookies) => {
|
||||||
writeFileSync(getCookiesFilePath(), toNetscapeCookieFile(cookies))
|
// Don't persist an empty jar: closing the window without signing in would
|
||||||
|
// otherwise leave a useless "saved" cookie file behind (L50). The renderer
|
||||||
|
// turns cookieCount === 0 into a "did you sign in?" hint.
|
||||||
|
if (cookies.length > 0) storeCookies(toNetscapeCookieFile(cookies))
|
||||||
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
|
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
|
||||||
pendingResolvers.forEach((resolve) => resolve(result))
|
pendingResolvers.forEach((resolve) => resolve(result))
|
||||||
})
|
})
|
||||||
@@ -124,7 +214,10 @@ function exportAndResolve(): void {
|
|||||||
* window — cookies are exported to a Netscape-format file at that point,
|
* window — cookies are exported to a Netscape-format file at that point,
|
||||||
* ready for yt-dlp's `--cookies`. Mirrors Seal's "log in via WebView" feature.
|
* ready for yt-dlp's `--cookies`. Mirrors Seal's "log in via WebView" feature.
|
||||||
*/
|
*/
|
||||||
export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult> {
|
export function openCookieLoginWindow(
|
||||||
|
url: string,
|
||||||
|
parent?: BrowserWindow
|
||||||
|
): Promise<CookiesLoginResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let validUrl: string
|
let validUrl: string
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +229,7 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
|||||||
|
|
||||||
if (loginWindow && !loginWindow.isDestroyed()) {
|
if (loginWindow && !loginWindow.isDestroyed()) {
|
||||||
pendingResolvers.push(resolve)
|
pendingResolvers.push(resolve)
|
||||||
loginWindow.loadURL(validUrl).catch(() => {})
|
loginWindow.loadURL(validUrl).catch((e) => logger.error('cookie login loadURL failed', e))
|
||||||
loginWindow.focus()
|
loginWindow.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -149,8 +242,11 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
|||||||
height: 720,
|
height: 720,
|
||||||
title: 'Sign in — AeroFetch',
|
title: 'Sign in — AeroFetch',
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
|
// Group under the app window instead of taking its own taskbar button
|
||||||
|
// (W6); a child window stays above its parent without a modal block.
|
||||||
|
parent: parent && !parent.isDestroyed() ? parent : undefined,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
partition: PARTITION,
|
partition: LOGIN_PARTITION,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
@@ -170,13 +266,26 @@ export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult>
|
|||||||
hardenLoginWebContents(win.webContents)
|
hardenLoginWebContents(win.webContents)
|
||||||
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
|
win.webContents.session.setPermissionRequestHandler((_wc, _permission, cb) => cb(false))
|
||||||
|
|
||||||
|
// 'close' fires on a normal user close; a programmatic destroy() fires only
|
||||||
|
// 'closed'. Latch whichever runs first so the partition is exported exactly
|
||||||
|
// once and the promise can never hang (B7) — without this, a destroy()
|
||||||
|
// without a preceding 'close' would leave pendingResolvers pending forever.
|
||||||
|
let exportStarted = false
|
||||||
|
const exportOnce = (): void => {
|
||||||
|
if (exportStarted) return
|
||||||
|
exportStarted = true
|
||||||
|
exportAndResolve()
|
||||||
|
}
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
loginWindow = null
|
loginWindow = null
|
||||||
|
// Fallback for destroy()/closed-without-close: the partition outlives the
|
||||||
|
// window, so a late export still captures whatever cookies were collected.
|
||||||
|
exportOnce()
|
||||||
})
|
})
|
||||||
// Closing the window IS "I'm done" — export whatever the partition
|
// Closing the window IS "I'm done" — export whatever the partition
|
||||||
// collected. The partition itself outlives the window, so this is safe
|
// collected. The partition itself outlives the window, so this is safe
|
||||||
// even though cookies.get() resolves after 'close' has already fired.
|
// even though cookies.get() resolves after 'close' has already fired.
|
||||||
win.on('close', exportAndResolve)
|
win.on('close', exportOnce)
|
||||||
|
|
||||||
win.loadURL(validUrl).catch(() => {
|
win.loadURL(validUrl).catch(() => {
|
||||||
/* navigation errors surface as Chromium's own error page */
|
/* navigation errors surface as Chromium's own error page */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { app, shell, type BrowserWindow } from 'electron'
|
|||||||
import { existsSync, openSync, readSync, closeSync } from 'fs'
|
import { existsSync, openSync, readSync, closeSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
|
import { parseUrlShortcutContent } from '@shared/ipc'
|
||||||
|
|
||||||
/** Only ever forward http(s) targets into the app — same restriction the
|
/** 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.
|
* external-link window-open handler in index.ts applies to in-page links.
|
||||||
@@ -29,8 +30,8 @@ function readUrlShortcut(path: string): string | null {
|
|||||||
fd = openSync(path, 'r')
|
fd = openSync(path, 'r')
|
||||||
const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
|
const buf = Buffer.alloc(MAX_URL_FILE_BYTES)
|
||||||
const bytes = readSync(fd, buf, 0, buf.length, 0)
|
const bytes = readSync(fd, buf, 0, buf.length, 0)
|
||||||
const match = /^URL=(.+)$/im.exec(buf.toString('utf8', 0, bytes))
|
const url = parseUrlShortcutContent(buf.toString('utf8', 0, bytes))
|
||||||
return match ? asHttpUrl(match[1].trim()) : null
|
return url ? asHttpUrl(url) : null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+234
-117
@@ -1,5 +1,6 @@
|
|||||||
import { spawn, execFile, type ChildProcess } from 'child_process'
|
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync, unlinkSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
||||||
import {
|
import {
|
||||||
@@ -8,11 +9,16 @@ import {
|
|||||||
getAria2cPath,
|
getAria2cPath,
|
||||||
getFfmpegPath,
|
getFfmpegPath,
|
||||||
getFfprobePath,
|
getFfprobePath,
|
||||||
getSystem32Path
|
getSystem32Path,
|
||||||
|
getAppIconImage,
|
||||||
|
YTDLP_MISSING_MSG
|
||||||
} from './binaries'
|
} from './binaries'
|
||||||
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
import { getSettings } from './settings'
|
||||||
|
import { execFileAsync } from './lib/exec'
|
||||||
|
import { createLineBuffer } from './lib/lineBuffer'
|
||||||
|
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
|
||||||
import { ensureManagedYtdlp } from './ytdlp'
|
import { ensureManagedYtdlp } from './ytdlp'
|
||||||
import { getCookiesFilePath } from './cookies'
|
import { materializeCookies, hasStoredCookies } from './cookies'
|
||||||
import { listTemplates } from './templates'
|
import { listTemplates } from './templates'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
import { isSafeOutputDir } from './validation'
|
import { isSafeOutputDir } from './validation'
|
||||||
@@ -20,10 +26,18 @@ import {
|
|||||||
buildArgs,
|
buildArgs,
|
||||||
selectExtraArgs,
|
selectExtraArgs,
|
||||||
formatCommandLine,
|
formatCommandLine,
|
||||||
collectionOutputTemplate
|
collectionOutputTemplate,
|
||||||
|
PROGRESS_MARKER,
|
||||||
|
FILEPATH_MARKER
|
||||||
} from './buildArgs'
|
} from './buildArgs'
|
||||||
import { cleanError } from './log'
|
import { cleanError } from './log'
|
||||||
import { addErrorLog } from './errorlog'
|
import { addErrorLog } from './errorlog'
|
||||||
|
import {
|
||||||
|
STALL_TIMEOUT_MS,
|
||||||
|
META_PROBE_TIMEOUT_MS,
|
||||||
|
META_MAX_BUFFER,
|
||||||
|
STDERR_TAIL_BYTES
|
||||||
|
} from './constants'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
@@ -31,7 +45,6 @@ import {
|
|||||||
type CommandPreviewResult,
|
type CommandPreviewResult,
|
||||||
type DownloadEvent,
|
type DownloadEvent,
|
||||||
type DownloadMeta,
|
type DownloadMeta,
|
||||||
type DownloadProgress,
|
|
||||||
type Settings
|
type Settings
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
@@ -43,6 +56,21 @@ interface ActiveDownload {
|
|||||||
|
|
||||||
const active = new Map<string, ActiveDownload>()
|
const active = new Map<string, ActiveDownload>()
|
||||||
|
|
||||||
|
// Per-spawn sequence, so a retry/resume that reuses the item id still gets a
|
||||||
|
// unique transient cookie file (below) — otherwise a superseded download's
|
||||||
|
// teardown could unlink the new spawn's jar mid-read.
|
||||||
|
let spawnSeq = 0
|
||||||
|
|
||||||
|
// Remove an item from the active map, but only if THIS rec still owns the slot.
|
||||||
|
// cancel/pause release the slot synchronously (so the renderer's just-promoted
|
||||||
|
// next item isn't rejected by the maxConcurrent guard while the killed tree is
|
||||||
|
// still tearing down); the doomed child's later 'close'/'error' then runs this as
|
||||||
|
// a no-op. The identity check matters because a retry/resume reuses the item id,
|
||||||
|
// so a stale teardown must not evict the newer same-id spawn. (L140/L148)
|
||||||
|
function releaseActive(id: string, rec: ActiveDownload): void {
|
||||||
|
if (active.get(id) === rec) active.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether any yt-dlp download is currently running. Used by the window's close
|
* Whether any yt-dlp download is currently running. Used by the window's close
|
||||||
* handler to keep the app alive in the tray (instead of quitting and killing the
|
* handler to keep the app alive in the tray (instead of quitting and killing the
|
||||||
@@ -53,63 +81,22 @@ export function hasActiveDownloads(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
||||||
|
// parseProgress lives in lib/formatters.ts (no electron import chain) so it can
|
||||||
function num(s?: string): number | undefined {
|
// be unit-tested in isolation (L37). Byte/ETA formatters live in @shared/format.
|
||||||
if (!s || s === 'NA') return undefined
|
export { parseProgress } from './lib/formatters'
|
||||||
const n = Number(s)
|
import { parseProgress } from './lib/formatters'
|
||||||
return Number.isFinite(n) ? n : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fmtBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`
|
|
||||||
const units = ['KB', 'MB', 'GB', 'TB']
|
|
||||||
let v = bytes / 1024
|
|
||||||
let i = 0
|
|
||||||
while (v >= 1024 && i < units.length - 1) {
|
|
||||||
v /= 1024
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtSpeed(bytesPerSec?: number): string | undefined {
|
|
||||||
if (bytesPerSec == null) return undefined
|
|
||||||
return `${fmtBytes(bytesPerSec)}/s`
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtEta(seconds?: number): string | undefined {
|
|
||||||
if (seconds == null) return undefined
|
|
||||||
const s = Math.max(0, Math.round(seconds))
|
|
||||||
const m = Math.floor(s / 60)
|
|
||||||
const r = s % 60
|
|
||||||
return `${m}:${String(r).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProgress(rest: string): DownloadProgress | null {
|
|
||||||
const parts = rest.split('|')
|
|
||||||
if (parts.length < 6) return null
|
|
||||||
const [status, dl, total, totalEst, speed, eta] = parts
|
|
||||||
const downloaded = num(dl)
|
|
||||||
const totalBytes = num(total) ?? num(totalEst)
|
|
||||||
let progress = 0
|
|
||||||
if (totalBytes && downloaded != null) progress = Math.min(1, downloaded / totalBytes)
|
|
||||||
return {
|
|
||||||
status: status || 'downloading',
|
|
||||||
progress,
|
|
||||||
speed: fmtSpeed(num(speed)),
|
|
||||||
eta: fmtEta(num(eta)),
|
|
||||||
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(wc: WebContents, ev: DownloadEvent): void {
|
function send(wc: WebContents, ev: DownloadEvent): void {
|
||||||
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
|
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */
|
/** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */
|
||||||
function notify(wc: WebContents, title: string, body: string): void {
|
function notify(wc: WebContents, title: string, body: string, incognito = false): void {
|
||||||
if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
|
if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
|
||||||
const n = new Notification({ title, body })
|
// L136: an incognito ("private") download must not leak its title/URL into an OS
|
||||||
|
// toast (it persists in the Windows Action Center). Callers pass a generic outcome
|
||||||
|
// title for the private case; here we additionally drop the detail body.
|
||||||
|
const n = new Notification({ title, body: incognito ? '' : body, icon: getAppIconImage() })
|
||||||
n.on('click', () => {
|
n.on('click', () => {
|
||||||
if (wc.isDestroyed()) return
|
if (wc.isDestroyed()) return
|
||||||
const win = BrowserWindow.fromWebContents(wc)
|
const win = BrowserWindow.fromWebContents(wc)
|
||||||
@@ -120,9 +107,17 @@ function notify(wc: WebContents, title: string, body: string): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
n.show()
|
n.show()
|
||||||
|
// W10: flash the taskbar button for attention when the event happened in the
|
||||||
|
// background (window unfocused/minimized/hidden). Windows stops the flash on its
|
||||||
|
// own once the window gets focus, so there's no explicit stop to manage.
|
||||||
|
const flashWin = BrowserWindow.fromWebContents(wc)
|
||||||
|
if (flashWin && !flashWin.isDestroyed() && !flashWin.isFocused()) flashWin.flashFrame(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void {
|
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void {
|
||||||
|
// L136: incognito downloads are never recorded — not even a failure entry, which
|
||||||
|
// would persist the title + URL in the user-facing diagnostics log.
|
||||||
|
if (opts.incognito) return
|
||||||
addErrorLog({
|
addErrorLog({
|
||||||
id: opts.id,
|
id: opts.id,
|
||||||
title,
|
title,
|
||||||
@@ -135,37 +130,34 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
|
|||||||
|
|
||||||
// --- Best-effort metadata probe (runs alongside the download) ---------------
|
// --- Best-effort metadata probe (runs alongside the download) ---------------
|
||||||
|
|
||||||
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
// Unit Separator (0x1F): a control char that can't appear in a title/uploader,
|
||||||
return new Promise((resolve) => {
|
// so it safely delimits the three fields in ONE --print template. Splitting on
|
||||||
execFile(
|
// newlines instead (B4) would mis-assign channel/duration whenever a title
|
||||||
|
// itself contains a newline, since each --print field is emitted on its own line.
|
||||||
|
const META_SEP = '\u001f'
|
||||||
|
|
||||||
|
async function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
||||||
|
const r = await execFileAsync(
|
||||||
ytdlp,
|
ytdlp,
|
||||||
[
|
[
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
'--print',
|
'--print',
|
||||||
'title',
|
`%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`,
|
||||||
'--print',
|
|
||||||
'uploader',
|
|
||||||
'--print',
|
|
||||||
'duration_string',
|
|
||||||
'--',
|
'--',
|
||||||
url
|
url
|
||||||
],
|
],
|
||||||
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 },
|
{ maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS }
|
||||||
(err, stdout) => {
|
)
|
||||||
if (err) return resolve(null)
|
if (!r.ok) return null
|
||||||
const [title, uploader, duration] = stdout.split('\n').map((l) => l.trim())
|
const [title, uploader, duration] = r.stdout.split(META_SEP).map((l) => l.trim())
|
||||||
const clean = (v?: string): string | undefined =>
|
const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined)
|
||||||
v && v !== 'NA' ? v : undefined
|
return {
|
||||||
resolve({
|
|
||||||
title: clean(title),
|
title: clean(title),
|
||||||
channel: clean(uploader),
|
channel: clean(uploader),
|
||||||
durationLabel: clean(duration)
|
durationLabel: clean(duration)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Argv construction (shared by startDownload and the command preview) ---
|
// --- Argv construction (shared by startDownload and the command preview) ---
|
||||||
@@ -175,6 +167,9 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
|||||||
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just
|
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just
|
||||||
// in the renderer UI, so the renderer can't be trusted to apply it.
|
// in the renderer UI, so the renderer can't be trusted to apply it.
|
||||||
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
|
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
|
||||||
|
// Gate the file read: listTemplates() parses templates.json on every call, so
|
||||||
|
// skip it entirely when the feature is off (PERF2).
|
||||||
|
if (!settings.customCommandEnabled) return []
|
||||||
return selectExtraArgs({
|
return selectExtraArgs({
|
||||||
customCommandEnabled: settings.customCommandEnabled,
|
customCommandEnabled: settings.customCommandEnabled,
|
||||||
perDownloadExtraArgs: opts.extraArgs,
|
perDownloadExtraArgs: opts.extraArgs,
|
||||||
@@ -184,8 +179,10 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
/** Resolve settings + per-download overrides into the full yt-dlp argv.
|
||||||
export function buildCommand(opts: StartDownloadOptions): string[] {
|
* `cookiesFile` is the transient decrypted jar the caller materialises for the
|
||||||
|
* 'login' cookie source (H7); the 'browser' source uses --cookies-from-browser. */
|
||||||
|
export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): string[] {
|
||||||
const settings = getSettings()
|
const settings = getSettings()
|
||||||
// Output dir resolution: a per-download override wins, then the user's explicit
|
// Output dir resolution: a per-download override wins, then the user's explicit
|
||||||
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
|
// per-kind folder (Settings → Video/Audio folder), and finally — when that's
|
||||||
@@ -210,17 +207,13 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
|
|||||||
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
|
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
|
||||||
// into resources/bin — the toggle shouldn't turn into a hard error.
|
// into resources/bin — the toggle shouldn't turn into a hard error.
|
||||||
const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
|
const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
|
||||||
// Same idea: 'login' cookies only apply once the sign-in window has actually
|
|
||||||
// exported a file; otherwise the download proceeds cookie-less rather than failing.
|
|
||||||
const cookiesFile =
|
|
||||||
settings.cookieSource === 'login' && existsSync(getCookiesFilePath())
|
|
||||||
? getCookiesFilePath()
|
|
||||||
: undefined
|
|
||||||
const access = {
|
const access = {
|
||||||
proxy: settings.proxy,
|
proxy: settings.proxy,
|
||||||
rateLimit: settings.rateLimit,
|
rateLimit: settings.rateLimit,
|
||||||
aria2cPath,
|
aria2cPath,
|
||||||
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
// L136/M6: incognito attaches no cookies from the browser either.
|
||||||
|
cookiesFromBrowser:
|
||||||
|
!opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
||||||
cookiesFile,
|
cookiesFile,
|
||||||
restrictFilenames: settings.restrictFilenames,
|
restrictFilenames: settings.restrictFilenames,
|
||||||
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
|
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
|
||||||
@@ -228,7 +221,7 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
|
|||||||
youtubePoToken: settings.youtubePoToken
|
youtubePoToken: settings.youtubePoToken
|
||||||
}
|
}
|
||||||
const extraArgs = resolveExtraArgs(opts, settings)
|
const extraArgs = resolveExtraArgs(opts, settings)
|
||||||
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs)
|
return buildArgs({ opts, outputTemplate, options, binDir: getBinDir(), access, extraArgs })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the exact command line for the current form state, without running it. */
|
/** Build the exact command line for the current form state, without running it. */
|
||||||
@@ -250,10 +243,7 @@ export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult
|
|||||||
|
|
||||||
// --- Public API -------------------------------------------------------------
|
// --- Public API -------------------------------------------------------------
|
||||||
|
|
||||||
export function startDownload(
|
export function startDownload(wc: WebContents, opts: StartDownloadOptions): StartDownloadResult {
|
||||||
wc: WebContents,
|
|
||||||
opts: StartDownloadOptions
|
|
||||||
): StartDownloadResult {
|
|
||||||
// Self-heal the managed copy from the bundled seed before spawning, so a
|
// Self-heal the managed copy from the bundled seed before spawning, so a
|
||||||
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
|
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
|
||||||
ensureManagedYtdlp()
|
ensureManagedYtdlp()
|
||||||
@@ -261,7 +251,7 @@ export function startDownload(
|
|||||||
if (!existsSync(ytdlp)) {
|
if (!existsSync(ytdlp)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `yt-dlp.exe is missing and couldn't be restored from the bundle.\nReinstall AeroFetch, or drop yt-dlp.exe into resources/bin/ (see the README there).`
|
error: YTDLP_MISSING_MSG
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
|
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
|
||||||
@@ -275,9 +265,7 @@ export function startDownload(
|
|||||||
if (missingBins.length > 0) {
|
if (missingBins.length > 0) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error:
|
error: `${missingBins.join(' and ')} not found in ${getBinDir()}. Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
|
||||||
`${missingBins.join(' and ')} not found in ${getBinDir()}\n` +
|
|
||||||
`Add the ffmpeg build's binaries to resources/bin/ (see the README there).`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv,
|
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv,
|
||||||
@@ -301,10 +289,34 @@ export function startDownload(
|
|||||||
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
|
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypt the stored cookie jar to a short-lived per-download temp file (H7).
|
||||||
|
// yt-dlp reads --cookies once at startup; we delete it the moment the download
|
||||||
|
// settles, so the plaintext never lingers at rest.
|
||||||
|
let cookiesFile: string | undefined
|
||||||
|
// L136/M6: an incognito download attaches no saved login cookies (the UI promises
|
||||||
|
// "no cookies"), so it can't be tied to the user's signed-in identity.
|
||||||
|
if (!opts.incognito && getSettings().cookieSource === 'login' && hasStoredCookies()) {
|
||||||
|
// Unique per spawn (not just per id): a retry/resume reuses opts.id, and the
|
||||||
|
// superseded download's cleanupCookies() must not unlink the new spawn's jar.
|
||||||
|
const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}-${++spawnSeq}.txt`)
|
||||||
|
if (materializeCookies(tmp)) cookiesFile = tmp
|
||||||
|
}
|
||||||
|
function cleanupCookies(): void {
|
||||||
|
if (cookiesFile) {
|
||||||
|
try {
|
||||||
|
unlinkSync(cookiesFile)
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
cookiesFile = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let child: ChildProcess
|
let child: ChildProcess
|
||||||
try {
|
try {
|
||||||
child = spawn(ytdlp, buildCommand(opts), { windowsHide: true })
|
child = spawn(ytdlp, buildCommand(opts, cookiesFile), { windowsHide: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
cleanupCookies()
|
||||||
return { ok: false, error: (e as Error).message }
|
return { ok: false, error: (e as Error).message }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,66 +336,162 @@ export function startDownload(
|
|||||||
// it in parallel so the card fills in quickly.
|
// it in parallel so the card fills in quickly.
|
||||||
probeMeta(ytdlp, opts.url).then((meta) => {
|
probeMeta(ytdlp, opts.url).then((meta) => {
|
||||||
if (meta?.title) resolvedTitle = meta.title
|
if (meta?.title) resolvedTitle = meta.title
|
||||||
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
|
if (active.has(opts.id)) {
|
||||||
|
// Always emit a meta event: on success it fills in title/channel/duration;
|
||||||
|
// on failure (null from timeout/error) the renderer clears the
|
||||||
|
// "Resolving…" placeholder via applyEvent('meta') (SR6 / L47).
|
||||||
|
send(wc, { type: 'meta', id: opts.id, meta: meta ?? {} })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let stdoutBuf = ''
|
// Wire the child's streams + teardown (CL3). resolvedTitle is passed as a getter
|
||||||
|
// because the parallel probeMeta above may fill it in after this returns.
|
||||||
|
wireChildProcess({ wc, opts, rec, cleanup: cleanupCookies, getTitle: () => resolvedTitle })
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Child-process wiring ---------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire a spawned yt-dlp child's stdout/stderr/close/error to download events, and
|
||||||
|
* run the B1 idle watchdog. Extracted from startDownload so that function reads as
|
||||||
|
* a linear spawn + pre-flight and this owns the streaming/teardown lifecycle (CL3).
|
||||||
|
*
|
||||||
|
* `getTitle` is read lazily on each event: the completion/error paths need the
|
||||||
|
* best title known *at settle time*, which the parallel metadata probe may only
|
||||||
|
* fill in after wiring is set up.
|
||||||
|
*/
|
||||||
|
function wireChildProcess(params: {
|
||||||
|
wc: WebContents
|
||||||
|
opts: StartDownloadOptions
|
||||||
|
rec: ActiveDownload
|
||||||
|
cleanup: () => void
|
||||||
|
getTitle: () => string | undefined
|
||||||
|
}): void {
|
||||||
|
const { wc, opts, rec, cleanup, getTitle } = params
|
||||||
|
const child = rec.child
|
||||||
|
|
||||||
let stderrTail = ''
|
let stderrTail = ''
|
||||||
let filePath: string | undefined
|
let filePath: string | undefined
|
||||||
|
// Latched once the first download stream reports 'finished'; flags later
|
||||||
|
// progress as the merge/post-processing "finishing" phase (SR7).
|
||||||
|
let finishing = false
|
||||||
// 'error' and 'close' can both fire for one process; only act on the first.
|
// 'error' and 'close' can both fire for one process; only act on the first.
|
||||||
let settled = false
|
let settled = false
|
||||||
|
|
||||||
|
// Idle watchdog (B1): reset on any output; if it ever fires, the child has been
|
||||||
|
// silent for STALL_TIMEOUT_MS, so kill + error it and free the slot.
|
||||||
|
let stallTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function clearWatchdog(): void {
|
||||||
|
if (stallTimer) {
|
||||||
|
clearTimeout(stallTimer)
|
||||||
|
stallTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bumpWatchdog(): void {
|
||||||
|
clearWatchdog()
|
||||||
|
stallTimer = setTimeout(() => {
|
||||||
|
// A cancel/pause races the timer: it already released the slot and the
|
||||||
|
// child's close stays silent, so don't fire a spurious stall error (mirrors
|
||||||
|
// the canceled/paused guards on the close handler below).
|
||||||
|
if (settled || rec.canceled || rec.paused) return
|
||||||
|
settled = true
|
||||||
|
clearWatchdog()
|
||||||
|
cleanup()
|
||||||
|
releaseActive(opts.id, rec)
|
||||||
|
killTree(rec)
|
||||||
|
const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
|
||||||
|
send(wc, { type: 'error', id: opts.id, error: msg })
|
||||||
|
logFailure(opts, getTitle(), msg)
|
||||||
|
notify(
|
||||||
|
wc,
|
||||||
|
opts.incognito ? 'Download stopped' : (getTitle() ?? 'Download failed'),
|
||||||
|
msg,
|
||||||
|
opts.incognito
|
||||||
|
)
|
||||||
|
}, STALL_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
bumpWatchdog()
|
||||||
|
|
||||||
|
// Split stdout into lines (shared helper, CC3/CC4) and parse our --print markers.
|
||||||
|
// No flush on close: yt-dlp's progress/path template lines are always
|
||||||
|
// newline-terminated, so a trailing partial is never a real marker.
|
||||||
|
const stdoutLines = createLineBuffer((line) => {
|
||||||
|
if (line.startsWith(PROGRESS_MARKER)) {
|
||||||
|
const p = parseProgress(line.slice(PROGRESS_MARKER.length))
|
||||||
|
if (p) {
|
||||||
|
// SR7: yt-dlp reports a 'finished' status when each download stream
|
||||||
|
// completes. A video+audio download has two streams, so the bar would
|
||||||
|
// otherwise fill 0→100% twice. Latch on the first 'finished' and flag
|
||||||
|
// every later tick as "finishing" so the renderer shows an indeterminate
|
||||||
|
// merge state instead of a visible restart.
|
||||||
|
if (p.status === 'finished') finishing = true
|
||||||
|
send(wc, { type: 'progress', id: opts.id, progress: { ...p, finishing } })
|
||||||
|
}
|
||||||
|
} else if (line.startsWith(FILEPATH_MARKER)) {
|
||||||
|
filePath = line.slice(FILEPATH_MARKER.length).trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
child.stdout?.on('data', (chunk: Buffer) => {
|
child.stdout?.on('data', (chunk: Buffer) => {
|
||||||
stdoutBuf += chunk.toString()
|
bumpWatchdog()
|
||||||
let nl: number
|
stdoutLines.push(chunk.toString())
|
||||||
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
|
|
||||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
|
||||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
|
||||||
if (line.startsWith('prog|')) {
|
|
||||||
const p = parseProgress(line.slice('prog|'.length))
|
|
||||||
if (p) send(wc, { type: 'progress', id: opts.id, progress: p })
|
|
||||||
} else if (line.startsWith('path|')) {
|
|
||||||
filePath = line.slice('path|'.length).trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
child.stderr?.on('data', (chunk: Buffer) => {
|
child.stderr?.on('data', (chunk: Buffer) => {
|
||||||
stderrTail = (stderrTail + chunk.toString()).slice(-4000)
|
bumpWatchdog()
|
||||||
|
stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_BYTES)
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
active.delete(opts.id)
|
clearWatchdog()
|
||||||
|
cleanup()
|
||||||
|
releaseActive(opts.id, rec)
|
||||||
// A paused download was killed on purpose — stay silent, like a cancel.
|
// A paused download was killed on purpose — stay silent, like a cancel.
|
||||||
if (!rec.canceled && !rec.paused) {
|
if (!rec.canceled && !rec.paused) {
|
||||||
send(wc, { type: 'error', id: opts.id, error: err.message })
|
send(wc, { type: 'error', id: opts.id, error: err.message })
|
||||||
logFailure(opts, resolvedTitle, err.message)
|
logFailure(opts, getTitle(), err.message)
|
||||||
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
notify(
|
||||||
|
wc,
|
||||||
|
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
|
||||||
|
err.message,
|
||||||
|
opts.incognito
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
active.delete(opts.id)
|
clearWatchdog()
|
||||||
|
cleanup()
|
||||||
|
releaseActive(opts.id, rec)
|
||||||
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
||||||
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
||||||
if (rec.canceled || rec.paused) return
|
if (rec.canceled || rec.paused) return
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
send(wc, { type: 'done', id: opts.id, filePath })
|
send(wc, { type: 'done', id: opts.id, filePath })
|
||||||
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
notify(
|
||||||
|
wc,
|
||||||
|
opts.incognito ? 'Download complete' : (getTitle() ?? 'Download complete'),
|
||||||
|
'Finished downloading.',
|
||||||
|
opts.incognito
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
|
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
|
||||||
send(wc, { type: 'error', id: opts.id, error: msg })
|
send(wc, { type: 'error', id: opts.id, error: msg })
|
||||||
logFailure(opts, resolvedTitle, msg)
|
logFailure(opts, getTitle(), msg)
|
||||||
notify(wc, resolvedTitle ?? 'Download failed', msg)
|
notify(
|
||||||
|
wc,
|
||||||
|
opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
|
||||||
|
msg,
|
||||||
|
opts.incognito
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return { ok: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
|
||||||
@@ -407,6 +515,11 @@ export function cancelDownload(id: string): void {
|
|||||||
const rec = active.get(id)
|
const rec = active.get(id)
|
||||||
if (!rec) return
|
if (!rec) return
|
||||||
rec.canceled = true
|
rec.canceled = true
|
||||||
|
// Free the slot now (not on the async 'close') so the renderer's just-promoted
|
||||||
|
// next item isn't rejected by the maxConcurrent guard — or run briefly over the
|
||||||
|
// cap — while taskkill tears down this tree. The child's later 'close' stays
|
||||||
|
// silent (rec.canceled) and releaseActive() no-ops. (L148)
|
||||||
|
active.delete(id)
|
||||||
killTree(rec)
|
killTree(rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,5 +534,9 @@ export function pauseDownload(id: string): void {
|
|||||||
const rec = active.get(id)
|
const rec = active.get(id)
|
||||||
if (!rec) return
|
if (!rec) return
|
||||||
rec.paused = true
|
rec.paused = true
|
||||||
|
// Free the slot now (see cancelDownload / L148). The partial .part stays on disk
|
||||||
|
// for a later resume, which reuses this id — releaseActive()'s identity check
|
||||||
|
// keeps the doomed child's 'close' from evicting that fresh spawn. (L140)
|
||||||
|
active.delete(id)
|
||||||
killTree(rec)
|
killTree(rec)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-32
@@ -1,45 +1,23 @@
|
|||||||
import { app } from 'electron'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
|
||||||
import type { ErrorLogEntry } from '@shared/ipc'
|
import type { ErrorLogEntry } from '@shared/ipc'
|
||||||
import { isValidErrorLogEntry } from './validation'
|
import { isValidErrorLogEntry } from './validation'
|
||||||
|
import { createJsonStore } from './jsonStore'
|
||||||
|
import { ERRORLOG_MAX_ENTRIES } from './constants'
|
||||||
|
|
||||||
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
|
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
|
||||||
// report survives the queue item being cleared (Seal's "debug report").
|
// report survives the queue item being cleared (Seal's "debug report"). Atomic
|
||||||
const MAX_ENTRIES = 200
|
// writes / corruption backup / caching come from the shared jsonStore (R1–R3).
|
||||||
|
// Per-entry validation (isValidErrorLogEntry) so a hand-edited or corrupted
|
||||||
|
// errorlog.json can't feed the UI entries with the wrong shape. (audit S5)
|
||||||
|
const store = createJsonStore('errorlog.json', isValidErrorLogEntry, ERRORLOG_MAX_ENTRIES)
|
||||||
|
|
||||||
function errorLogFile(): string {
|
|
||||||
return join(app.getPath('userData'), 'errorlog.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-entry validation (isValidErrorLogEntry, in validation.ts) so a hand-edited
|
|
||||||
// or corrupted errorlog.json can't feed the UI entries with the wrong shape —
|
|
||||||
// invalid rows are dropped. (audit S5)
|
|
||||||
export function listErrorLog(): ErrorLogEntry[] {
|
export function listErrorLog(): ErrorLogEntry[] {
|
||||||
try {
|
return store.read()
|
||||||
if (!existsSync(errorLogFile())) return []
|
|
||||||
const data = JSON.parse(readFileSync(errorLogFile(), 'utf8'))
|
|
||||||
return Array.isArray(data) ? data.filter(isValidErrorLogEntry) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(entries: ErrorLogEntry[]): void {
|
|
||||||
try {
|
|
||||||
writeFileSync(errorLogFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
|
|
||||||
} catch {
|
|
||||||
/* best-effort; a read-only data dir just means no persisted error log */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addErrorLog(entry: ErrorLogEntry): ErrorLogEntry[] {
|
export function addErrorLog(entry: ErrorLogEntry): ErrorLogEntry[] {
|
||||||
const entries = [entry, ...listErrorLog()]
|
return store.write([entry, ...listErrorLog()])
|
||||||
save(entries)
|
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearErrorLog(): ErrorLogEntry[] {
|
export function clearErrorLog(): ErrorLogEntry[] {
|
||||||
save([])
|
return store.write([])
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-13
@@ -1,6 +1,7 @@
|
|||||||
import { execFile } from 'child_process'
|
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { getFfmpegPath, getFfprobePath } from './binaries'
|
import { getFfmpegPath, getFfprobePath } from './binaries'
|
||||||
|
import { execFileAsync } from './lib/exec'
|
||||||
|
import { VERSION_TIMEOUT_MS } from './constants'
|
||||||
import type { FfmpegVersionResult } from '@shared/ipc'
|
import type { FfmpegVersionResult } from '@shared/ipc'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,19 +12,13 @@ import type { FfmpegVersionResult } from '@shared/ipc'
|
|||||||
* out, or the line doesn't parse — Settings then shows "not found" instead of
|
* out, or the line doesn't parse — Settings then shows "not found" instead of
|
||||||
* failing. Unlike yt-dlp these are never self-updated, so there's no update path.
|
* failing. Unlike yt-dlp these are never self-updated, so there's no update path.
|
||||||
*/
|
*/
|
||||||
function readToolVersion(path: string): Promise<string | null> {
|
async function readToolVersion(path: string): Promise<string | null> {
|
||||||
if (!existsSync(path)) return Promise.resolve(null)
|
if (!existsSync(path)) return null
|
||||||
return new Promise((resolve) => {
|
const r = await execFileAsync(path, ['-version'], { timeout: VERSION_TIMEOUT_MS })
|
||||||
execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => {
|
if (!r.ok) return null
|
||||||
if (err) {
|
const firstLine = r.stdout.split('\n', 1)[0] ?? ''
|
||||||
resolve(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const firstLine = stdout.split('\n', 1)[0] ?? ''
|
|
||||||
const m = firstLine.match(/version\s+(\S+)/i)
|
const m = firstLine.match(/version\s+(\S+)/i)
|
||||||
resolve(m ? m[1] : null)
|
return m?.[1] ?? null
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */
|
/** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */
|
||||||
|
|||||||
+20
-40
@@ -1,59 +1,39 @@
|
|||||||
import { app } from 'electron'
|
import { HISTORY_MAX_ENTRIES, type HistoryEntry } from '@shared/ipc'
|
||||||
import { join } from 'path'
|
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
|
||||||
import type { HistoryEntry } from '@shared/ipc'
|
|
||||||
import { isValidHistoryEntry } from './validation'
|
import { isValidHistoryEntry } from './validation'
|
||||||
|
import { createJsonStore } from './jsonStore'
|
||||||
|
|
||||||
// Plain JSON in userData (portable build redirects userData next to the exe).
|
// Plain JSON in userData (portable build redirects userData next to the exe).
|
||||||
// Kept simple per the build plan; can migrate to better-sqlite3 later.
|
// Kept simple per the build plan; can migrate to better-sqlite3 later. Atomic
|
||||||
const MAX_ENTRIES = 500
|
// writes, corruption backup, and caching come from the shared jsonStore (R1–R3).
|
||||||
|
// The row cap (HISTORY_MAX_ENTRIES) is shared with the renderer's optimistic list.
|
||||||
|
//
|
||||||
|
// Per-entry validation (isValidHistoryEntry) so a hand-edited or corrupted
|
||||||
|
// history.json can't feed the UI (or openPath) entries with the wrong shape —
|
||||||
|
// invalid rows are dropped rather than trusted. (audit S5)
|
||||||
|
const store = createJsonStore('history.json', isValidHistoryEntry, HISTORY_MAX_ENTRIES)
|
||||||
|
|
||||||
function historyFile(): string {
|
|
||||||
return join(app.getPath('userData'), 'history.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-entry validation (isValidHistoryEntry, in validation.ts) so a hand-edited
|
|
||||||
// or corrupted history.json can't feed the UI (or openPath) entries with the
|
|
||||||
// wrong shape — invalid rows are dropped rather than trusted. (audit S5)
|
|
||||||
export function listHistory(): HistoryEntry[] {
|
export function listHistory(): HistoryEntry[] {
|
||||||
try {
|
return store.read()
|
||||||
if (!existsSync(historyFile())) return []
|
|
||||||
const data = JSON.parse(readFileSync(historyFile(), 'utf8'))
|
|
||||||
return Array.isArray(data) ? data.filter(isValidHistoryEntry) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(entries: HistoryEntry[]): void {
|
|
||||||
try {
|
|
||||||
writeFileSync(historyFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
|
|
||||||
} catch {
|
|
||||||
/* best-effort; a read-only data dir just means no persisted history */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addHistory(entry: HistoryEntry): HistoryEntry[] {
|
export function addHistory(entry: HistoryEntry): HistoryEntry[] {
|
||||||
// De-dupe by id (a retry of the same item replaces its prior entry).
|
// De-dupe by id AND url (M35): a History re-download re-queues via addFromUrl,
|
||||||
const entries = [entry, ...listHistory().filter((e) => e.id !== entry.id)]
|
// which mints a NEW id, so id-only de-dup would let the same video accumulate a
|
||||||
save(entries)
|
// fresh row on every re-download. Dropping any prior entry with the same url
|
||||||
return entries
|
// keeps one row per video, refreshed to the top.
|
||||||
|
const prior = listHistory().filter((e) => e.id !== entry.id && e.url !== entry.url)
|
||||||
|
return store.write([entry, ...prior])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeHistory(id: string): HistoryEntry[] {
|
export function removeHistory(id: string): HistoryEntry[] {
|
||||||
const entries = listHistory().filter((e) => e.id !== id)
|
return store.write(listHistory().filter((e) => e.id !== id))
|
||||||
save(entries)
|
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeManyHistory(ids: string[]): HistoryEntry[] {
|
export function removeManyHistory(ids: string[]): HistoryEntry[] {
|
||||||
const remove = new Set(ids)
|
const remove = new Set(ids)
|
||||||
const entries = listHistory().filter((e) => !remove.has(e.id))
|
return store.write(listHistory().filter((e) => !remove.has(e.id)))
|
||||||
save(entries)
|
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearHistory(): HistoryEntry[] {
|
export function clearHistory(): HistoryEntry[] {
|
||||||
save([])
|
return store.write([])
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-234
@@ -1,55 +1,28 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Notification } from 'electron'
|
import { app, shell, BrowserWindow, nativeTheme, Notification, Menu } from 'electron'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
|
import { IpcChannels } from '@shared/ipc'
|
||||||
|
import { PAGE_BACKGROUND } from '@shared/theme'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
registerIpcHandlers,
|
||||||
type StartDownloadOptions,
|
resolveBackgroundMode,
|
||||||
type Settings,
|
applyNativeTheme,
|
||||||
type HistoryEntry,
|
getSystemThemeInfo
|
||||||
type CommandTemplate,
|
} from './ipc'
|
||||||
type YtdlpUpdateChannel,
|
import { runStartupYtdlpAutoUpdate } from './ytdlp'
|
||||||
type SystemThemeInfo,
|
import { getAppIconImage } from './binaries'
|
||||||
type TaskbarProgress
|
import { hasActiveDownloads } from './download'
|
||||||
} from '@shared/ipc'
|
import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
|
||||||
import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp'
|
import { ensureMediaDirs } from './paths'
|
||||||
import { getFfmpegVersions } from './ffmpeg'
|
|
||||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
|
||||||
import { probeMedia } from './probe'
|
|
||||||
import {
|
|
||||||
startDownload,
|
|
||||||
cancelDownload,
|
|
||||||
pauseDownload,
|
|
||||||
previewCommand,
|
|
||||||
hasActiveDownloads
|
|
||||||
} from './download'
|
|
||||||
import { runTerminal, cancelTerminal } from './terminal'
|
|
||||||
import {
|
|
||||||
getSettings,
|
|
||||||
setSettings,
|
|
||||||
ensureMediaDirs,
|
|
||||||
applyLaunchAtStartup,
|
|
||||||
migrateSecretsAtRest
|
|
||||||
} from './settings'
|
|
||||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
|
||||||
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
|
||||||
import { setupPortableData } from './portable'
|
import { setupPortableData } from './portable'
|
||||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
import { migrateLegacyCookies } from './cookies'
|
||||||
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
import { attachEditContextMenu } from './contextMenu'
|
||||||
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
import { flushAllStores } from './jsonStore'
|
||||||
import { exportBackup, importBackup } from './backup'
|
|
||||||
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
|
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
|
||||||
import {
|
import { isSyncLaunch } from './schedule'
|
||||||
listSources,
|
|
||||||
getSource,
|
|
||||||
removeSource,
|
|
||||||
listMediaItems,
|
|
||||||
setMediaItemDownloaded,
|
|
||||||
setSourceWatched
|
|
||||||
} from './sources'
|
|
||||||
import { indexSource } from './indexer'
|
|
||||||
import { syncWatchedSources } from './sync'
|
|
||||||
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
|
|
||||||
import { createTray, markQuitting, isQuitting } from './tray'
|
import { createTray, markQuitting, isQuitting } from './tray'
|
||||||
|
import { logger } from './logger'
|
||||||
|
import { initialWindowState, saveWindowState } from './windowState'
|
||||||
|
|
||||||
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
|
// 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
|
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
|
||||||
@@ -79,34 +52,15 @@ setupPortableData()
|
|||||||
// installer also declares this scheme (electron-builder.yml's `protocols`)
|
// installer also declares this scheme (electron-builder.yml's `protocols`)
|
||||||
// so it's registered even before first launch; this call additionally covers
|
// 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.
|
// the portable build and dev, which have no installer step to do it for us.
|
||||||
if (is.dev && process.argv.length >= 2) {
|
const devScript = process.argv[1]
|
||||||
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(process.argv[1])])
|
if (is.dev && devScript) {
|
||||||
|
app.setAsDefaultProtocolClient('aerofetch', process.execPath, [resolve(devScript)])
|
||||||
} else {
|
} else {
|
||||||
app.setAsDefaultProtocolClient('aerofetch')
|
app.setAsDefaultProtocolClient('aerofetch')
|
||||||
}
|
}
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
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: '#f7f7f8', 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tell the user (once per run) that closing the window left AeroFetch running so
|
// Tell the user (once per run) that closing the window left AeroFetch running so
|
||||||
// an in-progress download could finish — shown only when they haven't already
|
// an in-progress download could finish — shown only when they haven't already
|
||||||
// opted into tray mode, so a window that "won't close" doesn't read as a bug.
|
// opted into tray mode, so a window that "won't close" doesn't read as a bug.
|
||||||
@@ -116,7 +70,8 @@ function notifyBackgroundOnce(): void {
|
|||||||
notifiedBackground = true
|
notifiedBackground = true
|
||||||
new Notification({
|
new Notification({
|
||||||
title: 'AeroFetch is still running',
|
title: 'AeroFetch is still running',
|
||||||
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.'
|
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.',
|
||||||
|
icon: getAppIconImage()
|
||||||
}).show()
|
}).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,11 +95,22 @@ const DENIED_PERMISSIONS = new Set([
|
|||||||
])
|
])
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
|
// Reopen where the user left off (size / position / maximized), falling back to the
|
||||||
|
// default size when there's no usable saved state — first run, or the saved monitor
|
||||||
|
// is no longer connected (W2 / UX19).
|
||||||
|
const ws = initialWindowState()
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 920,
|
title: 'AeroFetch',
|
||||||
height: 700,
|
width: ws.width,
|
||||||
|
height: ws.height,
|
||||||
|
x: ws.x,
|
||||||
|
y: ws.y,
|
||||||
|
// Below this the 212px sidebar + content layout breaks; pin a sensible
|
||||||
|
// floor so the window can't be dragged down to unusable widths (W1).
|
||||||
|
minWidth: 640,
|
||||||
|
minHeight: 480,
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
|
backgroundColor: PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)],
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.cjs'),
|
preload: join(__dirname, '../preload/index.cjs'),
|
||||||
@@ -155,6 +121,22 @@ function createWindow(): void {
|
|||||||
})
|
})
|
||||||
mainWindow = win
|
mainWindow = win
|
||||||
|
|
||||||
|
if (ws.maximized) win.maximize()
|
||||||
|
|
||||||
|
// Persist size/position/maximized so the next launch restores them (W2). Debounced
|
||||||
|
// so a drag-resize writes once when it settles; also saved on close (which may only
|
||||||
|
// hide to tray) to capture the final state.
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const scheduleSave = (): void => {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = setTimeout(() => saveWindowState(win), 500)
|
||||||
|
}
|
||||||
|
win.on('resize', scheduleSave)
|
||||||
|
win.on('move', scheduleSave)
|
||||||
|
|
||||||
|
// Standard Cut/Copy/Paste/Select All right-click menu on editable fields (W4).
|
||||||
|
attachEditContextMenu(win.webContents)
|
||||||
|
|
||||||
win.on('ready-to-show', () => {
|
win.on('ready-to-show', () => {
|
||||||
// A scheduled `--sync` launch starts unobtrusively (shown but not focused) so
|
// A scheduled `--sync` launch starts unobtrusively (shown but not focused) so
|
||||||
// the daily background sync doesn't steal focus; a normal launch shows + focuses.
|
// the daily background sync doesn't steal focus; a normal launch shows + focuses.
|
||||||
@@ -167,6 +149,8 @@ function createWindow(): void {
|
|||||||
// kill the spawned yt-dlp processes and lose the download. A real quit (tray
|
// kill the spawned yt-dlp processes and lose the download. A real quit (tray
|
||||||
// menu / before-quit) sets isQuitting() so this lets the close through.
|
// menu / before-quit) sets isQuitting() so this lets the close through.
|
||||||
win.on('close', (e) => {
|
win.on('close', (e) => {
|
||||||
|
// Capture the final bounds even when the close only hides to tray (W2).
|
||||||
|
saveWindowState(win)
|
||||||
if (isQuitting()) return
|
if (isQuitting()) return
|
||||||
const downloadsRunning = hasActiveDownloads()
|
const downloadsRunning = hasActiveDownloads()
|
||||||
if (getSettings().minimizeToTray || downloadsRunning) {
|
if (getSettings().minimizeToTray || downloadsRunning) {
|
||||||
@@ -183,6 +167,13 @@ function createWindow(): void {
|
|||||||
if (mainWindow === win) mainWindow = null
|
if (mainWindow === win) mainWindow = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// When the user re-opens the window (via tray, second-instance, or deeplink)
|
||||||
|
// reset the background-notify latch so they're informed again if they close
|
||||||
|
// while a download is still running (L68).
|
||||||
|
win.on('show', () => {
|
||||||
|
notifiedBackground = false
|
||||||
|
})
|
||||||
|
|
||||||
// The OS may have launched us with an aerofetch:// link or a "Send to" .url
|
// 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
|
// 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.
|
// once the page (and its IPC listener) is actually ready to receive it.
|
||||||
@@ -225,161 +216,12 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerIpcHandlers(): void {
|
|
||||||
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
|
|
||||||
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
|
|
||||||
downloadAppUpdate(url, e.sender)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
|
|
||||||
const result = startDownload(e.sender, opts)
|
|
||||||
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
|
|
||||||
// download.ts's own close/error handlers, so log them here instead.
|
|
||||||
if (!result.ok) {
|
|
||||||
addErrorLog({
|
|
||||||
id: opts.id,
|
|
||||||
url: opts.url,
|
|
||||||
kind: opts.kind,
|
|
||||||
error: result.error ?? 'Unknown error',
|
|
||||||
occurredAt: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
|
||||||
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
|
|
||||||
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
|
|
||||||
runTerminal(e.sender, id, args)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.chooseFolder, async (e) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
|
||||||
const res = await dialog.showOpenDialog(win!, {
|
|
||||||
properties: ['openDirectory', 'createDirectory']
|
|
||||||
})
|
|
||||||
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
|
|
||||||
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
|
|
||||||
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
|
|
||||||
const result = setSettings(partial)
|
|
||||||
// Keep the window's native background in sync with the theme so a compositor
|
|
||||||
// repaint never flashes a mismatched color behind an overlay. Use the
|
|
||||||
// validated result, not the raw partial (which may hold a bogus value).
|
|
||||||
if (partial.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))
|
|
||||||
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
|
|
||||||
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
|
|
||||||
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
|
|
||||||
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
|
|
||||||
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) => saveTemplate(template))
|
|
||||||
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
|
|
||||||
previewCommand(opts)
|
|
||||||
)
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel))
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog())
|
|
||||||
ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog())
|
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.backupExport, (e) =>
|
|
||||||
exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.backupImport, async (e) => {
|
|
||||||
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
|
||||||
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[resolveBackgroundMode(getSettings().theme)])
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Media-manager sources (Pinchflat-style index; see ROADMAP-PINCHFLAT.md) ---
|
|
||||||
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
|
|
||||||
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
|
|
||||||
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
|
|
||||||
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
|
|
||||||
setMediaItemDownloaded(id, filePath)
|
|
||||||
)
|
|
||||||
// Indexing pushes live progress to the requesting renderer over `indexProgress`
|
|
||||||
// and resolves with the final result.
|
|
||||||
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
|
|
||||||
indexSource(url, (p) => {
|
|
||||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
|
|
||||||
const src = getSource(id)
|
|
||||||
if (!src) return { ok: false, error: 'Source not found.' }
|
|
||||||
return indexSource(src.url, (p) => {
|
|
||||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
|
|
||||||
setSourceWatched(id, watched)
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
|
||||||
syncWatchedSources((p) => {
|
|
||||||
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
|
|
||||||
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
|
|
||||||
|
|
||||||
// Reflect overall queue progress on the Windows taskbar (fire-and-forget).
|
|
||||||
ipcMain.on(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
||||||
if (p.mode === 'none') mainWindow.setProgressBar(-1)
|
|
||||||
else mainWindow.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push OS theme/contrast changes to every window, and keep the native
|
// Push OS theme/contrast changes to every window, and keep the native
|
||||||
// background in sync for windows currently following 'system'.
|
// background in sync for windows currently following 'system'.
|
||||||
function registerSystemThemeBridge(): void {
|
function registerSystemThemeBridge(): void {
|
||||||
nativeTheme.on('updated', () => {
|
nativeTheme.on('updated', () => {
|
||||||
const info = getSystemThemeInfo()
|
const info = getSystemThemeInfo()
|
||||||
const bg = THEME_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
|
const bg = PAGE_BACKGROUND[resolveBackgroundMode(getSettings().theme)]
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
win.webContents.send(IpcChannels.systemThemeUpdate, info)
|
win.webContents.send(IpcChannels.systemThemeUpdate, info)
|
||||||
if (getSettings().theme === 'system') win.setBackgroundColor(bg)
|
if (getSettings().theme === 'system') win.setBackgroundColor(bg)
|
||||||
@@ -400,6 +242,10 @@ if (isPrimaryInstance) {
|
|||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
electronApp.setAppUserModelId('com.aerofetch.app')
|
electronApp.setAppUserModelId('com.aerofetch.app')
|
||||||
|
|
||||||
|
// M31: suppress the default Electron menu (which exposes DevTools/Reload via
|
||||||
|
// Alt) in production builds. In dev the menu is kept so DevTools are accessible.
|
||||||
|
if (!is.dev) Menu.setApplicationMenu(null)
|
||||||
|
|
||||||
// Create the default Documents\Video and Documents\Audio destinations so they
|
// Create the default Documents\Video and Documents\Audio destinations so they
|
||||||
// exist from first launch (downloads are routed into them by kind).
|
// exist from first launch (downloads are routed into them by kind).
|
||||||
ensureMediaDirs()
|
ensureMediaDirs()
|
||||||
@@ -407,6 +253,9 @@ if (isPrimaryInstance) {
|
|||||||
// Encrypt any credential still stored as legacy plaintext (from before at-rest
|
// Encrypt any credential still stored as legacy plaintext (from before at-rest
|
||||||
// encryption), once safeStorage is available post-ready.
|
// encryption), once safeStorage is available post-ready.
|
||||||
migrateSecretsAtRest()
|
migrateSecretsAtRest()
|
||||||
|
// Same for a pre-encryption plaintext cookies.txt — encrypt it and delete the
|
||||||
|
// plaintext so a logged-in session no longer sits in the open (H7).
|
||||||
|
migrateLegacyCookies()
|
||||||
|
|
||||||
// Sync the Windows "run at sign-in" entry with the persisted setting, so it
|
// Sync the Windows "run at sign-in" entry with the persisted setting, so it
|
||||||
// reflects the user's choice even if they changed it on another install.
|
// reflects the user's choice even if they changed it on another install.
|
||||||
@@ -416,9 +265,11 @@ if (isPrimaryInstance) {
|
|||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
registerIpcHandlers()
|
registerIpcHandlers(() => mainWindow)
|
||||||
registerSystemThemeBridge()
|
registerSystemThemeBridge()
|
||||||
registerSendToShortcut()
|
registerSendToShortcut()
|
||||||
|
// Apply the persisted theme to the OS title bar before the window opens (W3).
|
||||||
|
applyNativeTheme(getSettings().theme)
|
||||||
createWindow()
|
createWindow()
|
||||||
createTray(() => mainWindow)
|
createTray(() => mainWindow)
|
||||||
|
|
||||||
@@ -443,20 +294,20 @@ if (isPrimaryInstance) {
|
|||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
|
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch((e) => logger.error('yt-dlp auto-update failed', e))
|
||||||
|
|
||||||
app.on('activate', () => {
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set
|
// Any real quit path (tray "Quit", OS shutdown, the updater's app.quit) must set
|
||||||
// the quitting flag so the window's close handler exits instead of hiding to tray.
|
// the quitting flag so the window's close handler exits instead of hiding to tray.
|
||||||
app.on('before-quit', () => markQuitting())
|
// Also flush any debounced JSON-store writes synchronously so a quit mid-debounce
|
||||||
|
// can't drop the last history/sources/template change (R3).
|
||||||
app.on('window-all-closed', () => {
|
app.on('before-quit', () => {
|
||||||
if (process.platform !== 'darwin') {
|
markQuitting()
|
||||||
app.quit()
|
flushAllStores()
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Windows-only app: closing the last window quits (the tray/in-flight-download
|
||||||
|
// paths hide rather than close, so this only fires on a real exit). The former
|
||||||
|
// macOS 'activate' handler and darwin guard were dead branches here (L147).
|
||||||
|
app.on('window-all-closed', () => app.quit())
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-19
@@ -9,9 +9,10 @@
|
|||||||
* this module is the impure shell that spawns yt-dlp and writes to disk.
|
* this module is the impure shell that spawns yt-dlp and writes to disk.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFile } from 'child_process'
|
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { getYtdlpPath } from './binaries'
|
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||||
|
import { execFileAsync } from './lib/exec'
|
||||||
|
import { INDEX_MAX_BUFFER, INDEX_TIMEOUT_MS } from './constants'
|
||||||
import { cleanError } from './log'
|
import { cleanError } from './log'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
import {
|
import {
|
||||||
@@ -45,29 +46,25 @@ interface FlatInfo {
|
|||||||
* flat upload list can be a few MB of JSON; the timeout is generous for the same
|
* flat upload list can be a few MB of JSON; the timeout is generous for the same
|
||||||
* reason. `--` terminates option parsing so the URL can't be read as a flag.
|
* reason. `--` terminates option parsing so the URL can't be read as a flag.
|
||||||
*/
|
*/
|
||||||
function probeFlat(url: string): Promise<FlatInfo> {
|
async function probeFlat(url: string): Promise<FlatInfo> {
|
||||||
return new Promise((resolve, reject) => {
|
const r = await execFileAsync(
|
||||||
execFile(
|
|
||||||
getYtdlpPath(),
|
getYtdlpPath(),
|
||||||
['-J', '--flat-playlist', '--no-warnings', '--', url],
|
['-J', '--flat-playlist', '--no-warnings', '--', url],
|
||||||
{ windowsHide: true, maxBuffer: 256 * 1024 * 1024, timeout: 180_000 },
|
{ maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS }
|
||||||
(err, stdout, stderr) => {
|
)
|
||||||
if (err) {
|
if (!r.ok) {
|
||||||
const msg = (err as { killed?: boolean }).killed
|
throw new Error(
|
||||||
|
r.timedOut
|
||||||
? 'Timed out indexing source. Check the link or your connection.'
|
? 'Timed out indexing source. Check the link or your connection.'
|
||||||
: cleanError(stderr) || err.message
|
: cleanError(r.stderr) || r.error?.message || ''
|
||||||
reject(new Error(msg))
|
)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
resolve(JSON.parse(stdout) as FlatInfo)
|
return JSON.parse(r.stdout) as FlatInfo
|
||||||
} catch {
|
} catch {
|
||||||
reject(new Error('Could not parse source info from yt-dlp.'))
|
throw new Error('Could not parse source info from yt-dlp.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Probe a channel tab (e.g. /videos, /playlists); never throws — returns null. */
|
/** Probe a channel tab (e.g. /videos, /playlists); never throws — returns null. */
|
||||||
function probeTab(url: string): Promise<FlatInfo | null> {
|
function probeTab(url: string): Promise<FlatInfo | null> {
|
||||||
@@ -90,7 +87,7 @@ export async function indexSource(
|
|||||||
return { ok: false, error: (e as Error).message }
|
return { ok: false, error: (e as Error).message }
|
||||||
}
|
}
|
||||||
if (!existsSync(getYtdlpPath())) {
|
if (!existsSync(getYtdlpPath())) {
|
||||||
return { ok: false, error: 'yt-dlp.exe not found. Drop it into resources/bin/.' }
|
return { ok: false, error: YTDLP_MISSING_MSG }
|
||||||
}
|
}
|
||||||
|
|
||||||
const cls = classifySource(url)
|
const cls = classifySource(url)
|
||||||
@@ -152,7 +149,8 @@ export async function indexSource(
|
|||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'That link is a single video, not a channel or playlist. Use the download bar for one video.'
|
error:
|
||||||
|
'That link is a single video, not a channel or playlist. Use the download bar for one video.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
title = data.title || 'Playlist'
|
title = data.title || 'Playlist'
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ export function classifySource(raw: string): SourceClass | null {
|
|||||||
// A playlist is identified purely by its list= param (works on any youtube host).
|
// A playlist is identified purely by its list= param (works on any youtube host).
|
||||||
const list = u.searchParams.get('list')
|
const list = u.searchParams.get('list')
|
||||||
if (isYouTube && list) {
|
if (isYouTube && list) {
|
||||||
return { kind: 'playlist', base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}` }
|
return {
|
||||||
|
kind: 'playlist',
|
||||||
|
base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isYouTube) return null
|
if (!isYouTube) return null
|
||||||
@@ -163,7 +166,7 @@ export function parseRssVideoIds(xml: string): string[] {
|
|||||||
const ids: string[] = []
|
const ids: string[] = []
|
||||||
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
|
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
|
||||||
let m: RegExpExecArray | null
|
let m: RegExpExecArray | null
|
||||||
while ((m = re.exec(xml)) !== null) ids.push(m[1])
|
while ((m = re.exec(xml)) !== null) if (m[1]) ids.push(m[1])
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* IPC surface for the main process. Every `ipcMain.handle` (and the one
|
||||||
|
* fire-and-forget `taskbarProgress` receiver) lives here, extracted from index.ts
|
||||||
|
* so that module is just app lifecycle + window creation and this is the single
|
||||||
|
* place the renderer's IPC contract is wired (L2).
|
||||||
|
*
|
||||||
|
* The handful of handlers that must touch the window (folder picker parenting,
|
||||||
|
* theme-synced background, taskbar progress) get the current window through the
|
||||||
|
* `getMainWindow` accessor rather than a captured reference, so they always act on
|
||||||
|
* the live window. The small theme helpers they share are defined and exported
|
||||||
|
* here too, since index's system-theme bridge uses them as well.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme } from 'electron'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import {
|
||||||
|
IpcChannels,
|
||||||
|
type StartDownloadOptions,
|
||||||
|
type Settings,
|
||||||
|
type HistoryEntry,
|
||||||
|
type CommandTemplate,
|
||||||
|
type YtdlpUpdateChannel,
|
||||||
|
type SystemThemeInfo,
|
||||||
|
type TaskbarProgress,
|
||||||
|
type PersistedQueueItem
|
||||||
|
} from '@shared/ipc'
|
||||||
|
import { PAGE_BACKGROUND } from '@shared/theme'
|
||||||
|
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||||
|
import { getFfmpegVersions } from './ffmpeg'
|
||||||
|
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||||
|
import { probeMedia } from './probe'
|
||||||
|
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||||
|
import { runTerminal, cancelTerminal } from './terminal'
|
||||||
|
import { getSettings, setSettings } from './settings'
|
||||||
|
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||||
|
import { listTemplates, saveTemplate, removeTemplate } from './templates'
|
||||||
|
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||||
|
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||||
|
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
|
||||||
|
import { listQueue, saveQueue } from './queue'
|
||||||
|
import { exportBackup, importBackup } from './backup'
|
||||||
|
import {
|
||||||
|
listSources,
|
||||||
|
getSource,
|
||||||
|
removeSource,
|
||||||
|
listMediaItems,
|
||||||
|
setMediaItemDownloaded,
|
||||||
|
setSourceWatched
|
||||||
|
} from './sources'
|
||||||
|
import { indexSource } from './indexer'
|
||||||
|
import { syncWatchedSources } from './sync'
|
||||||
|
import { getScheduledSync, setScheduledSync } from './schedule'
|
||||||
|
import { getActiveBadge, getErrorBadge } from './badge'
|
||||||
|
import { openPoTokenWindow } from './poToken'
|
||||||
|
import { logger } from './logger'
|
||||||
|
|
||||||
|
// --- Theme helpers (shared with index's system-theme bridge) ----------------
|
||||||
|
|
||||||
|
/** Resolve 'system' against the OS preference so callers always get a concrete color. */
|
||||||
|
export function resolveBackgroundMode(theme: Settings['theme']): 'light' | 'dark' {
|
||||||
|
return theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the OS-drawn title bar consistent with the in-app theme (W3). Setting
|
||||||
|
* themeSource to 'light'/'dark' forces the caption/chrome to match; 'system'
|
||||||
|
* restores OS control. Called at startup and on every theme change.
|
||||||
|
*/
|
||||||
|
export function applyNativeTheme(theme: Settings['theme']): void {
|
||||||
|
nativeTheme.themeSource = theme
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystemThemeInfo(): SystemThemeInfo {
|
||||||
|
return {
|
||||||
|
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
|
||||||
|
shouldUseHighContrastColors: nativeTheme.shouldUseHighContrastColors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IPC registration -------------------------------------------------------
|
||||||
|
|
||||||
|
export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null): void {
|
||||||
|
// M30: a broken contextBridge causes the renderer to silently run in preview/mock
|
||||||
|
// mode. Catch it here and show a hard error so the failure is never invisible.
|
||||||
|
ipcMain.once(IpcChannels.preloadBridgeFailure, (_e, detail: unknown) => {
|
||||||
|
dialog.showErrorBox(
|
||||||
|
'AeroFetch could not start',
|
||||||
|
`The renderer bridge failed to initialize. Please reinstall the application.\n\n${String(detail)}`
|
||||||
|
)
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
// CC8: persist renderer-side failures (the M29 `logError` sites) to the main log
|
||||||
|
// file — the renderer console is unreachable in a packaged build. Fire-and-forget.
|
||||||
|
ipcMain.on(IpcChannels.logWrite, (_e, op: string, detail: string) =>
|
||||||
|
logger.error(`[renderer] ${op}`, detail)
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.appVersion, () => app.getVersion())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.appUpdateCheck, () => checkForAppUpdate())
|
||||||
|
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
|
||||||
|
downloadAppUpdate(url, e.sender)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
|
||||||
|
const result = startDownload(e.sender, opts)
|
||||||
|
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
|
||||||
|
// download.ts's own close/error handlers, so log them here instead — unless the
|
||||||
|
// download is incognito, which is never recorded (L136).
|
||||||
|
if (!result.ok && !opts.incognito) {
|
||||||
|
addErrorLog({
|
||||||
|
id: opts.id,
|
||||||
|
url: opts.url,
|
||||||
|
kind: opts.kind,
|
||||||
|
error: result.error ?? 'Unknown error',
|
||||||
|
occurredAt: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
|
||||||
|
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
|
||||||
|
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
|
||||||
|
runTerminal(e.sender, id, args)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.chooseFolder, async (e, current?: string) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
||||||
|
// Seed the picker at the currently-configured folder so it opens where the
|
||||||
|
// user already points, not a generic default (W5). 'createDirectory' is a
|
||||||
|
// macOS-only property and a no-op on Windows, so it's dropped (L58).
|
||||||
|
const defaultPath = current && existsSync(current) ? current : undefined
|
||||||
|
// Use the parented overload only when we actually have a window — passing a
|
||||||
|
// forced non-null window that's gone can throw (L53).
|
||||||
|
const res = win
|
||||||
|
? await dialog.showOpenDialog(win, { properties: ['openDirectory'], defaultPath })
|
||||||
|
: await dialog.showOpenDialog({ properties: ['openDirectory'], defaultPath })
|
||||||
|
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
|
||||||
|
ipcMain.handle(IpcChannels.openUrl, (_e, url: string) => {
|
||||||
|
try {
|
||||||
|
const { protocol } = new URL(url)
|
||||||
|
if (protocol === 'http:' || protocol === 'https:') void shell.openExternal(url)
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed URLs.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
|
||||||
|
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
|
||||||
|
const result = setSettings(partial)
|
||||||
|
// Keep the window's native background and title bar in sync with the theme
|
||||||
|
// so a compositor repaint never flashes a mismatched color, and the OS-drawn
|
||||||
|
// caption always matches the in-app theme (W3). Use the validated result.
|
||||||
|
if (partial.theme) {
|
||||||
|
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(
|
||||||
|
PAGE_BACKGROUND[resolveBackgroundMode(result.theme)]
|
||||||
|
)
|
||||||
|
applyNativeTheme(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))
|
||||||
|
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
|
||||||
|
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.cookiesLogin, (e, url: string) =>
|
||||||
|
// Parent the sign-in window to the app window (W6) so it groups under
|
||||||
|
// AeroFetch instead of spawning a second taskbar button.
|
||||||
|
openCookieLoginWindow(url, BrowserWindow.fromWebContents(e.sender) ?? undefined)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
|
||||||
|
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
|
||||||
|
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) =>
|
||||||
|
saveTemplate(template)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
|
||||||
|
previewCommand(opts)
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog())
|
||||||
|
ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog())
|
||||||
|
|
||||||
|
// Persisted download queue (M4): the renderer mirrors its durable items here and
|
||||||
|
// rehydrates them on launch, so saved/scheduled/pending downloads survive a quit.
|
||||||
|
ipcMain.handle(IpcChannels.queueList, () => listQueue())
|
||||||
|
ipcMain.handle(IpcChannels.queueSave, (_e, items: PersistedQueueItem[]) => saveQueue(items))
|
||||||
|
|
||||||
|
ipcMain.handle(IpcChannels.backupExport, (e) =>
|
||||||
|
exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.backupImport, async (e) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
|
||||||
|
const result = await importBackup(win)
|
||||||
|
// A restored backup may have changed the theme; keep the native window
|
||||||
|
// background and title bar in sync the same way settingsSet does (W3).
|
||||||
|
if (result.ok) {
|
||||||
|
const theme = getSettings().theme
|
||||||
|
win?.setBackgroundColor(PAGE_BACKGROUND[resolveBackgroundMode(theme)])
|
||||||
|
applyNativeTheme(theme)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Media-manager sources (Pinchflat-style index; see ROADMAP-PINCHFLAT.md) ---
|
||||||
|
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
|
||||||
|
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
|
||||||
|
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
|
||||||
|
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
|
||||||
|
setMediaItemDownloaded(id, filePath)
|
||||||
|
)
|
||||||
|
// Indexing pushes live progress to the requesting renderer over `indexProgress`
|
||||||
|
// and resolves with the final result.
|
||||||
|
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
|
||||||
|
indexSource(url, (p) => {
|
||||||
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
|
||||||
|
const src = getSource(id)
|
||||||
|
if (!src) return { ok: false, error: 'Source not found.' }
|
||||||
|
return indexSource(src.url, (p) => {
|
||||||
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
|
||||||
|
setSourceWatched(id, watched)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
|
||||||
|
syncWatchedSources((p) => {
|
||||||
|
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
|
||||||
|
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
|
||||||
|
|
||||||
|
// Reflect overall queue progress on the Windows taskbar and window title (SR8).
|
||||||
|
ipcMain.handle(IpcChannels.taskbarProgress, (_e, p: TaskbarProgress) => {
|
||||||
|
const win = getMainWindow()
|
||||||
|
if (!win || win.isDestroyed()) return
|
||||||
|
if (p.mode === 'none') {
|
||||||
|
win.setProgressBar(-1)
|
||||||
|
win.setOverlayIcon(null, '')
|
||||||
|
win.setTitle('AeroFetch')
|
||||||
|
} else {
|
||||||
|
win.setProgressBar(Math.max(0, Math.min(1, p.fraction)), { mode: p.mode })
|
||||||
|
const badge = p.mode === 'error' ? getErrorBadge() : getActiveBadge()
|
||||||
|
const n = p.badgeCount ?? 0
|
||||||
|
const label =
|
||||||
|
p.mode === 'error' ? 'Download error' : `${n} download${n !== 1 ? 's' : ''} active`
|
||||||
|
win.setOverlayIcon(badge, label)
|
||||||
|
win.setTitle(p.mode === 'error' ? 'AeroFetch — Error' : `AeroFetch — ${label}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Open a YouTube WebView and extract a PO token for bot-check bypass (Phase P).
|
||||||
|
ipcMain.handle(IpcChannels.youtubePoTokenMint, async () => {
|
||||||
|
const token = await openPoTokenWindow()
|
||||||
|
if (token) await setSettings({ youtubePoToken: token })
|
||||||
|
return token
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, unlinkSync } from 'fs'
|
||||||
|
import { logger } from './logger'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared persistence layer for the hand-rolled JSON array stores (history,
|
||||||
|
* error log, templates, sources, media-items). Replaces the per-module
|
||||||
|
* read/parse/write copies (SIMP1) and fixes three data-safety blockers:
|
||||||
|
*
|
||||||
|
* - R1 -- atomic writes: write a temp file then `rename` over the target, so a
|
||||||
|
* crash / power-cut mid-write can never truncate the real file. (`electron-store`,
|
||||||
|
* used for settings, is already atomic; these stores were not.)
|
||||||
|
* - R2 -- corruption is no longer silent data loss: on a parse error the bad file
|
||||||
|
* is copied aside (`<file>.corrupt-<ts>`) before we fall back to empty, so a
|
||||||
|
* single bad byte can't wipe the user's history/sources from their perspective.
|
||||||
|
* - R3 -- an in-memory cache + debounced batched writes: the previous code
|
||||||
|
* re-read + re-parsed the entire (multi-MB) file and rewrote it synchronously
|
||||||
|
* on *every* download completion (≈O(n²) over a large channel, blocking the
|
||||||
|
* main thread). Reads now hit the cache; writes coalesce into one atomic flush.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Debounce window for batched writes. Long enough to coalesce a burst (e.g. many
|
||||||
|
// items enqueued/marked at once), short enough that data lands on disk promptly.
|
||||||
|
const FLUSH_DELAY_MS = 400
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read + validate a JSON array from `path`. Invalid rows are dropped. On a parse
|
||||||
|
* error the file is backed up to `<path>.corrupt-<timestamp>` before returning []
|
||||||
|
* so corruption is recoverable rather than a silent wipe (R2). A missing file is
|
||||||
|
* simply [] (first run), with no backup.
|
||||||
|
*/
|
||||||
|
export function readJsonArraySafe<T>(path: string, isValid: (o: unknown) => o is T): T[] {
|
||||||
|
let raw: string
|
||||||
|
try {
|
||||||
|
if (!existsSync(path)) return []
|
||||||
|
raw = readFileSync(path, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
return Array.isArray(data) ? data.filter(isValid) : []
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
copyFileSync(path, `${path}.corrupt-${Date.now()}`)
|
||||||
|
} catch {
|
||||||
|
/* best-effort -- if we can't back it up, still don't crash the read */
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically write `value` as JSON to `path`: serialise to `<path>.tmp`, then
|
||||||
|
* `rename` it over the target (an atomic operation on the same volume), so a
|
||||||
|
* crash mid-write leaves either the old file or the new one -- never a truncated
|
||||||
|
* one (R1). `pretty` indents for human-readable files; pass false for large
|
||||||
|
* machine-only stores to avoid inflating size/write time (R8). Returns whether the
|
||||||
|
* write landed: a failure (disk full, read-only data dir) is logged rather than
|
||||||
|
* swallowed silently, so a record that looked saved but vanished on restart is at
|
||||||
|
* least diagnosable (R6). The leftover temp file is removed on failure so a partial
|
||||||
|
* `.tmp` can't accumulate.
|
||||||
|
*/
|
||||||
|
export function writeJsonAtomic(path: string, value: unknown, pretty = true): boolean {
|
||||||
|
const tmp = `${path}.tmp`
|
||||||
|
try {
|
||||||
|
writeFileSync(tmp, pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value))
|
||||||
|
renameSync(tmp, path)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
// R6: previously a silent catch — a failed persist (disk full, read-only
|
||||||
|
// profile) dropped the change with no trace. It now lands in the file-backed
|
||||||
|
// diagnostic log (CC8); the user-facing toast ties to the global status surface (UI25/UX9).
|
||||||
|
logger.error(`failed to write ${path}`, e)
|
||||||
|
try {
|
||||||
|
if (existsSync(tmp)) unlinkSync(tmp)
|
||||||
|
} catch {
|
||||||
|
/* best-effort temp cleanup */
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonStore<T> {
|
||||||
|
/** All rows, validated; cached after the first read. */
|
||||||
|
read(): T[]
|
||||||
|
/** Replace all rows (capped), update the cache, and schedule an atomic flush. Returns the stored rows. */
|
||||||
|
write(items: T[]): T[]
|
||||||
|
/** Force any pending debounced write to disk synchronously (e.g. on quit). */
|
||||||
|
flush(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every store registers itself so app-quit can flush pending writes in one call.
|
||||||
|
const registry: JsonStore<unknown>[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a cached, atomic JSON array store backed by `<userData>/<filename>`.
|
||||||
|
* `cap` bounds the row count (pass Infinity for none). The path is resolved
|
||||||
|
* lazily so importing this module never touches `app` before it's ready.
|
||||||
|
*/
|
||||||
|
export function createJsonStore<T>(
|
||||||
|
filename: string,
|
||||||
|
isValid: (o: unknown) => o is T,
|
||||||
|
cap: number,
|
||||||
|
// Pretty-print by default (small, hand-inspectable files); pass false for a
|
||||||
|
// large machine-only store to keep writes compact (R8).
|
||||||
|
pretty = true
|
||||||
|
): JsonStore<T> {
|
||||||
|
let cache: T[] | null = null
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let dirty = false
|
||||||
|
|
||||||
|
const filePath = (): string => join(app.getPath('userData'), filename)
|
||||||
|
|
||||||
|
function read(): T[] {
|
||||||
|
if (cache === null) cache = readJsonArraySafe(filePath(), isValid)
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush(): void {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
if (!dirty) return
|
||||||
|
// Only clear the dirty flag once the write actually lands. A failed flush
|
||||||
|
// (disk full, transient lock) stays dirty so the next write() or the
|
||||||
|
// quit-time flushAllStores() retries it instead of silently dropping it (R6).
|
||||||
|
if (writeJsonAtomic(filePath(), cache ?? [], pretty)) dirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(items: T[]): T[] {
|
||||||
|
// Avoid an extra copy in the common under-cap case so a per-completion write
|
||||||
|
// stays O(1) rather than re-slicing the whole list each time (R3).
|
||||||
|
cache = items.length > cap ? items.slice(0, cap) : items
|
||||||
|
dirty = true
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(flush, FLUSH_DELAY_MS)
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const store: JsonStore<T> = { read, write, flush }
|
||||||
|
registry.push(store as JsonStore<unknown>)
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronously flush every store's pending write -- call on app quit. */
|
||||||
|
export function flushAllStores(): void {
|
||||||
|
for (const s of registry) s.flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { execFile, type ExecFileOptions } from 'child_process'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized result of a one-shot child-process run. `ok` is true on a clean
|
||||||
|
* exit-0; `timedOut` distinguishes a kill-by-timeout (Node sets `err.killed` when
|
||||||
|
* it terminates the process on the `timeout` option) from an ordinary non-zero
|
||||||
|
* exit, so callers can show "Timed out …" versus the captured stderr.
|
||||||
|
*/
|
||||||
|
export interface ExecResult {
|
||||||
|
ok: boolean
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
timedOut: boolean
|
||||||
|
/** The raw error on a non-zero exit / spawn failure (undefined when ok). */
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `execFile` as a promise that never rejects (audit SIMP2/CC4/CC5). Replaces the
|
||||||
|
* six hand-rolled `new Promise((resolve) => execFile(…, cb))` wrappers across
|
||||||
|
* probe / ytdlp (×2) / ffmpeg / indexer / download.probeMeta — each caller now maps
|
||||||
|
* this normalized result to its own shape instead of repeating the `err.killed`
|
||||||
|
* timeout check and the resolve/reject plumbing. (schedule.ts keeps its own wrapper:
|
||||||
|
* it needs the numeric exit code, which this helper deliberately doesn't surface.)
|
||||||
|
*
|
||||||
|
* `windowsHide` defaults on (every caller wants the console window suppressed);
|
||||||
|
* pass `timeout` / `maxBuffer` per call exactly as before.
|
||||||
|
*/
|
||||||
|
export function execFileAsync(
|
||||||
|
file: string,
|
||||||
|
args: readonly string[],
|
||||||
|
options: ExecFileOptions = {}
|
||||||
|
): Promise<ExecResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(file, [...args], { windowsHide: true, ...options }, (err, stdout, stderr) => {
|
||||||
|
// With the default (utf8) encoding stdout/stderr are strings, but ExecFileOptions
|
||||||
|
// permits a Buffer encoding, so normalize defensively.
|
||||||
|
const out = typeof stdout === 'string' ? stdout : stdout.toString()
|
||||||
|
const errOut = typeof stderr === 'string' ? stderr : stderr.toString()
|
||||||
|
if (err) {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
stdout: out,
|
||||||
|
stderr: errOut,
|
||||||
|
timedOut: (err as { killed?: boolean }).killed === true,
|
||||||
|
error: err
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolve({ ok: true, stdout: out, stderr: errOut, timedOut: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { DownloadProgress } from '@shared/ipc'
|
||||||
|
import { fmtBytes } from '@shared/format'
|
||||||
|
|
||||||
|
// yt-dlp progress-line parsing. Extracted from download.ts so it can be
|
||||||
|
// unit-tested without pulling in the electron import chain (L37). Byte/speed/ETA
|
||||||
|
// formatting lives in @shared/format — the one home for both sides of the IPC
|
||||||
|
// boundary; consumers import it directly.
|
||||||
|
|
||||||
|
function num(s?: string): number | undefined {
|
||||||
|
if (!s || s === 'NA') return undefined
|
||||||
|
const n = Number(s)
|
||||||
|
return Number.isFinite(n) ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProgress(rest: string): DownloadProgress | null {
|
||||||
|
const parts = rest.split('|')
|
||||||
|
if (parts.length < 6) return null
|
||||||
|
const [status, dl, total, totalEst, speed, eta] = parts
|
||||||
|
const downloaded = num(dl)
|
||||||
|
const totalBytes = num(total) ?? num(totalEst)
|
||||||
|
let progress = 0
|
||||||
|
if (totalBytes && downloaded != null) progress = Math.min(1, downloaded / totalBytes)
|
||||||
|
return {
|
||||||
|
status: status || 'downloading',
|
||||||
|
progress,
|
||||||
|
// Carry raw numbers across the IPC boundary; the renderer formats them for
|
||||||
|
// display AND aggregates them (combined speed / longest ETA) without having
|
||||||
|
// to re-parse a formatted string (H4).
|
||||||
|
speedBytesPerSec: num(speed),
|
||||||
|
etaSeconds: num(eta),
|
||||||
|
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined,
|
||||||
|
// No (estimated) total → the % can never advance; flag it so the renderer
|
||||||
|
// shows an indeterminate bar rather than a stuck 0% (L137).
|
||||||
|
sizeUnknown: !totalBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* A newline line-splitter for streamed child-process output (audit CC3/CC4/SIMP5).
|
||||||
|
*
|
||||||
|
* `download.ts` and `terminal.ts` each had the same hand-rolled loop: accumulate
|
||||||
|
* chunk strings, emit every complete `\n`-terminated line (stripping a trailing
|
||||||
|
* `\r`), and keep the partial remainder for the next chunk. This is that logic in
|
||||||
|
* one place, and — being pure and synchronous — it's unit-tested directly.
|
||||||
|
*
|
||||||
|
* Usage: feed decoded chunks with `push()`, and call `flush()` on stream close to
|
||||||
|
* emit any trailing partial line that never saw a newline.
|
||||||
|
*/
|
||||||
|
export interface LineBuffer {
|
||||||
|
/** Accept a decoded chunk; invokes the sink for each newly-completed line. */
|
||||||
|
push: (chunk: string) => void
|
||||||
|
/** Emit any buffered remainder (a final line with no trailing newline). */
|
||||||
|
flush: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLineBuffer(onLine: (line: string) => void): LineBuffer {
|
||||||
|
let buf = ''
|
||||||
|
return {
|
||||||
|
push(chunk: string): void {
|
||||||
|
buf += chunk
|
||||||
|
let nl: number
|
||||||
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||||
|
onLine(buf.slice(0, nl).replace(/\r$/, ''))
|
||||||
|
buf = buf.slice(nl + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flush(): void {
|
||||||
|
if (buf) {
|
||||||
|
onLine(buf)
|
||||||
|
buf = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { appendFileSync, mkdirSync, renameSync, statSync } from 'fs'
|
||||||
|
import { LOG_MAX_BYTES } from './constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One small leveled, file-backed logger for the whole app (audit CC8). Before this,
|
||||||
|
* diagnostics were effectively invisible in a packaged build: main-process catches
|
||||||
|
* did a bare `console.error` (unreachable — the app menu/DevTools are suppressed in
|
||||||
|
* production, M31) and the renderer's `logError` (M29) wrote to a console nobody could
|
||||||
|
* open. Every catch now routes here and lands in `<userData>/logs/aerofetch.log`, so a
|
||||||
|
* field-reported failure is actually diagnosable.
|
||||||
|
*
|
||||||
|
* Scope: this is the diagnostic log. `errorlog.ts` remains separate — it's user-facing
|
||||||
|
* *download* failure data shown in Settings → Diagnostics, not app logging. The
|
||||||
|
* user-facing toast half of CC8 ties to the global status surface (UI25/UX9).
|
||||||
|
*
|
||||||
|
* All `app`/fs access is lazy and guarded so importing this module never touches
|
||||||
|
* `app` before it's ready — and so it's a safe no-op under unit tests, where
|
||||||
|
* `electron`'s `app` is undefined.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
|
||||||
|
|
||||||
|
const LEVELS: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 }
|
||||||
|
|
||||||
|
// Write everything at or above this level to the file. `debug` is dev-only noise, so
|
||||||
|
// it never hits the file (but still prints to the dev console).
|
||||||
|
const FILE_THRESHOLD = LEVELS.info
|
||||||
|
|
||||||
|
/** Format one log line: `2026-07-01T08:30:00.000Z [ERROR] message`. Pure, for tests. */
|
||||||
|
export function formatLine(level: LogLevel, message: string, now: Date = new Date()): string {
|
||||||
|
return `${now.toISOString()} [${level.toUpperCase()}] ${message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combine an operation label with an optional error/detail into one message. Pure. */
|
||||||
|
export function composeMessage(label: string, detail?: unknown): string {
|
||||||
|
if (detail === undefined) return label
|
||||||
|
const text = detail instanceof Error ? detail.message : String(detail)
|
||||||
|
return `${label}: ${text}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// `undefined` = not yet resolved, `null` = resolution failed (no app/userData, e.g.
|
||||||
|
// under unit tests) so file logging is disabled; a string is the resolved path.
|
||||||
|
let cachedFile: string | null | undefined
|
||||||
|
function logFile(): string | null {
|
||||||
|
if (cachedFile !== undefined) return cachedFile
|
||||||
|
try {
|
||||||
|
const dir = join(app.getPath('userData'), 'logs')
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
cachedFile = join(dir, 'aerofetch.log')
|
||||||
|
} catch {
|
||||||
|
// No app / userData available (unit test, or app not ready) — disable the file sink.
|
||||||
|
cachedFile = null
|
||||||
|
}
|
||||||
|
return cachedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dev mode, resolved lazily and guarded (app is undefined in the test env). */
|
||||||
|
function isDev(): boolean {
|
||||||
|
try {
|
||||||
|
return !app.isPackaged
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate to `<file>.1` (single generation) once the live file passes the cap, so the
|
||||||
|
// log can't grow without bound. Best-effort: any fs error here is swallowed — logging
|
||||||
|
// must never throw into a catch handler.
|
||||||
|
function rotateIfNeeded(file: string): void {
|
||||||
|
try {
|
||||||
|
if (statSync(file).size > LOG_MAX_BYTES) renameSync(file, `${file}.1`)
|
||||||
|
} catch {
|
||||||
|
/* file missing (first write) or rename race — ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToFile(line: string): void {
|
||||||
|
const file = logFile()
|
||||||
|
if (!file) return
|
||||||
|
rotateIfNeeded(file)
|
||||||
|
try {
|
||||||
|
appendFileSync(file, line + '\n')
|
||||||
|
} catch {
|
||||||
|
/* disk full / read-only profile — nothing more we can safely do */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(level: LogLevel, label: string, detail?: unknown): void {
|
||||||
|
const message = composeMessage(label, detail)
|
||||||
|
// Console in dev keeps the familiar `--inspect` workflow; the file is the sink that
|
||||||
|
// survives into a packaged build.
|
||||||
|
if (isDev()) {
|
||||||
|
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
||||||
|
fn(`[AeroFetch] ${message}`)
|
||||||
|
}
|
||||||
|
if (LEVELS[level] <= FILE_THRESHOLD) writeToFile(formatLine(level, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
error: (label: string, detail?: unknown): void => emit('error', label, detail),
|
||||||
|
warn: (label: string, detail?: unknown): void => emit('warn', label, detail),
|
||||||
|
info: (label: string, detail?: unknown): void => emit('info', label, detail),
|
||||||
|
debug: (label: string, detail?: unknown): void => emit('debug', label, detail)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem path helpers for the main process. Kept separate from settings.ts
|
||||||
|
* (the electron-store persistence layer) so path derivation and the settings
|
||||||
|
* store don't share a module — they have different concerns and dependencies (L69).
|
||||||
|
* All resolve app paths, so callers must run post-`app.ready`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||||
|
export function getDownloadArchivePath(): string {
|
||||||
|
return join(app.getPath('userData'), 'download-archive.txt')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default per-kind download destination: video → Documents\Video,
|
||||||
|
* audio → Documents\Audio. Used when the user hasn't set an explicit output
|
||||||
|
* folder (Settings → Download folder), so downloads are sorted by type.
|
||||||
|
*/
|
||||||
|
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
|
||||||
|
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Documents\Video and Documents\Audio folders up front (called once
|
||||||
|
* at startup) so they exist the moment the app opens, not just after the first
|
||||||
|
* download. Best-effort: yt-dlp also creates the output dir at download time, so
|
||||||
|
* a failure here (read-only Documents, redirected folder) is non-fatal.
|
||||||
|
*/
|
||||||
|
export function ensureMediaDirs(): void {
|
||||||
|
for (const kind of ['video', 'audio'] as const) {
|
||||||
|
try {
|
||||||
|
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — the download path will be created on demand instead */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { BrowserWindow, type WebContents } from 'electron'
|
||||||
|
import { LOGIN_PARTITION } from './cookies'
|
||||||
|
|
||||||
|
/** A public YouTube video used as the extraction target (stable, always accessible). */
|
||||||
|
const YT_VIDEO = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restrict a PO-token window to *.youtube.com and accounts.google.com (for
|
||||||
|
* sign-in). Mirrors hardenLoginWebContents in cookies.ts.
|
||||||
|
*/
|
||||||
|
function isAllowedPoTokenUrl(target: string): boolean {
|
||||||
|
try {
|
||||||
|
const { protocol, hostname } = new URL(target)
|
||||||
|
if (protocol !== 'http:' && protocol !== 'https:') return false
|
||||||
|
return (
|
||||||
|
hostname === 'youtube.com' ||
|
||||||
|
hostname.endsWith('.youtube.com') ||
|
||||||
|
hostname === 'accounts.google.com' ||
|
||||||
|
hostname.endsWith('.accounts.google.com')
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hardenPoTokenWebContents(wc: WebContents): void {
|
||||||
|
wc.setWindowOpenHandler((details) => {
|
||||||
|
if (!isAllowedPoTokenUrl(details.url)) return { action: 'deny' }
|
||||||
|
return {
|
||||||
|
action: 'allow',
|
||||||
|
overrideBrowserWindowOptions: {
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
partition: LOGIN_PARTITION,
|
||||||
|
sandbox: true,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wc.on('will-navigate', (e, navUrl) => {
|
||||||
|
if (!isAllowedPoTokenUrl(navUrl)) e.preventDefault()
|
||||||
|
})
|
||||||
|
wc.on('will-redirect', (e, navUrl) => {
|
||||||
|
if (!isAllowedPoTokenUrl(navUrl)) e.preventDefault()
|
||||||
|
})
|
||||||
|
wc.on('did-create-window', (child) => hardenPoTokenWebContents(child.webContents))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async IIFE injected into the YouTube page after load. Polls
|
||||||
|
* ytInitialPlayerResponse.serviceIntegrityDimensions.poToken (the field
|
||||||
|
* yt-dlp's youtube extractor reads) for up to 8 s, returning the token
|
||||||
|
* string or null if it never appears. The poll loop is needed because the
|
||||||
|
* player JS initialises asynchronously after the DOM is ready.
|
||||||
|
*/
|
||||||
|
const EXTRACT_SCRIPT = `
|
||||||
|
(async function() {
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
try {
|
||||||
|
const pot = window.ytInitialPlayerResponse
|
||||||
|
&& window.ytInitialPlayerResponse.serviceIntegrityDimensions
|
||||||
|
&& window.ytInitialPlayerResponse.serviceIntegrityDimensions.poToken
|
||||||
|
if (typeof pot === 'string' && pot.length > 0) return pot
|
||||||
|
} catch (_) {}
|
||||||
|
await new Promise(r => setTimeout(r, 500))
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
let mintWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a visible BrowserWindow on a YouTube video page and extract the
|
||||||
|
* Proof-of-Origin token from the page runtime. The window uses the shared
|
||||||
|
* login session so a signed-in user's credentials are available.
|
||||||
|
*
|
||||||
|
* Resolves with the token string on success, or null if the window was closed
|
||||||
|
* by the user before extraction completed or if the field was not found.
|
||||||
|
*/
|
||||||
|
export function openPoTokenWindow(): Promise<string | null> {
|
||||||
|
// If a minting window is already open, bring it to the front rather than
|
||||||
|
// opening a second one.
|
||||||
|
if (mintWindow && !mintWindow.isDestroyed()) {
|
||||||
|
mintWindow.focus()
|
||||||
|
// Return a promise that resolves when the existing window closes.
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
mintWindow!.once('closed', () => resolve(null))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false
|
||||||
|
const finish = (token: string | null): void => {
|
||||||
|
if (resolved) return
|
||||||
|
resolved = true
|
||||||
|
resolve(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
let win: BrowserWindow
|
||||||
|
try {
|
||||||
|
win = new BrowserWindow({
|
||||||
|
width: 960,
|
||||||
|
height: 640,
|
||||||
|
title: 'AeroFetch — Fetch YouTube token',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
partition: LOGIN_PARTITION,
|
||||||
|
sandbox: true,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
finish(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mintWindow = win
|
||||||
|
hardenPoTokenWebContents(win.webContents)
|
||||||
|
win.webContents.session.setPermissionRequestHandler((_wc, _perm, cb) => cb(false))
|
||||||
|
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
win.webContents
|
||||||
|
.executeJavaScript(EXTRACT_SCRIPT, true)
|
||||||
|
.then((result: unknown) => {
|
||||||
|
const token = typeof result === 'string' && result.length > 0 ? result : null
|
||||||
|
finish(token)
|
||||||
|
win.close()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
finish(null)
|
||||||
|
win.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
mintWindow = null
|
||||||
|
finish(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
win.loadURL(YT_VIDEO).catch(() => {
|
||||||
|
/* navigation errors surface as Chromium's own error page */
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
+24
-32
@@ -1,7 +1,8 @@
|
|||||||
import { execFile } from 'child_process'
|
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { getYtdlpPath } from './binaries'
|
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||||
import { fmtBytes } from './download'
|
import { execFileAsync } from './lib/exec'
|
||||||
|
import { PROBE_MAX_BUFFER, PROBE_TIMEOUT_MS } from './constants'
|
||||||
|
import { fmtBytes } from '@shared/format'
|
||||||
import { cleanError } from './log'
|
import { cleanError } from './log'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
|
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
|
||||||
@@ -69,7 +70,10 @@ function buildVideoFormats(raw: RawFormat[]): FormatOption[] {
|
|||||||
const options = [...bestByHeight.values()]
|
const options = [...bestByHeight.values()]
|
||||||
.sort((a, b) => (b.height ?? 0) - (a.height ?? 0))
|
.sort((a, b) => (b.height ?? 0) - (a.height ?? 0))
|
||||||
.map(toOption)
|
.map(toOption)
|
||||||
options.unshift({ id: BEST_FORMAT_ID, label: 'Best available', ext: 'mp4', hasAudio: true })
|
// No `ext`: the final container for the auto-best pick depends on the user's
|
||||||
|
// videoContainer setting (mp4/mkv/webm), unknown here -- claiming 'mp4' would
|
||||||
|
// mislabel mkv/webm outputs (L62). hasAudio is always true (best merges audio).
|
||||||
|
options.unshift({ id: BEST_FORMAT_ID, label: 'Best available', hasAudio: true })
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,52 +114,40 @@ function buildPlaylist(data: RawInfo): PlaylistInfo {
|
|||||||
* its format list) or, when the URL is a playlist, the flat list of its entries.
|
* its format list) or, when the URL is a playlist, the flat list of its entries.
|
||||||
* `--flat-playlist` is a no-op for a lone video, so one call covers both cases.
|
* `--flat-playlist` is a no-op for a lone video, so one call covers both cases.
|
||||||
*/
|
*/
|
||||||
export function probeMedia(url: string): Promise<ProbeResult> {
|
export async function probeMedia(url: string): Promise<ProbeResult> {
|
||||||
const ytdlp = getYtdlpPath()
|
const ytdlp = getYtdlpPath()
|
||||||
if (!existsSync(ytdlp)) {
|
if (!existsSync(ytdlp)) {
|
||||||
return Promise.resolve({
|
return { ok: false, error: YTDLP_MISSING_MSG }
|
||||||
ok: false,
|
|
||||||
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
let target: string
|
let target: string
|
||||||
try {
|
try {
|
||||||
target = assertHttpUrl(url) // normalised form (audit F5)
|
target = assertHttpUrl(url) // normalised form (audit F5)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return Promise.resolve({ ok: false, error: (e as Error).message })
|
return { ok: false, error: (e as Error).message }
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
execFile(
|
|
||||||
ytdlp,
|
|
||||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||||
['-J', '--flat-playlist', '--no-warnings', '--', target],
|
const r = await execFileAsync(ytdlp, ['-J', '--flat-playlist', '--no-warnings', '--', target], {
|
||||||
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
|
maxBuffer: PROBE_MAX_BUFFER,
|
||||||
(err, stdout, stderr) => {
|
timeout: PROBE_TIMEOUT_MS
|
||||||
if (err) {
|
})
|
||||||
// execFile sets `killed` when it terminated the process on timeout.
|
if (!r.ok) {
|
||||||
const msg = (err as { killed?: boolean }).killed
|
const msg = r.timedOut
|
||||||
? 'Timed out fetching video info. Check the link or your connection.'
|
? 'Timed out fetching video info. Check the link or your connection.'
|
||||||
: cleanError(stderr) || err.message
|
: cleanError(r.stderr) || r.error?.message || ''
|
||||||
resolve({ ok: false, error: msg })
|
return { ok: false, error: msg }
|
||||||
return
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(stdout) as RawInfo
|
const data = JSON.parse(r.stdout) as RawInfo
|
||||||
if (data._type === 'playlist' || Array.isArray(data.entries)) {
|
if (data._type === 'playlist' || Array.isArray(data.entries)) {
|
||||||
const playlist = buildPlaylist(data)
|
const playlist = buildPlaylist(data)
|
||||||
if (playlist.count === 0) {
|
if (playlist.count === 0) {
|
||||||
resolve({ ok: false, error: 'This playlist has no downloadable entries.' })
|
return { ok: false, error: 'This playlist has no downloadable entries.' }
|
||||||
return
|
|
||||||
}
|
}
|
||||||
resolve({ ok: true, kind: 'playlist', playlist })
|
return { ok: true, kind: 'playlist', playlist }
|
||||||
} else {
|
|
||||||
resolve({ ok: true, kind: 'video', info: buildInfo(data) })
|
|
||||||
}
|
}
|
||||||
|
return { ok: true, kind: 'video', info: buildInfo(data) }
|
||||||
} catch {
|
} catch {
|
||||||
resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' })
|
return { ok: false, error: 'Could not parse video info from yt-dlp.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createJsonStore } from './jsonStore'
|
||||||
|
import { QUEUE_MAX } from './constants'
|
||||||
|
import type { PersistedQueueItem, PersistedQueueStatus } from '@shared/ipc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistence for the download queue (M4). The queue lived only in renderer memory,
|
||||||
|
* so `saved` / scheduled and other pending downloads were silently lost on quit.
|
||||||
|
* The renderer now mirrors its durable items here (via `queueSave`) and rehydrates
|
||||||
|
* them on launch (via `queueList`), so a parked/scheduled download returns.
|
||||||
|
*
|
||||||
|
* Reuses the shared cached-atomic `createJsonStore` (R1/R2/R3), so writes are atomic
|
||||||
|
* and coalesced — the renderer can call `queueSave` freely on queue changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PERSISTED_STATUSES: readonly PersistedQueueStatus[] = [
|
||||||
|
'queued',
|
||||||
|
'downloading',
|
||||||
|
'paused',
|
||||||
|
'saved',
|
||||||
|
'error'
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Defensive row validator (a hand-edited or partially-written file can't crash a read). */
|
||||||
|
function isValidQueueItem(o: unknown): o is PersistedQueueItem {
|
||||||
|
if (typeof o !== 'object' || o === null) return false
|
||||||
|
const it = o as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof it.id === 'string' &&
|
||||||
|
typeof it.url === 'string' &&
|
||||||
|
typeof it.title === 'string' &&
|
||||||
|
(it.kind === 'video' || it.kind === 'audio') &&
|
||||||
|
typeof it.quality === 'string' &&
|
||||||
|
typeof it.status === 'string' &&
|
||||||
|
(PERSISTED_STATUSES as readonly string[]).includes(it.status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = createJsonStore('queue.json', isValidQueueItem, QUEUE_MAX)
|
||||||
|
|
||||||
|
export function listQueue(): PersistedQueueItem[] {
|
||||||
|
return store.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveQueue(items: PersistedQueueItem[]): void {
|
||||||
|
store.write(items)
|
||||||
|
}
|
||||||
+27
-6
@@ -13,11 +13,29 @@ import { extname, isAbsolute } from 'path'
|
|||||||
*/
|
*/
|
||||||
const OPENABLE_EXTENSIONS = new Set([
|
const OPENABLE_EXTENSIONS = new Set([
|
||||||
// video
|
// video
|
||||||
'.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.ts', '.m4v', '.3gp', '.ogv',
|
'.mp4',
|
||||||
|
'.mkv',
|
||||||
|
'.webm',
|
||||||
|
'.mov',
|
||||||
|
'.avi',
|
||||||
|
'.flv',
|
||||||
|
'.ts',
|
||||||
|
'.m4v',
|
||||||
|
'.3gp',
|
||||||
|
'.ogv',
|
||||||
// audio
|
// audio
|
||||||
'.mp3', '.m4a', '.opus', '.ogg', '.oga', '.aac', '.flac', '.wav', '.wma',
|
'.mp3',
|
||||||
|
'.m4a',
|
||||||
|
'.opus',
|
||||||
|
'.ogg',
|
||||||
|
'.oga',
|
||||||
|
'.aac',
|
||||||
|
'.flac',
|
||||||
|
'.wav',
|
||||||
|
'.wma',
|
||||||
// subtitle sidecars (plain text — safe to open)
|
// subtitle sidecars (plain text — safe to open)
|
||||||
'.vtt', '.srt'
|
'.vtt',
|
||||||
|
'.srt'
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Open a downloaded media file with its default app. Returns '' on success,
|
/** Open a downloaded media file with its default app. Returns '' on success,
|
||||||
@@ -35,8 +53,11 @@ export async function safeOpenPath(p: unknown): Promise<string> {
|
|||||||
return shell.openPath(p)
|
return shell.openPath(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reveal a path in the OS file manager. No-op for missing/invalid paths. */
|
/** Reveal a path in the OS file manager. Returns '' on success, or an error
|
||||||
export function safeShowInFolder(p: unknown): void {
|
* string (mirroring safeOpenPath) so the renderer can surface it (UX6). */
|
||||||
if (typeof p !== 'string' || !isAbsolute(p) || !existsSync(p)) return
|
export function safeShowInFolder(p: unknown): string {
|
||||||
|
if (typeof p !== 'string' || !isAbsolute(p)) return 'Invalid path.'
|
||||||
|
if (!existsSync(p)) return 'File not found — it may have been moved or deleted.'
|
||||||
shell.showItemInFolder(p)
|
shell.showItemInFolder(p)
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,15 @@ function schtasks(args: string[]): Promise<{ code: number; stdout: string; stder
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Resolve schtasks from System32 by absolute path, not the bare name, so a
|
// Resolve schtasks from System32 by absolute path, not the bare name, so a
|
||||||
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
|
// planted schtasks.exe on PATH / in the CWD can't be invoked instead. (audit F3)
|
||||||
execFile(getSystem32Path('schtasks.exe'), args, { windowsHide: true }, (err, stdout, stderr) => {
|
execFile(
|
||||||
|
getSystem32Path('schtasks.exe'),
|
||||||
|
args,
|
||||||
|
{ windowsHide: true },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
const code = err ? ((err as { code?: number }).code ?? 1) : 0
|
const code = err ? ((err as { code?: number }).code ?? 1) : 0
|
||||||
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
|
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
|
||||||
})
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+89
-87
@@ -1,8 +1,7 @@
|
|||||||
import { app, safeStorage } from 'electron'
|
import { app, safeStorage } from 'electron'
|
||||||
import { join } from 'path'
|
|
||||||
import { mkdirSync } from 'fs'
|
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
|
||||||
|
import { logger } from './logger'
|
||||||
import {
|
import {
|
||||||
AUDIO_FORMATS,
|
AUDIO_FORMATS,
|
||||||
VIDEO_CONTAINERS,
|
VIDEO_CONTAINERS,
|
||||||
@@ -10,50 +9,24 @@ import {
|
|||||||
SPONSORBLOCK_CATEGORIES,
|
SPONSORBLOCK_CATEGORIES,
|
||||||
COOKIE_BROWSERS,
|
COOKIE_BROWSERS,
|
||||||
ACCENT_COLORS,
|
ACCENT_COLORS,
|
||||||
|
VIDEO_QUALITY_OPTIONS,
|
||||||
|
AUDIO_QUALITY_OPTIONS,
|
||||||
DEFAULT_DOWNLOAD_OPTIONS,
|
DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
isYtdlpUpdateChannel,
|
isYtdlpUpdateChannel,
|
||||||
type Settings,
|
type Settings,
|
||||||
type DownloadOptions,
|
type DownloadOptions,
|
||||||
type SponsorBlockCategory
|
type SponsorBlockCategory,
|
||||||
|
type AudioFormat,
|
||||||
|
type VideoContainer,
|
||||||
|
type VideoCodecPref
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
const DEFAULTS: Settings = {
|
// The electron-store defaults are the canonical DEFAULT_SETTINGS from the shared
|
||||||
// Both blank by default → downloads land in Documents\Video / Documents\Audio
|
// contract — single-sourced so the renderer FALLBACK and the preview mock can't
|
||||||
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
|
// drift from main (C1). The rationale for individual defaults (SR1/SR2/SR3) lives
|
||||||
videoDir: '',
|
// alongside DEFAULT_SETTINGS in shared/ipc.ts.
|
||||||
audioDir: '',
|
const DEFAULTS: Settings = DEFAULT_SETTINGS
|
||||||
defaultKind: 'video',
|
|
||||||
defaultVideoQuality: 'Best available',
|
|
||||||
defaultAudioQuality: 'Best (MP3)',
|
|
||||||
maxConcurrent: 2,
|
|
||||||
filenameTemplate: '%(title)s.%(ext)s',
|
|
||||||
theme: 'light',
|
|
||||||
accentColor: 'teal',
|
|
||||||
clipboardWatch: true,
|
|
||||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
|
||||||
proxy: '',
|
|
||||||
rateLimit: '',
|
|
||||||
useAria2c: false,
|
|
||||||
cookieSource: 'none',
|
|
||||||
cookiesBrowser: 'chrome',
|
|
||||||
youtubePlayerClient: '',
|
|
||||||
youtubePoToken: '',
|
|
||||||
restrictFilenames: false,
|
|
||||||
downloadArchive: false,
|
|
||||||
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
|
|
||||||
// nightly channel (fastest to follow YouTube changes that cause 403s).
|
|
||||||
autoUpdateYtdlp: true,
|
|
||||||
ytdlpChannel: 'nightly',
|
|
||||||
ytdlpLastUpdateCheck: 0,
|
|
||||||
customCommandEnabled: false,
|
|
||||||
defaultTemplateId: null,
|
|
||||||
notifyOnComplete: true,
|
|
||||||
autoDownloadNew: true,
|
|
||||||
hasCompletedOnboarding: false,
|
|
||||||
minimizeToTray: false,
|
|
||||||
launchAtStartup: false,
|
|
||||||
updateToken: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at
|
* Sync the OS "run at sign-in" entry with the launchAtStartup setting. Called at
|
||||||
@@ -69,56 +42,29 @@ export function applyLaunchAtStartup(enabled: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
|
||||||
export function getDownloadArchivePath(): string {
|
|
||||||
return join(app.getPath('userData'), 'download-archive.txt')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default per-kind download destination: video → Documents\Video,
|
|
||||||
* audio → Documents\Audio. Used when the user hasn't set an explicit output
|
|
||||||
* folder (Settings → Download folder), so downloads are sorted by type.
|
|
||||||
*/
|
|
||||||
export function getDefaultMediaDir(kind: 'video' | 'audio'): string {
|
|
||||||
return join(app.getPath('documents'), kind === 'audio' ? 'Audio' : 'Video')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the Documents\Video and Documents\Audio folders up front (called once
|
|
||||||
* at startup) so they exist the moment the app opens, not just after the first
|
|
||||||
* download. Best-effort: yt-dlp also creates the output dir at download time, so
|
|
||||||
* a failure here (read-only Documents, redirected folder) is non-fatal.
|
|
||||||
*/
|
|
||||||
export function ensureMediaDirs(): void {
|
|
||||||
for (const kind of ['video', 'audio'] as const) {
|
|
||||||
try {
|
|
||||||
mkdirSync(getDefaultMediaDir(kind), { recursive: true })
|
|
||||||
} catch {
|
|
||||||
/* non-fatal — the download path will be created on demand instead */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
||||||
// defaults for any missing/invalid field. Used both to migrate older settings
|
// defaults for any missing/invalid field. Used both to migrate older settings
|
||||||
// files (which predate downloadOptions) and to validate renderer writes.
|
// files (which predate downloadOptions) and to validate renderer writes.
|
||||||
function sanitizeOptions(input: unknown): DownloadOptions {
|
function sanitizeOptions(input: unknown): DownloadOptions {
|
||||||
const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions>
|
const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions>
|
||||||
const d = DEFAULT_DOWNLOAD_OPTIONS
|
const d = DEFAULT_DOWNLOAD_OPTIONS
|
||||||
const bool = (v: unknown, fallback: boolean): boolean =>
|
const bool = (v: unknown, fallback: boolean): boolean => (typeof v === 'boolean' ? v : fallback)
|
||||||
typeof v === 'boolean' ? v : fallback
|
|
||||||
const cats = Array.isArray(o.sponsorBlockCategories)
|
const cats = Array.isArray(o.sponsorBlockCategories)
|
||||||
? (o.sponsorBlockCategories.filter((c) =>
|
? (o.sponsorBlockCategories.filter((c) =>
|
||||||
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
|
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
|
||||||
) as SponsorBlockCategory[])
|
) as SponsorBlockCategory[])
|
||||||
: d.sponsorBlockCategories
|
: d.sponsorBlockCategories
|
||||||
return {
|
return {
|
||||||
audioFormat: AUDIO_FORMATS.includes(o.audioFormat as never) ? o.audioFormat! : d.audioFormat,
|
audioFormat: (AUDIO_FORMATS as readonly string[]).includes(o.audioFormat as string)
|
||||||
videoContainer: VIDEO_CONTAINERS.includes(o.videoContainer as never)
|
? (o.audioFormat as AudioFormat)
|
||||||
? o.videoContainer!
|
: d.audioFormat,
|
||||||
|
videoContainer: (VIDEO_CONTAINERS as readonly string[]).includes(o.videoContainer as string)
|
||||||
|
? (o.videoContainer as VideoContainer)
|
||||||
: d.videoContainer,
|
: d.videoContainer,
|
||||||
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
|
preferredVideoCodec: (VIDEO_CODECS as readonly string[]).includes(
|
||||||
? o.preferredVideoCodec!
|
o.preferredVideoCodec as string
|
||||||
|
)
|
||||||
|
? (o.preferredVideoCodec as VideoCodecPref)
|
||||||
: d.preferredVideoCodec,
|
: d.preferredVideoCodec,
|
||||||
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
|
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
|
||||||
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
|
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
|
||||||
@@ -133,6 +79,9 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
|||||||
embedChapters: bool(o.embedChapters, d.embedChapters),
|
embedChapters: bool(o.embedChapters, d.embedChapters),
|
||||||
splitChapters: bool(o.splitChapters, d.splitChapters),
|
splitChapters: bool(o.splitChapters, d.splitChapters),
|
||||||
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
|
||||||
|
metadataTitle: typeof o.metadataTitle === 'string' ? o.metadataTitle : undefined,
|
||||||
|
metadataArtist: typeof o.metadataArtist === 'string' ? o.metadataArtist : undefined,
|
||||||
|
metadataAlbum: typeof o.metadataAlbum === 'string' ? o.metadataAlbum : undefined,
|
||||||
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
|
||||||
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
|
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
|
||||||
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
|
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
|
||||||
@@ -149,13 +98,19 @@ function getStore(): Store<Settings> {
|
|||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypted-settings cache — avoids repeated DPAPI calls on hot paths such as
|
||||||
|
// buildCommand, the maxConcurrent check, completion notify, and the system-theme
|
||||||
|
// bridge (PERF1). Invalidated by every setSettings write and by the one-time
|
||||||
|
// migrateSecretsAtRest so callers always see the current values.
|
||||||
|
let cachedSettings: Settings | null = null
|
||||||
|
|
||||||
// --- Credential encryption at rest ------------------------------------------
|
// --- Credential encryption at rest ------------------------------------------
|
||||||
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
|
// proxy / youtubePoToken / updateToken can carry secrets (a proxy password, API
|
||||||
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
|
// tokens). They're stored encrypted via Electron safeStorage (DPAPI on Windows)
|
||||||
// so a leaked settings.json doesn't expose them. They're still decrypted before
|
// so a leaked settings.json doesn't expose them. They're decrypted before
|
||||||
// reaching the renderer and exported in clear by backup — this guards the file at
|
// reaching the renderer; backup export strips them entirely (see backup.ts, M22).
|
||||||
// rest only.
|
// Exported so backup.ts shares this one list rather than keeping a parallel copy.
|
||||||
const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
|
export const SECRET_KEYS = ['proxy', 'youtubePoToken', 'updateToken'] as const
|
||||||
|
|
||||||
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
|
// Marks a value produced by encryptSecret, so a read can tell ciphertext from a
|
||||||
// legacy plaintext value (written before encryption existed, or while safeStorage
|
// legacy plaintext value (written before encryption existed, or while safeStorage
|
||||||
@@ -172,6 +127,11 @@ function encryptSecret(plain: string): string {
|
|||||||
} catch {
|
} catch {
|
||||||
/* fall through — store plaintext, as it was before encryption existed */
|
/* fall through — store plaintext, as it was before encryption existed */
|
||||||
}
|
}
|
||||||
|
// R7: OS encryption (DPAPI/keychain) is unavailable, so this credential is about
|
||||||
|
// to be written in cleartext. Warn rather than fall back silently — a leaked
|
||||||
|
// settings.json would then expose the proxy password / API token. (DPAPI is
|
||||||
|
// effectively always present on Windows, so this should never fire in practice.)
|
||||||
|
logger.warn('OS secret encryption unavailable — storing credential as plaintext')
|
||||||
return plain
|
return plain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,9 +173,11 @@ export function migrateSecretsAtRest(): void {
|
|||||||
const raw = s.get(key) ?? ''
|
const raw = s.get(key) ?? ''
|
||||||
if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw))
|
if (raw && !raw.startsWith(ENC_PREFIX)) s.set(key, encryptSecret(raw))
|
||||||
}
|
}
|
||||||
|
cachedSettings = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSettings(): Settings {
|
export function getSettings(): Settings {
|
||||||
|
if (cachedSettings) return cachedSettings
|
||||||
const s = getStore()
|
const s = getStore()
|
||||||
// getSettings() is on hot paths (buildCommand, notification checks, the system-
|
// getSettings() is on hot paths (buildCommand, notification checks, the system-
|
||||||
// theme bridge, several IPC handlers). electron-store writes to disk on every
|
// theme bridge, several IPC handlers). electron-store writes to disk on every
|
||||||
@@ -236,9 +198,15 @@ export function getSettings(): Settings {
|
|||||||
if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) {
|
if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) {
|
||||||
s.set('accentColor', DEFAULTS.accentColor)
|
s.set('accentColor', DEFAULTS.accentColor)
|
||||||
}
|
}
|
||||||
|
// Migrate the legacy audio-quality label 'Best (MP3)' to the format-agnostic
|
||||||
|
// 'Best' so the dropdown matches and the label no longer names a format (M18).
|
||||||
|
if (cur.defaultAudioQuality === 'Best (MP3)') {
|
||||||
|
s.set('defaultAudioQuality', 'Best')
|
||||||
|
}
|
||||||
// Hand callers (and, via IPC, the renderer) plaintext credentials — they're
|
// Hand callers (and, via IPC, the renderer) plaintext credentials — they're
|
||||||
// only encrypted on disk (see withDecryptedSecrets / encryptSecret).
|
// only encrypted on disk (see withDecryptedSecrets / encryptSecret).
|
||||||
return withDecryptedSecrets(s.store)
|
cachedSettings = withDecryptedSecrets(s.store)
|
||||||
|
return cachedSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
|
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
|
||||||
@@ -264,6 +232,20 @@ function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean {
|
|||||||
// background (theme), so an out-of-range or malformed value shouldn't get stored.
|
// background (theme), so an out-of-range or malformed value shouldn't get stored.
|
||||||
export function setSettings(partial: Partial<Settings>): Settings {
|
export function setSettings(partial: Partial<Settings>): Settings {
|
||||||
const s = getStore()
|
const s = getStore()
|
||||||
|
try {
|
||||||
|
applySettings(s, partial)
|
||||||
|
} catch (e) {
|
||||||
|
// R5: a write failure (disk full, read-only profile) must not crash the IPC
|
||||||
|
// handler or surface as an unhandled rejection. Log it; getSettings() below
|
||||||
|
// returns the store's ACTUAL persisted state, so the renderer's reconciliation
|
||||||
|
// (M34) reflects what truly saved instead of the optimistic value.
|
||||||
|
logger.error('settings write failed', e)
|
||||||
|
}
|
||||||
|
cachedSettings = null
|
||||||
|
return getSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(s: Store<Settings>, partial: Partial<Settings>): void {
|
||||||
for (const key of Object.keys(partial) as (keyof Settings)[]) {
|
for (const key of Object.keys(partial) as (keyof Settings)[]) {
|
||||||
const value = partial[key]
|
const value = partial[key]
|
||||||
if (value === undefined) continue
|
if (value === undefined) continue
|
||||||
@@ -294,6 +276,7 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
case 'autoDownloadNew':
|
case 'autoDownloadNew':
|
||||||
case 'hasCompletedOnboarding':
|
case 'hasCompletedOnboarding':
|
||||||
case 'minimizeToTray':
|
case 'minimizeToTray':
|
||||||
|
case 'sidebarCollapsed':
|
||||||
if (typeof value === 'boolean') s.set(key, value)
|
if (typeof value === 'boolean') s.set(key, value)
|
||||||
break
|
break
|
||||||
case 'launchAtStartup':
|
case 'launchAtStartup':
|
||||||
@@ -343,23 +326,42 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'defaultVideoQuality':
|
case 'defaultVideoQuality':
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
(VIDEO_QUALITY_OPTIONS as readonly string[]).includes(value)
|
||||||
|
) {
|
||||||
|
s.set('defaultVideoQuality', value)
|
||||||
|
}
|
||||||
|
break
|
||||||
case 'defaultAudioQuality':
|
case 'defaultAudioQuality':
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
(AUDIO_QUALITY_OPTIONS as readonly string[]).includes(value)
|
||||||
|
) {
|
||||||
|
s.set('defaultAudioQuality', value)
|
||||||
|
}
|
||||||
|
break
|
||||||
case 'rateLimit':
|
case 'rateLimit':
|
||||||
|
// yt-dlp rate-limit format: empty (disabled) or e.g. "500K", "2.5M", "1G".
|
||||||
|
if (typeof value === 'string' && /^(\d+\.?\d*[KMGkmg]?B?)?$/.test(value.trim())) {
|
||||||
|
s.set('rateLimit', value.trim())
|
||||||
|
}
|
||||||
|
break
|
||||||
case 'youtubePlayerClient':
|
case 'youtubePlayerClient':
|
||||||
if (typeof value === 'string') s.set(key, value)
|
// Power-user field; yt-dlp's client list evolves. Accept any trimmed string.
|
||||||
|
if (typeof value === 'string') s.set('youtubePlayerClient', value.trim())
|
||||||
break
|
break
|
||||||
// Credential-bearing fields — encrypted at rest (see encryptSecret). proxy
|
// Credential-bearing fields — encrypted at rest (see encryptSecret). proxy
|
||||||
// may embed user:pass@host; youtubePoToken is an access token. exportBackup
|
// may embed user:pass@host; youtubePoToken is an access token. Both are
|
||||||
// still writes them in clear (via the decrypted getSettings), as documented.
|
// stripped from backup exports (see SECRET_KEYS / backup.ts).
|
||||||
case 'proxy':
|
case 'proxy':
|
||||||
case 'youtubePoToken':
|
case 'youtubePoToken':
|
||||||
if (typeof value === 'string') s.set(key, encryptSecret(value))
|
if (typeof value === 'string') s.set(key, encryptSecret(value))
|
||||||
break
|
break
|
||||||
case 'updateToken':
|
case 'updateToken':
|
||||||
// A Gitea token has no spaces; trim before encrypting (like proxy creds).
|
// An access token has no spaces; trim before encrypting (like proxy creds).
|
||||||
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
|
if (typeof value === 'string') s.set('updateToken', encryptSecret(value.trim()))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return getSettings()
|
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-52
@@ -2,58 +2,39 @@
|
|||||||
* Persistence for the media-manager index (Pinchflat-style; see
|
* Persistence for the media-manager index (Pinchflat-style; see
|
||||||
* ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the
|
* ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the
|
||||||
* history.ts pattern (and the same deliberate choice to stay on JSON rather than
|
* history.ts pattern (and the same deliberate choice to stay on JSON rather than
|
||||||
* better-sqlite3 — revisit if a user indexes many large channels, see the
|
* better-sqlite3 -- revisit if a user indexes many large channels, see the
|
||||||
* Phase H risk note in the roadmap):
|
* Phase H risk note in the roadmap):
|
||||||
*
|
*
|
||||||
* sources.json — one Source record per added channel/playlist
|
* sources.json -- one Source record per added channel/playlist
|
||||||
* media-items.json — every MediaItem across all sources (queried by sourceId)
|
* media-items.json -- every MediaItem across all sources (queried by sourceId)
|
||||||
*
|
*
|
||||||
* Per-row validation on read (validation.ts) so a hand-edited or corrupted file
|
* Per-row validation on read (validation.ts) so a hand-edited or corrupted file
|
||||||
* can't feed the UI malformed records (same approach as history/errorlog).
|
* can't feed the UI malformed records (same approach as history/errorlog).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { app } from 'electron'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
|
||||||
import type { Source, MediaItem } from '@shared/ipc'
|
import type { Source, MediaItem } from '@shared/ipc'
|
||||||
import { isValidSource, isValidMediaItem } from './validation'
|
import { isValidSource, isValidMediaItem } from './validation'
|
||||||
import { mergeItemsPreservingState } from './indexerCore'
|
import { mergeItemsPreservingState } from './indexerCore'
|
||||||
|
import { createJsonStore } from './jsonStore'
|
||||||
|
// A generous global cap (MEDIA_ITEMS_MAX, see constants.ts) so a runaway index
|
||||||
|
// can't grow the file unbounded; large enough for several big channels. When
|
||||||
|
// exceeded, the most-recently-written source's items are kept (they're placed
|
||||||
|
// first by replaceMediaItems).
|
||||||
|
import { MEDIA_ITEMS_MAX as MAX_ITEMS } from './constants'
|
||||||
|
|
||||||
// A generous global cap so a runaway index can't grow the file unbounded; large
|
// Two cached, atomically-written stores (R1–R3 via the shared jsonStore). The
|
||||||
// enough for several big channels. When exceeded, the most-recently-written
|
// media-items store is the hot path: setMediaItemDownloaded used to re-read and
|
||||||
// source's items are kept (they're placed first by replaceMediaItems).
|
// rewrite the whole (≤MAX_ITEMS) file on every completion; now reads hit the
|
||||||
const MAX_ITEMS = 20000
|
// cache and writes are batched. Sources are few, so that store is uncapped.
|
||||||
|
const sourcesStore = createJsonStore('sources.json', isValidSource, Infinity)
|
||||||
function sourcesFile(): string {
|
// Compact (not pretty) -- this store can hold up to MAX_ITEMS rows; indentation
|
||||||
return join(app.getPath('userData'), 'sources.json')
|
// would needlessly inflate its size and write time (R8).
|
||||||
}
|
const itemsStore = createJsonStore('media-items.json', isValidMediaItem, MAX_ITEMS, false)
|
||||||
|
|
||||||
function itemsFile(): string {
|
|
||||||
return join(app.getPath('userData'), 'media-items.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJsonArray<T>(path: string, isValid: (o: unknown) => o is T): T[] {
|
|
||||||
try {
|
|
||||||
if (!existsSync(path)) return []
|
|
||||||
const data = JSON.parse(readFileSync(path, 'utf8'))
|
|
||||||
return Array.isArray(data) ? data.filter(isValid) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeJson(path: string, value: unknown): void {
|
|
||||||
try {
|
|
||||||
writeFileSync(path, JSON.stringify(value, null, 2))
|
|
||||||
} catch {
|
|
||||||
/* best-effort; a read-only data dir just means no persisted index */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sources ----------------------------------------------------------------
|
// --- Sources ----------------------------------------------------------------
|
||||||
|
|
||||||
export function listSources(): Source[] {
|
export function listSources(): Source[] {
|
||||||
return readJsonArray(sourcesFile(), isValidSource)
|
return sourcesStore.read()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSource(id: string): Source | undefined {
|
export function getSource(id: string): Source | undefined {
|
||||||
@@ -62,31 +43,25 @@ export function getSource(id: string): Source | undefined {
|
|||||||
|
|
||||||
/** Insert or replace a source by id (a re-index updates the existing record). */
|
/** Insert or replace a source by id (a re-index updates the existing record). */
|
||||||
export function upsertSource(source: Source): Source[] {
|
export function upsertSource(source: Source): Source[] {
|
||||||
const sources = [source, ...listSources().filter((s) => s.id !== source.id)]
|
return sourcesStore.write([source, ...listSources().filter((s) => s.id !== source.id)])
|
||||||
writeJson(sourcesFile(), sources)
|
|
||||||
return sources
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
|
/** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
|
||||||
export function setSourceWatched(id: string, watched: boolean): Source[] {
|
export function setSourceWatched(id: string, watched: boolean): Source[] {
|
||||||
const sources = listSources().map((s) => (s.id === id ? { ...s, watched } : s))
|
return sourcesStore.write(listSources().map((s) => (s.id === id ? { ...s, watched } : s)))
|
||||||
writeJson(sourcesFile(), sources)
|
|
||||||
return sources
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a source and all of its media items. Returns the remaining sources. */
|
/** Remove a source and all of its media items. Returns the remaining sources. */
|
||||||
export function removeSource(id: string): Source[] {
|
export function removeSource(id: string): Source[] {
|
||||||
const sources = listSources().filter((s) => s.id !== id)
|
const sources = sourcesStore.write(listSources().filter((s) => s.id !== id))
|
||||||
writeJson(sourcesFile(), sources)
|
itemsStore.write(listAllItems().filter((m) => m.sourceId !== id))
|
||||||
const items = listAllItems().filter((m) => m.sourceId !== id)
|
|
||||||
writeJson(itemsFile(), items)
|
|
||||||
return sources
|
return sources
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Media items ------------------------------------------------------------
|
// --- Media items ------------------------------------------------------------
|
||||||
|
|
||||||
function listAllItems(): MediaItem[] {
|
function listAllItems(): MediaItem[] {
|
||||||
return readJsonArray(itemsFile(), isValidMediaItem)
|
return itemsStore.read()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMediaItems(sourceId: string): MediaItem[] {
|
export function listMediaItems(sourceId: string): MediaItem[] {
|
||||||
@@ -95,12 +70,14 @@ export function listMediaItems(sourceId: string): MediaItem[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace all media items for one source with a fresh set (the result of a
|
* Replace all media items for one source with a fresh set (the result of a
|
||||||
* (re)index). Other sources' items are preserved; the new items are placed first
|
* (re)index). Other sources' items are preserved in full; only the source being
|
||||||
* so they survive the MAX_ITEMS cap.
|
* written here absorbs the MAX_ITEMS cap, so re-indexing one (large) source can
|
||||||
|
* never silently evict another source's items past the global limit (M23).
|
||||||
*/
|
*/
|
||||||
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
|
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
|
||||||
const others = listAllItems().filter((m) => m.sourceId !== sourceId)
|
const others = listAllItems().filter((m) => m.sourceId !== sourceId)
|
||||||
writeJson(itemsFile(), [...items, ...others].slice(0, MAX_ITEMS))
|
const budget = Math.max(0, MAX_ITEMS - others.length)
|
||||||
|
itemsStore.write([...items.slice(0, budget), ...others])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,6 +107,6 @@ export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem
|
|||||||
const updated = all.map((m) =>
|
const updated = all.map((m) =>
|
||||||
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
|
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
|
||||||
)
|
)
|
||||||
writeJson(itemsFile(), updated)
|
itemsStore.write(updated)
|
||||||
return updated.filter((m) => m.sourceId === target.sourceId)
|
return updated.filter((m) => m.sourceId === target.sourceId)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@
|
|||||||
import { listSources, listMediaItems } from './sources'
|
import { listSources, listMediaItems } from './sources'
|
||||||
import { indexSource } from './indexer'
|
import { indexSource } from './indexer'
|
||||||
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
|
import { parseRssVideoIds, isYouTubeFeedUrl } from './indexerCore'
|
||||||
|
import { FEED_FETCH_TIMEOUT_MS } from './constants'
|
||||||
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
|
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
|
||||||
|
|
||||||
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
|
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
|
||||||
@@ -16,7 +17,7 @@ async function fetchFeedIds(feedUrl: string): Promise<string[]> {
|
|||||||
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
|
// corrupted sources.json might carry (SSRF guard, audit T7). A throw here is
|
||||||
// caught by the caller, which then falls back to a full yt-dlp re-index.
|
// caught by the caller, which then falls back to a full yt-dlp re-index.
|
||||||
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
|
if (!isYouTubeFeedUrl(feedUrl)) throw new Error('Refusing to fetch a non-YouTube feed URL.')
|
||||||
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
|
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(FEED_FETCH_TIMEOUT_MS) })
|
||||||
if (!res.ok) throw new Error(`feed responded ${res.status}`)
|
if (!res.ok) throw new Error(`feed responded ${res.status}`)
|
||||||
return parseRssVideoIds(await res.text())
|
return parseRssVideoIds(await res.text())
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-36
@@ -1,38 +1,20 @@
|
|||||||
import { app } from 'electron'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
|
||||||
import type { CommandTemplate } from '@shared/ipc'
|
import type { CommandTemplate } from '@shared/ipc'
|
||||||
import { isTemplateLike } from './validation'
|
import { isTemplateLike } from './validation'
|
||||||
|
import { createJsonStore } from './jsonStore'
|
||||||
|
import { TEMPLATES_MAX } from './constants'
|
||||||
|
|
||||||
// Plain JSON in userData, same shape as history.ts.
|
// Plain JSON in userData, same shape as history.ts. Atomic writes / corruption
|
||||||
const MAX_TEMPLATES = 100
|
// backup / caching come from the shared jsonStore (R1–R3).
|
||||||
|
|
||||||
function templatesFile(): string {
|
|
||||||
return join(app.getPath('userData'), 'templates.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
// A persisted template entry must at least be an object carrying an id (see
|
// A persisted template entry must at least be an object carrying an id (see
|
||||||
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
|
// isTemplateLike in validation.ts); everything else is coerced by sanitize().
|
||||||
// Drop anything that isn't, so a hand-edited templates.json can't inject
|
// Drop anything that isn't, so a hand-edited templates.json can't inject
|
||||||
// malformed entries. (audit S5)
|
// malformed entries. (audit S5)
|
||||||
export function listTemplates(): CommandTemplate[] {
|
const store = createJsonStore('templates.json', isTemplateLike, TEMPLATES_MAX)
|
||||||
try {
|
|
||||||
if (!existsSync(templatesFile())) return []
|
|
||||||
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
|
|
||||||
// Validate shape, then normalise each surviving entry through sanitize() so
|
|
||||||
// name/args are always well-formed strings regardless of what was on disk.
|
|
||||||
return Array.isArray(data) ? data.filter(isTemplateLike).map(sanitize) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(templates: CommandTemplate[]): void {
|
export function listTemplates(): CommandTemplate[] {
|
||||||
try {
|
// Normalise each surviving entry through sanitize() so name/args are always
|
||||||
writeFileSync(templatesFile(), JSON.stringify(templates.slice(0, MAX_TEMPLATES), null, 2))
|
// well-formed strings regardless of what was on disk.
|
||||||
} catch {
|
return store.read().map(sanitize)
|
||||||
/* best-effort; a read-only data dir just means no persisted templates */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitize(t: CommandTemplate): CommandTemplate {
|
function sanitize(t: CommandTemplate): CommandTemplate {
|
||||||
@@ -49,20 +31,14 @@ function sanitize(t: CommandTemplate): CommandTemplate {
|
|||||||
/** Add a new template, or update an existing one (matched by id). */
|
/** Add a new template, or update an existing one (matched by id). */
|
||||||
export function saveTemplate(template: CommandTemplate): CommandTemplate[] {
|
export function saveTemplate(template: CommandTemplate): CommandTemplate[] {
|
||||||
const clean = sanitize(template)
|
const clean = sanitize(template)
|
||||||
const templates = [clean, ...listTemplates().filter((t) => t.id !== clean.id)]
|
return store.write([clean, ...listTemplates().filter((t) => t.id !== clean.id)])
|
||||||
save(templates)
|
|
||||||
return templates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeTemplate(id: string): CommandTemplate[] {
|
export function removeTemplate(id: string): CommandTemplate[] {
|
||||||
const templates = listTemplates().filter((t) => t.id !== id)
|
return store.write(listTemplates().filter((t) => t.id !== id))
|
||||||
save(templates)
|
|
||||||
return templates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the entire template list (backup restore) rather than merge-by-id. */
|
/** Replace the entire template list (backup restore) rather than merge-by-id. */
|
||||||
export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] {
|
export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] {
|
||||||
const clean = templates.map(sanitize)
|
return store.write(templates.map(sanitize))
|
||||||
save(clean)
|
|
||||||
return clean
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-14
@@ -14,6 +14,7 @@ import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
|
|||||||
import { getSettings } from './settings'
|
import { getSettings } from './settings'
|
||||||
import { ensureManagedYtdlp } from './ytdlp'
|
import { ensureManagedYtdlp } from './ytdlp'
|
||||||
import { parseExtraArgs } from './buildArgs'
|
import { parseExtraArgs } from './buildArgs'
|
||||||
|
import { createLineBuffer } from './lib/lineBuffer'
|
||||||
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
|
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
|
||||||
|
|
||||||
const active = new Map<string, ChildProcess>()
|
const active = new Map<string, ChildProcess>()
|
||||||
@@ -47,18 +48,12 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
|
|||||||
active.set(id, child)
|
active.set(id, child)
|
||||||
|
|
||||||
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
|
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
|
||||||
const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }
|
const lineBufs = {
|
||||||
function feed(stream: 'stdout' | 'stderr', chunk: string): void {
|
stdout: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stdout' })),
|
||||||
buffers[stream] += chunk
|
stderr: createLineBuffer((line) => send(wc, { type: 'output', id, line, stream: 'stderr' }))
|
||||||
let nl: number
|
|
||||||
while ((nl = buffers[stream].indexOf('\n')) >= 0) {
|
|
||||||
const line = buffers[stream].slice(0, nl).replace(/\r$/, '')
|
|
||||||
buffers[stream] = buffers[stream].slice(nl + 1)
|
|
||||||
send(wc, { type: 'output', id, line, stream })
|
|
||||||
}
|
}
|
||||||
}
|
child.stdout?.on('data', (c: Buffer) => lineBufs.stdout.push(c.toString()))
|
||||||
child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString()))
|
child.stderr?.on('data', (c: Buffer) => lineBufs.stderr.push(c.toString()))
|
||||||
child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString()))
|
|
||||||
|
|
||||||
let settled = false
|
let settled = false
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
@@ -72,9 +67,8 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
|
|||||||
settled = true
|
settled = true
|
||||||
active.delete(id)
|
active.delete(id)
|
||||||
// Flush any trailing partial lines that never hit a newline.
|
// Flush any trailing partial lines that never hit a newline.
|
||||||
for (const stream of ['stdout', 'stderr'] as const) {
|
lineBufs.stdout.flush()
|
||||||
if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream })
|
lineBufs.stderr.flush()
|
||||||
}
|
|
||||||
send(wc, { type: 'done', id, code })
|
send(wc, { type: 'done', id, code })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -44,7 +44,8 @@ export function createTray(getWindow: () => BrowserWindow | null): void {
|
|||||||
// Prefer the real app icon; fall back to the embedded glyph when no icon.ico
|
// Prefer the real app icon; fall back to the embedded glyph when no icon.ico
|
||||||
// ships, so minimize-to-tray always has a tray to restore from.
|
// ships, so minimize-to-tray always has a tray to restore from.
|
||||||
let icon = nativeImage.createFromPath(getAppIconPath())
|
let icon = nativeImage.createFromPath(getAppIconPath())
|
||||||
if (icon.isEmpty()) icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
|
if (icon.isEmpty())
|
||||||
|
icon = nativeImage.createFromDataURL(`data:image/png;base64,${FALLBACK_TRAY_PNG}`)
|
||||||
if (icon.isEmpty()) return
|
if (icon.isEmpty()) return
|
||||||
|
|
||||||
tray = new Tray(icon)
|
tray = new Tray(icon)
|
||||||
|
|||||||
+37
-13
@@ -68,9 +68,10 @@ function updateDir(): string {
|
|||||||
* an "upgrade" over the same shipped version.
|
* an "upgrade" over the same shipped version.
|
||||||
*/
|
*/
|
||||||
function parseVersion(v: string): number[] {
|
function parseVersion(v: string): number[] {
|
||||||
return v
|
// split() always yields at least one element; `?? ''` only satisfies the type
|
||||||
.replace(/^v/i, '')
|
// checker (noUncheckedIndexedAccess) for the [0] access.
|
||||||
.split(/[-+]/)[0]
|
const core = v.replace(/^v/i, '').split(/[-+]/)[0] ?? ''
|
||||||
|
return core
|
||||||
.split('.')
|
.split('.')
|
||||||
.map((p) => parseInt(p, 10))
|
.map((p) => parseInt(p, 10))
|
||||||
.filter((n) => !Number.isNaN(n))
|
.filter((n) => !Number.isNaN(n))
|
||||||
@@ -105,9 +106,28 @@ interface GiteaRelease {
|
|||||||
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
|
* output (`<hash> file`), or PowerShell Get-FileHash (uppercase). Returns the
|
||||||
* lowercase digest, or null if there's no standalone 64-char hex token (so a
|
* lowercase digest, or null if there's no standalone 64-char hex token (so a
|
||||||
* longer run like a sha512 digest is ignored rather than sliced).
|
* longer run like a sha512 digest is ignored rather than sliced).
|
||||||
|
*
|
||||||
|
* When `fileName` is given (the installer asset's name), a multi-line / combined
|
||||||
|
* checksum file is matched line-by-line and the hash on the line naming THIS
|
||||||
|
* asset wins — so a `<asset>.sha256` that happens to list several files can't
|
||||||
|
* verify the installer against the wrong line's hash (B3). Falls back to the
|
||||||
|
* first standalone digest for bare single-hash files (no filename present).
|
||||||
*/
|
*/
|
||||||
export function extractSha256(text: string): string | null {
|
export function extractSha256(text: string, fileName?: string): string | null {
|
||||||
const m = text.match(/\b[a-f0-9]{64}\b/i)
|
const hashRe = /\b[a-f0-9]{64}\b/i
|
||||||
|
if (fileName) {
|
||||||
|
const base = fileName.toLowerCase()
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const m = line.match(hashRe)
|
||||||
|
if (!m) continue
|
||||||
|
// The filename is the last whitespace-delimited token on a sha256sum /
|
||||||
|
// Get-FileHash line (optionally with a '*' binary-mode marker). Match it
|
||||||
|
// exactly — a loose substring would let 'App.exe' match a 'MyApp.exe' line.
|
||||||
|
const last = (line.trim().split(/\s+/).pop() ?? '').replace(/^\*/, '')
|
||||||
|
if (last.toLowerCase() === base) return m[0].toLowerCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const m = text.match(hashRe)
|
||||||
return m ? m[0].toLowerCase() : null
|
return m ? m[0].toLowerCase() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +220,9 @@ function fetchTrustedText(
|
|||||||
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
|
): Promise<{ ok: true; text: string } | { ok: false; status?: number; error: string }> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false
|
let settled = false
|
||||||
const done = (r: { ok: true; text: string } | { ok: false; status?: number; error: string }): void => {
|
const done = (
|
||||||
|
r: { ok: true; text: string } | { ok: false; status?: number; error: string }
|
||||||
|
): void => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
@@ -261,7 +283,7 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
|
|||||||
const sum = await fetchTrustedText(checksumUrl)
|
const sum = await fetchTrustedText(checksumUrl)
|
||||||
let expectedSha: string | null = null
|
let expectedSha: string | null = null
|
||||||
if (sum.ok) {
|
if (sum.ok) {
|
||||||
expectedSha = extractSha256(sum.text)
|
expectedSha = extractSha256(sum.text, safeName)
|
||||||
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
|
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
|
||||||
} else if (sum.status === 404) {
|
} else if (sum.status === 404) {
|
||||||
if (REQUIRE_CHECKSUM) {
|
if (REQUIRE_CHECKSUM) {
|
||||||
@@ -391,9 +413,6 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Let the launched installer spawn before we quit to release our files (ms). */
|
|
||||||
const INSTALLER_HANDOFF_MS = 1500
|
|
||||||
|
|
||||||
/** Launch a freshly-downloaded installer, then quit so it can replace the app. */
|
/** Launch a freshly-downloaded installer, then quit so it can replace the app. */
|
||||||
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
|
export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
@@ -406,10 +425,15 @@ export async function runAppUpdate(filePath: string): Promise<{ ok: boolean; err
|
|||||||
return { ok: false, error: 'Refused to run an unexpected file.' }
|
return { ok: false, error: 'Refused to run an unexpected file.' }
|
||||||
}
|
}
|
||||||
await stat(target) // throws if the file is missing
|
await stat(target) // throws if the file is missing
|
||||||
const err = await shell.openPath(target) // hand the installer to the OS
|
// Hand the installer to the OS via ShellExecute, which (unlike a raw
|
||||||
|
// CreateProcess/spawn) honors the NSIS elevation manifest — needed if the
|
||||||
|
// build ever flips to perMachine. openPath resolves once the launch is
|
||||||
|
// initiated, so the installer process already exists when we quit.
|
||||||
|
const err = await shell.openPath(target)
|
||||||
if (err) return { ok: false, error: err }
|
if (err) return { ok: false, error: err }
|
||||||
// Give the installer a beat to spawn, then quit so it can overwrite our files.
|
// Quit on the next tick so this IPC response reaches the renderer first; the
|
||||||
setTimeout(() => app.quit(), INSTALLER_HANDOFF_MS)
|
// launched installer is independent of our process and survives the quit.
|
||||||
|
setImmediate(() => app.quit())
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { app, screen, type BrowserWindow, type Rectangle } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { logger } from './logger'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist and restore the main window's size / position / maximized state across
|
||||||
|
* launches (W2 / UX19) — Windows apps are expected to reopen where you left them.
|
||||||
|
* Stored as one small JSON object in `<userData>/window-state.json`; a disconnected
|
||||||
|
* monitor is handled by a visibility check so the window can never restore off-screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface WindowState {
|
||||||
|
bounds: Rectangle
|
||||||
|
maximized: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches the createWindow() defaults; used on first run and when a saved position
|
||||||
|
// is no longer on any connected display.
|
||||||
|
const DEFAULT_SIZE = { width: 920, height: 700 }
|
||||||
|
|
||||||
|
function stateFile(): string {
|
||||||
|
return join(app.getPath('userData'), 'window-state.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState(): WindowState | null {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(readFileSync(stateFile(), 'utf8')) as Partial<WindowState>
|
||||||
|
if (s?.bounds && typeof s.bounds.width === 'number' && typeof s.bounds.height === 'number') {
|
||||||
|
return { bounds: s.bounds as Rectangle, maximized: !!s.maximized }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* first run or corrupt — fall back to defaults */
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `b` overlaps the work area of some connected display (i.e. is reachable). */
|
||||||
|
function isOnSomeDisplay(b: Rectangle): boolean {
|
||||||
|
return screen.getAllDisplays().some((d) => {
|
||||||
|
const wa = d.workArea
|
||||||
|
return (
|
||||||
|
b.x < wa.x + wa.width &&
|
||||||
|
b.x + b.width > wa.x &&
|
||||||
|
b.y < wa.y + wa.height &&
|
||||||
|
b.y + b.height > wa.y
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bounds + maximized flag to open the window with. Returns just a size (Electron
|
||||||
|
* then centers it) when there's no usable saved position — first run, or the saved
|
||||||
|
* monitor is gone.
|
||||||
|
*/
|
||||||
|
export function initialWindowState(): {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
x?: number
|
||||||
|
y?: number
|
||||||
|
maximized: boolean
|
||||||
|
} {
|
||||||
|
const saved = readState()
|
||||||
|
if (saved && isOnSomeDisplay(saved.bounds)) {
|
||||||
|
return { ...saved.bounds, maximized: saved.maximized }
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_SIZE, maximized: saved?.maximized ?? false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the window's current *normal* bounds (un-maximized) + maximized flag.
|
||||||
|
* Best-effort; a write failure is logged, never thrown. Callers should debounce.
|
||||||
|
*/
|
||||||
|
export function saveWindowState(win: BrowserWindow): void {
|
||||||
|
if (win.isDestroyed() || win.isMinimized()) return
|
||||||
|
try {
|
||||||
|
const state: WindowState = { bounds: win.getNormalBounds(), maximized: win.isMaximized() }
|
||||||
|
writeFileSync(stateFile(), JSON.stringify(state))
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('failed to save window state', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-43
@@ -1,7 +1,9 @@
|
|||||||
import { execFile } from 'child_process'
|
|
||||||
import { existsSync, mkdirSync, copyFileSync } from 'fs'
|
import { existsSync, mkdirSync, copyFileSync } from 'fs'
|
||||||
import { dirname } from 'path'
|
import { dirname } from 'path'
|
||||||
import { getYtdlpPath, getBundledYtdlpPath } from './binaries'
|
import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
|
||||||
|
import { execFileAsync } from './lib/exec'
|
||||||
|
import { cleanError } from './log'
|
||||||
|
import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants'
|
||||||
import { getSettings, setSettings } from './settings'
|
import { getSettings, setSettings } from './settings'
|
||||||
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
|
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
|
||||||
import {
|
import {
|
||||||
@@ -32,32 +34,24 @@ export function ensureManagedYtdlp(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Spawn the bundled yt-dlp and read back its `--version` for the Settings panel. */
|
||||||
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
|
export async function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||||
* Proves the main-process → bundled-binary → IPC path end to end.
|
|
||||||
*/
|
|
||||||
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
|
||||||
const ytdlpPath = getYtdlpPath()
|
const ytdlpPath = getYtdlpPath()
|
||||||
|
|
||||||
if (!existsSync(ytdlpPath)) {
|
if (!existsSync(ytdlpPath)) {
|
||||||
return Promise.resolve({
|
return { ok: false, error: YTDLP_MISSING_MSG }
|
||||||
ok: false,
|
|
||||||
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const r = await execFileAsync(ytdlpPath, ['--version'], { timeout: VERSION_TIMEOUT_MS })
|
||||||
execFile(ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => {
|
if (!r.ok) {
|
||||||
if (err) {
|
// Run stderr through the shared cleaner so yt-dlp's noisy output collapses to
|
||||||
const msg = (err as { killed?: boolean }).killed
|
// its trailing error line, consistent with download/probe/indexer (CC6).
|
||||||
|
const msg = r.timedOut
|
||||||
? 'Timed out running yt-dlp.'
|
? 'Timed out running yt-dlp.'
|
||||||
: (stderr || err.message).trim()
|
: cleanError(r.stderr) || r.error?.message || ''
|
||||||
resolve({ ok: false, error: msg })
|
return { ok: false, error: msg }
|
||||||
return
|
|
||||||
}
|
}
|
||||||
resolve({ ok: true, version: stdout.trim() })
|
return { ok: true, version: r.stdout.trim() }
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,13 +60,13 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
|||||||
* yt-dlp.exe lives in, which holds for both the dev resources/bin checkout and
|
* yt-dlp.exe lives in, which holds for both the dev resources/bin checkout and
|
||||||
* a per-user install; it would fail under a locked-down system install.
|
* a per-user install; it would fail under a locked-down system install.
|
||||||
*/
|
*/
|
||||||
export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
|
export async function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
|
||||||
// Validate against the channel allowlist BEFORE the value reaches `--update-to`.
|
// Validate against the channel allowlist BEFORE the value reaches `--update-to`.
|
||||||
// That flag also accepts `OWNER/REPO@TAG`, which would download and install an
|
// That flag also accepts `OWNER/REPO@TAG`, which would download and install an
|
||||||
// arbitrary binary over yt-dlp.exe — so an unrecognised value (e.g. forged by a
|
// arbitrary binary over yt-dlp.exe — so an unrecognised value (e.g. forged by a
|
||||||
// compromised renderer over IPC) must never be forwarded. (audit F1)
|
// compromised renderer over IPC) must never be forwarded. (audit F1)
|
||||||
if (!isYtdlpUpdateChannel(channel)) {
|
if (!isYtdlpUpdateChannel(channel)) {
|
||||||
return Promise.resolve({ ok: false, error: 'Unsupported update channel.' })
|
return { ok: false, error: 'Unsupported update channel.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-heal: restore the managed copy from the bundled seed before updating, so
|
// Self-heal: restore the managed copy from the bundled seed before updating, so
|
||||||
@@ -81,29 +75,19 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
|
|||||||
const ytdlpPath = getYtdlpPath()
|
const ytdlpPath = getYtdlpPath()
|
||||||
|
|
||||||
if (!existsSync(ytdlpPath)) {
|
if (!existsSync(ytdlpPath)) {
|
||||||
return Promise.resolve({
|
return { ok: false, error: YTDLP_MISSING_MSG }
|
||||||
ok: false,
|
|
||||||
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const r = await execFileAsync(ytdlpPath, ['--update-to', channel], {
|
||||||
execFile(
|
timeout: YTDLP_UPDATE_TIMEOUT_MS
|
||||||
ytdlpPath,
|
|
||||||
['--update-to', channel],
|
|
||||||
{ windowsHide: true, timeout: 60_000 },
|
|
||||||
(err, stdout, stderr) => {
|
|
||||||
if (err) {
|
|
||||||
const msg = (err as { killed?: boolean }).killed
|
|
||||||
? 'Timed out updating yt-dlp.'
|
|
||||||
: (stderr || err.message).trim()
|
|
||||||
resolve({ ok: false, error: msg })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve({ ok: true, output: stdout.trim() })
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
if (!r.ok) {
|
||||||
|
const msg = r.timedOut
|
||||||
|
? 'Timed out updating yt-dlp.'
|
||||||
|
: cleanError(r.stderr) || r.error?.message || ''
|
||||||
|
return { ok: false, error: msg }
|
||||||
|
}
|
||||||
|
return { ok: true, output: r.stdout.trim() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,5 +21,10 @@ export function shouldAutoCheckYtdlp(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!enabled) return false
|
if (!enabled) return false
|
||||||
if (!lastCheck) return true
|
if (!lastCheck) return true
|
||||||
|
// A backward system-clock correction can leave `lastCheck` in the "future"
|
||||||
|
// relative to `now`, making `now - lastCheck` negative so the daily check would
|
||||||
|
// never come due again. Treat a future timestamp as skewed/bogus and run the
|
||||||
|
// check rather than waiting out a negative interval (R9).
|
||||||
|
if (lastCheck > now) return true
|
||||||
return now - lastCheck >= intervalMs
|
return now - lastCheck >= intervalMs
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-25
@@ -31,7 +31,8 @@ import {
|
|||||||
type ScheduledSyncStatus,
|
type ScheduledSyncStatus,
|
||||||
type TerminalEvent,
|
type TerminalEvent,
|
||||||
type TerminalRunResult,
|
type TerminalRunResult,
|
||||||
type TaskbarProgress
|
type TaskbarProgress,
|
||||||
|
type PersistedQueueItem
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
||||||
@@ -57,8 +58,7 @@ const api = {
|
|||||||
return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener)
|
return () => ipcRenderer.removeListener(IpcChannels.appUpdateProgress, listener)
|
||||||
},
|
},
|
||||||
|
|
||||||
getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
|
getYtdlpVersion: (): Promise<YtdlpVersionResult> => ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
||||||
ipcRenderer.invoke(IpcChannels.ytdlpVersion),
|
|
||||||
|
|
||||||
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
|
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
|
||||||
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
|
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
|
||||||
@@ -69,23 +69,26 @@ const api = {
|
|||||||
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
|
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
|
||||||
ipcRenderer.invoke(IpcChannels.downloadStart, opts),
|
ipcRenderer.invoke(IpcChannels.downloadStart, opts),
|
||||||
|
|
||||||
cancelDownload: (id: string): Promise<void> =>
|
cancelDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadCancel, id),
|
||||||
ipcRenderer.invoke(IpcChannels.downloadCancel, id),
|
|
||||||
|
|
||||||
pauseDownload: (id: string): Promise<void> =>
|
pauseDownload: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.downloadPause, id),
|
||||||
ipcRenderer.invoke(IpcChannels.downloadPause, id),
|
|
||||||
|
|
||||||
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
|
chooseFolder: (current?: string): Promise<string | null> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.chooseFolder, current),
|
||||||
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
|
|
||||||
|
|
||||||
openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path),
|
openPath: (path: string): Promise<string> => ipcRenderer.invoke(IpcChannels.openPath, path),
|
||||||
|
|
||||||
showInFolder: (path: string): Promise<void> =>
|
openUrl: (url: string): Promise<void> => ipcRenderer.invoke(IpcChannels.openUrl, url),
|
||||||
|
|
||||||
|
showInFolder: (path: string): Promise<string> =>
|
||||||
ipcRenderer.invoke(IpcChannels.showInFolder, path),
|
ipcRenderer.invoke(IpcChannels.showInFolder, path),
|
||||||
|
|
||||||
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
|
readClipboard: (): Promise<string> => ipcRenderer.invoke(IpcChannels.clipboardRead),
|
||||||
|
|
||||||
|
/** Forward a renderer-side failure to the main diagnostic log (CC8). Fire-and-forget. */
|
||||||
|
logError: (op: string, detail: string): void =>
|
||||||
|
ipcRenderer.send(IpcChannels.logWrite, op, detail),
|
||||||
|
|
||||||
getSettings: (): Promise<Settings> => ipcRenderer.invoke(IpcChannels.settingsGet),
|
getSettings: (): Promise<Settings> => ipcRenderer.invoke(IpcChannels.settingsGet),
|
||||||
|
|
||||||
setSettings: (partial: Partial<Settings>): Promise<Settings> =>
|
setSettings: (partial: Partial<Settings>): Promise<Settings> =>
|
||||||
@@ -138,6 +141,13 @@ const api = {
|
|||||||
|
|
||||||
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
|
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
|
||||||
|
|
||||||
|
/** Load the persisted download queue on launch (M4). */
|
||||||
|
listQueue: (): Promise<PersistedQueueItem[]> => ipcRenderer.invoke(IpcChannels.queueList),
|
||||||
|
|
||||||
|
/** Mirror the renderer's durable queue items to disk so they survive a quit (M4). */
|
||||||
|
saveQueue: (items: PersistedQueueItem[]): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.queueSave, items),
|
||||||
|
|
||||||
/** Opens a save dialog and writes settings + templates to the chosen JSON file. */
|
/** Opens a save dialog and writes settings + templates to the chosen JSON file. */
|
||||||
exportBackup: (): Promise<BackupExportResult> => ipcRenderer.invoke(IpcChannels.backupExport),
|
exportBackup: (): Promise<BackupExportResult> => ipcRenderer.invoke(IpcChannels.backupExport),
|
||||||
|
|
||||||
@@ -184,22 +194,23 @@ const api = {
|
|||||||
reindexSource: (id: string): Promise<IndexSourceResult> =>
|
reindexSource: (id: string): Promise<IndexSourceResult> =>
|
||||||
ipcRenderer.invoke(IpcChannels.sourceReindex, id),
|
ipcRenderer.invoke(IpcChannels.sourceReindex, id),
|
||||||
|
|
||||||
removeSource: (id: string): Promise<Source[]> =>
|
removeSource: (id: string): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourceRemove, id),
|
||||||
ipcRenderer.invoke(IpcChannels.sourceRemove, id),
|
|
||||||
|
|
||||||
listSourceItems: (sourceId: string): Promise<MediaItem[]> =>
|
listSourceItems: (sourceId: string): Promise<MediaItem[]> =>
|
||||||
ipcRenderer.invoke(IpcChannels.sourceItems, sourceId),
|
ipcRenderer.invoke(IpcChannels.sourceItems, sourceId),
|
||||||
|
|
||||||
/** Persist that a media item has finished downloading (drives incremental sync). */
|
/** Persist that a media item has finished downloading (drives incremental sync).
|
||||||
markSourceItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
|
* Named to match the main handler `setMediaItemDownloaded` (L92). */
|
||||||
|
setMediaItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
|
||||||
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath),
|
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath),
|
||||||
|
|
||||||
/** Toggle whether a source is watched for new uploads. */
|
/** Toggle whether a source is watched for new uploads. */
|
||||||
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
|
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
|
||||||
ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched),
|
ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched),
|
||||||
|
|
||||||
/** Re-index all watched sources; resolves with the videos found new. */
|
/** Re-index all watched sources; resolves with the videos found new. Named to
|
||||||
syncSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
* match the main function `syncWatchedSources` (L92). */
|
||||||
|
syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
||||||
|
|
||||||
/** Read / write the Windows daily-sync scheduled task. */
|
/** Read / write the Windows daily-sync scheduled task. */
|
||||||
getScheduledSync: (): Promise<ScheduledSyncStatus> =>
|
getScheduledSync: (): Promise<ScheduledSyncStatus> =>
|
||||||
@@ -220,8 +231,7 @@ const api = {
|
|||||||
runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
|
runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
|
||||||
ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
|
ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
|
||||||
|
|
||||||
cancelTerminal: (id: string): Promise<void> =>
|
cancelTerminal: (id: string): Promise<void> => ipcRenderer.invoke(IpcChannels.terminalCancel, id),
|
||||||
ipcRenderer.invoke(IpcChannels.terminalCancel, id),
|
|
||||||
|
|
||||||
/** Subscribe to live terminal output. Returns an unsubscribe function. */
|
/** Subscribe to live terminal output. Returns an unsubscribe function. */
|
||||||
onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => {
|
onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => {
|
||||||
@@ -230,9 +240,13 @@ const api = {
|
|||||||
return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener)
|
return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reflect overall queue progress on the Windows taskbar (fire-and-forget). */
|
/** Reflect overall queue progress on the Windows taskbar. */
|
||||||
setTaskbarProgress: (p: TaskbarProgress): void =>
|
setTaskbarProgress: (p: TaskbarProgress): Promise<void> =>
|
||||||
ipcRenderer.send(IpcChannels.taskbarProgress, p)
|
ipcRenderer.invoke(IpcChannels.taskbarProgress, p),
|
||||||
|
|
||||||
|
/** Open a YouTube WebView to automatically extract a PO token (Phase P). */
|
||||||
|
mintPoToken: (): Promise<string | null> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.youtubePoTokenMint) as Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Api = typeof api
|
export type Api = typeof api
|
||||||
@@ -248,11 +262,19 @@ if (process.contextIsolated) {
|
|||||||
contextBridge.exposeInMainWorld('electron', electron)
|
contextBridge.exposeInMainWorld('electron', electron)
|
||||||
contextBridge.exposeInMainWorld('api', api)
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
// M30: a broken bridge causes the renderer to silently run in preview/mock mode.
|
||||||
|
// Notify the main process so it can show a hard error dialog — without this the
|
||||||
|
// failure is completely invisible to the user.
|
||||||
|
console.error('[AeroFetch preload] contextBridge failed:', error)
|
||||||
|
try {
|
||||||
|
ipcRenderer.send(IpcChannels.preloadBridgeFailure, String(error))
|
||||||
|
} catch {
|
||||||
|
/* if IPC itself is broken there is nothing more we can do */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore (defined in index.d.ts)
|
// @ts-ignore window.electron is declared in index.d.ts
|
||||||
window.electron = electron
|
window.electron = electron
|
||||||
// @ts-ignore (defined in index.d.ts)
|
// @ts-ignore window.api is declared in index.d.ts
|
||||||
window.api = api
|
window.api = api
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-30
@@ -1,18 +1,47 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react'
|
||||||
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
||||||
import { Sidebar, type TabValue } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import { DownloadsView } from './components/DownloadsView'
|
import { DownloadsView } from './components/DownloadsView'
|
||||||
import { LibraryView } from './components/LibraryView'
|
|
||||||
import { HistoryView } from './components/HistoryView'
|
|
||||||
import { TerminalView } from './components/TerminalView'
|
|
||||||
import { SettingsView } from './components/SettingsView'
|
|
||||||
import { Onboarding } from './components/Onboarding'
|
import { Onboarding } from './components/Onboarding'
|
||||||
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||||
|
import { LiveRegion } from './components/LiveRegion'
|
||||||
|
import { Toaster } from './components/ui/Toaster'
|
||||||
import { getTheme, pageBackground } from './theme'
|
import { getTheme, pageBackground } from './theme'
|
||||||
|
import { SPACE } from './components/ui/tokens'
|
||||||
import { useSettings } from './store/settings'
|
import { useSettings } from './store/settings'
|
||||||
|
import { useNav } from './store/nav'
|
||||||
import { useDownloads } from './store/downloads'
|
import { useDownloads } from './store/downloads'
|
||||||
import { summarizeQueue } from './store/queueStats'
|
// Eagerly load the sources store for its startup side-effects (load persisted
|
||||||
|
// sources + the watched-source / scheduled `--sync` kickoff) and its cross-store
|
||||||
|
// subscription (coordinator's downloadCompleted → markDownloaded). Before C2 this
|
||||||
|
// was guaranteed by downloads.ts importing it; now that the cycle is broken, App
|
||||||
|
// owns the eager load so it survives the views becoming lazy-loaded (PERF8).
|
||||||
|
import './store/sources'
|
||||||
|
import { queueSummaryOf } from './store/queueStats'
|
||||||
import { useResolvedDark } from './store/systemTheme'
|
import { useResolvedDark } from './store/systemTheme'
|
||||||
|
import { logError } from './reportError'
|
||||||
|
|
||||||
|
// Code-split the non-default tabs (PERF8): Downloads is the launch tab and stays
|
||||||
|
// eager, but Library/History/Terminal/Settings load their chunks on first visit so
|
||||||
|
// they (and their slice of Fluent) aren't in the initial bundle. The stores these
|
||||||
|
// views use are imported eagerly above (`useDownloads` + `./store/sources`, and
|
||||||
|
// downloads → history), so lazy-loading the *view* never delays store startup.
|
||||||
|
const LibraryView = lazy(() =>
|
||||||
|
import('./components/LibraryView').then((m) => ({ default: m.LibraryView }))
|
||||||
|
)
|
||||||
|
const HistoryView = lazy(() =>
|
||||||
|
import('./components/HistoryView').then((m) => ({ default: m.HistoryView }))
|
||||||
|
)
|
||||||
|
const TerminalView = lazy(() =>
|
||||||
|
import('./components/TerminalView').then((m) => ({ default: m.TerminalView }))
|
||||||
|
)
|
||||||
|
const SettingsView = lazy(() =>
|
||||||
|
import('./components/SettingsView').then((m) => ({ default: m.SettingsView }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// How often to check whether a scheduled ('saved' + due) download should promote
|
||||||
|
// to the queue. 15s is plenty for the minute-granularity times the picker offers.
|
||||||
|
const SCHEDULE_TICK_MS = 15_000
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
provider: {
|
provider: {
|
||||||
@@ -27,7 +56,8 @@ const useStyles = makeStyles({
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '24px 28px'
|
// Symmetric page padding (UI4): the old 28px horizontal appeared nowhere else.
|
||||||
|
padding: SPACE.page
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,16 +66,30 @@ function App(): React.JSX.Element {
|
|||||||
const theme = useSettings((s) => s.theme)
|
const theme = useSettings((s) => s.theme)
|
||||||
const accentColor = useSettings((s) => s.accentColor)
|
const accentColor = useSettings((s) => s.accentColor)
|
||||||
const updateSettings = useSettings((s) => s.update)
|
const updateSettings = useSettings((s) => s.update)
|
||||||
|
const showTerminal = useSettings((s) => s.customCommandEnabled)
|
||||||
const isDark = useResolvedDark()
|
const isDark = useResolvedDark()
|
||||||
const [tab, setTab] = useState<TabValue>('downloads')
|
const tab = useNav((s) => s.tab)
|
||||||
|
const setTab = useNav((s) => s.setTab)
|
||||||
|
// Active-download count for the sidebar Downloads badge (UX9/UI25), so a run in
|
||||||
|
// progress is visible from any tab. A number selector only re-renders when the
|
||||||
|
// count actually changes -- not on every progress tick.
|
||||||
|
const activeCount = useDownloads(
|
||||||
|
(s) => s.items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
|
||||||
|
)
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||||
|
// Track the element that was focused before the palette opened so we can restore it on close.
|
||||||
|
const prePaletteRef = useRef<Element | null>(null)
|
||||||
|
|
||||||
// Ctrl/Cmd+K toggles the command palette.
|
// Ctrl+K: command palette. Ctrl+,: Settings. (W8/W9)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent): void {
|
function onKey(e: KeyboardEvent): void {
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
prePaletteRef.current = document.activeElement
|
||||||
setPaletteOpen((o) => !o)
|
setPaletteOpen((o) => !o)
|
||||||
|
} else if (e.ctrlKey && e.key === ',') {
|
||||||
|
e.preventDefault()
|
||||||
|
setTab('settings')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
@@ -54,30 +98,101 @@ function App(): React.JSX.Element {
|
|||||||
|
|
||||||
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
|
// Mirror overall queue progress onto the Windows taskbar. Subscribe to the store
|
||||||
// directly (not via a selector) so taskbar updates don't re-render the app.
|
// directly (not via a selector) so taskbar updates don't re-render the app.
|
||||||
|
// Compare against the last IPC call and skip when nothing changed -- the store
|
||||||
|
// fires on every progress tick and this avoids one IPC roundtrip per tick (L25).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let lastFraction = -1
|
||||||
|
let lastMode = ''
|
||||||
|
let lastBadge = -1
|
||||||
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
|
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
|
||||||
const s = summarizeQueue(items)
|
const s = queueSummaryOf(items)
|
||||||
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
|
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
|
||||||
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode })
|
if (s.progress === lastFraction && mode === lastMode && s.downloading === lastBadge) return
|
||||||
|
lastFraction = s.progress
|
||||||
|
lastMode = mode
|
||||||
|
lastBadge = s.downloading
|
||||||
|
void window.api?.setTaskbarProgress?.({
|
||||||
|
fraction: s.progress,
|
||||||
|
mode,
|
||||||
|
badgeCount: s.downloading
|
||||||
|
})
|
||||||
}
|
}
|
||||||
push(useDownloads.getState().items)
|
push(useDownloads.getState().items)
|
||||||
return useDownloads.subscribe((st) => push(st.items))
|
return useDownloads.subscribe((st) => push(st.items))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const paletteActions: PaletteAction[] = [
|
// Rehydrate the persisted download queue on launch (M4) so saved/scheduled and
|
||||||
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
|
// other pending items return from the previous session.
|
||||||
|
useEffect(() => {
|
||||||
|
useDownloads.getState().hydrate()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// UX16: focus the URL field on launch so the user can paste + type immediately
|
||||||
|
// (Downloads is the default tab). Double-rAF waits for paint + the DownloadBar to
|
||||||
|
// register its focuser (L118), matching the command palette's "New download" focus.
|
||||||
|
useEffect(() => {
|
||||||
|
if (useNav.getState().tab !== 'downloads') return
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => useNav.getState().focusUrlField()))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
|
||||||
|
// The queue is persisted (M4), so a scheduled item survives a quit and fires on the
|
||||||
|
// next launch once its time has passed. A 15s tick is plenty for minute-granularity
|
||||||
|
// times. Lives here (not at the downloads store's module load) so importing the store
|
||||||
|
// in tests/preview doesn't start a stray timer, and the interval is cleared cleanly
|
||||||
|
// on unmount (L1).
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
const st = useDownloads.getState()
|
||||||
|
const now = Date.now()
|
||||||
|
if (
|
||||||
|
st.items.some(
|
||||||
|
(i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
st.promoteDueScheduled()
|
||||||
|
}
|
||||||
|
}, SCHEDULE_TICK_MS)
|
||||||
|
return () => clearInterval(tick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Memoized so CommandPalette sees a stable array reference between renders --
|
||||||
|
// only rebuilds when the theme label needs to change (L32).
|
||||||
|
const paletteActions = useMemo<PaletteAction[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'go-downloads',
|
||||||
|
label: 'Go to Downloads',
|
||||||
|
hint: 'Navigate',
|
||||||
|
run: () => setTab('downloads')
|
||||||
|
},
|
||||||
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
|
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
|
||||||
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
|
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
|
||||||
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
|
{
|
||||||
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') },
|
id: 'go-terminal',
|
||||||
|
label: 'Go to Terminal',
|
||||||
|
hint: 'Navigate',
|
||||||
|
run: () => setTab('terminal')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'go-settings',
|
||||||
|
label: 'Go to Settings',
|
||||||
|
// Surface the real shortcut here so the palette doubles as shortcut
|
||||||
|
// discovery (UX21).
|
||||||
|
hint: 'Ctrl ,',
|
||||||
|
run: () => setTab('settings')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'new-download',
|
id: 'new-download',
|
||||||
label: 'New download',
|
label: 'New download',
|
||||||
hint: 'Focus URL',
|
hint: 'Focus URL',
|
||||||
run: () => {
|
run: () => {
|
||||||
setTab('downloads')
|
setTab('downloads')
|
||||||
// Wait for the download bar to mount after the tab switch, then focus.
|
// Double-rAF waits for React's re-render + paint (so the DownloadBar has
|
||||||
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
|
// mounted and registered its focuser) before requesting focus (L118).
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => useNav.getState().focusUrlField())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,24 +201,19 @@ function App(): React.JSX.Element {
|
|||||||
hint: 'Appearance',
|
hint: 'Appearance',
|
||||||
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
|
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
[isDark, setTab, updateSettings]
|
||||||
|
)
|
||||||
|
|
||||||
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
|
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
|
||||||
const [version, setVersion] = useState('')
|
const [version, setVersion] = useState('')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
|
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Sidebar collapse, persisted across launches in localStorage.
|
const collapsed = useSettings((s) => s.sidebarCollapsed)
|
||||||
const [collapsed, setCollapsed] = useState(
|
|
||||||
() => localStorage.getItem('aerofetch.sidebarCollapsed') === '1'
|
|
||||||
)
|
|
||||||
function toggleCollapsed(): void {
|
function toggleCollapsed(): void {
|
||||||
setCollapsed((c) => {
|
updateSettings({ sidebarCollapsed: !collapsed })
|
||||||
const next = !c
|
|
||||||
localStorage.setItem('aerofetch.sidebarCollapsed', next ? '1' : '0')
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// Gate on `loaded` so a returning user's real settings never get clobbered
|
// Gate on `loaded` so a returning user's real settings never get clobbered
|
||||||
// by a one-frame flash of the (default-false) onboarding state.
|
// by a one-frame flash of the (default-false) onboarding state.
|
||||||
@@ -136,21 +246,34 @@ function App(): React.JSX.Element {
|
|||||||
version={version}
|
version={version}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={toggleCollapsed}
|
onToggleCollapsed={toggleCollapsed}
|
||||||
|
showTerminal={showTerminal}
|
||||||
|
downloadCount={activeCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className={styles.content}>
|
<main className={styles.content}>
|
||||||
{tab === 'downloads' && <DownloadsView />}
|
{tab === 'downloads' && <DownloadsView />}
|
||||||
|
<Suspense fallback={null}>
|
||||||
{tab === 'library' && <LibraryView />}
|
{tab === 'library' && <LibraryView />}
|
||||||
{tab === 'history' && <HistoryView />}
|
{tab === 'history' && <HistoryView />}
|
||||||
{tab === 'terminal' && <TerminalView />}
|
{tab === 'terminal' && <TerminalView />}
|
||||||
{tab === 'settings' && <SettingsView />}
|
{tab === 'settings' && <SettingsView />}
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{paletteOpen && (
|
{paletteOpen && (
|
||||||
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
|
<CommandPalette
|
||||||
|
actions={paletteActions}
|
||||||
|
onClose={() => {
|
||||||
|
setPaletteOpen(false)
|
||||||
|
const el = prePaletteRef.current
|
||||||
|
if (el instanceof HTMLElement) requestAnimationFrame(() => el.focus())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<LiveRegion />
|
||||||
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</FluentProvider>
|
</FluentProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,57 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--colorBrandStroke1, #0078d4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zero the UA heading margin (UI33). Titles are rendered as real headings
|
||||||
|
* (h1/h2) for Narrator heading-navigation, but they carry Fluent's typography
|
||||||
|
* classes for their visual ramp (font size/weight/line-height); those classes
|
||||||
|
* out-specify this element selector, so only the browser's default heading
|
||||||
|
* margin needs removing to keep the layout identical to the former span titles.
|
||||||
|
*/
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One motion policy (UI26): honor the OS "reduce motion" preference everywhere.
|
||||||
|
* The app's transitions are subtle (see MOTION in components/ui/tokens.ts), but a
|
||||||
|
* user who asks for less motion gets none — this near-instant override covers any
|
||||||
|
* current or future transition/animation without each component opting in.
|
||||||
|
*/
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||||
|
import { useFocusStyles } from './ui/focusRing'
|
||||||
|
import { Z, SCRIM, ELEVATION, RADIUS } from './ui/tokens'
|
||||||
|
|
||||||
export interface PaletteAction {
|
export interface PaletteAction {
|
||||||
id: string
|
id: string
|
||||||
@@ -10,32 +12,31 @@ export interface PaletteAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
// A plain fixed overlay — NOT a Fluent Dialog, since this app avoids Fluent's
|
// A plain fixed overlay -- NOT a Fluent Dialog, since this app avoids Fluent's
|
||||||
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
|
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
|
||||||
backdrop: {
|
backdrop: {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 1000,
|
zIndex: Z.overlay,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
paddingTop: '14vh',
|
paddingTop: '14vh',
|
||||||
backgroundColor: 'rgba(0,0,0,0.32)'
|
backgroundColor: SCRIM
|
||||||
},
|
},
|
||||||
panel: {
|
panel: {
|
||||||
width: 'min(560px, 92vw)',
|
width: 'min(560px, 92vw)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
backgroundColor: tokens.colorNeutralBackground1,
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
...shorthands.borderRadius(tokens.borderRadiusXLarge),
|
...shorthands.borderRadius(RADIUS.card),
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
boxShadow: tokens.shadow28,
|
boxShadow: ELEVATION.overlay,
|
||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
appearance: 'none',
|
appearance: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
outline: 'none',
|
|
||||||
padding: '14px 16px',
|
padding: '14px 16px',
|
||||||
fontSize: tokens.fontSizeBase400,
|
fontSize: tokens.fontSizeBase400,
|
||||||
fontFamily: tokens.fontFamilyBase,
|
fontFamily: tokens.fontFamilyBase,
|
||||||
@@ -91,11 +92,16 @@ export function CommandPalette({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
const focus = useFocusStyles()
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [sel, setSel] = useState(0)
|
const [sel, setSel] = useState(0)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
|
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
|
||||||
|
const listId = 'cmdpalette-list'
|
||||||
|
const optionId = (id: string): string => `cmdpalette-option-${id}`
|
||||||
|
const activeId = filtered[sel] ? optionId(filtered[sel].id) : undefined
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
@@ -103,6 +109,10 @@ export function CommandPalette({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSel(0)
|
setSel(0)
|
||||||
}, [q])
|
}, [q])
|
||||||
|
useEffect(() => {
|
||||||
|
const row = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
|
||||||
|
row?.scrollIntoView({ block: 'nearest' })
|
||||||
|
}, [sel])
|
||||||
|
|
||||||
function onKeyDown(e: React.KeyboardEvent): void {
|
function onKeyDown(e: React.KeyboardEvent): void {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -133,21 +143,33 @@ export function CommandPalette({
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={styles.input}
|
className={mergeClasses(styles.input, focus.focusRing)}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Type a command…"
|
placeholder="Type a command…"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={filtered.length > 0}
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-activedescendant={activeId}
|
||||||
|
aria-autocomplete="list"
|
||||||
aria-label="Command palette search"
|
aria-label="Command palette search"
|
||||||
/>
|
/>
|
||||||
<div className={styles.list}>
|
<div className={styles.list} ref={listRef} id={listId} role="listbox" aria-label="Commands">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className={styles.empty}>No matching commands</div>
|
<div className={styles.empty}>No matching commands</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((a, i) => (
|
filtered.map((a, i) => (
|
||||||
<button
|
// A non-focusable option: focus stays on the combobox input and the
|
||||||
|
// active row is announced via aria-activedescendant (UI27), the
|
||||||
|
// standard combobox+listbox pattern. onMouseEnter keeps the pointer
|
||||||
|
// and keyboard highlight unified on one `sel` state.
|
||||||
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
type="button"
|
id={optionId(a.id)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === sel}
|
||||||
|
data-sel={i === sel ? 'true' : undefined}
|
||||||
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
|
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
a.run()
|
a.run()
|
||||||
@@ -157,7 +179,7 @@ export function CommandPalette({
|
|||||||
>
|
>
|
||||||
<span>{a.label}</span>
|
<span>{a.label}</span>
|
||||||
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
|
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
|
||||||
</button>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -83,6 +83,8 @@ const useStyles = makeStyles({
|
|||||||
interface Props {
|
interface Props {
|
||||||
value: DownloadOptions
|
value: DownloadOptions
|
||||||
onChange: (next: DownloadOptions) => void
|
onChange: (next: DownloadOptions) => void
|
||||||
|
/** When provided, hides controls that don't apply to this media kind. */
|
||||||
|
kind?: 'video' | 'audio'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,7 +92,7 @@ interface Props {
|
|||||||
* Used both for the persisted defaults (Settings) and for a per-download
|
* Used both for the persisted defaults (Settings) and for a per-download
|
||||||
* override (the download bar), so the two never drift apart.
|
* override (the download bar), so the two never drift apart.
|
||||||
*/
|
*/
|
||||||
export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Element {
|
export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void {
|
function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void {
|
||||||
@@ -107,35 +109,47 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
|
{kind !== 'video' && (
|
||||||
<Field label="Audio format" hint="For audio-only downloads.">
|
<Field label="Audio format" hint="For audio-only downloads.">
|
||||||
<Select
|
<Select
|
||||||
aria-label="Audio format"
|
|
||||||
value={value.audioFormat}
|
value={value.audioFormat}
|
||||||
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
|
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
|
||||||
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
|
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Video container" hint="Container for merged video.">
|
)}
|
||||||
|
{kind !== 'audio' && (
|
||||||
|
<Field
|
||||||
|
label="Video container"
|
||||||
|
hint="The file format used when video and audio are combined into one file."
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
aria-label="Video container"
|
|
||||||
value={value.videoContainer}
|
value={value.videoContainer}
|
||||||
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
|
options={VIDEO_CONTAINERS.map((c) => ({
|
||||||
|
value: c,
|
||||||
|
label: VIDEO_CONTAINER_LABELS[c]
|
||||||
|
}))}
|
||||||
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Preferred codec" hint="Tiebreaker, not a hard filter.">
|
)}
|
||||||
|
{kind !== 'audio' && (
|
||||||
|
<Field
|
||||||
|
label="Preferred codec"
|
||||||
|
hint="A preference when several formats match -- not a strict filter."
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
aria-label="Preferred video codec"
|
|
||||||
value={value.preferredVideoCodec}
|
value={value.preferredVideoCodec}
|
||||||
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
|
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
|
||||||
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
|
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Format sorting (advanced)"
|
label="Format sorting (advanced)"
|
||||||
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
|
hint="Custom expression to rank available formats, e.g. res:1080,vcodec:av01,size. Leave blank to use the Preferred codec setting above. See yt-dlp docs for the full syntax."
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={value.formatSort}
|
value={value.formatSort}
|
||||||
@@ -144,11 +158,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
|
<Field label="Embed subtitles" hint="Download subtitles and embed them in the video file.">
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.embedSubtitles}
|
checked={value.embedSubtitles}
|
||||||
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
|
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
|
||||||
label={value.embedSubtitles ? 'On' : 'Off'}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{value.embedSubtitles && (
|
{value.embedSubtitles && (
|
||||||
@@ -177,14 +190,12 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
<Switch
|
<Switch
|
||||||
checked={value.sponsorBlock}
|
checked={value.sponsorBlock}
|
||||||
onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
|
onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
|
||||||
label={value.sponsorBlock ? 'On' : 'Off'}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{value.sponsorBlock && (
|
{value.sponsorBlock && (
|
||||||
<div className={styles.subGroup}>
|
<div className={styles.subGroup}>
|
||||||
<Field label="Action">
|
<Field label="Action">
|
||||||
<Select
|
<Select
|
||||||
aria-label="SponsorBlock action"
|
|
||||||
value={value.sponsorBlockMode}
|
value={value.sponsorBlockMode}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'remove', label: 'Remove segments (cut from file)' },
|
{ value: 'remove', label: 'Remove segments (cut from file)' },
|
||||||
@@ -193,7 +204,15 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
onChange={(v) => setOpt('sponsorBlockMode', v as SponsorBlockMode)}
|
onChange={(v) => setOpt('sponsorBlockMode', v as SponsorBlockMode)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Categories">
|
<Field
|
||||||
|
label="Categories"
|
||||||
|
validationState={value.sponsorBlockCategories.length === 0 ? 'warning' : 'none'}
|
||||||
|
validationMessage={
|
||||||
|
value.sponsorBlockCategories.length === 0
|
||||||
|
? 'No categories selected -- no segments will be skipped.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className={styles.categoryGrid}>
|
<div className={styles.categoryGrid}>
|
||||||
{SPONSORBLOCK_CATEGORIES.map((cat) => (
|
{SPONSORBLOCK_CATEGORIES.map((cat) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -208,11 +227,13 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field label="Embed chapters">
|
<Field
|
||||||
|
label="Embed chapters"
|
||||||
|
hint="Write chapter markers into the file. Only useful if the video has chapters."
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.embedChapters}
|
checked={value.embedChapters}
|
||||||
onChange={(_, d) => setOpt('embedChapters', d.checked)}
|
onChange={(_, d) => setOpt('embedChapters', d.checked)}
|
||||||
label={value.embedChapters ? 'On' : 'Off'}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
@@ -222,21 +243,40 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
<Switch
|
<Switch
|
||||||
checked={value.splitChapters}
|
checked={value.splitChapters}
|
||||||
onChange={(_, d) => setOpt('splitChapters', d.checked)}
|
onChange={(_, d) => setOpt('splitChapters', d.checked)}
|
||||||
label={value.splitChapters ? 'On' : 'Off'}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
|
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.embedMetadata}
|
checked={value.embedMetadata}
|
||||||
onChange={(_, d) => setOpt('embedMetadata', d.checked)}
|
onChange={(_, d) => setOpt('embedMetadata', d.checked)}
|
||||||
label={value.embedMetadata ? 'On' : 'Off'}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Metadata overrides"
|
||||||
|
hint="Override specific tags before embedding. Leave blank to keep the extracted value. Automatically enables embed metadata."
|
||||||
|
>
|
||||||
|
<div className={styles.subGroup}>
|
||||||
|
<Input
|
||||||
|
placeholder="Title"
|
||||||
|
value={value.metadataTitle ?? ''}
|
||||||
|
onChange={(_, d) => setOpt('metadataTitle', d.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Artist"
|
||||||
|
value={value.metadataArtist ?? ''}
|
||||||
|
onChange={(_, d) => setOpt('metadataArtist', d.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Album"
|
||||||
|
value={value.metadataAlbum ?? ''}
|
||||||
|
onChange={(_, d) => setOpt('metadataAlbum', d.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
|
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.embedThumbnail}
|
checked={value.embedThumbnail}
|
||||||
onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
|
onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
|
||||||
label={value.embedThumbnail ? 'On' : 'Off'}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{value.embedThumbnail && (
|
{value.embedThumbnail && (
|
||||||
@@ -254,7 +294,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Sidecar files"
|
label="Sidecar files"
|
||||||
hint="Write separate metadata/poster/description files next to each download — handy for Jellyfin, Plex or Kodi libraries."
|
hint="Write separate metadata/poster/description files next to each download -- handy for Jellyfin, Plex or Kodi libraries."
|
||||||
>
|
>
|
||||||
<div className={styles.subGroup}>
|
<div className={styles.subGroup}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
Subtitle2,
|
Subtitle2,
|
||||||
Body1,
|
|
||||||
Caption1,
|
Caption1,
|
||||||
Button,
|
Button,
|
||||||
ProgressBar,
|
ProgressBar,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
|
import { ArrowDownloadRegular, ArrowClockwiseRegular, DismissRegular } from '@fluentui/react-icons'
|
||||||
import { useDownloads } from '../store/downloads'
|
import { useDownloads, type DownloadItem } from '../store/downloads'
|
||||||
import { summarizeQueue } from '../store/queueStats'
|
import { queueSummaryOf } from '../store/queueStats'
|
||||||
import { DownloadBar } from './DownloadBar'
|
import { DownloadBar } from './DownloadBar'
|
||||||
import { QueueItem } from './QueueItem'
|
import { QueueItem } from './QueueItem'
|
||||||
import { VirtualList } from './VirtualList'
|
import { VirtualList } from './VirtualList'
|
||||||
|
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||||
|
import { EmptyState } from './ui/EmptyState'
|
||||||
|
import { SPACE, ICON, META_SEP } from './ui/tokens'
|
||||||
|
|
||||||
|
// Hoisted so the virtualizer gets stable function identities and doesn't churn its
|
||||||
|
// measurement/key cache on every render (PERF5).
|
||||||
|
const estimateRowSize = (): number => 100
|
||||||
|
const getItemKey = (item: DownloadItem): string => item.id
|
||||||
|
const renderQueueRow = (item: DownloadItem): React.JSX.Element => <QueueItem item={item} />
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
@@ -22,7 +31,8 @@ const useStyles = makeStyles({
|
|||||||
// growing to thousands of rows.
|
// growing to thousands of rows.
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '20px',
|
// One section gap on every screen (UI2).
|
||||||
|
gap: SPACE.section,
|
||||||
height: '100%'
|
height: '100%'
|
||||||
},
|
},
|
||||||
queueHeader: {
|
queueHeader: {
|
||||||
@@ -56,47 +66,59 @@ const useStyles = makeStyles({
|
|||||||
// min-height:0 lets this flex child shrink below its content height so it,
|
// min-height:0 lets this flex child shrink below its content height so it,
|
||||||
// not the page, owns the scrolling.
|
// not the page, owns the scrolling.
|
||||||
minHeight: 0
|
minHeight: 0
|
||||||
},
|
|
||||||
empty: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '56px 16px',
|
|
||||||
color: tokens.colorNeutralForeground3,
|
|
||||||
textAlign: 'center'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export function DownloadsView(): React.JSX.Element {
|
export function DownloadsView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
const screen = useScreenStyles()
|
||||||
const items = useDownloads((s) => s.items)
|
const items = useDownloads((s) => s.items)
|
||||||
const clearFinished = useDownloads((s) => s.clearFinished)
|
const clearFinished = useDownloads((s) => s.clearFinished)
|
||||||
const retryAll = useDownloads((s) => s.retryAll)
|
const retryAll = useDownloads((s) => s.retryAll)
|
||||||
|
const cancelAll = useDownloads((s) => s.cancelAll)
|
||||||
|
|
||||||
const summary = summarizeQueue(items)
|
const summary = queueSummaryOf(items)
|
||||||
const hasFinished = items.some(
|
const hasFinished = items.some(
|
||||||
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
||||||
)
|
)
|
||||||
|
// The header count is the live queue (work not yet finished), not the whole
|
||||||
|
// list -- completed/canceled/error rows linger until "Clear finished" (L11).
|
||||||
|
const queueCount = items.filter(
|
||||||
|
(i) =>
|
||||||
|
i.status === 'downloading' ||
|
||||||
|
i.status === 'queued' ||
|
||||||
|
i.status === 'paused' ||
|
||||||
|
i.status === 'saved'
|
||||||
|
).length
|
||||||
|
// Cancelable = the items with a live/pending run (downloading + queued); this is
|
||||||
|
// exactly what "Cancel all" acts on (UX15).
|
||||||
|
const cancelable = items.filter((i) => i.status === 'downloading' || i.status === 'queued').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
|
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." />
|
||||||
|
|
||||||
<DownloadBar />
|
<DownloadBar />
|
||||||
|
|
||||||
{summary.active && (
|
{summary.active && (
|
||||||
<div className={styles.summary}>
|
<div className={styles.summary}>
|
||||||
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
|
<ProgressBar
|
||||||
|
className={styles.summaryBar}
|
||||||
|
value={summary.progress}
|
||||||
|
thickness="large"
|
||||||
|
aria-label="Overall download progress"
|
||||||
|
/>
|
||||||
<Caption1 className={styles.summaryText}>
|
<Caption1 className={styles.summaryText}>
|
||||||
{summary.downloading} downloading
|
{summary.downloading} downloading
|
||||||
{summary.queued ? `, ${summary.queued} queued` : ''}
|
{summary.queued ? `, ${summary.queued} queued` : ''}
|
||||||
{summary.speedLabel ? ` • ${summary.speedLabel}` : ''}
|
{summary.speedLabel ? `${META_SEP}${summary.speedLabel}` : ''}
|
||||||
{summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''}
|
{summary.etaLabel ? `${META_SEP}~${summary.etaLabel} left` : ''}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.queueHeader}>
|
<div className={styles.queueHeader}>
|
||||||
<Subtitle2>Queue ({items.length})</Subtitle2>
|
<Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
{summary.failed > 0 && (
|
{summary.failed > 0 && (
|
||||||
<Button
|
<Button
|
||||||
@@ -108,6 +130,11 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
Retry all failed ({summary.failed})
|
Retry all failed ({summary.failed})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{cancelable > 0 && (
|
||||||
|
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={cancelAll}>
|
||||||
|
Cancel all ({cancelable})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasFinished && (
|
{hasFinished && (
|
||||||
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
<Button size="small" appearance="subtle" onClick={clearFinished}>
|
||||||
Clear finished
|
Clear finished
|
||||||
@@ -117,19 +144,19 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className={styles.empty}>
|
<EmptyState
|
||||||
<ArrowDownloadRegular fontSize={40} />
|
icon={<ArrowDownloadRegular fontSize={ICON.hero} />}
|
||||||
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
|
message="Nothing queued yet. Paste a URL above, then click Download."
|
||||||
</div>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={items}
|
items={items}
|
||||||
className={styles.listScroll}
|
className={styles.listScroll}
|
||||||
gap={10}
|
gap={10}
|
||||||
overscan={6}
|
overscan={6}
|
||||||
estimateSize={() => 100}
|
estimateSize={estimateRowSize}
|
||||||
getKey={(item) => item.id}
|
getKey={getItemKey}
|
||||||
renderItem={(item) => <QueueItem item={item} />}
|
renderItem={renderQueueRow}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
interface State {
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level renderer error boundary (M16). Before this, an exception thrown in
|
||||||
|
* render by any view unmounted the whole shell to a blank white window with no
|
||||||
|
* way out. This catches it, logs it (so it reaches the dev console / future log
|
||||||
|
* sink), and shows a recover affordance.
|
||||||
|
*
|
||||||
|
* The fallback is intentionally dependency-free — no Fluent components, no theme
|
||||||
|
* provider — because the error may have come from inside that very tree. It paints
|
||||||
|
* its own full-window dark surface (matching the app's dark charcoal + toffee
|
||||||
|
* accent) rather than reading theme tokens, so it renders identically regardless
|
||||||
|
* of the active theme and uses only inline CSS that can't itself throw.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends React.Component<Props, State> {
|
||||||
|
state: State = { error: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||||
|
console.error('[AeroFetch] renderer crashed:', error, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReload = (): void => {
|
||||||
|
// A full reload re-runs the renderer from a clean state; persisted data
|
||||||
|
// (settings/history/sources) lives in main, so nothing is lost.
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
const { error } = this.state
|
||||||
|
if (!error) return this.props.children
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 16,
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: 32,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'Segoe UI, system-ui, sans-serif',
|
||||||
|
color: '#c8c6c4',
|
||||||
|
background: '#201f1e'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600, color: '#f3f2f1' }}>Something went wrong</div>
|
||||||
|
<div style={{ maxWidth: 440, fontSize: 13, lineHeight: 1.5 }}>
|
||||||
|
AeroFetch hit an unexpected error and couldn’t continue. Your downloads and settings are
|
||||||
|
saved — reloading the window should bring you back.
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
maxWidth: 480,
|
||||||
|
maxHeight: 120,
|
||||||
|
overflow: 'auto',
|
||||||
|
margin: 0,
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 11,
|
||||||
|
textAlign: 'left',
|
||||||
|
color: '#d29ca0',
|
||||||
|
background: '#2b2a29',
|
||||||
|
borderRadius: 6
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error.message || String(error)}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleReload}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#1c1611',
|
||||||
|
background: '#b5917d',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload AeroFetch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||||
|
import { Z } from './ui/tokens'
|
||||||
|
|
||||||
// An instant, theme-styled tooltip implemented entirely in CSS — shown via a
|
// An instant, theme-styled tooltip implemented entirely in CSS — shown via a
|
||||||
// `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child
|
// `:hover` / `:focus-within` visibility toggle on an absolutely-positioned child
|
||||||
@@ -17,7 +18,7 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
bubble: {
|
bubble: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
zIndex: 1000,
|
zIndex: Z.tooltip,
|
||||||
visibility: 'hidden',
|
visibility: 'hidden',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@@ -36,12 +37,17 @@ const useStyles = makeStyles({
|
|||||||
alignEnd: { right: 0 }
|
alignEnd: { right: 0 }
|
||||||
})
|
})
|
||||||
|
|
||||||
interface HintProps {
|
// `align` only shifts a top/bottom bubble left/right; it does nothing for a
|
||||||
|
// left/right placement. The union makes that explicit — passing `align` with
|
||||||
|
// `placement="left"|"right"` is a type error now, instead of being silently
|
||||||
|
// ignored (L129).
|
||||||
|
type HintProps = {
|
||||||
label: string
|
label: string
|
||||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
|
||||||
align?: 'start' | 'end'
|
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
} & (
|
||||||
|
| { placement?: 'top' | 'bottom'; align?: 'start' | 'end' }
|
||||||
|
| { placement: 'left' | 'right'; align?: never }
|
||||||
|
)
|
||||||
|
|
||||||
export function Hint({
|
export function Hint({
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Input,
|
Input,
|
||||||
Body1,
|
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type { HistoryEntry, MediaKind } from '@shared/ipc'
|
import type { HistoryEntry, MediaKind } from '@shared/ipc'
|
||||||
import { useHistory } from '../store/history'
|
import { useHistory } from '../store/history'
|
||||||
|
import { formatWhen } from '../datetime'
|
||||||
import { useResolvedDark } from '../store/systemTheme'
|
import { useResolvedDark } from '../store/systemTheme'
|
||||||
import { useDownloads } from '../store/downloads'
|
import { useDownloads } from '../store/downloads'
|
||||||
import { thumbColors } from '../theme'
|
import { thumbColors } from '../theme'
|
||||||
@@ -29,12 +30,18 @@ import { thumbUrl } from '../thumb'
|
|||||||
import { MediaThumb } from './MediaThumb'
|
import { MediaThumb } from './MediaThumb'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
|
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||||
|
import { EmptyState } from './ui/EmptyState'
|
||||||
|
import { useFocusStyles } from './ui/focusRing'
|
||||||
|
import { useTextStyles } from './ui/text'
|
||||||
|
import { SPACE, ICON, META_SEP } from './ui/tokens'
|
||||||
|
import { THUMB_SM } from '../thumbSizes'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '12px'
|
gap: SPACE.section
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -74,8 +81,8 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
thumb: {
|
thumb: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: '72px',
|
width: `${THUMB_SM.w}px`,
|
||||||
height: '44px',
|
height: `${THUMB_SM.h}px`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -87,27 +94,15 @@ const useStyles = makeStyles({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
},
|
},
|
||||||
title: {
|
|
||||||
fontWeight: tokens.fontWeightSemibold,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
color: tokens.colorNeutralForeground3
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '4px',
|
gap: '4px',
|
||||||
flexShrink: 0
|
flexShrink: 0,
|
||||||
},
|
// >=40px hit target for the icon-only row actions (W16).
|
||||||
empty: {
|
'& button': {
|
||||||
display: 'flex',
|
minWidth: '40px',
|
||||||
flexDirection: 'column',
|
minHeight: '40px'
|
||||||
alignItems: 'center',
|
}
|
||||||
gap: '10px',
|
|
||||||
padding: '56px 16px',
|
|
||||||
textAlign: 'center'
|
|
||||||
},
|
},
|
||||||
emptyBadge: {
|
emptyBadge: {
|
||||||
width: '56px',
|
width: '56px',
|
||||||
@@ -115,11 +110,9 @@ const useStyles = makeStyles({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: '50%',
|
borderRadius: tokens.borderRadiusCircular,
|
||||||
fontSize: '26px'
|
// Empty-state badge glyph, snapped to the nearest ICON tier (UI11).
|
||||||
},
|
fontSize: `${ICON.section}px`
|
||||||
emptyHint: {
|
|
||||||
color: tokens.colorNeutralForeground3
|
|
||||||
},
|
},
|
||||||
noMatches: {
|
noMatches: {
|
||||||
padding: '32px 16px',
|
padding: '32px 16px',
|
||||||
@@ -134,19 +127,11 @@ const KIND_FILTER_OPTIONS = [
|
|||||||
{ value: 'audio', label: 'Audio' }
|
{ value: 'audio', label: 'Audio' }
|
||||||
]
|
]
|
||||||
|
|
||||||
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 {
|
export function HistoryView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
const screen = useScreenStyles()
|
||||||
|
const focus = useFocusStyles()
|
||||||
|
const text = useTextStyles()
|
||||||
const isDark = useResolvedDark()
|
const isDark = useResolvedDark()
|
||||||
const tc = thumbColors[isDark ? 'dark' : 'light']
|
const tc = thumbColors[isDark ? 'dark' : 'light']
|
||||||
const entries = useHistory((s) => s.entries)
|
const entries = useHistory((s) => s.entries)
|
||||||
@@ -161,6 +146,8 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
|
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
|
||||||
const [selectMode, setSelectMode] = useState(false)
|
const [selectMode, setSelectMode] = useState(false)
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
@@ -175,8 +162,31 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
}, [entries, query, kindFilter])
|
}, [entries, query, kindFilter])
|
||||||
|
|
||||||
|
// Clear selections that are no longer visible after a filter change (L86).
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected.size === 0) return
|
||||||
|
const visible = new Set(filtered.map((h) => h.id))
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set([...prev].filter((id) => visible.has(id)))
|
||||||
|
return next.size === prev.size ? prev : next
|
||||||
|
})
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
function redownload(h: HistoryEntry): void {
|
function redownload(h: HistoryEntry): void {
|
||||||
addFromUrl(h.url, h.kind, h.quality, { title: h.title, channel: h.channel })
|
// Strip compound format labels ("720p · mp4 · 184 MB" → "720p") when there is
|
||||||
|
// no stored formatId, so videoFormat() can still match the preset (H5).
|
||||||
|
const quality = (h.quality.split(' · ')[0] ?? h.quality).trim()
|
||||||
|
addFromUrl(h.url, h.kind, quality, {
|
||||||
|
title: h.title,
|
||||||
|
channel: h.channel,
|
||||||
|
thumbnail: h.thumbnail,
|
||||||
|
// Replay the original post-processing options (L87); undefined falls back to
|
||||||
|
// the global defaults for entries recorded before this was carried.
|
||||||
|
options: h.options,
|
||||||
|
format: h.formatId
|
||||||
|
? { id: h.formatId, hasAudio: h.formatHasAudio ?? false, label: h.quality }
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelected(id: string, on: boolean): void {
|
function toggleSelected(id: string, on: boolean): void {
|
||||||
@@ -196,6 +206,7 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
function exitSelectMode(): void {
|
function exitSelectMode(): void {
|
||||||
setSelectMode(false)
|
setSelectMode(false)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
|
setConfirmDelete(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSelected(): void {
|
function deleteSelected(): void {
|
||||||
@@ -205,21 +216,27 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.empty}>
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
|
<ScreenHeader title="History" description="Files you've finished downloading." />
|
||||||
|
<EmptyState
|
||||||
|
icon={
|
||||||
<div
|
<div
|
||||||
className={styles.emptyBadge}
|
className={styles.emptyBadge}
|
||||||
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
||||||
>
|
>
|
||||||
<HistoryRegular />
|
<HistoryRegular />
|
||||||
</div>
|
</div>
|
||||||
<Body1>No downloads yet.</Body1>
|
}
|
||||||
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
|
message="No downloads yet."
|
||||||
|
hint="Finished downloads will show up here."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
|
<ScreenHeader title="History" description="Files you've finished downloading." />
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{selectMode ? (
|
{selectMode ? (
|
||||||
<>
|
<>
|
||||||
@@ -228,19 +245,45 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
{allFilteredSelected ? 'Select none' : 'Select all'}
|
{allFilteredSelected ? 'Select none' : 'Select all'}
|
||||||
</Button>
|
</Button>
|
||||||
<div className={styles.spacer} />
|
<div className={styles.spacer} />
|
||||||
|
{confirmDelete ? (
|
||||||
|
<>
|
||||||
|
<Caption1 className={styles.count}>
|
||||||
|
Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
|
||||||
|
</Caption1>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
icon={<DeleteRegular />}
|
icon={<DeleteRegular />}
|
||||||
onClick={deleteSelected}
|
onClick={deleteSelected}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="primary"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
disabled={selected.size === 0}
|
disabled={selected.size === 0}
|
||||||
>
|
>
|
||||||
Delete selected
|
Delete selected
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={exitSelectMode}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={exitSelectMode}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Caption1 className={styles.count}>
|
<Caption1 className={styles.count}>
|
||||||
@@ -248,6 +291,7 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
</Caption1>
|
</Caption1>
|
||||||
<Input
|
<Input
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
|
input={{ 'aria-label': 'Search history' }}
|
||||||
size="small"
|
size="small"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(_, d) => setQuery(d.value)}
|
onChange={(_, d) => setQuery(d.value)}
|
||||||
@@ -270,9 +314,36 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
Select
|
Select
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
|
{confirmClear ? (
|
||||||
|
<>
|
||||||
|
<Caption1 className={styles.count}>
|
||||||
|
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
|
||||||
|
</Caption1>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="primary"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => {
|
||||||
|
clear()
|
||||||
|
setConfirmClear(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
>
|
||||||
Clear history
|
Clear history
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -280,10 +351,33 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
|
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.list}>
|
<div
|
||||||
|
className={styles.list}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
|
||||||
|
// select mode if needed). Scoped to the list, so it never hijacks
|
||||||
|
// Ctrl+A in the search field, which lives in the header above.
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectMode(true)
|
||||||
|
setSelected(new Set(filtered.map((x) => x.id)))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{filtered.map((h: HistoryEntry) => {
|
{filtered.map((h: HistoryEntry) => {
|
||||||
return (
|
return (
|
||||||
<div key={h.id} className={styles.row}>
|
<div
|
||||||
|
key={h.id}
|
||||||
|
className={mergeClasses(styles.row, focus.focusRing)}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Delete removes the focused row (not when a child button is focused).
|
||||||
|
if (e.key === 'Delete' && e.target === e.currentTarget) {
|
||||||
|
e.preventDefault()
|
||||||
|
remove(h.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.has(h.id)}
|
checked={selected.has(h.id)}
|
||||||
@@ -298,11 +392,11 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
iconSize={22}
|
iconSize={22}
|
||||||
/>
|
/>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<Text className={styles.title}>{h.title}</Text>
|
<Text className={text.title}>{h.title}</Text>
|
||||||
<Caption1 className={styles.meta}>
|
<Caption1 className={text.muted}>
|
||||||
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
|
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' • ')}
|
.join(META_SEP)}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Subtitle2,
|
|
||||||
Body1,
|
|
||||||
Caption1,
|
Caption1,
|
||||||
Text,
|
Text,
|
||||||
Input,
|
Input,
|
||||||
@@ -15,9 +13,8 @@ import {
|
|||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import {
|
import {
|
||||||
SearchRegular,
|
AddRegular,
|
||||||
ArrowSyncRegular,
|
ArrowSyncRegular,
|
||||||
ArrowClockwiseRegular,
|
|
||||||
DeleteRegular,
|
DeleteRegular,
|
||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
ChevronDownRegular,
|
ChevronDownRegular,
|
||||||
@@ -25,21 +22,30 @@ import {
|
|||||||
AppsListRegular,
|
AppsListRegular,
|
||||||
VideoClipMultipleRegular,
|
VideoClipMultipleRegular,
|
||||||
AlertRegular,
|
AlertRegular,
|
||||||
LibraryRegular,
|
LibraryRegular
|
||||||
LinkRegular,
|
|
||||||
DismissRegular
|
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type { MediaItem, Source } from '@shared/ipc'
|
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
|
||||||
|
import { isPreview as PREVIEW } from '../isPreview'
|
||||||
import { useSources } from '../store/sources'
|
import { useSources } from '../store/sources'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { useNav } from '../store/nav'
|
||||||
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
|
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
|
||||||
|
import { logError } from '../reportError'
|
||||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||||
import { thumbUrl } from '../thumb'
|
import { thumbUrl } from '../thumb'
|
||||||
|
import { relTime } from '../datetime'
|
||||||
import { MediaThumb } from './MediaThumb'
|
import { MediaThumb } from './MediaThumb'
|
||||||
import { VirtualList } from './VirtualList'
|
import { VirtualList } from './VirtualList'
|
||||||
|
import { Hint } from './Hint'
|
||||||
// True in the standalone browser preview (no Electron preload).
|
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
import { StatusChip } from './ui/StatusChip'
|
||||||
|
import { SegmentedControl } from './ui/SegmentedControl'
|
||||||
|
import { EmptyState } from './ui/EmptyState'
|
||||||
|
import { LinkSuggestion } from './ui/LinkSuggestion'
|
||||||
|
import { useFocusStyles } from './ui/focusRing'
|
||||||
|
import { useTextStyles } from './ui/text'
|
||||||
|
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
|
||||||
|
import { THUMB_XS } from '../thumbSizes'
|
||||||
|
|
||||||
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
|
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
|
||||||
type ItemStatus = DownloadStatus | 'pending'
|
type ItemStatus = DownloadStatus | 'pending'
|
||||||
@@ -50,45 +56,28 @@ type ItemStatus = DownloadStatus | 'pending'
|
|||||||
* window cleanly (one virtualizer over a flat array, headers included).
|
* window cleanly (one virtualizer over a flat array, headers included).
|
||||||
*/
|
*/
|
||||||
type LibRow =
|
type LibRow =
|
||||||
| { kind: 'header'; title: string; items: MediaItem[] }
|
{ kind: 'header'; title: string; items: MediaItem[] } | { kind: 'item'; item: MediaItem }
|
||||||
| { kind: 'item'; item: MediaItem }
|
|
||||||
|
|
||||||
/** Above this many flattened rows, the item list switches to a virtualized panel. */
|
/** Above this many flattened rows, the item list switches to a virtualized panel. */
|
||||||
const VIRTUALIZE_AT = 100
|
const VIRTUALIZE_AT = 100
|
||||||
|
|
||||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
// When a URL appears more than once in the queue ("Download anyway" duplicates),
|
||||||
pending: 'Pending',
|
// the library row should reflect the most meaningful state: a finished copy wins,
|
||||||
queued: 'Queued',
|
// then anything in flight, then terminal failures (M17).
|
||||||
downloading: 'Downloading',
|
const STATUS_PRIORITY: Record<DownloadStatus, number> = {
|
||||||
paused: 'Paused',
|
completed: 6,
|
||||||
saved: 'Saved',
|
downloading: 5,
|
||||||
completed: 'Downloaded',
|
queued: 4,
|
||||||
error: 'Failed',
|
saved: 3,
|
||||||
canceled: 'Canceled'
|
paused: 2,
|
||||||
|
error: 1,
|
||||||
|
canceled: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
|
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
|
||||||
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
|
||||||
sub: { color: tokens.colorNeutralForeground3 },
|
|
||||||
addRow: { display: 'flex', gap: '8px' },
|
addRow: { display: 'flex', gap: '8px' },
|
||||||
addInput: { flexGrow: 1 },
|
addInput: { flexGrow: 1 },
|
||||||
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'
|
|
||||||
},
|
|
||||||
toolbar: {
|
toolbar: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -111,15 +100,6 @@ const useStyles = makeStyles({
|
|||||||
fontSize: tokens.fontSizeBase200
|
fontSize: tokens.fontSizeBase200
|
||||||
},
|
},
|
||||||
error: { color: tokens.colorPaletteRedForeground1 },
|
error: { color: tokens.colorPaletteRedForeground1 },
|
||||||
empty: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
padding: '56px 16px',
|
|
||||||
color: tokens.colorNeutralForeground3,
|
|
||||||
textAlign: 'center'
|
|
||||||
},
|
|
||||||
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
|
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
|
||||||
card: {
|
card: {
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
@@ -132,6 +112,14 @@ const useStyles = makeStyles({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
|
width: '100%',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
// A native <button> doesn't inherit color; set it so the chevron (currentColor)
|
||||||
|
// matches the page foreground in dark mode instead of UA ButtonText.
|
||||||
|
color: tokens.colorNeutralForeground1,
|
||||||
|
fontFamily: tokens.fontFamilyBase,
|
||||||
|
textAlign: 'left',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
||||||
},
|
},
|
||||||
@@ -145,7 +133,8 @@ const useStyles = makeStyles({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '18px'
|
// Source tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
|
||||||
|
fontSize: `${ICON.control}px`
|
||||||
},
|
},
|
||||||
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
||||||
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
|
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
|
||||||
@@ -158,7 +147,6 @@ const useStyles = makeStyles({
|
|||||||
backgroundColor: tokens.colorBrandBackground2,
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
color: tokens.colorBrandForeground2
|
color: tokens.colorBrandForeground2
|
||||||
},
|
},
|
||||||
srcSub: { color: tokens.colorNeutralForeground3 },
|
|
||||||
detail: {
|
detail: {
|
||||||
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
@@ -172,9 +160,22 @@ const useStyles = makeStyles({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
padding: '6px 4px',
|
padding: '2px 4px'
|
||||||
|
},
|
||||||
|
groupToggle: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: '4px 4px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
color: tokens.colorNeutralForeground2,
|
color: tokens.colorNeutralForeground2,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
fontFamily: tokens.fontFamilyBase,
|
||||||
|
fontSize: tokens.fontSizeBase300,
|
||||||
|
textAlign: 'left',
|
||||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
||||||
},
|
},
|
||||||
@@ -187,38 +188,12 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
plainList: { display: 'flex', flexDirection: 'column' },
|
plainList: { display: 'flex', flexDirection: 'column' },
|
||||||
rowThumb: {
|
rowThumb: {
|
||||||
width: '60px',
|
width: `${THUMB_XS.w}px`,
|
||||||
height: '34px',
|
height: `${THUMB_XS.h}px`,
|
||||||
...shorthands.borderRadius(tokens.borderRadiusSmall)
|
// One thumbnail radius app-wide (UI6): control tier / Medium.
|
||||||
|
...shorthands.borderRadius(RADIUS.control)
|
||||||
},
|
},
|
||||||
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }
|
||||||
rowTitle: {
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
color: tokens.colorNeutralForeground1
|
|
||||||
},
|
|
||||||
rowMeta: { color: tokens.colorNeutralForeground3 },
|
|
||||||
pill: {
|
|
||||||
flexShrink: 0,
|
|
||||||
fontSize: tokens.fontSizeBase200,
|
|
||||||
padding: '1px 8px',
|
|
||||||
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
|
||||||
backgroundColor: tokens.colorNeutralBackground3,
|
|
||||||
color: tokens.colorNeutralForeground3
|
|
||||||
},
|
|
||||||
pillDownloading: {
|
|
||||||
backgroundColor: tokens.colorBrandBackground2,
|
|
||||||
color: tokens.colorBrandForeground2
|
|
||||||
},
|
|
||||||
pillCompleted: {
|
|
||||||
backgroundColor: tokens.colorPaletteGreenBackground2,
|
|
||||||
color: tokens.colorPaletteGreenForeground2
|
|
||||||
},
|
|
||||||
pillError: {
|
|
||||||
backgroundColor: tokens.colorPaletteRedBackground2,
|
|
||||||
color: tokens.colorPaletteRedForeground2
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
|
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
|
||||||
@@ -241,18 +216,11 @@ function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
function relTime(ms?: number): string {
|
|
||||||
if (!ms) return 'never'
|
|
||||||
const mins = Math.round((Date.now() - ms) / 60000)
|
|
||||||
if (mins < 1) return 'just now'
|
|
||||||
if (mins < 60) return `${mins} min ago`
|
|
||||||
const hrs = Math.round(mins / 60)
|
|
||||||
if (hrs < 24) return `${hrs} h ago`
|
|
||||||
return `${Math.round(hrs / 24)} d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LibraryView(): React.JSX.Element {
|
export function LibraryView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
const screen = useScreenStyles()
|
||||||
|
const focus = useFocusStyles()
|
||||||
|
const text = useTextStyles()
|
||||||
const sources = useSources((s) => s.sources)
|
const sources = useSources((s) => s.sources)
|
||||||
const itemsBySource = useSources((s) => s.itemsBySource)
|
const itemsBySource = useSources((s) => s.itemsBySource)
|
||||||
const selectedSourceId = useSources((s) => s.selectedSourceId)
|
const selectedSourceId = useSources((s) => s.selectedSourceId)
|
||||||
@@ -271,10 +239,32 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// A channel/playlist URL handed over from the Downloads bar (UX3): pre-fill the
|
||||||
|
// add field with it once, so the user lands here ready to add it.
|
||||||
|
const pendingLibraryUrl = useNav((s) => s.pendingLibraryUrl)
|
||||||
|
const consumeLibraryUrl = useNav((s) => s.consumeLibraryUrl)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingLibraryUrl === null) return
|
||||||
|
const handed = consumeLibraryUrl()
|
||||||
|
if (handed) setUrl(handed)
|
||||||
|
}, [pendingLibraryUrl, consumeLibraryUrl])
|
||||||
|
|
||||||
|
// Reset any pending Remove confirmation when the expanded source changes so a
|
||||||
|
// stale confirm can't reappear after navigating between sources.
|
||||||
|
useEffect(() => {
|
||||||
|
setConfirmRemoveId(null)
|
||||||
|
}, [selectedSourceId])
|
||||||
// Offer a freshly-copied link the way the Downloads tab does, but skip single
|
// Offer a freshly-copied link the way the Downloads tab does, but skip single
|
||||||
// videos — a library source is a channel/playlist to sync, not a one-off.
|
// videos -- a library source is a channel/playlist to sync, not a one-off.
|
||||||
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
|
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
// Per-batch video/audio choice for queueing items, seeded from the global
|
||||||
|
// default so behavior is unchanged until the user flips it (M27).
|
||||||
|
const [enqueueKind, setEnqueueKind] = useState<MediaKind>(
|
||||||
|
() => useSettings.getState().defaultKind
|
||||||
|
)
|
||||||
const [batchNote, setBatchNote] = useState<string | null>(null)
|
const [batchNote, setBatchNote] = useState<string | null>(null)
|
||||||
const [syncNote, setSyncNote] = useState<string | null>(null)
|
const [syncNote, setSyncNote] = useState<string | null>(null)
|
||||||
// Which playlist groups are expanded. Empty = all collapsed (the default), so
|
// Which playlist groups are expanded. Empty = all collapsed (the default), so
|
||||||
@@ -287,7 +277,10 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
// Load the current scheduled-sync (Task Scheduler) state once.
|
// Load the current scheduled-sync (Task Scheduler) state once.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (PREVIEW) return
|
if (PREVIEW) return
|
||||||
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {})
|
window.api
|
||||||
|
.getScheduledSync()
|
||||||
|
.then((s) => setScheduled(s.enabled))
|
||||||
|
.catch(logError('getScheduledSync'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function onCheckNew(): Promise<void> {
|
async function onCheckNew(): Promise<void> {
|
||||||
@@ -314,7 +307,13 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
// Live per-URL queue status so a video row reflects its real download state.
|
// Live per-URL queue status so a video row reflects its real download state.
|
||||||
const statusByUrl = useMemo(() => {
|
const statusByUrl = useMemo(() => {
|
||||||
const m = new Map<string, DownloadStatus>()
|
const m = new Map<string, DownloadStatus>()
|
||||||
for (const d of downloadItems) m.set(d.url, d.status)
|
for (const d of downloadItems) {
|
||||||
|
const prev = m.get(d.url)
|
||||||
|
// Keep the highest-priority status when a URL has duplicate queue entries.
|
||||||
|
if (prev === undefined || STATUS_PRIORITY[d.status] > STATUS_PRIORITY[prev]) {
|
||||||
|
m.set(d.url, d.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
}, [downloadItems])
|
}, [downloadItems])
|
||||||
|
|
||||||
@@ -322,8 +321,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
const groups = useMemo(() => groupByPlaylist(items), [items])
|
const groups = useMemo(() => groupByPlaylist(items), [items])
|
||||||
// A group is shown when toggled open, or auto-expanded when it's the only group
|
// A group is shown when toggled open, or auto-expanded when it's the only group
|
||||||
// (a single "Uploads" channel shouldn't need a second click to reach its videos).
|
// (a single "Uploads" channel shouldn't need a second click to reach its videos).
|
||||||
const isGroupOpen = (title: string): boolean =>
|
const isGroupOpen = (title: string): boolean => groups.length === 1 || expandedGroups.has(title)
|
||||||
groups.length === 1 || expandedGroups.has(title)
|
|
||||||
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
|
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
|
||||||
const flatRows = useMemo<LibRow[]>(() => {
|
const flatRows = useMemo<LibRow[]>(() => {
|
||||||
const rows: LibRow[] = []
|
const rows: LibRow[] = []
|
||||||
@@ -360,7 +358,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
if (!u || indexing.active) return
|
if (!u || indexing.active) return
|
||||||
const res = await indexSource(u)
|
const res = await indexSource(u)
|
||||||
if (res.ok) setUrl('')
|
if (res.ok) setUrl('')
|
||||||
else setError(res.error ?? 'Could not index that link.')
|
else setError(res.error ?? 'Could not add that link.')
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(id: string, on: boolean): void {
|
function toggle(id: string, on: boolean): void {
|
||||||
@@ -385,6 +383,9 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
for (const it of groupItems) {
|
for (const it of groupItems) {
|
||||||
|
// Only actionable rows are selectable, so the selection count can never
|
||||||
|
// exceed what "Download N selected" will actually queue (M36).
|
||||||
|
if (!actionable(it)) continue
|
||||||
if (on) next.add(it.id)
|
if (on) next.add(it.id)
|
||||||
else next.delete(it.id)
|
else next.delete(it.id)
|
||||||
}
|
}
|
||||||
@@ -397,18 +398,18 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
|
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
// One click queues every chosen item — maxConcurrent gates how many actually
|
// One click queues every chosen item -- maxConcurrent gates how many actually
|
||||||
// run, the rest wait in the queue. Selection clears since nothing is held back.
|
// run, the rest wait in the queue. Selection clears since nothing is held back.
|
||||||
function downloadSelected(): void {
|
function downloadSelected(): void {
|
||||||
if (!selectedSourceId || selectedActionable.length === 0) return
|
if (!selectedSourceId || selectedActionable.length === 0) return
|
||||||
const n = enqueueItems(selectedSourceId, selectedActionable)
|
const n = enqueueItems(selectedSourceId, selectedActionable, enqueueKind)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadPending(): void {
|
function downloadPending(): void {
|
||||||
if (!selectedSourceId || pendingItems.length === 0) return
|
if (!selectedSourceId || pendingItems.length === 0) return
|
||||||
const n = enqueueItems(selectedSourceId, pendingItems)
|
const n = enqueueItems(selectedSourceId, pendingItems, enqueueKind)
|
||||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,18 +419,11 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
if (!selectedSourceId) return
|
if (!selectedSourceId) return
|
||||||
const toQueue = groupItems.filter(actionable)
|
const toQueue = groupItems.filter(actionable)
|
||||||
if (toQueue.length === 0) return
|
if (toQueue.length === 0) return
|
||||||
const n = enqueueItems(selectedSourceId, toQueue)
|
const n = enqueueItems(selectedSourceId, toQueue, enqueueKind)
|
||||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function pillClass(status: ItemStatus): string {
|
// One row of the item list -- a playlist header or a video -- shared by the
|
||||||
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
|
|
||||||
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
|
|
||||||
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
|
|
||||||
return styles.pill
|
|
||||||
}
|
|
||||||
|
|
||||||
// One row of the item list — a playlist header or a video — shared by the
|
|
||||||
// inline (small source) and virtualized (large source) render paths.
|
// inline (small source) and virtualized (large source) render paths.
|
||||||
function rowKey(row: LibRow): string {
|
function rowKey(row: LibRow): string {
|
||||||
return row.kind === 'header' ? `h:${row.title}` : row.item.id
|
return row.kind === 'header' ? `h:${row.title}` : row.item.id
|
||||||
@@ -437,32 +431,27 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
|
|
||||||
function renderRow(row: LibRow): React.JSX.Element {
|
function renderRow(row: LibRow): React.JSX.Element {
|
||||||
if (row.kind === 'header') {
|
if (row.kind === 'header') {
|
||||||
const allOn = row.items.every((it) => selected.has(it.id))
|
|
||||||
const open = isGroupOpen(row.title)
|
const open = isGroupOpen(row.title)
|
||||||
const groupActionable = row.items.filter(actionable).length
|
// "All on" is judged over the ACTIONABLE rows only -- downloaded rows aren't
|
||||||
|
// selectable, so they must not keep the group from reading as fully selected (M36).
|
||||||
|
const groupActionableItems = row.items.filter(actionable)
|
||||||
|
const groupActionable = groupActionableItems.length
|
||||||
|
const allOn = groupActionable > 0 && groupActionableItems.every((it) => selected.has(it.id))
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={styles.groupHead}>
|
||||||
className={styles.groupHead}
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mergeClasses(styles.groupToggle, focus.focusRing)}
|
||||||
onClick={() => toggleGroupExpand(row.title)}
|
onClick={() => toggleGroupExpand(row.title)}
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) =>
|
|
||||||
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)
|
|
||||||
}
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-label={`${row.title}, ${row.items.length} items`}
|
||||||
>
|
>
|
||||||
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||||
<AppsListRegular />
|
<AppsListRegular />
|
||||||
<span className={styles.groupTitle}>{row.title}</span>
|
<Text className={styles.groupTitle}>{row.title}</Text>
|
||||||
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
|
<Caption1 className={text.muted}>{row.items.length}</Caption1>
|
||||||
<Button
|
</button>
|
||||||
size="small"
|
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
|
||||||
appearance="subtle"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
toggleGroup(row.items, !allOn)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allOn ? 'None' : 'All'}
|
{allOn ? 'None' : 'All'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -470,10 +459,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
icon={<ArrowDownloadRegular />}
|
icon={<ArrowDownloadRegular />}
|
||||||
disabled={groupActionable === 0}
|
disabled={groupActionable === 0}
|
||||||
onClick={(e) => {
|
onClick={() => downloadGroup(row.items)}
|
||||||
e.stopPropagation()
|
|
||||||
downloadGroup(row.items)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
|
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -486,6 +472,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.has(it.id)}
|
checked={selected.has(it.id)}
|
||||||
|
disabled={!actionable(it)}
|
||||||
onChange={(_, d) => toggle(it.id, !!d.checked)}
|
onChange={(_, d) => toggle(it.id, !!d.checked)}
|
||||||
aria-label={`Select ${it.title}`}
|
aria-label={`Select ${it.title}`}
|
||||||
/>
|
/>
|
||||||
@@ -496,32 +483,31 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
iconSize={16}
|
iconSize={16}
|
||||||
/>
|
/>
|
||||||
<div className={styles.rowMain}>
|
<div className={styles.rowMain}>
|
||||||
<span className={styles.rowTitle}>
|
<Text className={text.truncate}>
|
||||||
{it.playlistIndex}. {it.title}
|
{it.playlistIndex}. {it.title}
|
||||||
</span>
|
</Text>
|
||||||
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
|
{it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
|
||||||
</div>
|
</div>
|
||||||
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
|
<StatusChip status={status} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
<div className={styles.header}>
|
<ScreenHeader
|
||||||
<Subtitle2>Library</Subtitle2>
|
title="Library"
|
||||||
<Caption1 className={styles.sub}>
|
description="Add a channel or playlist once, then download its videos into organized folders."
|
||||||
Index a channel or playlist once, then download it into organized folders.
|
/>
|
||||||
</Caption1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.addRow}>
|
<div className={styles.addRow}>
|
||||||
<Input
|
<Input
|
||||||
className={styles.addInput}
|
className={styles.addInput}
|
||||||
|
input={{ 'aria-label': 'Channel or playlist URL' }}
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(_, d) => setUrl(d.value)}
|
onChange={(_, d) => setUrl(d.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
||||||
placeholder="Paste a channel or playlist URL…"
|
placeholder="Paste a channel or playlist URL to add it…"
|
||||||
size="large"
|
size="large"
|
||||||
contentBefore={<LibraryRegular />}
|
contentBefore={<LibraryRegular />}
|
||||||
disabled={indexing.active}
|
disabled={indexing.active}
|
||||||
@@ -529,49 +515,51 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />}
|
icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
|
||||||
onClick={onIndex}
|
onClick={onIndex}
|
||||||
disabled={!url.trim() || indexing.active}
|
disabled={!url.trim() || indexing.active}
|
||||||
>
|
>
|
||||||
Index
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{clip.suggestion && (
|
{clip.suggestion && (
|
||||||
<div className={styles.suggestion}>
|
<LinkSuggestion
|
||||||
<LinkRegular />
|
prefix="Use copied link? "
|
||||||
<Caption1 className={styles.suggestionText}>Use copied link? {clip.suggestion}</Caption1>
|
link={clip.suggestion}
|
||||||
<Button
|
onAccept={() => {
|
||||||
size="small"
|
|
||||||
appearance="primary"
|
|
||||||
onClick={() => {
|
|
||||||
const link = clip.accept()
|
const link = clip.accept()
|
||||||
if (link) setUrl(link)
|
if (link) setUrl(link)
|
||||||
}}
|
}}
|
||||||
>
|
onDismiss={clip.dismiss}
|
||||||
Use
|
dismissLabel="Dismiss suggested link"
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<DismissRegular />}
|
|
||||||
onClick={clip.dismiss}
|
|
||||||
aria-label="Dismiss"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
|
<Hint
|
||||||
|
label={
|
||||||
|
watchedCount === 0
|
||||||
|
? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
|
||||||
|
: 'Check your watched sources for new uploads'
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="secondary"
|
appearance="subtle"
|
||||||
icon={syncing ? <Spinner size="tiny" /> : <ArrowClockwiseRegular />}
|
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
||||||
onClick={onCheckNew}
|
onClick={onCheckNew}
|
||||||
disabled={syncing || watchedCount === 0}
|
// disabledFocusable (not disabled) when nothing is watched, so the
|
||||||
|
// button still shows its explanatory tooltip on hover/focus (UX24).
|
||||||
|
disabled={syncing}
|
||||||
|
disabledFocusable={watchedCount === 0}
|
||||||
>
|
>
|
||||||
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
|
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
|
||||||
</Button>
|
</Button>
|
||||||
{syncNote && <Caption1 className={styles.sub}>{syncNote}</Caption1>}
|
</Hint>
|
||||||
|
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
|
||||||
<div className={styles.toolbarSpacer} />
|
<div className={styles.toolbarSpacer} />
|
||||||
<span className={styles.switchRow}>
|
<span className={styles.switchRow}>
|
||||||
<Caption1>Auto-download new</Caption1>
|
<Caption1>Auto-download new</Caption1>
|
||||||
@@ -595,7 +583,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
<div className={styles.progress}>
|
<div className={styles.progress}>
|
||||||
<Spinner size="tiny" />
|
<Spinner size="tiny" />
|
||||||
<Text>
|
<Text>
|
||||||
{indexing.message ?? 'Indexing…'}
|
{indexing.message ?? 'Adding…'}
|
||||||
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
|
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -603,10 +591,10 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
{error && <Caption1 className={styles.error}>{error}</Caption1>}
|
{error && <Caption1 className={styles.error}>{error}</Caption1>}
|
||||||
|
|
||||||
{sources.length === 0 && !indexing.active ? (
|
{sources.length === 0 && !indexing.active ? (
|
||||||
<div className={styles.empty}>
|
<EmptyState
|
||||||
<LibraryRegular fontSize={40} />
|
icon={<LibraryRegular fontSize={ICON.hero} />}
|
||||||
<Body1>No channels or playlists yet. Paste one above to index it.</Body1>
|
message="No channels or playlists yet. Paste one above to add it."
|
||||||
</div>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{sources.map((src) => (
|
{sources.map((src) => (
|
||||||
@@ -615,9 +603,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
styles={styles}
|
styles={styles}
|
||||||
source={src}
|
source={src}
|
||||||
expanded={selectedSourceId === src.id}
|
expanded={selectedSourceId === src.id}
|
||||||
onToggleExpand={() =>
|
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
|
||||||
selectSource(selectedSourceId === src.id ? null : src.id)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className={styles.detail}>
|
<div className={styles.detail}>
|
||||||
<div className={styles.actionRow}>
|
<div className={styles.actionRow}>
|
||||||
@@ -627,14 +613,27 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
label="Select all"
|
label="Select all"
|
||||||
disabled={actionableItems.length === 0}
|
disabled={actionableItems.length === 0}
|
||||||
/>
|
/>
|
||||||
<Caption1 className={styles.srcSub}>
|
<Caption1 className={text.muted}>
|
||||||
{items.length} videos · {pendingItems.length} pending · indexed{' '}
|
{items.length} videos{META_SEP}
|
||||||
{relTime(src.lastIndexedAt)}
|
{pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
<div className={styles.actionSpacer} />
|
<div className={styles.actionSpacer} />
|
||||||
|
<SegmentedControl<MediaKind>
|
||||||
|
value={enqueueKind}
|
||||||
|
options={[
|
||||||
|
{ value: 'video', label: 'Video' },
|
||||||
|
{ value: 'audio', label: 'Audio' }
|
||||||
|
]}
|
||||||
|
onChange={setEnqueueKind}
|
||||||
|
ariaLabel="Download as video or audio"
|
||||||
|
/>
|
||||||
{selected.size > 0 ? (
|
{selected.size > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
onClick={() => setSelected(new Set())}
|
||||||
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -673,19 +672,43 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
onClick={() => reindexSource(src.id)}
|
onClick={() => reindexSource(src.id)}
|
||||||
disabled={indexing.active}
|
disabled={indexing.active}
|
||||||
>
|
>
|
||||||
Re-index
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{confirmRemoveId === src.id ? (
|
||||||
|
<>
|
||||||
|
<Caption1 className={text.muted}>Remove {src.title}?</Caption1>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="primary"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => {
|
||||||
|
removeSource(src.id)
|
||||||
|
setConfirmRemoveId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
onClick={() => setConfirmRemoveId(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
icon={<DeleteRegular />}
|
icon={<DeleteRegular />}
|
||||||
onClick={() => removeSource(src.id)}
|
onClick={() => setConfirmRemoveId(src.id)}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
|
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
|
||||||
|
|
||||||
{flatRows.length > VIRTUALIZE_AT ? (
|
{flatRows.length > VIRTUALIZE_AT ? (
|
||||||
// Big source: a fixed-height, internally-scrolling virtualized
|
// Big source: a fixed-height, internally-scrolling virtualized
|
||||||
@@ -695,7 +718,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
items={flatRows}
|
items={flatRows}
|
||||||
style={{ height: '58vh' }}
|
style={{ height: '58vh' }}
|
||||||
overscan={10}
|
overscan={10}
|
||||||
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
|
estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
|
||||||
getKey={(row) => rowKey(row)}
|
getKey={(row) => rowKey(row)}
|
||||||
renderItem={(row) => renderRow(row)}
|
renderItem={(row) => renderRow(row)}
|
||||||
/>
|
/>
|
||||||
@@ -730,15 +753,16 @@ function SourceCard({
|
|||||||
onToggleExpand: () => void
|
onToggleExpand: () => void
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
const focus = useFocusStyles()
|
||||||
|
const text = useTextStyles()
|
||||||
return (
|
return (
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div
|
<button
|
||||||
className={styles.cardHead}
|
type="button"
|
||||||
|
className={mergeClasses(styles.cardHead, focus.focusRing)}
|
||||||
onClick={onToggleExpand}
|
onClick={onToggleExpand}
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggleExpand()}
|
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
|
aria-label={source.title}
|
||||||
>
|
>
|
||||||
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||||
<div className={styles.srcIcon}>
|
<div className={styles.srcIcon}>
|
||||||
@@ -746,19 +770,23 @@ function SourceCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.srcMeta}>
|
<div className={styles.srcMeta}>
|
||||||
<span className={styles.srcTitleRow}>
|
<span className={styles.srcTitleRow}>
|
||||||
<span className={styles.srcTitle}>{source.title}</span>
|
<Text className={styles.srcTitle}>{source.title}</Text>
|
||||||
{source.watched && (
|
{source.watched && (
|
||||||
<span className={styles.watchBadge}>
|
<span className={styles.watchBadge}>
|
||||||
<AlertRegular fontSize={11} /> Watching
|
<AlertRegular fontSize={11} /> Watching
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<Caption1 className={styles.srcSub}>
|
<Caption1 className={text.muted}>
|
||||||
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos
|
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
|
||||||
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''}
|
{META_SEP}
|
||||||
|
{source.itemCount} videos
|
||||||
|
{source.channel && source.channel !== source.title
|
||||||
|
? `${META_SEP}${source.channel}`
|
||||||
|
: ''}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
{expanded && children}
|
{expanded && children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { makeStyles } from '@fluentui/react-components'
|
||||||
|
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
// Visually hidden, but present for screen readers (the standard sr-only recipe).
|
||||||
|
srOnly: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
padding: 0,
|
||||||
|
margin: '-1px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0, 0, 0, 0)',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A polite ARIA live region (W17) so Narrator announces download completions and
|
||||||
|
* failures even when the user isn't on the Downloads tab. Subscribes to the store
|
||||||
|
* directly and announces only terminal transitions (→ completed / error), so it
|
||||||
|
* never chatters on progress ticks. Mounted once, near the app root.
|
||||||
|
*/
|
||||||
|
export function LiveRegion(): React.JSX.Element {
|
||||||
|
const styles = useStyles()
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const prev = useRef<Map<string, DownloadStatus>>(new Map())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function check(items: ReturnType<typeof useDownloads.getState>['items']): void {
|
||||||
|
const announcements: string[] = []
|
||||||
|
for (const it of items) {
|
||||||
|
if (prev.current.get(it.id) !== it.status) {
|
||||||
|
if (it.status === 'completed') announcements.push(`Finished downloading ${it.title}`)
|
||||||
|
else if (it.status === 'error') announcements.push(`Download failed: ${it.title}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev.current = new Map(items.map((i) => [i.id, i.status]))
|
||||||
|
if (announcements.length > 0) setMessage(announcements.join('. '))
|
||||||
|
}
|
||||||
|
// Seed the baseline without announcing the items already present on mount.
|
||||||
|
prev.current = new Map(useDownloads.getState().items.map((i) => [i.id, i.status]))
|
||||||
|
return useDownloads.subscribe((st) => check(st.items))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.srOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export function MediaThumb({
|
|||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const isDark = useResolvedDark()
|
const isDark = useResolvedDark()
|
||||||
const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video']
|
const colors = thumbColors[isDark ? 'dark' : 'light'][kind]
|
||||||
const [failedSrc, setFailedSrc] = useState<string | null>(null)
|
const [failedSrc, setFailedSrc] = useState<string | null>(null)
|
||||||
const showImg = !!src && failedSrc !== src
|
const showImg = !!src && failedSrc !== src
|
||||||
|
|
||||||
@@ -53,13 +53,7 @@ export function MediaThumb({
|
|||||||
style={{ backgroundColor: colors.bg, color: colors.fg }}
|
style={{ backgroundColor: colors.bg, color: colors.fg }}
|
||||||
>
|
>
|
||||||
{showImg ? (
|
{showImg ? (
|
||||||
<img
|
<img className={styles.img} src={src} alt="" onError={() => setFailedSrc(src ?? null)} />
|
||||||
className={styles.img}
|
|
||||||
src={src}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
onError={() => setFailedSrc(src ?? null)}
|
|
||||||
/>
|
|
||||||
) : kind === 'audio' ? (
|
) : kind === 'audio' ? (
|
||||||
<MusicNote2Regular fontSize={iconSize} />
|
<MusicNote2Regular fontSize={iconSize} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
Button,
|
Button,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
@@ -14,9 +15,14 @@ import {
|
|||||||
ClipboardPasteRegular,
|
ClipboardPasteRegular,
|
||||||
HistoryRegular,
|
HistoryRegular,
|
||||||
OptionsRegular,
|
OptionsRegular,
|
||||||
RocketRegular
|
RocketRegular,
|
||||||
|
FolderRegular,
|
||||||
|
MusicNote2Regular,
|
||||||
|
VideoClipRegular,
|
||||||
|
KeyboardRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { SPACE, ICON } from './ui/tokens'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
@@ -26,13 +32,14 @@ const useStyles = makeStyles({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '24px'
|
padding: SPACE.page
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '20px',
|
gap: SPACE.roomy,
|
||||||
padding: '32px',
|
// The welcome hero card (UI3 hero tier).
|
||||||
|
padding: SPACE.hero,
|
||||||
maxWidth: '460px',
|
maxWidth: '460px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
...shorthands.borderRadius(tokens.borderRadiusXLarge)
|
...shorthands.borderRadius(tokens.borderRadiusXLarge)
|
||||||
@@ -52,10 +59,45 @@ const useStyles = makeStyles({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '26px'
|
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
|
||||||
|
fontSize: `${ICON.section}px`
|
||||||
},
|
},
|
||||||
folderNote: {
|
folderNote: {
|
||||||
color: tokens.colorNeutralForeground3
|
color: tokens.colorNeutralForeground3,
|
||||||
|
marginBottom: SPACE.tight
|
||||||
|
},
|
||||||
|
folderRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
|
},
|
||||||
|
folderRowGap: {
|
||||||
|
marginTop: SPACE.tight
|
||||||
|
},
|
||||||
|
folderIcon: {
|
||||||
|
fontSize: `${ICON.control}px`,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: tokens.colorCompoundBrandForeground1
|
||||||
|
},
|
||||||
|
folderCol: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: 0,
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
folderLabel: {
|
||||||
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
|
color: tokens.colorNeutralForeground1
|
||||||
|
},
|
||||||
|
folderPath: {
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
},
|
},
|
||||||
tips: {
|
tips: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -68,9 +110,9 @@ const useStyles = makeStyles({
|
|||||||
gap: '10px'
|
gap: '10px'
|
||||||
},
|
},
|
||||||
tipIcon: {
|
tipIcon: {
|
||||||
fontSize: '18px',
|
fontSize: `${ICON.control}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
marginTop: '2px',
|
marginTop: SPACE.hairline,
|
||||||
color: tokens.colorCompoundBrandForeground1
|
color: tokens.colorCompoundBrandForeground1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -78,7 +120,7 @@ const useStyles = makeStyles({
|
|||||||
const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
||||||
{
|
{
|
||||||
icon: <ClipboardPasteRegular />,
|
icon: <ClipboardPasteRegular />,
|
||||||
text: 'Copy a video link and AeroFetch offers to fetch it as soon as you switch back.'
|
text: 'Turn on clipboard detection in Settings and AeroFetch will offer to fetch links you copy.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <HistoryRegular />,
|
icon: <HistoryRegular />,
|
||||||
@@ -87,12 +129,19 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
|||||||
{
|
{
|
||||||
icon: <OptionsRegular />,
|
icon: <OptionsRegular />,
|
||||||
text: 'Subtitles, SponsorBlock, custom yt-dlp commands, and more live in Settings.'
|
text: 'Subtitles, SponsorBlock, custom yt-dlp commands, and more live in Settings.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <KeyboardRegular />,
|
||||||
|
text: 'Press Ctrl+K anytime to jump to any screen or start a new download.'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Onboarding(): React.JSX.Element {
|
export function Onboarding(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const update = useSettings((s) => s.update)
|
const update = useSettings((s) => s.update)
|
||||||
|
const videoDir = useSettings((s) => s.videoDir)
|
||||||
|
const audioDir = useSettings((s) => s.audioDir)
|
||||||
|
const chooseDir = useSettings((s) => s.chooseDir)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
@@ -101,7 +150,7 @@ export function Onboarding(): React.JSX.Element {
|
|||||||
<div className={styles.mark}>
|
<div className={styles.mark}>
|
||||||
<ArrowDownloadFilled />
|
<ArrowDownloadFilled />
|
||||||
</div>
|
</div>
|
||||||
<Title2>Welcome to AeroFetch</Title2>
|
<Title2 as="h1">Welcome to AeroFetch</Title2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Body1>
|
<Body1>
|
||||||
@@ -111,10 +160,33 @@ export function Onboarding(): React.JSX.Element {
|
|||||||
|
|
||||||
<Field label="Where downloads go">
|
<Field label="Where downloads go">
|
||||||
<Caption1 className={styles.folderNote}>
|
<Caption1 className={styles.folderNote}>
|
||||||
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
|
Videos and audio save to your Documents folders by default. Pick a different folder now,
|
||||||
<strong>Documents\Audio</strong>. You can point each to a different folder any time
|
or change it any time in Settings.
|
||||||
in Settings.
|
|
||||||
</Caption1>
|
</Caption1>
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<VideoClipRegular className={styles.folderIcon} />
|
||||||
|
<div className={styles.folderCol}>
|
||||||
|
<Caption1 className={styles.folderLabel}>Video</Caption1>
|
||||||
|
<Caption1 className={styles.folderPath} title={videoDir || 'Documents\\Video'}>
|
||||||
|
{videoDir || 'Documents\\Video'}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||||
|
Choose…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={mergeClasses(styles.folderRow, styles.folderRowGap)}>
|
||||||
|
<MusicNote2Regular className={styles.folderIcon} />
|
||||||
|
<div className={styles.folderCol}>
|
||||||
|
<Caption1 className={styles.folderLabel}>Audio</Caption1>
|
||||||
|
<Caption1 className={styles.folderPath} title={audioDir || 'Documents\\Audio'}>
|
||||||
|
{audioDir || 'Documents\\Audio'}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
|
||||||
|
Choose…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className={styles.tips}>
|
<div className={styles.tips}>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
Button,
|
Button,
|
||||||
ProgressBar,
|
ProgressBar,
|
||||||
Badge,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
@@ -25,28 +25,38 @@ import {
|
|||||||
ErrorCircleFilled,
|
ErrorCircleFilled,
|
||||||
EyeOffRegular
|
EyeOffRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { useDownloads, type DownloadItem } from '../store/downloads'
|
||||||
import { thumbUrl } from '../thumb'
|
import { thumbUrl } from '../thumb'
|
||||||
|
import { fmtSchedule } from '../datetime'
|
||||||
|
import { fmtSpeed, fmtEta } from '@shared/format'
|
||||||
import { MediaThumb } from './MediaThumb'
|
import { MediaThumb } from './MediaThumb'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
|
import { StatusChip } from './ui/StatusChip'
|
||||||
|
import { useFocusStyles } from './ui/focusRing'
|
||||||
|
import { useTextStyles } from './ui/text'
|
||||||
|
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
|
||||||
|
import { THUMB_MD } from '../thumbSizes'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '14px',
|
gap: '14px',
|
||||||
padding: '14px',
|
// List-item card: card padding + list-item radius (UI3/UI7).
|
||||||
|
padding: SPACE.section,
|
||||||
backgroundColor: tokens.colorNeutralBackground1,
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
...shorthands.borderRadius(RADIUS.surface),
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
},
|
},
|
||||||
thumb: {
|
thumb: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: '108px',
|
width: `${THUMB_MD.w}px`,
|
||||||
height: '64px',
|
height: `${THUMB_MD.h}px`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
// One thumbnail radius app-wide (UI6): control tier / Medium.
|
||||||
|
...shorthands.borderRadius(RADIUS.control)
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
@@ -60,20 +70,11 @@ const useStyles = makeStyles({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px'
|
gap: '8px'
|
||||||
},
|
},
|
||||||
title: {
|
|
||||||
fontWeight: tokens.fontWeightSemibold,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
color: tokens.colorNeutralForeground3
|
|
||||||
},
|
|
||||||
progressRow: {
|
progressRow: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
marginTop: '4px'
|
marginTop: SPACE.xtight
|
||||||
},
|
},
|
||||||
progressBar: {
|
progressBar: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
@@ -88,63 +89,88 @@ const useStyles = makeStyles({
|
|||||||
actions: {
|
actions: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '4px',
|
gap: SPACE.xtight,
|
||||||
flexShrink: 0
|
flexShrink: 0,
|
||||||
|
// Icon-only row actions get a >=40px hit target for touch/pen (W16); the
|
||||||
|
// glyph stays its normal size, the subtle button just carries more padding.
|
||||||
|
'& button': {
|
||||||
|
minWidth: '40px',
|
||||||
|
minHeight: '40px'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
|
|
||||||
queued: { label: 'Queued', color: 'subtle' },
|
|
||||||
downloading: { label: 'Downloading', color: 'brand' },
|
|
||||||
paused: { label: 'Paused', color: 'warning' },
|
|
||||||
saved: { label: 'Saved', color: 'subtle' },
|
|
||||||
completed: { label: 'Completed', color: 'success' },
|
|
||||||
error: { label: 'Failed', color: 'danger' },
|
|
||||||
canceled: { label: 'Canceled', color: 'warning' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function pct(progress: number): string {
|
function pct(progress: number): string {
|
||||||
return `${Math.round(progress * 100)}%`
|
return `${Math.round(progress * 100)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtSchedule(ms: number): string {
|
|
||||||
try {
|
|
||||||
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const QueueItem = memo(function QueueItem({
|
export const QueueItem = memo(function QueueItem({
|
||||||
item
|
item
|
||||||
}: {
|
}: {
|
||||||
item: DownloadItem
|
item: DownloadItem
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const cancel = useDownloads((s) => s.cancel)
|
const focus = useFocusStyles()
|
||||||
const pause = useDownloads((s) => s.pause)
|
const text = useTextStyles()
|
||||||
const resume = useDownloads((s) => s.resume)
|
// One shallow-compared selection instead of 10 separate subscriptions per row
|
||||||
const prioritize = useDownloads((s) => s.prioritize)
|
// (L163). The actions are stable, so this never re-renders from the store.
|
||||||
const saveForLater = useDownloads((s) => s.saveForLater)
|
const {
|
||||||
const queueNow = useDownloads((s) => s.queueNow)
|
cancel,
|
||||||
const remove = useDownloads((s) => s.remove)
|
pause,
|
||||||
const retry = useDownloads((s) => s.retry)
|
resume,
|
||||||
const openFile = useDownloads((s) => s.openFile)
|
prioritize,
|
||||||
const showInFolder = useDownloads((s) => s.showInFolder)
|
saveForLater,
|
||||||
|
queueNow,
|
||||||
|
remove,
|
||||||
|
retry,
|
||||||
|
openFile,
|
||||||
|
showInFolder
|
||||||
|
} = useDownloads(
|
||||||
|
useShallow((s) => ({
|
||||||
|
cancel: s.cancel,
|
||||||
|
pause: s.pause,
|
||||||
|
resume: s.resume,
|
||||||
|
prioritize: s.prioritize,
|
||||||
|
saveForLater: s.saveForLater,
|
||||||
|
queueNow: s.queueNow,
|
||||||
|
remove: s.remove,
|
||||||
|
retry: s.retry,
|
||||||
|
openFile: s.openFile,
|
||||||
|
showInFolder: s.showInFolder
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
const badge = STATUS_BADGE[item.status]
|
|
||||||
const active = item.status === 'downloading' || item.status === 'queued'
|
const active = item.status === 'downloading' || item.status === 'queued'
|
||||||
|
|
||||||
|
// W7: Delete on a focused row removes it -- cancelling first if it's still running,
|
||||||
|
// since an in-flight download can't just be dropped from the list. Only when the
|
||||||
|
// row itself holds focus, so Delete on one of its action buttons is unaffected.
|
||||||
|
function onKeyDown(e: React.KeyboardEvent): void {
|
||||||
|
if (e.key === 'Delete' && e.target === e.currentTarget) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (active) cancel(item.id)
|
||||||
|
else remove(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the raw progress numbers for display (H4: main sends bytes/sec + secs,
|
||||||
|
// the renderer owns formatting via the one shared formatter).
|
||||||
|
const speedLabel = fmtSpeed(item.speedBytesPerSec)
|
||||||
|
const etaLabel = fmtEta(item.etaSeconds)
|
||||||
|
|
||||||
|
// L55: a probed-format quality label already carries the size ("720p · mp4 ·
|
||||||
|
// 184 MB"); skip sizeLabel when it's already embedded to avoid showing it twice.
|
||||||
|
const sizeAlreadyInQuality = item.sizeLabel && item.quality.includes(item.sizeLabel)
|
||||||
const metaParts = [
|
const metaParts = [
|
||||||
item.channel,
|
item.channel,
|
||||||
item.durationLabel,
|
item.durationLabel,
|
||||||
item.quality,
|
item.quality,
|
||||||
item.kind === 'audio' ? 'Audio' : 'Video',
|
item.kind === 'audio' ? 'Audio' : 'Video',
|
||||||
item.sizeLabel
|
!sizeAlreadyInQuality ? item.sizeLabel : undefined
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={mergeClasses(styles.root, focus.focusRing)} tabIndex={0} onKeyDown={onKeyDown}>
|
||||||
<MediaThumb
|
<MediaThumb
|
||||||
className={styles.thumb}
|
className={styles.thumb}
|
||||||
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
|
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
|
||||||
@@ -156,57 +182,82 @@ export const QueueItem = memo(function QueueItem({
|
|||||||
<div className={styles.titleRow}>
|
<div className={styles.titleRow}>
|
||||||
{item.status === 'completed' && (
|
{item.status === 'completed' && (
|
||||||
<CheckmarkCircleFilled
|
<CheckmarkCircleFilled
|
||||||
fontSize={16}
|
fontSize={ICON.inline}
|
||||||
style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }}
|
style={{ color: tokens.colorPaletteGreenForeground1, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{item.status === 'error' && (
|
{item.status === 'error' && (
|
||||||
<ErrorCircleFilled
|
<ErrorCircleFilled
|
||||||
fontSize={16}
|
fontSize={ICON.inline}
|
||||||
style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }}
|
style={{ color: tokens.colorPaletteRedForeground1, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text className={styles.title}>{item.title}</Text>
|
<Text className={text.title}>{item.title}</Text>
|
||||||
<Badge appearance="tint" color={badge.color}>
|
<StatusChip status={item.status} />
|
||||||
{badge.label}
|
|
||||||
</Badge>
|
|
||||||
{item.incognito && (
|
{item.incognito && (
|
||||||
<Hint label="Private — not saved to history" placement="top" align="start">
|
<Hint label="Private -- not saved to history" placement="top" align="start">
|
||||||
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
|
<EyeOffRegular
|
||||||
|
fontSize={ICON.inline}
|
||||||
|
style={{ color: tokens.colorNeutralForeground3 }}
|
||||||
|
/>
|
||||||
</Hint>
|
</Hint>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
|
<Caption1 className={text.muted}>{metaParts.join(META_SEP)}</Caption1>
|
||||||
|
|
||||||
{item.status === 'downloading' && (
|
{item.status === 'downloading' && (
|
||||||
<div className={styles.progressRow}>
|
<div className={styles.progressRow}>
|
||||||
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
|
{/* Indeterminate when finishing (the second stream / merge reads as
|
||||||
|
"working" rather than a 0→100% restart, SR7) or when yt-dlp can't
|
||||||
|
report a total size, so the bar never sits frozen at 0% (L137). */}
|
||||||
|
<ProgressBar
|
||||||
|
className={styles.progressBar}
|
||||||
|
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
|
||||||
|
thickness="large"
|
||||||
|
aria-label={`Download progress for ${item.title ?? item.url}`}
|
||||||
|
/>
|
||||||
<Caption1 className={styles.stats}>
|
<Caption1 className={styles.stats}>
|
||||||
{pct(item.progress)}
|
{item.finishing ? (
|
||||||
{item.speed ? ` • ${item.speed}` : ''}
|
'Finishing…'
|
||||||
{item.eta ? ` • ${item.eta} left` : ''}
|
) : (
|
||||||
|
<>
|
||||||
|
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
|
||||||
|
{speedLabel ? `${META_SEP}${speedLabel}` : ''}
|
||||||
|
{etaLabel ? `${META_SEP}${etaLabel} left` : ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.status === 'queued' && (
|
{item.status === 'queued' && (
|
||||||
<div className={styles.progressRow}>
|
<div className={styles.progressRow}>
|
||||||
<Spinner size="extra-tiny" />
|
<Spinner size="tiny" />
|
||||||
<Caption1 className={styles.stats}>Waiting to start…</Caption1>
|
<Caption1 className={styles.stats}>Waiting to start…</Caption1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.status === 'paused' && (
|
{item.status === 'paused' && (
|
||||||
<div className={styles.progressRow}>
|
<div className={styles.progressRow}>
|
||||||
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
|
<ProgressBar
|
||||||
<Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1>
|
className={styles.progressBar}
|
||||||
|
value={item.progress}
|
||||||
|
thickness="large"
|
||||||
|
aria-label={`Download progress for ${item.title ?? item.url}`}
|
||||||
|
/>
|
||||||
|
<Caption1 className={styles.stats}>
|
||||||
|
Paused{META_SEP}
|
||||||
|
{pct(item.progress)}
|
||||||
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.status === 'saved' && (
|
{item.status === 'saved' && (
|
||||||
<Caption1 className={styles.stats}>
|
<Caption1 className={styles.stats}>
|
||||||
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
|
{item.scheduledFor
|
||||||
|
? `Scheduled for ${fmtSchedule(item.scheduledFor)}`
|
||||||
|
: 'Saved for later'}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
import {
|
||||||
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
|
tokens,
|
||||||
|
shorthands,
|
||||||
|
useFieldControlProps_unstable
|
||||||
|
} from '@fluentui/react-components'
|
||||||
|
|
||||||
// A native <select> styled to match Fluent inputs. We use this instead of
|
// 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
|
// 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
|
// 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 —
|
// paint blanks the whole window (same family as the documented flicker --
|
||||||
// hardware acceleration is already disabled; see memory "aerofetch-gpu-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 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
|
// The popup's light/dark styling follows the `color-scheme` set on the app root
|
||||||
@@ -27,6 +33,20 @@ const useStyles = makeStyles({
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
...shorthands.borderColor(tokens.colorCompoundBrandStroke)
|
...shorthands.borderColor(tokens.colorCompoundBrandStroke)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
large: {
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 12px',
|
||||||
|
fontSize: tokens.fontSizeBase400
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
color: tokens.colorNeutralForegroundDisabled,
|
||||||
|
backgroundColor: tokens.colorNeutralBackgroundDisabled,
|
||||||
|
...shorthands.borderColor(tokens.colorNeutralStrokeDisabled),
|
||||||
|
':hover': {
|
||||||
|
...shorthands.borderColor(tokens.colorNeutralStrokeDisabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -41,6 +61,8 @@ interface SelectProps {
|
|||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
className?: string
|
className?: string
|
||||||
'aria-label'?: string
|
'aria-label'?: string
|
||||||
|
size?: 'medium' | 'large'
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Select({
|
export function Select({
|
||||||
@@ -48,15 +70,30 @@ export function Select({
|
|||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
'aria-label': ariaLabel
|
'aria-label': ariaLabel,
|
||||||
|
size,
|
||||||
|
disabled
|
||||||
}: SelectProps): React.JSX.Element {
|
}: SelectProps): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
// Integrate with a wrapping Fluent <Field> (L133): pick up the id + label/hint
|
||||||
|
// association so the Field's visible label names the select natively (via
|
||||||
|
// `htmlFor`), instead of every call site repeating the name in an `aria-label`.
|
||||||
|
// Outside a Field this returns the props unchanged, so standalone selects keep
|
||||||
|
// their own `aria-label`. `supportsLabelFor` = native <label for>, not aria-labelledby.
|
||||||
|
const fieldProps = useFieldControlProps_unstable({}, { supportsLabelFor: true })
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className={mergeClasses(styles.select, className)}
|
{...fieldProps}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={mergeClasses(
|
||||||
|
styles.select,
|
||||||
|
size === 'large' && styles.large,
|
||||||
|
disabled && styles.disabled,
|
||||||
|
className
|
||||||
|
)}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
aria-label={ariaLabel}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,11 @@
|
|||||||
import { Caption1, makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
import {
|
||||||
|
Caption1,
|
||||||
|
CounterBadge,
|
||||||
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
|
tokens,
|
||||||
|
shorthands
|
||||||
|
} from '@fluentui/react-components'
|
||||||
import {
|
import {
|
||||||
ArrowDownloadFilled,
|
ArrowDownloadFilled,
|
||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
@@ -14,6 +21,11 @@ import {
|
|||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type { ThemeMode } from '@shared/ipc'
|
import type { ThemeMode } from '@shared/ipc'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
|
import { SegmentedControl } from './ui/SegmentedControl'
|
||||||
|
import { IconButton } from './ui/IconButton'
|
||||||
|
import { useFocusStyles } from './ui/focusRing'
|
||||||
|
import { useTextStyles } from './ui/text'
|
||||||
|
import { MOTION, ICON } from './ui/tokens'
|
||||||
|
|
||||||
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
|
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
|
||||||
|
|
||||||
@@ -27,7 +39,8 @@ const useStyles = makeStyles({
|
|||||||
padding: '16px 12px',
|
padding: '16px 12px',
|
||||||
backgroundColor: tokens.colorNeutralBackground1,
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
|
borderRight: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
transition: 'width 0.15s ease'
|
// The app's one motion timing, gated globally on prefers-reduced-motion (UI26).
|
||||||
|
transition: `width ${MOTION.duration} ${MOTION.curve}`
|
||||||
},
|
},
|
||||||
rootCollapsed: {
|
rootCollapsed: {
|
||||||
width: '60px',
|
width: '60px',
|
||||||
@@ -41,24 +54,6 @@ const useStyles = makeStyles({
|
|||||||
topBarCollapsed: {
|
topBarCollapsed: {
|
||||||
justifyContent: 'center'
|
justifyContent: 'center'
|
||||||
},
|
},
|
||||||
iconBtn: {
|
|
||||||
appearance: 'none',
|
|
||||||
border: 'none',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: tokens.colorNeutralForeground3,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
fontSize: '18px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
|
||||||
color: tokens.colorNeutralForeground2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
brand: {
|
brand: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -79,7 +74,8 @@ const useStyles = makeStyles({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '20px'
|
// Brand tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
|
||||||
|
fontSize: `${ICON.control}px`
|
||||||
},
|
},
|
||||||
brandText: {
|
brandText: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -92,9 +88,6 @@ const useStyles = makeStyles({
|
|||||||
lineHeight: tokens.lineHeightBase400,
|
lineHeight: tokens.lineHeightBase400,
|
||||||
color: tokens.colorNeutralForeground1
|
color: tokens.colorNeutralForeground1
|
||||||
},
|
},
|
||||||
caption: {
|
|
||||||
color: tokens.colorNeutralForeground3
|
|
||||||
},
|
|
||||||
nav: {
|
nav: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -102,6 +95,8 @@ const useStyles = makeStyles({
|
|||||||
alignSelf: 'stretch'
|
alignSelf: 'stretch'
|
||||||
},
|
},
|
||||||
navItem: {
|
navItem: {
|
||||||
|
// relative so the collapsed count badge can pin to the icon's corner.
|
||||||
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '11px',
|
gap: '11px',
|
||||||
@@ -119,6 +114,16 @@ const useStyles = makeStyles({
|
|||||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Expanded: the active-download count sits at the far right of the nav row.
|
||||||
|
navBadge: {
|
||||||
|
marginLeft: 'auto'
|
||||||
|
},
|
||||||
|
// Collapsed: pin the count to the top-right corner of the centered icon.
|
||||||
|
navBadgeCollapsed: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '4px',
|
||||||
|
right: '8px'
|
||||||
|
},
|
||||||
navItemCollapsed: {
|
navItemCollapsed: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '9px 0'
|
padding: '9px 0'
|
||||||
@@ -132,47 +137,12 @@ const useStyles = makeStyles({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
navIcon: {
|
navIcon: {
|
||||||
fontSize: '18px',
|
fontSize: `${ICON.control}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex'
|
display: 'flex'
|
||||||
},
|
},
|
||||||
spacer: {
|
spacer: {
|
||||||
flexGrow: 1
|
flexGrow: 1
|
||||||
},
|
|
||||||
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
|
|
||||||
themeGroup: {
|
|
||||||
alignSelf: 'stretch',
|
|
||||||
display: 'flex',
|
|
||||||
width: '100%',
|
|
||||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
|
||||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
|
||||||
overflow: 'hidden'
|
|
||||||
},
|
|
||||||
themeSeg: {
|
|
||||||
flex: 1,
|
|
||||||
appearance: 'none',
|
|
||||||
border: 'none',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: tokens.colorNeutralForeground2,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '5px',
|
|
||||||
padding: '7px 4px',
|
|
||||||
fontSize: tokens.fontSizeBase200,
|
|
||||||
fontFamily: tokens.fontFamilyBase,
|
|
||||||
cursor: 'pointer',
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
|
||||||
}
|
|
||||||
},
|
|
||||||
themeSegActive: {
|
|
||||||
backgroundColor: tokens.colorBrandBackground,
|
|
||||||
color: tokens.colorNeutralForegroundOnBrand,
|
|
||||||
fontWeight: tokens.fontWeightSemibold,
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: tokens.colorBrandBackgroundHover
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -202,6 +172,10 @@ interface SidebarProps {
|
|||||||
version: string
|
version: string
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
onToggleCollapsed: () => void
|
onToggleCollapsed: () => void
|
||||||
|
/** Show the Terminal nav item (only when custom commands are enabled) */
|
||||||
|
showTerminal: boolean
|
||||||
|
/** Active (downloading + queued) count, badged on the Downloads nav item (UX9). */
|
||||||
|
downloadCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
@@ -212,22 +186,27 @@ export function Sidebar({
|
|||||||
onSetTheme,
|
onSetTheme,
|
||||||
version,
|
version,
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapsed
|
onToggleCollapsed,
|
||||||
|
showTerminal,
|
||||||
|
downloadCount
|
||||||
}: SidebarProps): React.JSX.Element {
|
}: SidebarProps): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
const focus = useFocusStyles()
|
||||||
|
const text = useTextStyles()
|
||||||
|
|
||||||
// Collapsed view shows one button that cycles Light → Dark → Auto.
|
// Collapsed view shows one button that cycles Light → Dark → Auto.
|
||||||
const order: ThemeMode[] = ['light', 'dark', 'system']
|
const order: ThemeMode[] = ['light', 'dark', 'system']
|
||||||
function cycleTheme(): void {
|
function cycleTheme(): void {
|
||||||
onSetTheme(order[(order.indexOf(theme) + 1) % order.length])
|
const next = order[(order.indexOf(theme) + 1) % order.length]
|
||||||
|
if (next) onSetTheme(next)
|
||||||
}
|
}
|
||||||
const themeIcon =
|
const themeIcon =
|
||||||
theme === 'system' ? (
|
theme === 'system' ? (
|
||||||
<DesktopRegular fontSize={18} />
|
<DesktopRegular fontSize={ICON.control} />
|
||||||
) : isDark ? (
|
) : isDark ? (
|
||||||
<WeatherMoonRegular fontSize={18} />
|
<WeatherMoonRegular fontSize={ICON.control} />
|
||||||
) : (
|
) : (
|
||||||
<WeatherSunnyRegular fontSize={18} />
|
<WeatherSunnyRegular fontSize={ICON.control} />
|
||||||
)
|
)
|
||||||
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
|
const themeLabel = theme === 'system' ? 'Auto (system)' : isDark ? 'Dark' : 'Light'
|
||||||
|
|
||||||
@@ -235,15 +214,12 @@ export function Sidebar({
|
|||||||
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
|
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
|
||||||
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
|
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
|
||||||
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
|
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
|
||||||
className={styles.iconBtn}
|
|
||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
aria-pressed={collapsed}
|
aria-pressed={collapsed}
|
||||||
>
|
/>
|
||||||
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
|
|
||||||
</button>
|
|
||||||
</Hint>
|
</Hint>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -254,13 +230,18 @@ export function Sidebar({
|
|||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className={styles.brandText}>
|
<div className={styles.brandText}>
|
||||||
<span className={styles.brandName}>AeroFetch</span>
|
<span className={styles.brandName}>AeroFetch</span>
|
||||||
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
|
{/* Stable tagline so the caption never flips from text to a version
|
||||||
|
string once it loads (L66); the version shows on hover and in
|
||||||
|
Settings → About. */}
|
||||||
|
<Caption1 className={text.muted} title={version ? `Version ${version}` : undefined}>
|
||||||
|
Video downloader
|
||||||
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.nav}>
|
<div className={styles.nav}>
|
||||||
{NAV.map((n) => {
|
{NAV.filter((n) => n.value !== 'terminal' || showTerminal).map((n) => {
|
||||||
const active = tab === n.value
|
const active = tab === n.value
|
||||||
const btn = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
@@ -269,7 +250,8 @@ export function Sidebar({
|
|||||||
className={mergeClasses(
|
className={mergeClasses(
|
||||||
styles.navItem,
|
styles.navItem,
|
||||||
collapsed && styles.navItemCollapsed,
|
collapsed && styles.navItemCollapsed,
|
||||||
active && styles.navItemActive
|
active && styles.navItemActive,
|
||||||
|
focus.focusRing
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
active && !collapsed
|
active && !collapsed
|
||||||
@@ -278,10 +260,23 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
onClick={() => onTabChange(n.value)}
|
onClick={() => onTabChange(n.value)}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
aria-label={n.label}
|
aria-label={
|
||||||
|
n.value === 'downloads' && downloadCount > 0
|
||||||
|
? `${n.label}, ${downloadCount} active`
|
||||||
|
: n.label
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className={styles.navIcon}>{n.icon}</span>
|
<span className={styles.navIcon}>{n.icon}</span>
|
||||||
{!collapsed && n.label}
|
{!collapsed && n.label}
|
||||||
|
{n.value === 'downloads' && downloadCount > 0 && (
|
||||||
|
<CounterBadge
|
||||||
|
className={collapsed ? styles.navBadgeCollapsed : styles.navBadge}
|
||||||
|
count={downloadCount}
|
||||||
|
size="small"
|
||||||
|
color="brand"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
return collapsed ? (
|
return collapsed ? (
|
||||||
@@ -298,34 +293,20 @@ export function Sidebar({
|
|||||||
|
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<Hint label={`Theme: ${themeLabel}`} placement="right">
|
<Hint label={`Theme: ${themeLabel}`} placement="right">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
icon={themeIcon}
|
||||||
className={styles.iconBtn}
|
|
||||||
onClick={cycleTheme}
|
onClick={cycleTheme}
|
||||||
aria-label={`Theme: ${themeLabel}. Click to change.`}
|
aria-label={`Change theme (currently ${themeLabel})`}
|
||||||
>
|
/>
|
||||||
{themeIcon}
|
|
||||||
</button>
|
|
||||||
</Hint>
|
</Hint>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
|
<SegmentedControl<ThemeMode>
|
||||||
{THEMES.map((t) => {
|
fitted
|
||||||
const on = theme === t.value
|
value={theme}
|
||||||
return (
|
options={THEMES}
|
||||||
<button
|
onChange={onSetTheme}
|
||||||
key={t.value}
|
ariaLabel="Theme"
|
||||||
type="button"
|
/>
|
||||||
role="radio"
|
|
||||||
aria-checked={on}
|
|
||||||
className={mergeClasses(styles.themeSeg, on && styles.themeSegActive)}
|
|
||||||
onClick={() => onSetTheme(t.value)}
|
|
||||||
>
|
|
||||||
<span className={styles.navIcon}>{t.icon}</span>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Field,
|
||||||
Input,
|
Input,
|
||||||
Textarea,
|
Textarea,
|
||||||
Caption1,
|
Caption1,
|
||||||
@@ -9,10 +10,18 @@ import {
|
|||||||
tokens,
|
tokens,
|
||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import { AddRegular, EditRegular, DeleteRegular, SaveRegular, DismissRegular } from '@fluentui/react-icons'
|
import {
|
||||||
|
AddRegular,
|
||||||
|
EditRegular,
|
||||||
|
DeleteRegular,
|
||||||
|
SaveRegular,
|
||||||
|
DismissRegular
|
||||||
|
} from '@fluentui/react-icons'
|
||||||
import type { CommandTemplate } from '@shared/ipc'
|
import type { CommandTemplate } from '@shared/ipc'
|
||||||
import { useTemplates } from '../store/templates'
|
import { useTemplates } from '../store/templates'
|
||||||
|
import { newId } from '../id'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
|
import { EmptyState } from './ui/EmptyState'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
@@ -63,9 +72,6 @@ const useStyles = makeStyles({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
justifyContent: 'flex-end'
|
justifyContent: 'flex-end'
|
||||||
},
|
|
||||||
empty: {
|
|
||||||
color: tokens.colorNeutralForeground3
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -79,10 +85,6 @@ interface Draft {
|
|||||||
}
|
}
|
||||||
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
|
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
|
||||||
|
|
||||||
function newId(): string {
|
|
||||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline (no-modal — see the Hint/Select comments re: composited overlays
|
* Inline (no-modal — see the Hint/Select comments re: composited overlays
|
||||||
* flickering on this dev machine's GPU) CRUD list + add/edit form for
|
* flickering on this dev machine's GPU) CRUD list + add/edit form for
|
||||||
@@ -100,13 +102,24 @@ export function TemplateManager(): React.JSX.Element {
|
|||||||
setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
|
setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patternError(): string | undefined {
|
||||||
|
const p = draft?.urlPattern.trim()
|
||||||
|
if (!p) return undefined
|
||||||
|
try {
|
||||||
|
new RegExp(p)
|
||||||
|
return undefined
|
||||||
|
} catch {
|
||||||
|
return 'Invalid regex — this pattern will never match.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function commit(): void {
|
function commit(): void {
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
const name = draft.name.trim()
|
const name = draft.name.trim()
|
||||||
if (!name) return
|
if (!name || patternError()) return
|
||||||
const urlPattern = draft.urlPattern.trim()
|
const urlPattern = draft.urlPattern.trim()
|
||||||
save({
|
save({
|
||||||
id: draft.id ?? newId(),
|
id: draft.id ?? newId('tpl'),
|
||||||
name,
|
name,
|
||||||
args: draft.args.trim(),
|
args: draft.args.trim(),
|
||||||
...(urlPattern ? { urlPattern } : {})
|
...(urlPattern ? { urlPattern } : {})
|
||||||
@@ -117,7 +130,7 @@ export function TemplateManager(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
{templates.length === 0 && !draft && (
|
{templates.length === 0 && !draft && (
|
||||||
<Caption1 className={styles.empty}>No custom command templates yet.</Caption1>
|
<EmptyState compact message="No custom command templates yet." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{templates.map((t) => (
|
{templates.map((t) => (
|
||||||
@@ -162,11 +175,16 @@ export function TemplateManager(): React.JSX.Element {
|
|||||||
resize="vertical"
|
resize="vertical"
|
||||||
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
|
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
|
||||||
/>
|
/>
|
||||||
|
<Field
|
||||||
|
validationState={patternError() ? 'error' : 'none'}
|
||||||
|
validationMessage={patternError()}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={draft.urlPattern}
|
value={draft.urlPattern}
|
||||||
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
|
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
|
||||||
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
|
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
<div className={styles.formActions}>
|
<div className={styles.formActions}>
|
||||||
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -182,7 +200,11 @@ export function TemplateManager(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button appearance="subtle" icon={<AddRegular />} onClick={() => setDraft({ ...BLANK_DRAFT })}>
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<AddRegular />}
|
||||||
|
onClick={() => setDraft({ ...BLANK_DRAFT })}
|
||||||
|
>
|
||||||
Add template
|
Add template
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,28 +1,42 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Textarea,
|
Textarea,
|
||||||
Subtitle2,
|
|
||||||
Body1,
|
Body1,
|
||||||
Caption1,
|
Caption1,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
shorthands
|
shorthands
|
||||||
} from '@fluentui/react-components'
|
} from '@fluentui/react-components'
|
||||||
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
|
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { newId } from '../id'
|
||||||
|
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||||
|
import { EmptyState } from './ui/EmptyState'
|
||||||
|
import { SPACE } from './ui/tokens'
|
||||||
|
|
||||||
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||||
interface Line {
|
interface Line {
|
||||||
|
id: number
|
||||||
text: string
|
text: string
|
||||||
kind: LineKind
|
kind: LineKind
|
||||||
}
|
}
|
||||||
|
let lineSeq = 0
|
||||||
|
const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, kind })
|
||||||
|
|
||||||
|
// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
|
||||||
|
// lines in seconds. Cap the visible log to prevent unbounded React state growth
|
||||||
|
// and keep the <pre> scrollable (H6); older lines are dropped from the front.
|
||||||
|
const MAX_LOG_LINES = 2000
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
|
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section, height: '100%' },
|
||||||
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
|
||||||
sub: { color: tokens.colorNeutralForeground3 },
|
|
||||||
gate: {
|
gate: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||||
color: tokens.colorStatusWarningForeground1,
|
color: tokens.colorStatusWarningForeground1,
|
||||||
@@ -48,24 +62,24 @@ const useStyles = makeStyles({
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word'
|
wordBreak: 'break-word'
|
||||||
},
|
},
|
||||||
|
// Center the "no output yet" placeholder in the log box, so it reads as an
|
||||||
|
// empty state rather than a stray line of text at the top (L117).
|
||||||
|
logEmpty: { display: 'flex', alignItems: 'center', justifyContent: 'center' },
|
||||||
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
|
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
|
||||||
stderr: { color: tokens.colorPaletteRedForeground1 },
|
stderr: { color: tokens.colorPaletteRedForeground1 },
|
||||||
sys: { color: tokens.colorNeutralForeground3 },
|
sys: { color: tokens.colorNeutralForeground3 }
|
||||||
empty: { color: tokens.colorNeutralForeground3 }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function newId(): string {
|
|
||||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
|
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
|
||||||
* binary, and watch its output stream. Gated on the customCommandEnabled consent
|
* binary, and watch its output stream. Gated on the customCommandEnabled consent
|
||||||
* flag (enforced again in main — see src/main/terminal.ts).
|
* flag (enforced again in main -- see src/main/terminal.ts).
|
||||||
*/
|
*/
|
||||||
export function TerminalView(): React.JSX.Element {
|
export function TerminalView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
const screen = useScreenStyles()
|
||||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||||
|
const updateSettings = useSettings((s) => s.update)
|
||||||
|
|
||||||
const [args, setArgs] = useState('')
|
const [args, setArgs] = useState('')
|
||||||
const [lines, setLines] = useState<Line[]>([])
|
const [lines, setLines] = useState<Line[]>([])
|
||||||
@@ -79,13 +93,15 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
window.api.onTerminalOutput((ev) => {
|
window.api.onTerminalOutput((ev) => {
|
||||||
if (ev.id !== runId.current) return
|
if (ev.id !== runId.current) return
|
||||||
if (ev.type === 'output') {
|
if (ev.type === 'output') {
|
||||||
setLines((ls) => [...ls, { text: ev.line, kind: ev.stream }])
|
setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
|
||||||
} else if (ev.type === 'error') {
|
} else if (ev.type === 'error') {
|
||||||
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
|
setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
runId.current = null
|
runId.current = null
|
||||||
} else {
|
} else {
|
||||||
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }])
|
setLines((ls) =>
|
||||||
|
[...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
|
||||||
|
)
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
runId.current = null
|
runId.current = null
|
||||||
}
|
}
|
||||||
@@ -101,21 +117,21 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
function run(): void {
|
function run(): void {
|
||||||
const a = args.trim()
|
const a = args.trim()
|
||||||
if (!a || running) return
|
if (!a || running) return
|
||||||
const id = newId()
|
const id = newId('t')
|
||||||
runId.current = id
|
runId.current = id
|
||||||
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
|
setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
window.api
|
window.api
|
||||||
.runTerminal(id, a)
|
.runTerminal(id, a)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
|
setLines((ls) => [...ls, mkLine(res.error ?? 'Failed to start.', 'stderr')])
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
runId.current = null
|
runId.current = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
|
setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
runId.current = null
|
runId.current = null
|
||||||
})
|
})
|
||||||
@@ -126,29 +142,47 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lineClass = (kind: LineKind): string | undefined =>
|
const lineClass = (kind: LineKind): string | undefined =>
|
||||||
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
|
kind === 'cmd'
|
||||||
|
? styles.cmd
|
||||||
|
: kind === 'stderr'
|
||||||
|
? styles.stderr
|
||||||
|
: kind === 'sys'
|
||||||
|
? styles.sys
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={mergeClasses(styles.root, screen.width)}>
|
||||||
<div className={styles.header}>
|
<ScreenHeader
|
||||||
<Subtitle2>Terminal</Subtitle2>
|
title="Terminal"
|
||||||
<Caption1 className={styles.sub}>
|
description={
|
||||||
|
<>
|
||||||
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
||||||
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
||||||
</Caption1>
|
</>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{!customCommandEnabled && (
|
{!customCommandEnabled && (
|
||||||
<Body1 className={styles.gate}>
|
<div className={styles.gate}>
|
||||||
The terminal is part of custom commands. Turn on “Run custom commands” in Settings →
|
<Body1>
|
||||||
Custom commands to use it.
|
The terminal runs the bundled yt-dlp with your own arguments — part of custom commands,
|
||||||
|
which can pass arbitrary yt-dlp flags. Turn it on to use the terminal.
|
||||||
</Body1>
|
</Body1>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => updateSettings({ customCommandEnabled: true })}
|
||||||
|
>
|
||||||
|
Enable custom commands
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.inputRow}>
|
<div className={styles.inputRow}>
|
||||||
<div className={styles.inputCol}>
|
<div className={styles.inputCol}>
|
||||||
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
|
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
textarea={{ 'aria-label': 'yt-dlp arguments' }}
|
||||||
value={args}
|
value={args}
|
||||||
onChange={(_, d) => setArgs(d.value)}
|
onChange={(_, d) => setArgs(d.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -164,7 +198,7 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
{running ? (
|
{running ? (
|
||||||
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
|
<Button appearance="subtle" icon={<DismissRegular />} onClick={stop}>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -187,12 +221,12 @@ export function TerminalView(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<pre className={styles.log} ref={logRef}>
|
<pre className={mergeClasses(styles.log, lines.length === 0 && styles.logEmpty)} ref={logRef}>
|
||||||
{lines.length === 0 ? (
|
{lines.length === 0 ? (
|
||||||
<span className={styles.empty}>Output will appear here.</span>
|
<EmptyState compact message="No output yet." hint="Run yt-dlp above to see its output." />
|
||||||
) : (
|
) : (
|
||||||
lines.map((l, i) => (
|
lines.map((l) => (
|
||||||
<div key={i} className={lineClass(l.kind)}>
|
<div key={l.id} className={lineClass(l.kind)}>
|
||||||
{l.text}
|
{l.text}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -42,13 +42,19 @@ export function VirtualList<T>({
|
|||||||
estimateSize,
|
estimateSize,
|
||||||
overscan,
|
overscan,
|
||||||
gap,
|
gap,
|
||||||
getItemKey: (index) => getKey(items[index], index)
|
getItemKey: (index) => {
|
||||||
|
const it = items[index]
|
||||||
|
return it !== undefined ? getKey(it, index) : index
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
|
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
|
||||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||||
{virtualizer.getVirtualItems().map((vrow) => (
|
{virtualizer.getVirtualItems().map((vrow) => {
|
||||||
|
const item = items[vrow.index]
|
||||||
|
if (item === undefined) return null
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={vrow.key}
|
key={vrow.key}
|
||||||
data-index={vrow.index}
|
data-index={vrow.index}
|
||||||
@@ -61,9 +67,10 @@ export function VirtualList<T>({
|
|||||||
transform: `translateY(${vrow.start}px)`
|
transform: `translateY(${vrow.start}px)`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderItem(items[vrow.index], vrow.index)}
|
{renderItem(item, vrow.index)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Button, Checkbox, Caption1, mergeClasses } from '@fluentui/react-components'
|
||||||
|
import { AppsListRegular, VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
|
||||||
|
import { type PlaylistInfo } from '@shared/ipc'
|
||||||
|
import { type MediaKind } from '../../store/downloads'
|
||||||
|
import { Hint } from '../Hint'
|
||||||
|
import { IconButton } from '../ui/IconButton'
|
||||||
|
import { useTextStyles } from '../ui/text'
|
||||||
|
import { META_SEP } from '../ui/tokens'
|
||||||
|
import { useDownloadBarStyles } from './styles'
|
||||||
|
|
||||||
|
interface PlaylistPanelProps {
|
||||||
|
playlist: PlaylistInfo
|
||||||
|
selected: Set<number>
|
||||||
|
allSelected: boolean
|
||||||
|
onToggleAll: () => void
|
||||||
|
onSetAllKinds: (k: MediaKind) => void
|
||||||
|
onToggleEntry: (index: number, on: boolean) => void
|
||||||
|
effKind: (index: number) => MediaKind
|
||||||
|
onToggleItemKind: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistPanel({
|
||||||
|
playlist,
|
||||||
|
selected,
|
||||||
|
allSelected,
|
||||||
|
onToggleAll,
|
||||||
|
onSetAllKinds,
|
||||||
|
onToggleEntry,
|
||||||
|
effKind,
|
||||||
|
onToggleItemKind
|
||||||
|
}: PlaylistPanelProps): React.JSX.Element {
|
||||||
|
const styles = useDownloadBarStyles()
|
||||||
|
const text = useTextStyles()
|
||||||
|
return (
|
||||||
|
<div className={styles.plPanel}>
|
||||||
|
<div className={styles.plHeader}>
|
||||||
|
<AppsListRegular />
|
||||||
|
<Caption1 className={mergeClasses(styles.plHeaderGrow, text.title)}>
|
||||||
|
{playlist.title}
|
||||||
|
{playlist.uploader ? `${META_SEP}${playlist.uploader}` : ''}
|
||||||
|
</Caption1>
|
||||||
|
<Caption1 className={text.muted}>
|
||||||
|
{selected.size} of {playlist.count} selected
|
||||||
|
</Caption1>
|
||||||
|
<Button size="small" appearance="subtle" onClick={onToggleAll}>
|
||||||
|
{allSelected ? 'Select none' : 'Select all'}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" appearance="subtle" onClick={() => onSetAllKinds('video')}>
|
||||||
|
All video
|
||||||
|
</Button>
|
||||||
|
<Button size="small" appearance="subtle" onClick={() => onSetAllKinds('audio')}>
|
||||||
|
All audio
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.plList}>
|
||||||
|
{playlist.entries.map((e) => (
|
||||||
|
<div key={e.index} className={styles.plItemRow}>
|
||||||
|
<Checkbox
|
||||||
|
className={styles.plItem}
|
||||||
|
checked={selected.has(e.index)}
|
||||||
|
onChange={(_, d) => onToggleEntry(e.index, !!d.checked)}
|
||||||
|
label={
|
||||||
|
<span className={styles.plItemLabel}>
|
||||||
|
<span className={text.truncate}>
|
||||||
|
{e.index}. {e.title}
|
||||||
|
</span>
|
||||||
|
{(e.durationLabel || e.uploader) && (
|
||||||
|
<Caption1 className={text.muted}>
|
||||||
|
{[e.durationLabel, e.uploader].filter(Boolean).join(META_SEP)}
|
||||||
|
</Caption1>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Hint
|
||||||
|
label={
|
||||||
|
effKind(e.index) === 'audio'
|
||||||
|
? 'Audio -- click for video'
|
||||||
|
: 'Video -- click for audio'
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
icon={effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
|
||||||
|
onClick={() => onToggleItemKind(e.index)}
|
||||||
|
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
|
||||||
|
import { THUMB_LG } from '../../thumbSizes'
|
||||||
|
import { SPACE, RADIUS, ELEVATION } from '../ui/tokens'
|
||||||
|
|
||||||
|
export const useDownloadBarStyles = makeStyles({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: SPACE.cozy,
|
||||||
|
// Page-level card: card padding + card radius, and the app's one "raised"
|
||||||
|
// elevation — the download bar floats above the queue (UI3/UI7/L100).
|
||||||
|
padding: SPACE.section,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
...shorthands.borderRadius(RADIUS.card),
|
||||||
|
boxShadow: ELEVATION.raised
|
||||||
|
},
|
||||||
|
// Highlight while a link / .url file is dragged over the card.
|
||||||
|
rootDragging: {
|
||||||
|
outline: `2px dashed ${tokens.colorBrandStroke1}`,
|
||||||
|
outlineOffset: '-2px'
|
||||||
|
},
|
||||||
|
urlRow: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px'
|
||||||
|
},
|
||||||
|
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: `${THUMB_LG.w}px`,
|
||||||
|
height: `${THUMB_LG.h}px`,
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
|
color: tokens.colorBrandForeground1,
|
||||||
|
// One thumbnail radius app-wide (UI6): control tier / Medium.
|
||||||
|
...shorthands.borderRadius(RADIUS.control)
|
||||||
|
},
|
||||||
|
previewBody: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '2px'
|
||||||
|
},
|
||||||
|
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'
|
||||||
|
},
|
||||||
|
quality: {
|
||||||
|
minWidth: '220px'
|
||||||
|
},
|
||||||
|
spacer: {
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
// --- playlist selection ---
|
||||||
|
plPanel: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
|
},
|
||||||
|
plHeader: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
},
|
||||||
|
// Just the layout role; the truncation + weight come from the shared text.title
|
||||||
|
// (L114/L126), merged on at the call site.
|
||||||
|
plHeaderGrow: {
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
plList: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: '260px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingRight: '4px'
|
||||||
|
},
|
||||||
|
plItemRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
},
|
||||||
|
plItem: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: '2px 0',
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0
|
||||||
|
},
|
||||||
|
plItemLabel: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: 0
|
||||||
|
},
|
||||||
|
// --- trim / schedule panels ---
|
||||||
|
trimBlock: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'flex-start'
|
||||||
|
},
|
||||||
|
optButtons: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px'
|
||||||
|
},
|
||||||
|
trimPanel: {
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
|
},
|
||||||
|
// Native datetime-local input, themed to sit beside the Fluent controls.
|
||||||
|
// No explicit `colorScheme` here (UI32): it inherits the resolved in-app scheme
|
||||||
|
// set on the app root in App.tsx, so the native calendar popup follows the app's
|
||||||
|
// Light/Dark theme — consistent with the native <Select> — rather than the OS
|
||||||
|
// preference (which the former `'light dark'` value tied it to).
|
||||||
|
dtInput: {
|
||||||
|
fontFamily: tokens.fontFamilyBase,
|
||||||
|
fontSize: tokens.fontSizeBase300,
|
||||||
|
padding: '6px 10px',
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
color: tokens.colorNeutralForeground1
|
||||||
|
},
|
||||||
|
// --- command preview & options panels ---
|
||||||
|
commandBlock: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px'
|
||||||
|
},
|
||||||
|
commandPreviewPanel: {
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
fontFamily: tokens.fontFamilyMonospace,
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
overflowX: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
color: tokens.colorNeutralForeground1
|
||||||
|
},
|
||||||
|
commandError: {
|
||||||
|
color: tokens.colorStatusDangerForeground1
|
||||||
|
},
|
||||||
|
optionsPanel: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
|
||||||
|
import {
|
||||||
|
parseUrlShortcutContent,
|
||||||
|
type MediaInfo,
|
||||||
|
type FormatOption,
|
||||||
|
type PlaylistInfo,
|
||||||
|
type DownloadOptions,
|
||||||
|
DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
|
type CommandPreviewResult
|
||||||
|
} from '@shared/ipc'
|
||||||
|
import { useDownloads, type MediaKind } from '../../store/downloads'
|
||||||
|
import { useHistory } from '../../store/history'
|
||||||
|
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
||||||
|
import { sameVideo } from '../../store/queueStats'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useNav } from '../../store/nav'
|
||||||
|
import {
|
||||||
|
useClipboardLink,
|
||||||
|
looksLikeUrl,
|
||||||
|
looksLikeChannelOrPlaylist,
|
||||||
|
firstUrlInText,
|
||||||
|
type SuggestionSource
|
||||||
|
} from '../../useClipboardLink'
|
||||||
|
import { logError } from '../../reportError'
|
||||||
|
|
||||||
|
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
|
||||||
|
function parseUrlFile(content: string): string | null {
|
||||||
|
const url = parseUrlShortcutContent(content)
|
||||||
|
return url && looksLikeUrl(url) ? url : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a Date as the `YYYY-MM-DDTHH:mm` value a datetime-local input expects,
|
||||||
|
// in LOCAL time -- used as the picker's `min` so a past time can't be chosen and
|
||||||
|
// then silently download immediately (L156/L57).
|
||||||
|
export function toLocalDatetimeValue(d: Date): string {
|
||||||
|
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the DownloadBar render needs — the hook's public surface. */
|
||||||
|
export interface DownloadBarController {
|
||||||
|
// form state
|
||||||
|
url: string
|
||||||
|
kind: MediaKind
|
||||||
|
quality: string
|
||||||
|
// trim / schedule
|
||||||
|
showTrim: boolean
|
||||||
|
setShowTrim: Dispatch<SetStateAction<boolean>>
|
||||||
|
trim: string
|
||||||
|
showSchedule: boolean
|
||||||
|
setShowSchedule: Dispatch<SetStateAction<boolean>>
|
||||||
|
scheduleAt: string
|
||||||
|
setScheduleAt: Dispatch<SetStateAction<string>>
|
||||||
|
// drag / dup
|
||||||
|
dragActive: boolean
|
||||||
|
setDragActive: Dispatch<SetStateAction<boolean>>
|
||||||
|
dup: string | null
|
||||||
|
setDup: Dispatch<SetStateAction<string | null>>
|
||||||
|
/** true when the duplicate was found in history (already downloaded) vs the live queue (L144). */
|
||||||
|
dupInHistory: boolean
|
||||||
|
// advanced / preview
|
||||||
|
showAdvanced: boolean
|
||||||
|
setShowAdvanced: Dispatch<SetStateAction<boolean>>
|
||||||
|
downloadOptions: DownloadOptions
|
||||||
|
incognito: boolean
|
||||||
|
setIncognito: Dispatch<SetStateAction<boolean>>
|
||||||
|
commandPreview: CommandPreviewResult | null
|
||||||
|
previewLoading: boolean
|
||||||
|
// probe (single video)
|
||||||
|
info: MediaInfo | null
|
||||||
|
thumbFailed: string | null
|
||||||
|
setThumbFailed: Dispatch<SetStateAction<string | null>>
|
||||||
|
probing: boolean
|
||||||
|
probeError: string | null
|
||||||
|
usingFormats: boolean
|
||||||
|
selectedFormat: FormatOption | undefined
|
||||||
|
// playlist
|
||||||
|
playlist: PlaylistInfo | null
|
||||||
|
selected: Set<number>
|
||||||
|
allSelected: boolean
|
||||||
|
// suggestion banner
|
||||||
|
suggestion: string | null
|
||||||
|
suggestionSource: SuggestionSource
|
||||||
|
dismissSuggestion: () => void
|
||||||
|
// channel/playlist nudge → Library (UX3)
|
||||||
|
channelHint: boolean
|
||||||
|
openInLibrary: () => void
|
||||||
|
dismissChannelHint: () => void
|
||||||
|
// handlers
|
||||||
|
onDragEnter: (e: React.DragEvent) => void
|
||||||
|
onDragOver: (e: React.DragEvent) => void
|
||||||
|
onDragLeave: () => void
|
||||||
|
onDrop: (e: React.DragEvent) => void
|
||||||
|
onUrlChange: (next: string) => void
|
||||||
|
onKindChange: (next: MediaKind) => void
|
||||||
|
onQualityChange: (v: string) => void
|
||||||
|
onFormatIdChange: (v: string) => void
|
||||||
|
onTrimChange: (v: string) => void
|
||||||
|
onDownloadOptionsChange: (next: DownloadOptions) => void
|
||||||
|
toggleCommandPreview: () => Promise<void>
|
||||||
|
fetchFormats: () => Promise<void>
|
||||||
|
paste: () => Promise<void>
|
||||||
|
acceptSuggestion: () => void
|
||||||
|
download: () => void
|
||||||
|
downloadAnyway: () => void
|
||||||
|
addPlaylist: () => void
|
||||||
|
toggleEntry: (index: number, on: boolean) => void
|
||||||
|
toggleAll: () => void
|
||||||
|
effKind: (index: number) => MediaKind
|
||||||
|
toggleItemKind: (index: number) => void
|
||||||
|
setAllKinds: (k: MediaKind) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All state and behaviour for the DownloadBar. Extracted from the component so
|
||||||
|
* the ~17 interdependent state hooks and their handlers live in one place and
|
||||||
|
* the component is render-only.
|
||||||
|
*/
|
||||||
|
export function useDownloadBar(): DownloadBarController {
|
||||||
|
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||||
|
const addMany = useDownloads((s) => s.addMany)
|
||||||
|
const openLibraryWith = useNav((s) => s.openLibraryWith)
|
||||||
|
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<string>(QUALITY_OPTIONS.video[0])
|
||||||
|
|
||||||
|
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
|
||||||
|
const [showTrim, setShowTrim] = useState(false)
|
||||||
|
const [trim, setTrim] = useState('')
|
||||||
|
|
||||||
|
// Optional schedule: a datetime-local value; a future time parks the download.
|
||||||
|
const [showSchedule, setShowSchedule] = useState(false)
|
||||||
|
const [scheduleAt, setScheduleAt] = useState('')
|
||||||
|
|
||||||
|
// Drag-and-drop: highlight while a link / .url file hovers the card.
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
|
||||||
|
// Channel/playlist nudge: a whole channel/playlist belongs in the Library, not
|
||||||
|
// the one-off queue. Once dismissed for the current URL, stay quiet (UX3).
|
||||||
|
const [channelHintDismissed, setChannelHintDismissed] = useState(false)
|
||||||
|
|
||||||
|
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
||||||
|
const [dup, setDup] = useState<string | null>(null)
|
||||||
|
// Whether the duplicate matched a history entry (already downloaded) rather than
|
||||||
|
// a live queue item (L144) — the banner wording differs.
|
||||||
|
const [dupInHistory, setDupInHistory] = useState(false)
|
||||||
|
const confirmDup = useRef(false)
|
||||||
|
|
||||||
|
// Per-download options (M5/M6/UX1): advanced features.
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
|
const [downloadOptions, setDownloadOptions] = useState<DownloadOptions>(DEFAULT_DOWNLOAD_OPTIONS)
|
||||||
|
const [incognito, setIncognito] = useState(false)
|
||||||
|
const [commandPreview, setCommandPreview] = useState<CommandPreviewResult | null>(null)
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
|
||||||
|
// Probe state for the single-video format picker.
|
||||||
|
const [info, setInfo] = useState<MediaInfo | null>(null)
|
||||||
|
const [thumbFailed, setThumbFailed] = useState<string | null>(null)
|
||||||
|
const [probing, setProbing] = useState(false)
|
||||||
|
const [probeError, setProbeError] = useState<string | null>(null)
|
||||||
|
const [formatId, setFormatId] = useState<string>('')
|
||||||
|
|
||||||
|
// Probe state for a playlist URL.
|
||||||
|
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
|
||||||
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
|
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
|
||||||
|
// bar's global kind. Lets a playlist mix video and audio downloads.
|
||||||
|
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
|
||||||
|
|
||||||
|
// Drag highlight is gated on a depth counter, not raw enter/leave (L158): a
|
||||||
|
// dragleave fires every time the cursor crosses onto a child element, so toggling
|
||||||
|
// on leave made the dashed outline blink. Count enters vs leaves and only clear
|
||||||
|
// the highlight when the drag has actually left the whole card (depth back to 0).
|
||||||
|
const dragDepth = useRef(0)
|
||||||
|
function onDragEnter(e: React.DragEvent): void {
|
||||||
|
e.preventDefault()
|
||||||
|
dragDepth.current += 1
|
||||||
|
if (!dragActive) setDragActive(true)
|
||||||
|
}
|
||||||
|
function onDragOver(e: React.DragEvent): void {
|
||||||
|
// Required so the element is a valid drop target; the highlight is owned by
|
||||||
|
// onDragEnter/onDragLeave.
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onDragLeave(): void {
|
||||||
|
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
||||||
|
if (dragDepth.current === 0) setDragActive(false)
|
||||||
|
}
|
||||||
|
function onDrop(e: React.DragEvent): void {
|
||||||
|
e.preventDefault()
|
||||||
|
dragDepth.current = 0
|
||||||
|
setDragActive(false)
|
||||||
|
const dt = e.dataTransfer
|
||||||
|
const fromText = firstUrlInText(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
|
||||||
|
if (fromText) {
|
||||||
|
onUrlChange(fromText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A dropped Windows .url Internet Shortcut -- read its URL= line.
|
||||||
|
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
|
||||||
|
if (file) {
|
||||||
|
file
|
||||||
|
.text()
|
||||||
|
.then((content) => {
|
||||||
|
const u = parseUrlFile(content)
|
||||||
|
if (u) onUrlChange(u)
|
||||||
|
})
|
||||||
|
.catch(logError('dropped .url file read'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When settings load or change, update kind/quality -- but only while the bar
|
||||||
|
// is idle (URL empty) so an in-progress setup isn't unexpectedly reset.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsLoaded || url.trim()) return
|
||||||
|
setKind(defaultKind)
|
||||||
|
setQuality(defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality)
|
||||||
|
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality, url])
|
||||||
|
|
||||||
|
// Clipboard auto-detect and links handed to AeroFetch from outside (the
|
||||||
|
// aerofetch:// protocol or a "Send to" .url file) share one suggestion banner,
|
||||||
|
// driven by the same hook the library's add-source field uses. An external link
|
||||||
|
// always takes priority over a clipboard guess -- it's a direct request.
|
||||||
|
const {
|
||||||
|
suggestion,
|
||||||
|
source: suggestionSource,
|
||||||
|
accept: acceptLink,
|
||||||
|
dismiss: dismissSuggestion,
|
||||||
|
offer: offerLink
|
||||||
|
} = useClipboardLink(url)
|
||||||
|
useEffect(
|
||||||
|
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
|
||||||
|
[offerLink]
|
||||||
|
)
|
||||||
|
|
||||||
|
function acceptSuggestion(): void {
|
||||||
|
const link = acceptLink()
|
||||||
|
if (link) onUrlChange(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Show the "add this in the Library" nudge for a clear channel/playlist URL,
|
||||||
|
// but only while the bar is still idle for it (nothing probed/resolved yet) and
|
||||||
|
// the user hasn't waved it away (UX3).
|
||||||
|
const channelHint =
|
||||||
|
looksLikeChannelOrPlaylist(url) &&
|
||||||
|
!channelHintDismissed &&
|
||||||
|
info === null &&
|
||||||
|
playlist === null &&
|
||||||
|
probeError === null
|
||||||
|
|
||||||
|
function clearProbe(): void {
|
||||||
|
setInfo(null)
|
||||||
|
setProbeError(null)
|
||||||
|
setFormatId('')
|
||||||
|
setPlaylist(null)
|
||||||
|
setSelected(new Set())
|
||||||
|
setItemKinds({})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the whole bar after a download / playlist is enqueued: clear the URL +
|
||||||
|
// probe and drop the one-shot overrides (trim / schedule / advanced / incognito /
|
||||||
|
// per-download options) so the next download starts from the global defaults.
|
||||||
|
// Shared by download() and addPlaylist() so a playlist add resets the same state
|
||||||
|
// a single download does (L132 — addPlaylist previously left trim/schedule open).
|
||||||
|
function resetForm(): void {
|
||||||
|
setUrl('')
|
||||||
|
setTrim('')
|
||||||
|
setShowTrim(false)
|
||||||
|
setScheduleAt('')
|
||||||
|
setShowSchedule(false)
|
||||||
|
setShowAdvanced(false)
|
||||||
|
setCommandPreview(null)
|
||||||
|
setIncognito(false)
|
||||||
|
setDownloadOptions(DEFAULT_DOWNLOAD_OPTIONS)
|
||||||
|
clearProbe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch (or toggle off) the exact yt-dlp command line for the current form
|
||||||
|
// state. The options mirror what download() sends so the preview matches the
|
||||||
|
// command that will actually run -- including a probe-selected format and any
|
||||||
|
// trim spec (M5).
|
||||||
|
async function toggleCommandPreview(): Promise<void> {
|
||||||
|
if (commandPreview) {
|
||||||
|
setCommandPreview(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setPreviewLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.api.previewCommand({
|
||||||
|
id: 'preview',
|
||||||
|
url: trimmed,
|
||||||
|
kind: usingFormats ? 'video' : kind,
|
||||||
|
quality: usingFormats && selectedFormat ? selectedFormat.label : quality,
|
||||||
|
formatId: usingFormats ? selectedFormat?.id : undefined,
|
||||||
|
formatHasAudio: usingFormats ? selectedFormat?.hasAudio : undefined,
|
||||||
|
options: downloadOptions,
|
||||||
|
trim: trim.trim() || undefined
|
||||||
|
})
|
||||||
|
setCommandPreview(result)
|
||||||
|
} catch (e) {
|
||||||
|
setCommandPreview({ ok: false, error: (e as Error).message })
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUrlChange(next: string): void {
|
||||||
|
setUrl(next)
|
||||||
|
// Any probed info -- or a duplicate warning -- is stale once the URL changes.
|
||||||
|
if (info || probeError || playlist) clearProbe()
|
||||||
|
if (dup) setDup(null)
|
||||||
|
setCommandPreview(null)
|
||||||
|
confirmDup.current = false
|
||||||
|
// A new URL gets a fresh chance to nudge toward the Library.
|
||||||
|
setChannelHintDismissed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKindChange(next: MediaKind): void {
|
||||||
|
setKind(next)
|
||||||
|
setQuality(QUALITY_OPTIONS[next][0])
|
||||||
|
// The command changes with kind/quality, so any shown preview is now stale.
|
||||||
|
setCommandPreview(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any change to quality / format / trim / options invalidates a shown preview.
|
||||||
|
function onQualityChange(v: string): void {
|
||||||
|
setQuality(v)
|
||||||
|
setCommandPreview(null)
|
||||||
|
}
|
||||||
|
function onFormatIdChange(v: string): void {
|
||||||
|
setFormatId(v)
|
||||||
|
setCommandPreview(null)
|
||||||
|
}
|
||||||
|
function onTrimChange(v: string): void {
|
||||||
|
setTrim(v)
|
||||||
|
setCommandPreview(null)
|
||||||
|
}
|
||||||
|
function onDownloadOptionsChange(next: DownloadOptions): void {
|
||||||
|
setDownloadOptions(next)
|
||||||
|
setCommandPreview(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFormats(): Promise<void> {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!trimmed || probing) return
|
||||||
|
setProbing(true)
|
||||||
|
setProbeError(null)
|
||||||
|
setInfo(null)
|
||||||
|
setPlaylist(null)
|
||||||
|
// Probing may switch the URL to the format-picker path, changing the command.
|
||||||
|
setCommandPreview(null)
|
||||||
|
try {
|
||||||
|
const res = await window.api.probe(trimmed)
|
||||||
|
if (res.ok && res.kind === 'playlist' && res.playlist) {
|
||||||
|
setPlaylist(res.playlist)
|
||||||
|
// Pre-select every entry; the user can trim the list.
|
||||||
|
setSelected(new Set(res.playlist.entries.map((e) => e.index)))
|
||||||
|
} else 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 window.api?.readClipboard?.()) ?? ''
|
||||||
|
if (text) onUrlChange(text.trim())
|
||||||
|
} catch {
|
||||||
|
/* ignore in preview */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand the current channel/playlist URL to the Library tab, which pre-fills its
|
||||||
|
// add field with it, and clear the bar (the URL now lives over there) (UX3).
|
||||||
|
function openInLibrary(): void {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
openLibraryWith(trimmed)
|
||||||
|
setUrl('')
|
||||||
|
// Clear the URL-specific command preview too, so no stale command lingers over
|
||||||
|
// the now-empty bar (the same invariant onUrlChange keeps).
|
||||||
|
setCommandPreview(null)
|
||||||
|
clearProbe()
|
||||||
|
}
|
||||||
|
function dismissChannelHint(): void {
|
||||||
|
setChannelHintDismissed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection helpers for the playlist panel.
|
||||||
|
const allSelected = playlist !== null && selected.size === playlist.entries.length
|
||||||
|
function toggleEntry(index: number, on: boolean): void {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (on) next.add(index)
|
||||||
|
else next.delete(index)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function toggleAll(): void {
|
||||||
|
if (!playlist) return
|
||||||
|
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-entry kind: the override if set, else the bar's global kind.
|
||||||
|
function effKind(index: number): MediaKind {
|
||||||
|
return itemKinds[index] ?? kind
|
||||||
|
}
|
||||||
|
function toggleItemKind(index: number): void {
|
||||||
|
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
|
||||||
|
}
|
||||||
|
function setAllKinds(k: MediaKind): void {
|
||||||
|
if (!playlist) return
|
||||||
|
const all: Record<number, MediaKind> = {}
|
||||||
|
for (const e of playlist.entries) all[e.index] = k
|
||||||
|
setItemKinds(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(): void {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
|
||||||
|
// Duplicate guard: if this URL is already in the queue (and the user hasn't
|
||||||
|
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
|
||||||
|
// Canceled/failed items don't count -- re-adding those is a legitimate retry.
|
||||||
|
if (!confirmDup.current) {
|
||||||
|
const existing = useDownloads
|
||||||
|
.getState()
|
||||||
|
.items.find(
|
||||||
|
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
|
||||||
|
)
|
||||||
|
if (existing) {
|
||||||
|
// Use the URL as fallback if the title is still the placeholder generated
|
||||||
|
// by titleFromUrl() before metadata loads (L76).
|
||||||
|
const isPlaceholder =
|
||||||
|
/video \(\w+\)$/.test(existing.title) ||
|
||||||
|
existing.title.endsWith(' download') ||
|
||||||
|
existing.title === 'New download'
|
||||||
|
setDup(isPlaceholder ? existing.url : existing.title)
|
||||||
|
setDupInHistory(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not in the queue, but maybe already downloaded earlier (L144): warn so a
|
||||||
|
// re-download is a deliberate choice, not an accidental duplicate.
|
||||||
|
const downloaded = useHistory.getState().entries.find((h) => sameVideo(h.url, trimmed))
|
||||||
|
if (downloaded) {
|
||||||
|
setDup(downloaded.title || downloaded.url)
|
||||||
|
setDupInHistory(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
confirmDup.current = false
|
||||||
|
setDup(null)
|
||||||
|
|
||||||
|
const meta = info
|
||||||
|
? {
|
||||||
|
title: info.title,
|
||||||
|
channel: info.channel,
|
||||||
|
durationLabel: info.durationLabel,
|
||||||
|
thumbnail: info.thumbnail
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const trimSpec = trim.trim() || undefined
|
||||||
|
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
|
||||||
|
|
||||||
|
if (usingFormats && selectedFormat) {
|
||||||
|
addFromUrl(trimmed, 'video', selectedFormat.label, {
|
||||||
|
...meta,
|
||||||
|
trim: trimSpec,
|
||||||
|
scheduledFor,
|
||||||
|
options: downloadOptions,
|
||||||
|
incognito,
|
||||||
|
format: {
|
||||||
|
id: selectedFormat.id,
|
||||||
|
hasAudio: selectedFormat.hasAudio,
|
||||||
|
label: selectedFormat.label
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
addFromUrl(trimmed, kind, quality, {
|
||||||
|
...meta,
|
||||||
|
trim: trimSpec,
|
||||||
|
scheduledFor,
|
||||||
|
options: downloadOptions,
|
||||||
|
incognito
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadAnyway(): void {
|
||||||
|
confirmDup.current = true
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPlaylist(): void {
|
||||||
|
if (!playlist) return
|
||||||
|
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
||||||
|
// Each entry uses its own kind override; quality falls back to that kind's
|
||||||
|
// default unless the entry matches the bar's current kind/quality.
|
||||||
|
addMany(
|
||||||
|
chosen.map((e) => {
|
||||||
|
const k = effKind(e.index)
|
||||||
|
return {
|
||||||
|
url: e.url,
|
||||||
|
kind: k,
|
||||||
|
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
|
||||||
|
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// form state
|
||||||
|
url,
|
||||||
|
kind,
|
||||||
|
quality,
|
||||||
|
// trim / schedule
|
||||||
|
showTrim,
|
||||||
|
setShowTrim,
|
||||||
|
trim,
|
||||||
|
showSchedule,
|
||||||
|
setShowSchedule,
|
||||||
|
scheduleAt,
|
||||||
|
setScheduleAt,
|
||||||
|
// drag / dup
|
||||||
|
dupInHistory,
|
||||||
|
dragActive,
|
||||||
|
setDragActive,
|
||||||
|
dup,
|
||||||
|
setDup,
|
||||||
|
// advanced / preview
|
||||||
|
showAdvanced,
|
||||||
|
setShowAdvanced,
|
||||||
|
downloadOptions,
|
||||||
|
incognito,
|
||||||
|
setIncognito,
|
||||||
|
commandPreview,
|
||||||
|
previewLoading,
|
||||||
|
// probe (single video)
|
||||||
|
info,
|
||||||
|
thumbFailed,
|
||||||
|
setThumbFailed,
|
||||||
|
probing,
|
||||||
|
probeError,
|
||||||
|
usingFormats,
|
||||||
|
selectedFormat,
|
||||||
|
// playlist
|
||||||
|
playlist,
|
||||||
|
selected,
|
||||||
|
allSelected,
|
||||||
|
// suggestion banner
|
||||||
|
suggestion,
|
||||||
|
suggestionSource,
|
||||||
|
dismissSuggestion,
|
||||||
|
// channel/playlist nudge → Library (UX3)
|
||||||
|
channelHint,
|
||||||
|
openInLibrary,
|
||||||
|
dismissChannelHint,
|
||||||
|
// handlers
|
||||||
|
onDragEnter,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
onDrop,
|
||||||
|
onUrlChange,
|
||||||
|
onKindChange,
|
||||||
|
onQualityChange,
|
||||||
|
onFormatIdChange,
|
||||||
|
onTrimChange,
|
||||||
|
onDownloadOptionsChange,
|
||||||
|
toggleCommandPreview,
|
||||||
|
fetchFormats,
|
||||||
|
paste,
|
||||||
|
acceptSuggestion,
|
||||||
|
download,
|
||||||
|
downloadAnyway,
|
||||||
|
addPlaylist,
|
||||||
|
toggleEntry,
|
||||||
|
toggleAll,
|
||||||
|
effKind,
|
||||||
|
toggleItemKind,
|
||||||
|
setAllKinds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Subtitle2,
|
||||||
|
Caption1,
|
||||||
|
Text,
|
||||||
|
Spinner
|
||||||
|
} from '@fluentui/react-components'
|
||||||
|
import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons'
|
||||||
|
import {
|
||||||
|
type YtdlpVersionResult,
|
||||||
|
type YtdlpUpdateChannel,
|
||||||
|
type YtdlpUpdateResult,
|
||||||
|
type FfmpegVersionResult
|
||||||
|
} from '@shared/ipc'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { Select } from '../Select'
|
||||||
|
import { useErrorTextStyles } from '../ui/errorText'
|
||||||
|
import { logError } from '../../reportError'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
const UPDATE_CHANNEL_OPTIONS = [
|
||||||
|
{ value: 'stable', label: 'Stable' },
|
||||||
|
{ value: 'nightly', label: 'Nightly' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AboutCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const errText = useErrorTextStyles()
|
||||||
|
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
|
||||||
|
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
|
||||||
|
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
|
||||||
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
|
||||||
|
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
|
||||||
|
const [updating, setUpdating] = useState(false)
|
||||||
|
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Show the current yt-dlp version without a manual click, and reflect a
|
||||||
|
// background auto-update (which may run on launch) live in this panel.
|
||||||
|
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
|
||||||
|
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
|
||||||
|
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
|
||||||
|
return window.api.onYtdlpAutoUpdateStatus((s) => {
|
||||||
|
if (s.phase === 'checking') {
|
||||||
|
setChecking(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChecking(false)
|
||||||
|
if (s.version) setVersion({ ok: true, version: s.version })
|
||||||
|
if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
|
||||||
|
if (s.phase === 'updated') {
|
||||||
|
setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
|
||||||
|
} else if (s.phase === 'error' && s.error) {
|
||||||
|
setUpdateResult({ ok: false, error: s.error })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runUpdate(): Promise<void> {
|
||||||
|
setUpdating(true)
|
||||||
|
setUpdateResult(null)
|
||||||
|
try {
|
||||||
|
const result = await window.api.updateYtdlp(ytdlpChannel)
|
||||||
|
setUpdateResult(result)
|
||||||
|
if (result.ok) setVersion(null) // stale -- prompt a re-check rather than show a wrong version
|
||||||
|
} catch (e) {
|
||||||
|
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
||||||
|
} finally {
|
||||||
|
setUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<InfoRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">About</Subtitle2>
|
||||||
|
</div>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
|
||||||
|
yt-dlp, keeping it up to date automatically.
|
||||||
|
</Caption1>
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
{/* Re-show the first-run welcome/tips screen on demand (UX22). */}
|
||||||
|
<Button size="small" onClick={() => update({ hasCompletedOnboarding: false })}>
|
||||||
|
Show welcome tips again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Keep yt-dlp updated automatically"
|
||||||
|
hint="Checks once a day on launch and updates the downloader in the background, so site changes don't start breaking your downloads."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={autoUpdateYtdlp}
|
||||||
|
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{version?.ok && <Text className={styles.mono}>yt-dlp {version.version}</Text>}
|
||||||
|
{version && !version.ok && <Caption1 className={errText.errorPre}>{version.error}</Caption1>}
|
||||||
|
{ffmpeg && (
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<div>
|
||||||
|
<Text className={styles.monoBlock}>ffmpeg {ffmpeg.ffmpeg ?? 'not found'}</Text>
|
||||||
|
<Text className={styles.monoBlock}>ffprobe {ffmpeg.ffprobe ?? 'not found'}</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowSyncRegular />}
|
||||||
|
onClick={() =>
|
||||||
|
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
|
||||||
|
}
|
||||||
|
aria-label="Re-check ffmpeg versions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ytdlpLastUpdateCheck > 0 && (
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
|
||||||
|
</Caption1>
|
||||||
|
)}
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button
|
||||||
|
icon={checking ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
||||||
|
onClick={checkVersion}
|
||||||
|
disabled={checking}
|
||||||
|
>
|
||||||
|
Check yt-dlp version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Update channel"
|
||||||
|
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={ytdlpChannel}
|
||||||
|
options={UPDATE_CHANNEL_OPTIONS}
|
||||||
|
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button
|
||||||
|
icon={updating ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
||||||
|
onClick={runUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
>
|
||||||
|
{updating ? 'Updating…' : 'Update yt-dlp'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{updateResult?.ok && (
|
||||||
|
<Text className={styles.monoPre}>
|
||||||
|
{updateResult.output || 'yt-dlp is already up to date.'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{updateResult && !updateResult.ok && (
|
||||||
|
<Caption1 className={errText.errorPre}>{updateResult.error}</Caption1>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import { Field, Button, Card, Subtitle2, Caption1, mergeClasses } from '@fluentui/react-components'
|
||||||
|
import { PaintBucketRegular, AccessibilityRegular } from '@fluentui/react-icons'
|
||||||
|
import { type ThemeMode, type AccentColor } from '@shared/ipc'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useSystemTheme } from '../../store/systemTheme'
|
||||||
|
import { Select } from '../Select'
|
||||||
|
import { ACCENT_OPTIONS } from '../../theme'
|
||||||
|
import { useFocusStyles } from '../ui/focusRing'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
const THEME_MODE_OPTIONS = [
|
||||||
|
{ value: 'light', label: 'Light' },
|
||||||
|
{ value: 'dark', label: 'Dark' },
|
||||||
|
{ value: 'system', label: 'Follow system' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AppearanceCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const focus = useFocusStyles()
|
||||||
|
const theme = useSettings((s) => s.theme)
|
||||||
|
const accentColor = useSettings((s) => s.accentColor)
|
||||||
|
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
|
||||||
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
|
// Roving-tabindex arrow-key navigation for the accent radiogroup (L155), so the
|
||||||
|
// single-select swatches behave like the Theme radio pattern rather than a row
|
||||||
|
// of plain buttons.
|
||||||
|
const swatchRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
|
function onSwatchKey(e: React.KeyboardEvent, index: number): void {
|
||||||
|
const n = ACCENT_OPTIONS.length
|
||||||
|
let next = -1
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (index + 1) % n
|
||||||
|
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (index - 1 + n) % n
|
||||||
|
else if (e.key === 'Home') next = 0
|
||||||
|
else if (e.key === 'End') next = n - 1
|
||||||
|
else return
|
||||||
|
const opt = ACCENT_OPTIONS[next]
|
||||||
|
if (!opt) return
|
||||||
|
e.preventDefault()
|
||||||
|
update({ accentColor: opt.value as AccentColor })
|
||||||
|
swatchRefs.current[next]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<PaintBucketRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Appearance</Subtitle2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Theme">
|
||||||
|
<Select
|
||||||
|
value={theme}
|
||||||
|
options={THEME_MODE_OPTIONS}
|
||||||
|
onChange={(v) => update({ theme: v as ThemeMode })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Accent color">
|
||||||
|
<div className={styles.swatchRow} role="radiogroup" aria-label="Accent color">
|
||||||
|
{ACCENT_OPTIONS.map((opt, i) => {
|
||||||
|
const selected = accentColor === opt.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
ref={(el) => {
|
||||||
|
swatchRefs.current[i] = el
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
// Roving tabindex: only the selected swatch is a tab stop; arrows
|
||||||
|
// move within the group (L155).
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
className={mergeClasses(
|
||||||
|
styles.swatch,
|
||||||
|
selected && styles.swatchActive,
|
||||||
|
focus.focusRing
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: opt.swatch }}
|
||||||
|
onClick={() => update({ accentColor: opt.value as AccentColor })}
|
||||||
|
onKeyDown={(e) => onSwatchKey(e, i)}
|
||||||
|
aria-label={opt.label}
|
||||||
|
title={opt.label}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
{highContrast
|
||||||
|
? 'A Windows high-contrast theme is active -- AeroFetch follows your system colors.'
|
||||||
|
: 'AeroFetch automatically follows a Windows high-contrast theme if you turn one on.'}
|
||||||
|
</Caption1>
|
||||||
|
{highContrast && (
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<AccessibilityRegular />}
|
||||||
|
onClick={() => window.api.openHighContrastSettings()}
|
||||||
|
>
|
||||||
|
Open accessibility settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||||
|
import { DocumentArrowDownRegular, DocumentArrowUpRegular } from '@fluentui/react-icons'
|
||||||
|
import { type BackupExportResult, type BackupImportResult } from '@shared/ipc'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useTemplates } from '../../store/templates'
|
||||||
|
import { useErrorTextStyles } from '../ui/errorText'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
export function BackupCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const errText = useErrorTextStyles()
|
||||||
|
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
|
||||||
|
|
||||||
|
async function exportBackup(): Promise<void> {
|
||||||
|
setExporting(true)
|
||||||
|
setExportResult(null)
|
||||||
|
try {
|
||||||
|
setExportResult(await window.api.exportBackup())
|
||||||
|
} catch (e) {
|
||||||
|
setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBackup(): Promise<void> {
|
||||||
|
setImporting(true)
|
||||||
|
setImportResult(null)
|
||||||
|
try {
|
||||||
|
const result = await window.api.importBackup()
|
||||||
|
setImportResult(result)
|
||||||
|
if (result.ok) {
|
||||||
|
// Settings + templates changed underneath the stores -- reload both.
|
||||||
|
const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
|
||||||
|
useSettings.setState({ ...s, loaded: true })
|
||||||
|
useTemplates.setState({ templates: t })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Backup & restore</Subtitle2>
|
||||||
|
</div>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
Save your settings and custom-command templates to a JSON file, or restore them on another
|
||||||
|
machine. Does not include download history or credentials (proxy, API tokens -- re-enter
|
||||||
|
those after import).
|
||||||
|
</Caption1>
|
||||||
|
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button icon={<DocumentArrowDownRegular />} onClick={exportBackup} disabled={exporting}>
|
||||||
|
{exporting ? 'Exporting…' : 'Export backup…'}
|
||||||
|
</Button>
|
||||||
|
<Button icon={<DocumentArrowUpRegular />} onClick={importBackup} disabled={importing}>
|
||||||
|
{importing ? 'Importing…' : 'Import backup…'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{exportResult?.ok && exportResult.path && (
|
||||||
|
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
|
||||||
|
)}
|
||||||
|
{exportResult && !exportResult.ok && exportResult.error && (
|
||||||
|
<Caption1 className={errText.error}>{exportResult.error}</Caption1>
|
||||||
|
)}
|
||||||
|
{importResult?.ok && (
|
||||||
|
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
|
||||||
|
)}
|
||||||
|
{importResult && !importResult.ok && importResult.error && (
|
||||||
|
<Caption1 className={errText.error}>{importResult.error}</Caption1>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Field, Input, Button, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||||
|
import { CookiesRegular } from '@fluentui/react-icons'
|
||||||
|
import {
|
||||||
|
COOKIE_BROWSERS,
|
||||||
|
type CookieSource,
|
||||||
|
type CookieBrowser,
|
||||||
|
type CookiesStatus
|
||||||
|
} from '@shared/ipc'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { Select } from '../Select'
|
||||||
|
import { useErrorTextStyles } from '../ui/errorText'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
const COOKIE_SOURCE_OPTIONS = [
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
{ value: 'browser', label: "From a browser's cookie store" },
|
||||||
|
{ value: 'login', label: 'Sign-in window (built into AeroFetch)' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
|
||||||
|
value: b,
|
||||||
|
label: b.charAt(0).toUpperCase() + b.slice(1)
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function CookiesCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const errText = useErrorTextStyles()
|
||||||
|
const cookieSource = useSettings((s) => s.cookieSource)
|
||||||
|
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
|
||||||
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
|
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
|
||||||
|
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
|
||||||
|
const [signingIn, setSigningIn] = useState(false)
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.cookiesStatus().then(setCookiesStatus)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function signIn(): Promise<void> {
|
||||||
|
setSigningIn(true)
|
||||||
|
setLoginError(null)
|
||||||
|
try {
|
||||||
|
const result = await window.api.cookiesLogin(loginUrl)
|
||||||
|
if (result.ok && result.cookieCount === 0) {
|
||||||
|
// Window closed without capturing anything -- don't imply success (L50).
|
||||||
|
setLoginError('No cookies were captured -- did you sign in before closing the window?')
|
||||||
|
} else if (result.ok) {
|
||||||
|
setCookiesStatus(await window.api.cookiesStatus())
|
||||||
|
} else {
|
||||||
|
setLoginError(result.error ?? 'Sign-in failed.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoginError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setSigningIn(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSavedCookies(): Promise<void> {
|
||||||
|
await window.api.cookiesClear()
|
||||||
|
setCookiesStatus({ exists: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<CookiesRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Cookies</Subtitle2>
|
||||||
|
</div>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
|
||||||
|
session. Supply cookies so yt-dlp can act like one.
|
||||||
|
</Caption1>
|
||||||
|
|
||||||
|
<Field label="Cookie source">
|
||||||
|
<Select
|
||||||
|
value={cookieSource}
|
||||||
|
options={COOKIE_SOURCE_OPTIONS}
|
||||||
|
onChange={(v) => update({ cookieSource: v as CookieSource })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{cookieSource === 'browser' && (
|
||||||
|
<Field
|
||||||
|
label="Browser"
|
||||||
|
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={cookiesBrowser}
|
||||||
|
options={COOKIE_BROWSER_OPTIONS}
|
||||||
|
onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cookieSource === 'login' && (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label="Site to sign in to"
|
||||||
|
hint="Opens a sign-in window. Log in, then close the window -- your cookies are saved automatically."
|
||||||
|
>
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Input
|
||||||
|
className={styles.folderInput}
|
||||||
|
value={loginUrl}
|
||||||
|
placeholder="https://www.youtube.com"
|
||||||
|
onChange={(_, d) => setLoginUrl(d.value)}
|
||||||
|
/>
|
||||||
|
<Button appearance="primary" onClick={signIn} disabled={signingIn}>
|
||||||
|
{signingIn ? 'Signing in…' : 'Sign in…'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
{cookiesStatus?.exists
|
||||||
|
? `Cookies saved ${new Date(cookiesStatus.savedAt!).toLocaleString()}.`
|
||||||
|
: 'Not signed in yet.'}
|
||||||
|
</Caption1>
|
||||||
|
{cookiesStatus?.exists && (
|
||||||
|
<Button size="small" onClick={clearSavedCookies}>
|
||||||
|
Clear saved cookies
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginError && <Caption1 className={errText.error}>{loginError}</Caption1>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Field, Switch, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||||
|
import { CodeRegular } from '@fluentui/react-icons'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useTemplates } from '../../store/templates'
|
||||||
|
import { Select } from '../Select'
|
||||||
|
import { TemplateManager } from '../TemplateManager'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
export function CustomCommandsCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||||
|
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
|
||||||
|
const update = useSettings((s) => s.update)
|
||||||
|
const templates = useTemplates((s) => s.templates)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<CodeRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Custom commands</Subtitle2>
|
||||||
|
</div>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
||||||
|
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other option,
|
||||||
|
so a template flag can override a setting above it.
|
||||||
|
</Caption1>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Run custom command"
|
||||||
|
hint="Applies the default template below to every new download (still overridable per download)."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={customCommandEnabled}
|
||||||
|
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{customCommandEnabled && (
|
||||||
|
<Field label="Default template">
|
||||||
|
<Select
|
||||||
|
value={defaultTemplateId ?? 'none'}
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: 'None' },
|
||||||
|
...templates.map((t) => ({ value: t.id, label: t.name }))
|
||||||
|
]}
|
||||||
|
onChange={(v) => update({ defaultTemplateId: v === 'none' ? null : v })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TemplateManager />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Button, Card, Subtitle2, Caption1, Text } from '@fluentui/react-components'
|
||||||
|
import { BugRegular, CopyRegular, DeleteRegular } from '@fluentui/react-icons'
|
||||||
|
import { useErrorLog } from '../../store/errorlog'
|
||||||
|
import { useErrorTextStyles } from '../ui/errorText'
|
||||||
|
import { EmptyState } from '../ui/EmptyState'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
export function DiagnosticsCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const errText = useErrorTextStyles()
|
||||||
|
const errorEntries = useErrorLog((s) => s.entries)
|
||||||
|
const clearErrorLog = useErrorLog((s) => s.clear)
|
||||||
|
|
||||||
|
const [confirmClearLog, setConfirmClearLog] = useState(false)
|
||||||
|
|
||||||
|
function copyErrorReport(): void {
|
||||||
|
// The button is disabled when there are no entries, so `report` is always
|
||||||
|
// non-empty here -- no need for an unreachable empty-report fallback (L159).
|
||||||
|
const report = errorEntries
|
||||||
|
.map((e) =>
|
||||||
|
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
|
||||||
|
)
|
||||||
|
.join('\n\n---\n\n')
|
||||||
|
navigator.clipboard.writeText(report).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<BugRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Diagnostics</Subtitle2>
|
||||||
|
</div>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
Failed downloads are logged here even after you clear the queue, so you can copy the details
|
||||||
|
into a bug report.
|
||||||
|
</Caption1>
|
||||||
|
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button
|
||||||
|
icon={<CopyRegular />}
|
||||||
|
onClick={copyErrorReport}
|
||||||
|
disabled={errorEntries.length === 0}
|
||||||
|
>
|
||||||
|
Copy full report
|
||||||
|
</Button>
|
||||||
|
{confirmClearLog ? (
|
||||||
|
<>
|
||||||
|
<Caption1>
|
||||||
|
Clear all {errorEntries.length} {errorEntries.length === 1 ? 'error' : 'errors'}?
|
||||||
|
</Caption1>
|
||||||
|
<Button
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
appearance="primary"
|
||||||
|
onClick={() => {
|
||||||
|
clearErrorLog()
|
||||||
|
setConfirmClearLog(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear log
|
||||||
|
</Button>
|
||||||
|
<Button appearance="subtle" onClick={() => setConfirmClearLog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => setConfirmClearLog(true)}
|
||||||
|
disabled={errorEntries.length === 0}
|
||||||
|
>
|
||||||
|
Clear log
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorEntries.length === 0 ? (
|
||||||
|
<EmptyState compact message="No errors yet." />
|
||||||
|
) : (
|
||||||
|
<div className={styles.errorList}>
|
||||||
|
{errorEntries.slice(0, 20).map((e) => (
|
||||||
|
<div key={e.id} className={styles.errorRow}>
|
||||||
|
<div className={styles.errorRowHeader}>
|
||||||
|
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
{new Date(e.occurredAt).toLocaleString()}
|
||||||
|
</Caption1>
|
||||||
|
</div>
|
||||||
|
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{errorEntries.length > 20 && (
|
||||||
|
<Caption1 className={styles.hint}>Showing 20 of {errorEntries.length} errors.</Caption1>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import {
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
SpinButton,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Subtitle2
|
||||||
|
} from '@fluentui/react-components'
|
||||||
|
import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
|
||||||
|
import { type MediaKind } from '@shared/ipc'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useDownloads } from '../../store/downloads'
|
||||||
|
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
||||||
|
import { Select } from '../Select'
|
||||||
|
import { SegmentedControl } from '../ui/SegmentedControl'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
export function DownloadsCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const videoDir = useSettings((s) => s.videoDir)
|
||||||
|
const audioDir = useSettings((s) => s.audioDir)
|
||||||
|
const chooseDir = useSettings((s) => s.chooseDir)
|
||||||
|
const clearDir = useSettings((s) => s.clearDir)
|
||||||
|
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 clipboardWatch = useSettings((s) => s.clipboardWatch)
|
||||||
|
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
|
||||||
|
const minimizeToTray = useSettings((s) => s.minimizeToTray)
|
||||||
|
const launchAtStartup = useSettings((s) => s.launchAtStartup)
|
||||||
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
|
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
|
||||||
|
|
||||||
|
function onKindSelect(kind: MediaKind): void {
|
||||||
|
update({ defaultKind: kind })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQualitySelect(quality: string): void {
|
||||||
|
update(
|
||||||
|
defaultKind === 'audio' ? { defaultAudioQuality: quality } : { defaultVideoQuality: quality }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<ArrowDownloadRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Downloads</Subtitle2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Video folder"
|
||||||
|
hint="Where video downloads are saved. Leave blank to use Documents\Video."
|
||||||
|
>
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Input
|
||||||
|
className={styles.folderInput}
|
||||||
|
value={videoDir}
|
||||||
|
placeholder="Documents\Video (default)"
|
||||||
|
contentBefore={<FolderRegular />}
|
||||||
|
onChange={(_, d) => update({ videoDir: d.value })}
|
||||||
|
/>
|
||||||
|
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||||
|
Browse…
|
||||||
|
</Button>
|
||||||
|
{videoDir && (
|
||||||
|
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Audio folder"
|
||||||
|
hint="Where audio downloads are saved. Leave blank to use Documents\Audio."
|
||||||
|
>
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Input
|
||||||
|
className={styles.folderInput}
|
||||||
|
value={audioDir}
|
||||||
|
placeholder="Documents\Audio (default)"
|
||||||
|
contentBefore={<FolderRegular />}
|
||||||
|
onChange={(_, d) => update({ audioDir: d.value })}
|
||||||
|
/>
|
||||||
|
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
|
||||||
|
Browse…
|
||||||
|
</Button>
|
||||||
|
{audioDir && (
|
||||||
|
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Default format">
|
||||||
|
<div className={styles.formatRow}>
|
||||||
|
<SegmentedControl<MediaKind>
|
||||||
|
value={defaultKind}
|
||||||
|
options={[
|
||||||
|
{ value: 'video', label: 'Video' },
|
||||||
|
{ value: 'audio', label: 'Audio' }
|
||||||
|
]}
|
||||||
|
onChange={onKindSelect}
|
||||||
|
ariaLabel="Default download type"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
className={styles.formatQuality}
|
||||||
|
value={formatQuality}
|
||||||
|
options={QUALITY_OPTIONS[defaultKind].map((q) => ({ value: q, label: q }))}
|
||||||
|
onChange={onQualitySelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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 })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Notify when downloads finish"
|
||||||
|
hint="Shows a native Windows notification when a download completes or fails."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={notifyOnComplete}
|
||||||
|
onChange={(_, d) => update({ notifyOnComplete: d.checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Keep running in the tray"
|
||||||
|
hint="Closing the window minimizes to the system tray instead of quitting, so downloads and watched-channel syncing keep running."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={minimizeToTray}
|
||||||
|
onChange={(_, d) => update({ minimizeToTray: d.checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Start with Windows"
|
||||||
|
hint="Launch AeroFetch automatically when you sign in -- useful with auto-download so watched channels stay current in the background."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={launchAtStartup}
|
||||||
|
onChange={(_, d) => update({ launchAtStartup: d.checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Field, Input, Switch, Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||||
|
import { DocumentRegular } from '@fluentui/react-icons'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
export function FilenamesCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
||||||
|
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
||||||
|
const downloadArchive = useSettings((s) => s.downloadArchive)
|
||||||
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<DocumentRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Filenames</Subtitle2>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
label="Filename template"
|
||||||
|
hint="Controls how saved files are named. Use %(title)s for the video title, %(ext)s for the extension, and similar tokens."
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Restrict filenames"
|
||||||
|
hint="Sanitize titles to plain ASCII letters/digits, no spaces -- safer for old filesystems and shells."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={restrictFilenames}
|
||||||
|
onChange={(_, d) => update({ restrictFilenames: d.checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Skip already-downloaded videos"
|
||||||
|
hint="Keeps a record of completed downloads (--download-archive) and skips them on repeat playlist/channel runs."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={downloadArchive}
|
||||||
|
onChange={(_, d) => update({ downloadArchive: d.checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Field, Input, Switch, Button, Card, Subtitle2, Spinner } from '@fluentui/react-components'
|
||||||
|
import { GlobeRegular } from '@fluentui/react-icons'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
export function NetworkCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const proxy = useSettings((s) => s.proxy)
|
||||||
|
const rateLimit = useSettings((s) => s.rateLimit)
|
||||||
|
const useAria2c = useSettings((s) => s.useAria2c)
|
||||||
|
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
|
||||||
|
const youtubePoToken = useSettings((s) => s.youtubePoToken)
|
||||||
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
|
const [mintingPot, setMintingPot] = useState(false)
|
||||||
|
const [potHint, setPotHint] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<GlobeRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Network</Subtitle2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Proxy"
|
||||||
|
hint="HTTP/HTTPS/SOCKS proxy URL, e.g. socks5://127.0.0.1:1080. Leave blank to use the system default."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={proxy}
|
||||||
|
placeholder="socks5://127.0.0.1:1080"
|
||||||
|
onChange={(_, d) => update({ proxy: d.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Rate limit"
|
||||||
|
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={rateLimit}
|
||||||
|
placeholder="2M"
|
||||||
|
onChange={(_, d) => update({ rateLimit: d.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Use aria2c downloader"
|
||||||
|
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
|
||||||
|
>
|
||||||
|
<Switch checked={useAria2c} onChange={(_, d) => update({ useAria2c: d.checked })} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="YouTube client (advanced)"
|
||||||
|
hint="Override how AeroFetch identifies itself to YouTube when downloads start failing. Try web_safari, tv, or mweb. Leave blank for the automatic default."
|
||||||
|
>
|
||||||
|
<datalist id="yt-client-list">
|
||||||
|
{['web', 'web_safari', 'web_embedded', 'mweb', 'tv', 'ios', 'android'].map((c) => (
|
||||||
|
<option key={c} value={c} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<Input
|
||||||
|
value={youtubePlayerClient}
|
||||||
|
placeholder="web_safari"
|
||||||
|
list="yt-client-list"
|
||||||
|
onChange={(_, d) => update({ youtubePlayerClient: d.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="YouTube PO Token (advanced)"
|
||||||
|
hint={
|
||||||
|
potHint ??
|
||||||
|
'A token that helps get past YouTube\'s bot check. Click "Fetch" to grab one automatically, or paste it manually. Signing in with cookies (above) is usually the easier fix.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
value={youtubePoToken}
|
||||||
|
placeholder="Optional -- paste a token"
|
||||||
|
onChange={(_, d) => {
|
||||||
|
update({ youtubePoToken: d.value })
|
||||||
|
setPotHint(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={mintingPot}
|
||||||
|
icon={mintingPot ? <Spinner size="tiny" /> : undefined}
|
||||||
|
onClick={async () => {
|
||||||
|
setMintingPot(true)
|
||||||
|
setPotHint(null)
|
||||||
|
try {
|
||||||
|
const token = await window.api.mintPoToken()
|
||||||
|
if (token) {
|
||||||
|
setPotHint('Token saved.')
|
||||||
|
} else {
|
||||||
|
setPotHint('Token not found -- try signing into YouTube first via Cookies above.')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setPotHint('Failed to open YouTube window.')
|
||||||
|
} finally {
|
||||||
|
setMintingPot(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mintingPot ? 'Fetching…' : 'Fetch'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Card, Subtitle2, Caption1 } from '@fluentui/react-components'
|
||||||
|
import { OptionsRegular } from '@fluentui/react-icons'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { DownloadOptionsForm } from '../DownloadOptionsForm'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
export function PostProcessingCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const defaultKind = useSettings((s) => s.defaultKind)
|
||||||
|
const downloadOptions = useSettings((s) => s.downloadOptions)
|
||||||
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<OptionsRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Format & post-processing</Subtitle2>
|
||||||
|
</div>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
|
||||||
|
</Caption1>
|
||||||
|
|
||||||
|
<DownloadOptionsForm
|
||||||
|
value={downloadOptions}
|
||||||
|
kind={defaultKind}
|
||||||
|
onChange={(o) => update({ downloadOptions: o })}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Subtitle2,
|
||||||
|
Caption1,
|
||||||
|
Text,
|
||||||
|
Spinner,
|
||||||
|
ProgressBar,
|
||||||
|
tokens
|
||||||
|
} from '@fluentui/react-components'
|
||||||
|
import { ArrowSyncRegular, ArrowDownloadRegular, OpenRegular } from '@fluentui/react-icons'
|
||||||
|
import { type AppUpdateInfo } from '@shared/ipc'
|
||||||
|
import { useSettings } from '../../store/settings'
|
||||||
|
import { useErrorTextStyles } from '../ui/errorText'
|
||||||
|
import { logError } from '../../reportError'
|
||||||
|
import { useSettingsStyles } from './settingsStyles'
|
||||||
|
|
||||||
|
export function SoftwareUpdateCard(): React.JSX.Element {
|
||||||
|
const styles = useSettingsStyles()
|
||||||
|
const errText = useErrorTextStyles()
|
||||||
|
const updateToken = useSettings((s) => s.updateToken)
|
||||||
|
const update = useSettings((s) => s.update)
|
||||||
|
|
||||||
|
const [appVersion, setAppVersion] = useState('')
|
||||||
|
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null)
|
||||||
|
const [appChecking, setAppChecking] = useState(false)
|
||||||
|
const [appDownloading, setAppDownloading] = useState(false)
|
||||||
|
const [appFraction, setAppFraction] = useState<number | undefined>(undefined)
|
||||||
|
const [appUpdError, setAppUpdError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
|
||||||
|
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function checkAppUpdate(): Promise<void> {
|
||||||
|
setAppChecking(true)
|
||||||
|
setAppUpd(null)
|
||||||
|
setAppUpdError(null)
|
||||||
|
try {
|
||||||
|
const info = await window.api.checkForAppUpdate()
|
||||||
|
// Funnel a failed check into the single error slot rather than storing a
|
||||||
|
// second not-ok AppUpdateInfo, so only one error message can ever render.
|
||||||
|
if (info.ok) setAppUpd(info)
|
||||||
|
else setAppUpdError(info.error ?? 'Update check failed.')
|
||||||
|
} catch (e) {
|
||||||
|
setAppUpdError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setAppChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installAppUpdate(): Promise<void> {
|
||||||
|
if (!appUpd?.downloadUrl) return
|
||||||
|
setAppDownloading(true)
|
||||||
|
setAppFraction(undefined)
|
||||||
|
setAppUpdError(null)
|
||||||
|
try {
|
||||||
|
const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
|
||||||
|
if (!dl.ok || !dl.filePath) {
|
||||||
|
setAppUpdError(dl.error ?? 'Download failed.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const run = await window.api.runAppUpdate(dl.filePath)
|
||||||
|
// On success the app quits as the installer launches; only errors return here.
|
||||||
|
if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
|
||||||
|
} catch (e) {
|
||||||
|
setAppUpdError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setAppDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<ArrowSyncRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2 as="h2">Software update</Subtitle2>
|
||||||
|
</div>
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
{appVersion
|
||||||
|
? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.`
|
||||||
|
: 'Checking your AeroFetch version…'}
|
||||||
|
</Caption1>
|
||||||
|
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button
|
||||||
|
icon={appChecking ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
||||||
|
onClick={checkAppUpdate}
|
||||||
|
disabled={appChecking || appDownloading}
|
||||||
|
>
|
||||||
|
{appChecking ? 'Checking…' : 'Check for updates'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(updateToken.trim() !== '' || !!appUpdError) && (
|
||||||
|
<Field
|
||||||
|
label="Update access token"
|
||||||
|
hint="Only needed if the update server requires sign-in. Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={updateToken}
|
||||||
|
placeholder="Optional -- access token"
|
||||||
|
onChange={(_, d) => update({ updateToken: d.value })}
|
||||||
|
contentBefore={<ArrowSyncRegular />}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appUpd?.ok && appUpd.available && (
|
||||||
|
<>
|
||||||
|
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
|
||||||
|
Version {appUpd.latestVersion} is available -- here's what changed:
|
||||||
|
</Text>
|
||||||
|
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
|
||||||
|
<div className={styles.folderRow}>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
icon={<ArrowDownloadRegular />}
|
||||||
|
onClick={installAppUpdate}
|
||||||
|
disabled={appDownloading || !appUpd.downloadUrl}
|
||||||
|
>
|
||||||
|
{appDownloading ? 'Downloading…' : 'Update now'}
|
||||||
|
</Button>
|
||||||
|
{appUpd.htmlUrl && (
|
||||||
|
<Button
|
||||||
|
icon={<OpenRegular />}
|
||||||
|
onClick={() => void window.api?.openUrl?.(appUpd.htmlUrl ?? '')}
|
||||||
|
>
|
||||||
|
View release
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!appUpd.downloadUrl && (
|
||||||
|
<Caption1 className={styles.hint}>
|
||||||
|
This release has no installer attached -- use "View release" to download it manually.
|
||||||
|
</Caption1>
|
||||||
|
)}
|
||||||
|
{appDownloading && (
|
||||||
|
<ProgressBar value={appFraction} aria-label="App update download progress" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appUpd?.ok && !appUpd.available && (
|
||||||
|
<Text>You're up to date -- v{appUpd.currentVersion} is the latest.</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { makeStyles, tokens, shorthands } from '@fluentui/react-components'
|
||||||
|
import { SPACE, RADIUS, ICON } from '../ui/tokens'
|
||||||
|
|
||||||
|
// Shared styles for the Settings cards. Each card renders a single <Card> so it
|
||||||
|
// stays one direct child of SettingsView's root -- the search filter toggles
|
||||||
|
// `display` per child, so the one-DOM-node-per-card shape must be preserved.
|
||||||
|
export const useSettingsStyles = makeStyles({
|
||||||
|
card: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
// Page-level card: card padding + card radius (UI3/UI7).
|
||||||
|
gap: SPACE.section,
|
||||||
|
padding: SPACE.section,
|
||||||
|
...shorthands.borderRadius(RADIUS.card)
|
||||||
|
},
|
||||||
|
notesBox: {
|
||||||
|
maxHeight: '180px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '10px 12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
lineHeight: tokens.lineHeightBase300
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px'
|
||||||
|
},
|
||||||
|
sectionIcon: {
|
||||||
|
// Section-header glyph, next to each card's <Subtitle2> title (UI12 section tier).
|
||||||
|
fontSize: `${ICON.section}px`,
|
||||||
|
color: tokens.colorCompoundBrandForeground1
|
||||||
|
},
|
||||||
|
folderRow: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'flex-end'
|
||||||
|
},
|
||||||
|
folderInput: {
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
// The "Default format" control: a Video/Audio segmented toggle beside a quality
|
||||||
|
// dropdown, mirroring the DownloadBar so both read as one mental model (L102).
|
||||||
|
formatRow: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
},
|
||||||
|
formatQuality: {
|
||||||
|
minWidth: '200px'
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
color: tokens.colorNeutralForeground3
|
||||||
|
},
|
||||||
|
// Monospace version/output text (About card). Replaces the repeated inline
|
||||||
|
// `style={{ fontFamily: fontFamilyMonospace }}` (L5).
|
||||||
|
mono: {
|
||||||
|
fontFamily: tokens.fontFamilyMonospace
|
||||||
|
},
|
||||||
|
monoBlock: {
|
||||||
|
fontFamily: tokens.fontFamilyMonospace,
|
||||||
|
display: 'block'
|
||||||
|
},
|
||||||
|
monoPre: {
|
||||||
|
fontFamily: tokens.fontFamilyMonospace,
|
||||||
|
whiteSpace: 'pre-wrap'
|
||||||
|
},
|
||||||
|
swatchRow: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px'
|
||||||
|
},
|
||||||
|
swatch: {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
flexShrink: 0,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||||
|
...shorthands.border('2px', 'solid', 'transparent'),
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
// The swatch *is* a color preview, so keep the accent color under Windows
|
||||||
|
// High Contrast / forced-colors (W18); otherwise every swatch flattens to one
|
||||||
|
// system color and the accents become indistinguishable.
|
||||||
|
forcedColorAdjust: 'none'
|
||||||
|
},
|
||||||
|
swatchActive: {
|
||||||
|
...shorthands.borderColor(tokens.colorNeutralForeground1)
|
||||||
|
},
|
||||||
|
errorList: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px'
|
||||||
|
},
|
||||||
|
errorRow: {
|
||||||
|
padding: '10px 12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||||
|
},
|
||||||
|
errorRowHeader: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
},
|
||||||
|
errorRowTitle: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||||
|
import { RADIUS } from './tokens'
|
||||||
|
import { useTextStyles } from './text'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One inline notification bar (UI19). The DownloadBar's copied-link suggestion,
|
||||||
|
* its channel/playlist nudge, its duplicate warning, and the Library's copied-link
|
||||||
|
* suggestion had each hand-rolled the same tinted row (leading icon + flex-grow
|
||||||
|
* text + trailing actions). This is that row, once: a leading `icon`, `children`
|
||||||
|
* content that truncates by default (`wrap` to let it flow onto multiple lines),
|
||||||
|
* and a trailing `actions` slot.
|
||||||
|
*
|
||||||
|
* `tone` picks the semantic tint — `brand` for suggestions/nudges, `warning` for
|
||||||
|
* the duplicate caution — matching the former per-component colors exactly.
|
||||||
|
*/
|
||||||
|
export type BannerTone = 'brand' | 'warning'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 8px 8px 12px',
|
||||||
|
...shorthands.borderRadius(RADIUS.surface)
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
backgroundColor: tokens.colorBrandBackground2,
|
||||||
|
color: tokens.colorBrandForeground2
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||||
|
color: tokens.colorStatusWarningForeground1
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function Banner({
|
||||||
|
icon,
|
||||||
|
tone = 'brand',
|
||||||
|
wrap = false,
|
||||||
|
children,
|
||||||
|
actions
|
||||||
|
}: {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
tone?: BannerTone
|
||||||
|
/** Let the content wrap onto multiple lines instead of truncating (the channel nudge). */
|
||||||
|
wrap?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const styles = useStyles()
|
||||||
|
const text = useTextStyles()
|
||||||
|
return (
|
||||||
|
<div className={mergeClasses(styles.root, styles[tone])}>
|
||||||
|
{icon}
|
||||||
|
<div className={mergeClasses(styles.content, !wrap && text.truncate)}>{children}</div>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Body1, Caption1, makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||||
|
import { SPACE } from './tokens'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared empty-state block (L116/L117). The screens had drifted into three
|
||||||
|
* shapes — Downloads (icon + line), History (colored badge + line + sub-hint),
|
||||||
|
* Library (icon + line, no sub-hint) — and the Terminal / Diagnostics "empty"
|
||||||
|
* text wasn't a centered block at all (inline text). This centers them all on one
|
||||||
|
* structure: an optional focal `icon` (a hero glyph or a colored badge), a `Body1`
|
||||||
|
* message at normal foreground, and an optional muted `hint` line.
|
||||||
|
*
|
||||||
|
* `compact` swaps the tall screen-level padding (56px) for a smaller inset (32px),
|
||||||
|
* for placeholders that sit inside an already-populated screen (the Terminal log,
|
||||||
|
* the Diagnostics list) rather than filling an empty one.
|
||||||
|
*/
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
root: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: SPACE.tight,
|
||||||
|
padding: '56px 16px',
|
||||||
|
// The container is muted, so a hero glyph and the hint line read as secondary;
|
||||||
|
// the message overrides back to the normal foreground so it stays legible.
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
textAlign: 'center'
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
padding: '32px 16px'
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
color: tokens.colorNeutralForeground1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
message,
|
||||||
|
hint,
|
||||||
|
compact = false,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
message: React.ReactNode
|
||||||
|
hint?: React.ReactNode
|
||||||
|
compact?: boolean
|
||||||
|
className?: string
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const styles = useStyles()
|
||||||
|
return (
|
||||||
|
<div className={mergeClasses(styles.root, compact && styles.compact, className)}>
|
||||||
|
{icon}
|
||||||
|
<Body1 className={styles.message}>{message}</Body1>
|
||||||
|
{hint && <Caption1>{hint}</Caption1>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { forwardRef } from 'react'
|
||||||
|
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||||
|
import { useFocusStyles } from './focusRing'
|
||||||
|
import { ICON } from './tokens'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
btn: {
|
||||||
|
appearance: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||||
|
color: tokens.colorNeutralForeground2
|
||||||
|
},
|
||||||
|
':disabled': {
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
color: tokens.colorNeutralForegroundDisabled,
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
md: { width: '32px', height: '32px', fontSize: `${ICON.control}px` },
|
||||||
|
sm: { width: '28px', height: '28px', fontSize: `${ICON.inline}px` }
|
||||||
|
})
|
||||||
|
|
||||||
|
type IconButtonProps = {
|
||||||
|
icon: React.JSX.Element
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recurring icon-only ghost button (UI15) — one implementation for the
|
||||||
|
* sidebar's collapse/theme toggles and the playlist row's video/audio switch,
|
||||||
|
* which were separate hand-rolled buttons with the same look. Carries the shared
|
||||||
|
* focus ring (UI29). Extra props (onClick, aria-label, aria-pressed, disabled, …)
|
||||||
|
* pass straight through to the underlying `<button>`.
|
||||||
|
*/
|
||||||
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
||||||
|
{ icon, size = 'md', className, type = 'button', ...rest },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const styles = useStyles()
|
||||||
|
const focus = useFocusStyles()
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={mergeClasses(styles.btn, styles[size], focus.focusRing, className)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Caption1 } from '@fluentui/react-components'
|
||||||
|
import { LinkRegular, DismissRegular } from '@fluentui/react-icons'
|
||||||
|
import { Banner } from './Banner'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "use this copied/received link?" banner (UI19), shared by the DownloadBar
|
||||||
|
* and the Library add-source field, which had identical markup + CSS. Renders the
|
||||||
|
* link (with an optional lead-in like "Link received: ") and Use / Dismiss actions
|
||||||
|
* on the shared {@link Banner}.
|
||||||
|
*/
|
||||||
|
export function LinkSuggestion({
|
||||||
|
prefix,
|
||||||
|
link,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
dismissLabel
|
||||||
|
}: {
|
||||||
|
/** Short lead-in before the link, e.g. "Use copied link? " or "Link received: ". */
|
||||||
|
prefix: string
|
||||||
|
link: string
|
||||||
|
onAccept: () => void
|
||||||
|
onDismiss: () => void
|
||||||
|
/** Accessible name for the dismiss button — say what's being dismissed (L153). */
|
||||||
|
dismissLabel: string
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Banner
|
||||||
|
icon={<LinkRegular />}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button size="small" appearance="primary" onClick={onAccept}>
|
||||||
|
Use
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label={dismissLabel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Caption1>
|
||||||
|
{prefix}
|
||||||
|
{link}
|
||||||
|
</Caption1>
|
||||||
|
</Banner>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-components'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared content max-width for every screen (UI1).
|
||||||
|
*
|
||||||
|
* Settings used to be a 640px column while the list screens (Downloads, Library,
|
||||||
|
* History, Terminal) were full-width, so on a wide window Settings read as an odd
|
||||||
|
* narrow strip beside edge-to-edge siblings. This caps them all at the same
|
||||||
|
* reading width and centers, so they line up. On typical window sizes the content
|
||||||
|
* is already under the cap, so the list screens are visually unchanged — only the
|
||||||
|
* wide-window upper bound is now shared.
|
||||||
|
*
|
||||||
|
* Merge `screen.width` onto each screen's existing root with `mergeClasses`.
|
||||||
|
*/
|
||||||
|
export const useScreenStyles = makeStyles({
|
||||||
|
width: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '1200px',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '12px',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
},
|
||||||
|
titleBlock: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '2px',
|
||||||
|
minWidth: 0,
|
||||||
|
flexGrow: 1
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: tokens.colorNeutralForeground3
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
flexShrink: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The consistent screen header block (UI9/UI10): one page-title style on every
|
||||||
|
* screen, with an optional one-line description and an optional right-aligned
|
||||||
|
* action slot. Previously each screen rolled its own (Subtitle2 / Title2 / none),
|
||||||
|
* and only Library had a description.
|
||||||
|
*/
|
||||||
|
export function ScreenHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description?: React.ReactNode
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const styles = useStyles()
|
||||||
|
return (
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<Subtitle2 as="h1">{title}</Subtitle2>
|
||||||
|
{description && <Caption1 className={styles.description}>{description}</Caption1>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className={styles.actions}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||||
|
import { useFocusStyles } from './focusRing'
|
||||||
|
import { ICON } from './tokens'
|
||||||
|
|
||||||
|
export interface SegmentOption<T extends string> {
|
||||||
|
value: T
|
||||||
|
label: string
|
||||||
|
/** optional leading icon (used by the sidebar theme switch) */
|
||||||
|
icon?: React.JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
group: {
|
||||||
|
display: 'inline-flex',
|
||||||
|
width: 'fit-content',
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
overflow: 'hidden'
|
||||||
|
},
|
||||||
|
// Full-width variant: segments split the available width (the sidebar style).
|
||||||
|
fitted: {
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%'
|
||||||
|
},
|
||||||
|
segment: {
|
||||||
|
appearance: 'none',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: tokens.colorNeutralForeground2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '5px',
|
||||||
|
padding: '7px 16px',
|
||||||
|
fontSize: tokens.fontSizeBase300,
|
||||||
|
fontFamily: tokens.fontFamilyBase,
|
||||||
|
cursor: 'pointer',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||||
|
}
|
||||||
|
},
|
||||||
|
segmentFitted: {
|
||||||
|
flexGrow: 1,
|
||||||
|
flexBasis: 0,
|
||||||
|
padding: '7px 4px',
|
||||||
|
fontSize: tokens.fontSizeBase200
|
||||||
|
},
|
||||||
|
segmentActive: {
|
||||||
|
backgroundColor: tokens.colorBrandBackground,
|
||||||
|
color: tokens.colorNeutralForegroundOnBrand,
|
||||||
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: tokens.colorBrandBackgroundHover
|
||||||
|
},
|
||||||
|
// Under Windows High Contrast the brand background is flattened to the system
|
||||||
|
// canvas, so the checked segment would be indistinguishable from the others
|
||||||
|
// (W18). Paint it with the system Highlight pair so the selection stays visible.
|
||||||
|
'@media (forced-colors: active)': {
|
||||||
|
backgroundColor: 'Highlight',
|
||||||
|
color: 'HighlightText',
|
||||||
|
forcedColorAdjust: 'none'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
display: 'flex',
|
||||||
|
fontSize: `${ICON.inline}px`,
|
||||||
|
flexShrink: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared segmented control (UI14) replacing the two hand-rolled versions —
|
||||||
|
* the DownloadBar's Video/Audio kind toggle and the Sidebar's Light/Dark/Auto
|
||||||
|
* theme switch. Renders an ARIA radiogroup with roving-tabindex arrow-key
|
||||||
|
* navigation (UI30) and the shared focus ring (UI29). `fitted` makes the segments
|
||||||
|
* split the full width (sidebar); the default is fit-content (download bar).
|
||||||
|
*
|
||||||
|
* Active treatment is solid brand — the app's one rule for *toggles* (a compact
|
||||||
|
* set where the chosen option should read as pressed). This is deliberately
|
||||||
|
* distinct from the brand-*tint* used for *selection within a list* (the Sidebar
|
||||||
|
* nav item, the CommandPalette active option): one active treatment per control
|
||||||
|
* class (UI20).
|
||||||
|
*/
|
||||||
|
export function SegmentedControl<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
fitted = false
|
||||||
|
}: {
|
||||||
|
value: T
|
||||||
|
options: SegmentOption<T>[]
|
||||||
|
onChange: (value: T) => void
|
||||||
|
ariaLabel: string
|
||||||
|
fitted?: boolean
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const styles = useStyles()
|
||||||
|
const focus = useFocusStyles()
|
||||||
|
const refs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
|
|
||||||
|
function onKeyDown(e: React.KeyboardEvent, index: number): void {
|
||||||
|
let next = -1
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (index + 1) % options.length
|
||||||
|
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
|
||||||
|
next = (index - 1 + options.length) % options.length
|
||||||
|
else if (e.key === 'Home') next = 0
|
||||||
|
else if (e.key === 'End') next = options.length - 1
|
||||||
|
else return
|
||||||
|
const opt = options[next]
|
||||||
|
if (!opt) return
|
||||||
|
e.preventDefault()
|
||||||
|
onChange(opt.value)
|
||||||
|
refs.current[next]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={mergeClasses(styles.group, fitted && styles.fitted)}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{options.map((opt, i) => {
|
||||||
|
const on = opt.value === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
ref={(el) => {
|
||||||
|
refs.current[i] = el
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={on}
|
||||||
|
// Roving tabindex: only the selected segment is a tab stop; arrow keys
|
||||||
|
// move within the group, as a radiogroup is expected to behave.
|
||||||
|
tabIndex={on ? 0 : -1}
|
||||||
|
className={mergeClasses(
|
||||||
|
styles.segment,
|
||||||
|
fitted && styles.segmentFitted,
|
||||||
|
on && styles.segmentActive,
|
||||||
|
focus.focusRing
|
||||||
|
)}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
onKeyDown={(e) => onKeyDown(e, i)}
|
||||||
|
>
|
||||||
|
{opt.icon && <span className={styles.icon}>{opt.icon}</span>}
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Badge } from '@fluentui/react-components'
|
||||||
|
import type { DownloadStatus } from '../../store/downloads'
|
||||||
|
|
||||||
|
/** The item-download status shown as a chip — the queue's live status plus the
|
||||||
|
* library's 'pending' (catalogued but not yet downloaded). */
|
||||||
|
export type ChipStatus = DownloadStatus | 'pending'
|
||||||
|
|
||||||
|
type ChipColor = 'brand' | 'success' | 'danger' | 'warning' | 'subtle'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One label + color per status (UI18 / M8) — the single source of truth that
|
||||||
|
* replaces QueueItem's Fluent `Badge` map and LibraryView's custom color `pill`
|
||||||
|
* spans, so the same status concept looks and reads identically on both screens
|
||||||
|
* (e.g. Library no longer says "Downloaded" where the queue says "Completed").
|
||||||
|
*/
|
||||||
|
const STATUS_CHIP: Record<ChipStatus, { label: string; color: ChipColor }> = {
|
||||||
|
pending: { label: 'Pending', color: 'subtle' },
|
||||||
|
queued: { label: 'Queued', color: 'subtle' },
|
||||||
|
downloading: { label: 'Downloading', color: 'brand' },
|
||||||
|
paused: { label: 'Paused', color: 'warning' },
|
||||||
|
saved: { label: 'Saved', color: 'subtle' },
|
||||||
|
completed: { label: 'Completed', color: 'success' },
|
||||||
|
error: { label: 'Failed', color: 'danger' },
|
||||||
|
canceled: { label: 'Canceled', color: 'subtle' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared status chip used by the download queue and the library item list. */
|
||||||
|
export function StatusChip({ status }: { status: ChipStatus }): React.JSX.Element {
|
||||||
|
const { label, color } = STATUS_CHIP[status]
|
||||||
|
return (
|
||||||
|
<Badge appearance="tint" color={color}>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
Caption1,
|
||||||
|
Button,
|
||||||
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
|
tokens,
|
||||||
|
shorthands
|
||||||
|
} from '@fluentui/react-components'
|
||||||
|
import {
|
||||||
|
DismissRegular,
|
||||||
|
ErrorCircleFilled,
|
||||||
|
CheckmarkCircleFilled,
|
||||||
|
InfoFilled
|
||||||
|
} from '@fluentui/react-icons'
|
||||||
|
import { useToasts, type ToastTone } from '../../store/toasts'
|
||||||
|
import { Z, ELEVATION, RADIUS, ICON } from './tokens'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the transient-toast stack (UX6/UX9) bottom-center. Deliberately NOT a
|
||||||
|
* Fluent Toaster/portal — this app avoids portal-based overlays (they blank/flicker
|
||||||
|
* on the dev GPU; see Select.tsx / CommandPalette.tsx) — so it's a plain fixed
|
||||||
|
* stack. No enter/exit animation, matching the app's conservative motion policy on
|
||||||
|
* this machine.
|
||||||
|
*/
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
stack: {
|
||||||
|
position: 'fixed',
|
||||||
|
left: '50%',
|
||||||
|
bottom: '24px',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: Z.tooltip,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
// Let clicks fall through the gaps between toasts to the app underneath.
|
||||||
|
pointerEvents: 'none'
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
maxWidth: 'min(520px, 90vw)',
|
||||||
|
padding: '10px 8px 10px 12px',
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
...shorthands.borderRadius(RADIUS.surface),
|
||||||
|
boxShadow: ELEVATION.overlay
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: `${ICON.control}px`,
|
||||||
|
display: 'flex'
|
||||||
|
},
|
||||||
|
info: { color: tokens.colorNeutralForeground3 },
|
||||||
|
error: { color: tokens.colorStatusDangerForeground1 },
|
||||||
|
success: { color: tokens.colorStatusSuccessForeground1 },
|
||||||
|
message: {
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const ICONS: Record<ToastTone, React.JSX.Element> = {
|
||||||
|
info: <InfoFilled />,
|
||||||
|
error: <ErrorCircleFilled />,
|
||||||
|
success: <CheckmarkCircleFilled />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toaster(): React.JSX.Element | null {
|
||||||
|
const styles = useStyles()
|
||||||
|
const toasts = useToasts((s) => s.toasts)
|
||||||
|
const dismiss = useToasts((s) => s.dismiss)
|
||||||
|
const toneClass: Record<ToastTone, string> = {
|
||||||
|
info: styles.info,
|
||||||
|
error: styles.error,
|
||||||
|
success: styles.success
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className={styles.stack} role="status" aria-live="polite">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div key={t.id} className={styles.toast}>
|
||||||
|
<span className={mergeClasses(styles.icon, toneClass[t.tone])}>{ICONS[t.tone]}</span>
|
||||||
|
<Caption1 className={styles.message}>{t.message}</Caption1>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={() => dismiss(t.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { makeStyles, tokens } from '@fluentui/react-components'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared error-text styling (M12). The red `colorPaletteRedForeground1` was being
|
||||||
|
* applied via inline `style={{ color: … }}` across the app; this hook is the one
|
||||||
|
* place that owns it.
|
||||||
|
*
|
||||||
|
* - `error` — a single line of error copy (e.g. an inline validation/result message).
|
||||||
|
* - `errorPre` — the same colour but preserving newlines/long tokens, for multi-line
|
||||||
|
* yt-dlp/updater error output that would otherwise overflow.
|
||||||
|
*/
|
||||||
|
export const useErrorTextStyles = makeStyles({
|
||||||
|
error: {
|
||||||
|
color: tokens.colorPaletteRedForeground1
|
||||||
|
},
|
||||||
|
errorPre: {
|
||||||
|
color: tokens.colorPaletteRedForeground1,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { makeStyles, tokens } from '@fluentui/react-components'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One focus-visible ring for every hand-rolled interactive element (UI29).
|
||||||
|
*
|
||||||
|
* Fluent's own controls draw their own focus indicator; the app's bespoke
|
||||||
|
* buttons, segmented controls, command-palette rows, and `role="button"` headers
|
||||||
|
* previously fell back to the UA default (often invisible) or removed it
|
||||||
|
* entirely. This gives them all the same visible, theme-aware keyboard focus.
|
||||||
|
*
|
||||||
|
* Apply with `mergeClasses(focus.focusRing, …)`. The ring is inset (negative
|
||||||
|
* offset) so it never clips inside `overflow: hidden` containers and follows each
|
||||||
|
* element's own border-radius; it shows only for `:focus-visible`, so pointer
|
||||||
|
* clicks stay quiet.
|
||||||
|
*/
|
||||||
|
export const useFocusStyles = makeStyles({
|
||||||
|
focusRing: {
|
||||||
|
outlineStyle: 'none',
|
||||||
|
':focus-visible': {
|
||||||
|
outlineWidth: '2px',
|
||||||
|
outlineStyle: 'solid',
|
||||||
|
outlineColor: tokens.colorStrokeFocus2,
|
||||||
|
outlineOffset: '-2px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { makeStyles, tokens } from '@fluentui/react-components'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared text-role styles + the app's type-role convention (Batch 7).
|
||||||
|
*
|
||||||
|
* The renderer had drifted on typography: the one muted color was re-declared under
|
||||||
|
* eight class names (L110), row titles were rendered as three different components
|
||||||
|
* (L114), and `Caption1`/`Body1`/`Text` were applied by feel (L123/L124). The
|
||||||
|
* convention below makes the roles explicit; this module supplies the two styles
|
||||||
|
* that back it.
|
||||||
|
*
|
||||||
|
* Type roles — reach for the Fluent type component, add `muted` for secondary color:
|
||||||
|
* - `<Subtitle2>` — screen titles and settings-section headers (via `ScreenHeader`
|
||||||
|
* and each settings card's header).
|
||||||
|
* - `<Text>` — the one row/item title component (add `title` for the semibold,
|
||||||
|
* single-line-with-ellipsis treatment). Previously split across `<Text>`, a bare
|
||||||
|
* `<span>`, and `<Caption1>` (L114).
|
||||||
|
* - `<Body1>` — body copy: empty-state messages, onboarding prose, gated notices.
|
||||||
|
* Not for titles or metadata (L124).
|
||||||
|
* - `<Caption1>` — small secondary text: metadata, hints, counts, timestamps. One
|
||||||
|
* role doing several closely-related jobs, all "small secondary text" (L123).
|
||||||
|
*/
|
||||||
|
export const useTextStyles = makeStyles({
|
||||||
|
/**
|
||||||
|
* The single muted / secondary-text color (L110) — was re-declared as
|
||||||
|
* hint/sub/meta/srcSub/stats/count/emptyHint/previewMeta. Merge onto a `<Caption1>`
|
||||||
|
* or `<Text>`, or use alone where the element sets no other style.
|
||||||
|
*/
|
||||||
|
muted: {
|
||||||
|
color: tokens.colorNeutralForeground3
|
||||||
|
},
|
||||||
|
/** The shared row/item title treatment (L114): semibold, single line, ellipsis. */
|
||||||
|
title: {
|
||||||
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The single-line truncation utility (L126): clip overflowing text to one line
|
||||||
|
* with an ellipsis. This is `title` without the semibold weight — for metadata
|
||||||
|
* and non-title text that should truncate rather than wrap. Needs `minWidth: 0`
|
||||||
|
* so it can shrink inside a flex row.
|
||||||
|
*/
|
||||||
|
truncate: {
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { tokens } from '@fluentui/react-components'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AeroFetch design tokens — the app's own semantic scales, layered on Fluent's.
|
||||||
|
*
|
||||||
|
* Batch 6 of the audit (UI2–UI7, UI12, UI26, L100, L101, L121, L125, L160, L164)
|
||||||
|
* found the same visual decisions expressed as ad-hoc literals scattered across
|
||||||
|
* components: four section gaps, seven card paddings, two floating shadows, two
|
||||||
|
* hardcoded `zIndex: 1000`s, a raw `rgba()` scrim, nine inline icon sizes, and
|
||||||
|
* three metadata separators. This module is the single home for those values so
|
||||||
|
* every screen reads as one product and a change happens in one place.
|
||||||
|
*
|
||||||
|
* Where Fluent already defines a scale (spacing, radius, shadow, motion, the
|
||||||
|
* overlay scrim) these alias Fluent's tokens, so the app stays pixel-aligned with
|
||||||
|
* Fluent's own controls. Where Fluent has no token (z-index, icon px, the
|
||||||
|
* metadata separator) the value lives here.
|
||||||
|
*
|
||||||
|
* Griffel evaluates `makeStyles` at runtime in this project (no build plugin — see
|
||||||
|
* `thumbSizes` already imported into `downloadBar/styles.ts`), so these imported
|
||||||
|
* constants work directly inside `makeStyles` blocks and in inline `style={{}}`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spacing ramp (px). Gaps, padding, and margins all draw from this one scale; the
|
||||||
|
* comments record the role each step plays so call sites stay self-documenting.
|
||||||
|
* Values match Fluent's spacing ramp (XXS 2 · XS 4 · S 8 · MNudge 10 · M 12 · L 16
|
||||||
|
* · XL 20 · XXL 24 · XXXL 32).
|
||||||
|
*/
|
||||||
|
export const SPACE = {
|
||||||
|
/** 2px — optical hairline nudge (e.g. aligning an icon to a text baseline) (L160). */
|
||||||
|
hairline: '2px',
|
||||||
|
/** 4px — icon-button clusters (UI5); tight margins between stacked lines (L160). */
|
||||||
|
xtight: '4px',
|
||||||
|
/** 8px — labelled-control rows (UI5); the default inline gap. */
|
||||||
|
tight: '8px',
|
||||||
|
/** 10px — control inner padding; small tiles. */
|
||||||
|
snug: '10px',
|
||||||
|
/** 12px — list-row padding; the gap between rows inside a card. */
|
||||||
|
cozy: '12px',
|
||||||
|
/** 16px — the one gap between screen sections (UI2) and the card padding tier (UI3). */
|
||||||
|
section: '16px',
|
||||||
|
/** 20px — a larger block gap. */
|
||||||
|
roomy: '20px',
|
||||||
|
/** 24px — page content padding (UI4); the hero padding tier's floor. */
|
||||||
|
page: '24px',
|
||||||
|
/** 32px — the onboarding / welcome hero card padding (UI3 hero tier). */
|
||||||
|
hero: '32px'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corner-radius tiers by surface (UI6/UI7). Aliases Fluent's radius tokens, which
|
||||||
|
* `friendlyRadii` (theme.ts) tunes to Medium 10 · Large 12 · XLarge 16.
|
||||||
|
*
|
||||||
|
* - `control` (10) — inputs, thumbnails, small tiles, popover rows.
|
||||||
|
* - `surface` (12) — list-item cards and inline panels.
|
||||||
|
* - `card` (16) — page-level cards (the download bar, settings cards, onboarding).
|
||||||
|
*/
|
||||||
|
export const RADIUS = {
|
||||||
|
control: tokens.borderRadiusMedium,
|
||||||
|
surface: tokens.borderRadiusLarge,
|
||||||
|
card: tokens.borderRadiusXLarge
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elevation scale (L100). Cards are flat by default (border only — the app's
|
||||||
|
* resting surface); only genuinely floating surfaces cast a shadow.
|
||||||
|
*
|
||||||
|
* - `flat` — the default card: no shadow, a 1px neutral-stroke border.
|
||||||
|
* - `raised` (shadow4) — the download bar, which floats above the queue.
|
||||||
|
* - `overlay` (shadow28) — the command palette and any future popover.
|
||||||
|
*/
|
||||||
|
export const ELEVATION = {
|
||||||
|
flat: 'none',
|
||||||
|
raised: tokens.shadow4,
|
||||||
|
overlay: tokens.shadow28
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Z-index scale (L101). Replaces the two hardcoded `zIndex: 1000`s so stacking is
|
||||||
|
* ordered on purpose: tooltips sit above full-screen overlays.
|
||||||
|
*/
|
||||||
|
export const Z = {
|
||||||
|
/** full-screen scrims and the command palette. */
|
||||||
|
overlay: 1000,
|
||||||
|
/** hint bubbles — above overlays so a tooltip is never clipped by one. */
|
||||||
|
tooltip: 1100
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme-aware scrim for full-screen overlays (L121). Fluent's overlay token
|
||||||
|
* replaces the renderer's one raw `rgba(0,0,0,0.32)` — it's the semantic dialog
|
||||||
|
* scrim and darkens a touch more, so the palette separates cleanly from a dark UI.
|
||||||
|
*/
|
||||||
|
export const SCRIM = tokens.colorBackgroundOverlay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon-size scale in px (UI12/L125): one small set instead of the nine scattered
|
||||||
|
* `fontSize` literals. Applied as a numeric `fontSize={ICON.x}` on standalone
|
||||||
|
* glyphs, or `` `${ICON.x}px` `` on the rare makeStyles container.
|
||||||
|
*
|
||||||
|
* - `inline` (16) — glyphs sitting inline with text (row actions, small badges).
|
||||||
|
* - `control` (20) — glyphs inside buttons, nav items, toggles, and small leading
|
||||||
|
* accent icons (e.g. the onboarding folder/tip rows).
|
||||||
|
* - `section` (24) — section/card-header glyphs (e.g. each settings card's title).
|
||||||
|
* - `hero` (40) — the focal icon in an empty state.
|
||||||
|
*
|
||||||
|
* Snapped to the nearest tier rather than a literal px even when centered in a
|
||||||
|
* fixed tile (UI11 — no literal px font sizes): the brand/source tiles
|
||||||
|
* (Sidebar/Onboarding `mark`, Library `srcIcon`, History `emptyBadge`) take
|
||||||
|
* `control` or `section`. Genuinely off this scale: the tiny `watchBadge` glyph
|
||||||
|
* (matched to its badge-caption text size) and the thumbnail placeholder icons
|
||||||
|
* (which track `thumbSizes`).
|
||||||
|
*/
|
||||||
|
export const ICON = {
|
||||||
|
inline: 16,
|
||||||
|
control: 20,
|
||||||
|
section: 24,
|
||||||
|
hero: 40
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One motion policy (UI26). The app keeps a single, subtle transition timing
|
||||||
|
* (matching the sidebar's former `0.15s ease`) and gates *all* transitions and
|
||||||
|
* animations behind `prefers-reduced-motion` globally in base.css — so motion is
|
||||||
|
* consistent where it exists and fully off for users who ask for less.
|
||||||
|
*/
|
||||||
|
export const MOTION = {
|
||||||
|
duration: tokens.durationFast,
|
||||||
|
curve: tokens.curveEasyEase
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single metadata separator (L164). Joins independent fields on a meta line
|
||||||
|
* (e.g. `Channel • 12:04`, `1.2 GB/s • 3s left`). Previously three styles were in
|
||||||
|
* use (`' • '`, `' · '`, `' • '`). Note this is distinct from the *intra-value*
|
||||||
|
* separator inside a compound quality label ("720p · mp4 · 184 MB"), which is a
|
||||||
|
* data format parsed elsewhere and intentionally left alone.
|
||||||
|
*/
|
||||||
|
export const META_SEP = ' • '
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// One home for the renderer's date/time formatters (M9). These were previously
|
||||||
|
// three private functions — `relTime` (LibraryView), `formatWhen` (HistoryView)
|
||||||
|
// and `fmtSchedule` (QueueItem) — each reimplementing date math inline.
|
||||||
|
|
||||||
|
/** Relative "time since" label: "just now" / "5 min ago" / "3 h ago" /
|
||||||
|
* "2 d ago" / "3 w ago" / "2 mo ago". Returns "never" for a missing
|
||||||
|
* timestamp. (Library last-indexed.) */
|
||||||
|
export function relTime(ms?: number): string {
|
||||||
|
if (!ms) return 'never'
|
||||||
|
const mins = Math.round((Date.now() - ms) / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins} min ago`
|
||||||
|
const hrs = Math.round(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs} h ago`
|
||||||
|
const days = Math.round(hrs / 24)
|
||||||
|
if (days < 7) return `${days} d ago`
|
||||||
|
const weeks = Math.round(days / 7)
|
||||||
|
if (weeks < 5) return `${weeks} w ago`
|
||||||
|
return `${Math.round(days / 30)} mo ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute "when" label: "Today, 3:04 PM" / "Yesterday, 3:04 PM" /
|
||||||
|
* "Jun 5" (current year) / "Jun 5, 2024" (past year). (History rows.) */
|
||||||
|
export 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}`
|
||||||
|
const sameYear = d.getFullYear() === now.getFullYear()
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
...(sameYear ? {} : { year: 'numeric' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full date + time for a scheduled download, e.g. "Jun 5, 2025, 3:04 PM".
|
||||||
|
* Returns '' if the timestamp can't be formatted. (Queue scheduled badge.) */
|
||||||
|
export function fmtSchedule(ms: number): string {
|
||||||
|
try {
|
||||||
|
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user