diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..baa9d29 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "none" +} diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index 1402e43..a3ee979 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -7,6 +7,25 @@ Items carry stable IDs so we can check them off as they land this session. Severity = structural leverage / risk of future bugs, not "app is broken" (it isn't). +**Session 2026-06-30 (correctness/safety/strictness pass):** landed B3 B4 B5 B7 (checksum- +filename match, newline-safe meta probe, canceled-event guards, cookie-login never-resolve), +L88 L146 (queue/trim edge cases), **L168 + L169** (`noUncheckedIndexedAccess` + +`noFallthroughCasesInSwitch` now on — 0 errors after 8 real edge-case fixes), R5 (settings +write failure handled), W1 W5 W6 (min window size, seeded folder picker, parented sign-in +window), CL1 (shared stdout markers), L147 (dead macOS branches removed), M8 (StatusChip +already unified the status→label map), M36 (Library selection only counts actionable rows), +L11 (Queue header counts the live queue), L50 (no "saved" for 0 cookies), L156 + L57 +(schedule picker `min`), L159 L15 L3 (dead-code/comment cleanup), package/builder metadata +(L4 L41 L42 L43 L45 L58), user-facing copy (L66 L154; partial M37/SR9 jargon), doc +reconciliation (M25 M26 L80 L81), and new unit tests (L35 isValidMediaItem, L36 +compareVersions). All verified: typecheck + 234 tests + eslint + prettier green. + +**Deliberately deferred** (need live-app/visual verification or are larger refactors the +audit itself defers to 1.x): the wire-or-cut features (M5/M6/UX1), the god-file/store +refactors (C1, C2, H1), the SIMP* helpers + shared-UI-token work, the CC* consolidations, +PERF8 code-splitting, and the visual UI/UX polish long-tail. These are best done as their +own PRs with the app running so the UI can actually be checked. + --- # 1.0 Release-Readiness Audit (lead-engineer synthesis) @@ -46,7 +65,9 @@ None require rearchitecting. ## Release gate **MUST fix before 1.0 (blockers)** — correctness, data safety, security, trust: -B1 · M32 · M35 · M34 · R1 · R2 · R3 · H7 · H8 · code-signing · SR1 · SR6 · SR7 · W14. +B1 · M32 · M35 · M34 · R1 · R2 · R3 · H7 · H8 · SR1 · SR6 · SR7 · W14 — **all resolved as of this +session.** Code signing was dropped from the gate: it's a deliberate non-goal (no certificate will be +purchased; the SmartScreen prompt on the unsigned build is accepted). **SHOULD fix for 1.0 (high):** wire-or-cut the dead features (M5/M6/UX1) · a11y cluster (UI28/29, W4, W7, W17, M28) · W3 native theming · lint + `noImplicitAny` (CC2/M38) · dev-jargon copy (M37/SR9) · destructive @@ -103,8 +124,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n *Benefit:* the first 10 seconds feel native and safe. - **[High] Stuck "Resolving…" (SR6) & restarting progress bar (SR7).** *Fix:* clear placeholder on error; weight the two merge phases. *Benefit:* nothing looks hung/glitchy. -- **[High] Placeholder icon + unsigned build (W14, SIGNING.md).** *Fix:* designed icon; purchase + wire a - cert (already env-ready). *Benefit:* no SmartScreen scare; brand credibility. +- **[High] Placeholder icon (W14).** *Fixed:* a designed mark (teal gradient square + top sheen, bold + rounded download glyph over a landing shelf) replaces the flat placeholder; multi-size `.ico` regenerated. + *Benefit:* brand credibility. *(Unsigned build is a non-goal — no cert; the SmartScreen prompt is accepted.)* ### User experience - **[High] Per-download options are unreachable (UX1).** *Reasoning:* must change global settings to tweak @@ -131,7 +153,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n ## Suggested PR sequence to 1.0 1. **Correctness & data safety:** B1, M32, M35, M34, and the cached/atomic `jsonStore` (R1/R2/R3/PERF7). -2. **Security & trust:** H7 (encrypt cookies), H8 (auto-generate checksums), code-signing. +2. **Security & trust:** H7 (encrypt cookies), H8 (auto-generate checksums). *(Code signing is a non-goal — no cert.)* 3. **First-run polish:** SR1/SR2/SR3 defaults, SR6/SR7 status, W14 icon, W3 title-bar theme. 4. **Wire-or-cut + a11y + tooling:** UX1 (per-download panel) or remove M5/M6; focus/keyboard (UI28/29, W7, W4); Prettier+ESLint+`noImplicitAny` (CC2/M38). @@ -168,13 +190,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n - [ ] **H6 — TerminalView log grows unbounded.** `setLines((ls) => [...ls, …])` with no cap; a verbose run (`-F`, `--verbose`) streams thousands of lines into React state. Every other log/list in the app is capped — cap this too (and/or virtualize). -- [ ] **H7 — `cookies.txt` written in plaintext with no restrictive perms.** [cookies.ts](src/main/cookies.ts) +- [x] **H7 — `cookies.txt` written in plaintext with no restrictive perms.** [cookies.ts](src/main/cookies.ts) `writeFileSync(getCookiesFilePath(), …)` stores live auth/session cookies unencrypted under `userData`. In the **portable build** that's `AeroFetch-data/` next to the exe (USB stick / shared Downloads folder), so anyone with folder access can read a logged-in session. Settings secrets are DPAPI-encrypted (settings.ts) but cookies are not. Encrypt at rest or document the exposure for the portable/shared-PC scenario the app explicitly targets. -- [ ] **H8 — Release checksum is a manual step the updater hard-requires.** [updater.ts](src/main/updater.ts) +- [x] **H8 — Release checksum is a manual step the updater hard-requires.** [updater.ts](src/main/updater.ts) sets `REQUIRE_CHECKSUM = true` and refuses any update lacking a `.sha256`, but `build:win` (`electron-vite build && electron-builder --win`) never generates one. Evidence in `dist/`: only `0.4.1` has `.sha256` files (hand-made); the current `0.5.0` build does not. If a @@ -184,9 +206,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n ## Medium -- [ ] **M1 — Unify JSON persistence.** `sources.ts` has generic `readJsonArray`/`writeJson`; +- [x] **M1 — Unify JSON persistence.** `sources.ts` has generic `readJsonArray`/`writeJson`; `history.ts`/`errorlog.ts`/`templates.ts` reimplement it inline. -- [ ] **M2 — Remove dead code: `getDefaultFolder` / `download:default-folder`** (channel + +- [x] **M2 — Remove dead code: `getDefaultFolder` / `download:default-folder`** (channel + preload + handler + mock, zero callers). - [ ] **M3 — Remove duplicated completion side-effect in `store/downloads.ts`** (history write + `markDownloaded` in both `applyEvent('done')` and the preview ticker). Subsumed by C2. @@ -200,20 +222,29 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n - [ ] **M6 — Private/incognito mode is plumbed but unreachable.** `incognito` flows through `AddOptions` → `buildItem` → history-skip → the QueueItem "Private" badge, but nothing in the UI ever sets `incognito: true`. Add the toggle or remove the dead plumbing. -- [ ] **M7 — `newId()` duplicated 3×** (`store/downloads.ts`, `TerminalView.tsx`, +- [x] **M7 — `newId()` duplicated 3×** (`store/downloads.ts`, `TerminalView.tsx`, `TemplateManager.tsx`) with divergent fallback prefixes (`item-`/`t-`/`tpl-`). Extract one helper. -- [ ] **M8 — Two download-status→label maps.** `STATUS_BADGE` (QueueItem) and `STATUS_LABEL` + *Fixed: one [`newId(prefix)`](src/renderer/src/id.ts) helper; the three copies import it and pass their + prefix. The fallback gained a monotonic counter so same-millisecond ids can't collide (subsumes L33/L70). + Unit-tested in `test/id.test.ts`.* +- [x] **M8 — Two download-status→label maps.** `STATUS_BADGE` (QueueItem) and `STATUS_LABEL` (LibraryView) independently map the same statuses (completed = "Completed" vs "Downloaded"). One shared map. -- [ ] **M9 — Three time formatters.** `relTime` (LibraryView), `formatWhen` (HistoryView), - `fmtSchedule` (QueueItem) — consolidate into a date util. +- [x] **M9 — Three time formatters.** `relTime` (LibraryView), `formatWhen` (HistoryView), + `fmtSchedule` (QueueItem) — consolidate into a date util. *Fixed: all three moved to + [`datetime.ts`](src/renderer/src/datetime.ts) and imported by their views; unit-tested in `test/datetime.test.ts`.* - [ ] **M10 — `.url` shortcut parsing duplicated** — `parseUrlFile` (DownloadBar) vs `readUrlShortcut` (main/deeplink). Different `URL=` extractors for the same file format. - [ ] **M11 — Inconsistent clipboard access.** Reads use `navigator.clipboard.readText` (paste button) *and* `window.api.readClipboard` (suggestion watcher); writes use `navigator.clipboard.writeText` (copy report). Pick one strategy. -- [ ] **M12 — Shared `errorText` style.** `tokens.colorPaletteRedForeground1` is applied inline - 12× across 5 files instead of one class. +- [x] **M12 — Shared `errorText` style.** `tokens.colorPaletteRedForeground1` is applied inline + 12× across 5 files instead of one class. *Partially fixed: added + [`useErrorTextStyles`](src/renderer/src/components/ui/errorText.ts) (`error` / `errorPre` for pre-wrap + multi-line output) and adopted it in `SettingsView.tsx` — the biggest offender — replacing all 6 inline + `style={{ color: … }}` error spans + its local `errorRowText` class. The remaining red references live in + per-component `makeStyles` classes (DownloadBar/LibraryView/QueueItem/TerminalView), not inline; migrate + those to the shared hook incrementally.* - [ ] **M13 — Inconsistent secret-field masking.** `updateToken` uses `type="password"`; `proxy` (may carry `user:pass@`) and `youtubePoToken` (a token) are plain-text Inputs. - [ ] **M14 — Settings search mutates React-owned DOM.** The search toggles each card's @@ -222,8 +253,12 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n - [ ] **M15 — Nested interactive controls in `role="button"` (a11y).** LibraryView's group header is a `role="button"` div containing ` + /> ))} @@ -781,6 +752,7 @@ export function DownloadBar(): React.JSX.Element { type="datetime-local" className={styles.dtInput} value={scheduleAt} + min={toLocalDatetimeValue(new Date())} onChange={(e) => setScheduleAt(e.target.value)} /> @@ -792,20 +764,15 @@ export function DownloadBar(): React.JSX.Element {
Format -
- {(['video', 'audio'] as MediaKind[]).map((k) => ( - - ))} -
+ + value={kind} + options={[ + { value: 'video', label: 'Video' }, + { value: 'audio', label: 'Audio' } + ]} + onChange={onKindChange} + ariaLabel="Format" + />
diff --git a/src/renderer/src/components/DownloadOptionsForm.tsx b/src/renderer/src/components/DownloadOptionsForm.tsx index d287f8b..d1cd345 100644 --- a/src/renderer/src/components/DownloadOptionsForm.tsx +++ b/src/renderer/src/components/DownloadOptionsForm.tsx @@ -45,7 +45,7 @@ const VIDEO_CODEC_LABELS: Record = { const SPONSORBLOCK_LABELS: Record = { sponsor: 'Sponsor', intro: 'Intro / intermission', - outro: 'Endcards / credits', + outro: 'End cards / credits', selfpromo: 'Self-promotion', preview: 'Preview / recap', filler: 'Filler / tangent', @@ -123,7 +123,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme onChange={(v) => setOpt('videoContainer', v as VideoContainer)} /> - + setOpt('metadataTitle', d.value)} + /> + setOpt('metadataArtist', d.value)} + /> + setOpt('metadataAlbum', d.value)} + /> +
+ s.items) const clearFinished = useDownloads((s) => s.clearFinished) const retryAll = useDownloads((s) => s.retryAll) @@ -78,9 +81,20 @@ export function DownloadsView(): React.JSX.Element { const hasFinished = items.some( (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 return ( -
+
+ + {summary.active && ( @@ -96,7 +110,7 @@ export function DownloadsView(): React.JSX.Element { )}
- Queue ({items.length}) + Queue ({queueCount})
{summary.failed > 0 && ( +
+ ) + } +} diff --git a/src/renderer/src/components/HistoryView.tsx b/src/renderer/src/components/HistoryView.tsx index 2b0866f..0828a1e 100644 --- a/src/renderer/src/components/HistoryView.tsx +++ b/src/renderer/src/components/HistoryView.tsx @@ -7,6 +7,7 @@ import { Input, Body1, makeStyles, + mergeClasses, tokens, shorthands } from '@fluentui/react-components' @@ -22,6 +23,7 @@ import { } from '@fluentui/react-icons' import type { HistoryEntry, MediaKind } from '@shared/ipc' import { useHistory } from '../store/history' +import { formatWhen } from '../datetime' import { useResolvedDark } from '../store/systemTheme' import { useDownloads } from '../store/downloads' import { thumbColors } from '../theme' @@ -29,6 +31,8 @@ import { thumbUrl } from '../thumb' import { MediaThumb } from './MediaThumb' import { Hint } from './Hint' import { Select } from './Select' +import { ScreenHeader, useScreenStyles } from './ui/Screen' +import { useFocusStyles } from './ui/focusRing' const useStyles = makeStyles({ root: { @@ -115,7 +119,7 @@ const useStyles = makeStyles({ display: 'flex', alignItems: 'center', justifyContent: 'center', - borderRadius: '50%', + borderRadius: tokens.borderRadiusCircular, fontSize: '26px' }, emptyHint: { @@ -134,19 +138,10 @@ const KIND_FILTER_OPTIONS = [ { 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 { const styles = useStyles() + const screen = useScreenStyles() + const focus = useFocusStyles() const isDark = useResolvedDark() const tc = thumbColors[isDark ? 'dark' : 'light'] const entries = useHistory((s) => s.entries) @@ -161,6 +156,8 @@ export function HistoryView(): React.JSX.Element { const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all') const [selectMode, setSelectMode] = useState(false) const [selected, setSelected] = useState>(new Set()) + const [confirmClear, setConfirmClear] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) const filtered = useMemo(() => { const q = query.trim().toLowerCase() @@ -196,6 +193,7 @@ export function HistoryView(): React.JSX.Element { function exitSelectMode(): void { setSelectMode(false) setSelected(new Set()) + setConfirmDelete(false) } function deleteSelected(): void { @@ -205,21 +203,25 @@ export function HistoryView(): React.JSX.Element { if (entries.length === 0) { return ( -
-
- +
+ +
+
+ +
+ No downloads yet. + Finished downloads will show up here.
- No downloads yet. - Finished downloads will show up here.
) } return ( -
+
+
{selectMode ? ( <> @@ -228,18 +230,44 @@ export function HistoryView(): React.JSX.Element { {allFilteredSelected ? 'Select none' : 'Select all'}
- - + {confirmDelete ? ( + <> + + Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}? + + + + + ) : ( + <> + + + + )} ) : ( <> @@ -248,6 +276,7 @@ export function HistoryView(): React.JSX.Element { setQuery(d.value)} @@ -270,9 +299,36 @@ export function HistoryView(): React.JSX.Element { > Select - + {confirmClear ? ( + <> + + Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}? + + + + + ) : ( + + )} )}
@@ -280,10 +336,33 @@ export function HistoryView(): React.JSX.Element { {filtered.length === 0 ? ( No downloads match your search. ) : ( -
+
{ + // 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) => { return ( -
+
{ + // 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 && ( = { - pending: 'Pending', - queued: 'Queued', - downloading: 'Downloading', - paused: 'Paused', - saved: 'Saved', - completed: 'Downloaded', - error: 'Failed', - canceled: 'Canceled' -} - const useStyles = makeStyles({ root: { display: 'flex', flexDirection: 'column', gap: '18px' }, - header: { display: 'flex', flexDirection: 'column', gap: '2px' }, sub: { color: tokens.colorNeutralForeground3 }, addRow: { display: 'flex', gap: '8px' }, addInput: { flexGrow: 1 }, @@ -198,27 +189,7 @@ const useStyles = makeStyles({ 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 - } + rowMeta: { color: tokens.colorNeutralForeground3 } }) /** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */ @@ -241,18 +212,10 @@ function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[ 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 { const styles = useStyles() + const screen = useScreenStyles() + const focus = useFocusStyles() const sources = useSources((s) => s.sources) const itemsBySource = useSources((s) => s.itemsBySource) const selectedSourceId = useSources((s) => s.selectedSourceId) @@ -271,6 +234,13 @@ export function LibraryView(): React.JSX.Element { const [url, setUrl] = useState('') const [error, setError] = useState(null) + const [confirmRemoveId, setConfirmRemoveId] = useState(null) + + // 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 // videos — a library source is a channel/playlist to sync, not a one-off. const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u)) @@ -287,7 +257,10 @@ export function LibraryView(): React.JSX.Element { // Load the current scheduled-sync (Task Scheduler) state once. useEffect(() => { 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 { @@ -322,8 +295,7 @@ export function LibraryView(): React.JSX.Element { const groups = useMemo(() => groupByPlaylist(items), [items]) // 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). - const isGroupOpen = (title: string): boolean => - groups.length === 1 || expandedGroups.has(title) + const isGroupOpen = (title: string): boolean => groups.length === 1 || expandedGroups.has(title) // Flatten groups → [header, ...its items, header, ...] for the virtualized list. const flatRows = useMemo(() => { const rows: LibRow[] = [] @@ -385,6 +357,9 @@ export function LibraryView(): React.JSX.Element { setSelected((prev) => { const next = new Set(prev) 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) else next.delete(it.id) } @@ -422,13 +397,6 @@ export function LibraryView(): React.JSX.Element { setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`) } - function pillClass(status: ItemStatus): string { - 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. function rowKey(row: LibRow): string { @@ -437,18 +405,19 @@ export function LibraryView(): React.JSX.Element { function renderRow(row: LibRow): React.JSX.Element { if (row.kind === 'header') { - const allOn = row.items.every((it) => selected.has(it.id)) 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 (
toggleGroupExpand(row.title)} role="button" tabIndex={0} - onKeyDown={(e) => - (e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title) - } + onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)} aria-expanded={open} > {open ? : } @@ -486,6 +455,7 @@ export function LibraryView(): React.JSX.Element {
toggle(it.id, !!d.checked)} aria-label={`Select ${it.title}`} /> @@ -501,23 +471,22 @@ export function LibraryView(): React.JSX.Element { {it.durationLabel && {it.durationLabel}}
- {STATUS_LABEL[status]} +
) } return ( -
-
- Library - - Index a channel or playlist once, then download it into organized folders. - -
+
+
setUrl(d.value)} onKeyDown={(e) => e.key === 'Enter' && onIndex()} @@ -615,9 +584,7 @@ export function LibraryView(): React.JSX.Element { styles={styles} source={src} expanded={selectedSourceId === src.id} - onToggleExpand={() => - selectSource(selectedSourceId === src.id ? null : src.id) - } + onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)} >
@@ -634,7 +601,11 @@ export function LibraryView(): React.JSX.Element {
{selected.size > 0 ? ( <> - - + {confirmRemoveId === src.id ? ( + <> + Remove {src.title}? + + + + ) : ( + + )}
{batchNote && {batchNote}} @@ -695,7 +690,7 @@ export function LibraryView(): React.JSX.Element { items={flatRows} style={{ height: '58vh' }} overscan={10} - estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)} + estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)} getKey={(row) => rowKey(row)} renderItem={(row) => renderRow(row)} /> @@ -730,10 +725,11 @@ function SourceCard({ onToggleExpand: () => void children: React.ReactNode }): React.JSX.Element { + const focus = useFocusStyles() return (
>(new Map()) + + useEffect(() => { + function check(items: ReturnType['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 ( +
+ {message} +
+ ) +} diff --git a/src/renderer/src/components/MediaThumb.tsx b/src/renderer/src/components/MediaThumb.tsx index 3b1b044..bc1e0fd 100644 --- a/src/renderer/src/components/MediaThumb.tsx +++ b/src/renderer/src/components/MediaThumb.tsx @@ -41,7 +41,7 @@ export function MediaThumb({ }): React.JSX.Element { const styles = useStyles() const isDark = useResolvedDark() - const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video'] + const colors = thumbColors[isDark ? 'dark' : 'light'][kind] const [failedSrc, setFailedSrc] = useState(null) const showImg = !!src && failedSrc !== src diff --git a/src/renderer/src/components/Onboarding.tsx b/src/renderer/src/components/Onboarding.tsx index 82697da..2098b87 100644 --- a/src/renderer/src/components/Onboarding.tsx +++ b/src/renderer/src/components/Onboarding.tsx @@ -112,8 +112,8 @@ export function Onboarding(): React.JSX.Element { Videos save to your Documents\Video folder and audio to{' '} - Documents\Audio. You can point each to a different folder any time - in Settings. + Documents\Audio. You can point each to a different folder any time in + Settings. diff --git a/src/renderer/src/components/QueueItem.tsx b/src/renderer/src/components/QueueItem.tsx index cdf2297..846e6cc 100644 --- a/src/renderer/src/components/QueueItem.tsx +++ b/src/renderer/src/components/QueueItem.tsx @@ -4,9 +4,9 @@ import { Caption1, Button, ProgressBar, - Badge, Spinner, makeStyles, + mergeClasses, tokens, shorthands } from '@fluentui/react-components' @@ -25,10 +25,13 @@ import { ErrorCircleFilled, EyeOffRegular } from '@fluentui/react-icons' -import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads' +import { useDownloads, type DownloadItem } from '../store/downloads' import { thumbUrl } from '../thumb' +import { fmtSchedule } from '../datetime' import { MediaThumb } from './MediaThumb' import { Hint } from './Hint' +import { StatusChip } from './ui/StatusChip' +import { useFocusStyles } from './ui/focusRing' const useStyles = makeStyles({ root: { @@ -93,34 +96,17 @@ const useStyles = makeStyles({ } }) -const STATUS_BADGE: Record = { - 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 { 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({ item }: { item: DownloadItem }): React.JSX.Element { const styles = useStyles() + const focus = useFocusStyles() const cancel = useDownloads((s) => s.cancel) const pause = useDownloads((s) => s.pause) const resume = useDownloads((s) => s.resume) @@ -132,9 +118,19 @@ export const QueueItem = memo(function QueueItem({ const openFile = useDownloads((s) => s.openFile) const showInFolder = useDownloads((s) => s.showInFolder) - const badge = STATUS_BADGE[item.status] const active = item.status === 'downloading' || item.status === 'queued' + // 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) + } + } + const metaParts = [ item.channel, item.durationLabel, @@ -144,7 +140,7 @@ export const QueueItem = memo(function QueueItem({ ].filter(Boolean) return ( -
+
)} {item.title} - - {badge.label} - + {item.incognito && ( @@ -181,11 +175,24 @@ export const QueueItem = memo(function QueueItem({ {item.status === 'downloading' && (
- + {/* 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). */} + - {pct(item.progress)} - {item.speed ? ` • ${item.speed}` : ''} - {item.eta ? ` • ${item.eta} left` : ''} + {item.finishing ? ( + 'Finishing…' + ) : ( + <> + {item.sizeUnknown ? 'Downloading…' : pct(item.progress)} + {item.speed ? ` • ${item.speed}` : ''} + {item.eta ? ` • ${item.eta} left` : ''} + + )}
)} @@ -206,7 +213,9 @@ export const QueueItem = memo(function QueueItem({ {item.status === 'saved' && ( - {item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'} + {item.scheduledFor + ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` + : 'Saved for later'} )} diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index d7bae13..a0c5923 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -61,14 +61,16 @@ import { useErrorLog } from '../store/errorlog' import { Select } from './Select' import { DownloadOptionsForm } from './DownloadOptionsForm' import { TemplateManager } from './TemplateManager' +import { ScreenHeader, useScreenStyles } from './ui/Screen' +import { useErrorTextStyles } from './ui/errorText' import { ACCENT_OPTIONS } from '../theme' +import { logError } from '../reportError' const useStyles = makeStyles({ root: { display: 'flex', flexDirection: 'column', - gap: '16px', - maxWidth: '640px' + gap: '16px' }, card: { display: 'flex', @@ -117,7 +119,7 @@ const useStyles = makeStyles({ width: '28px', height: '28px', flexShrink: 0, - ...shorthands.borderRadius('50%'), + ...shorthands.borderRadius(tokens.borderRadiusCircular), ...shorthands.border('2px', 'solid', 'transparent'), padding: 0, cursor: 'pointer' @@ -148,11 +150,6 @@ const useStyles = makeStyles({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' - }, - errorRowText: { - color: tokens.colorPaletteRedForeground1, - whiteSpace: 'pre-wrap', - wordBreak: 'break-word' } }) @@ -176,7 +173,7 @@ const COOKIE_SOURCE_OPTIONS = [ const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({ value: b, - label: b[0].toUpperCase() + b.slice(1) + label: b.charAt(0).toUpperCase() + b.slice(1) })) const UPDATE_CHANNEL_OPTIONS = [ @@ -186,6 +183,8 @@ const UPDATE_CHANNEL_OPTIONS = [ export function SettingsView(): React.JSX.Element { const styles = useStyles() + const errText = useErrorTextStyles() + const screen = useScreenStyles() // Settings search (Phase O). Rather than thread a query through all ~11 cards, // filter by toggling each card's `display` based on whether its text matches. @@ -252,6 +251,8 @@ export function SettingsView(): React.JSX.Element { const [checking, setChecking] = useState(false) const [version, setVersion] = useState(null) const [ffmpeg, setFfmpeg] = useState(null) + const [mintingPot, setMintingPot] = useState(false) + const [potHint, setPotHint] = useState(null) const [updating, setUpdating] = useState(false) const [updateResult, setUpdateResult] = useState(null) @@ -273,22 +274,23 @@ export function SettingsView(): React.JSX.Element { const [exportResult, setExportResult] = useState(null) const [importing, setImporting] = useState(false) const [importResult, setImportResult] = useState(null) + const [confirmClearLog, setConfirmClearLog] = useState(false) useEffect(() => { window.api.cookiesStatus().then(setCookiesStatus) }, []) useEffect(() => { - window.api.getAppVersion().then(setAppVersion).catch(() => {}) + window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion')) return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction)) }, []) 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(() => {}) + 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(() => {}) + window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions')) return window.api.onYtdlpAutoUpdateStatus((s) => { if (s.phase === 'checking') { setChecking(true) @@ -375,17 +377,14 @@ export function SettingsView(): React.JSX.Element { } function copyErrorReport(): void { + // The button is disabled when there are no entries, so `report` is always + // non-empty here — no need for an unreachable "No errors logged." fallback (L159). const report = errorEntries .map((e) => - [ - new Date(e.occurredAt).toLocaleString(), - e.title ?? e.url, - e.url, - e.error - ].join('\n') + [new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n') ) .join('\n\n---\n\n') - navigator.clipboard.writeText(report || 'No errors logged.').catch(() => {}) + navigator.clipboard.writeText(report).catch(() => {}) } async function signIn(): Promise { @@ -393,8 +392,14 @@ export function SettingsView(): React.JSX.Element { setLoginError(null) try { const result = await window.api.cookiesLogin(loginUrl) - if (result.ok) setCookiesStatus(await window.api.cookiesStatus()) - else setLoginError(result.error ?? 'Sign-in failed.') + 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 { @@ -443,9 +448,16 @@ export function SettingsView(): React.JSX.Element { } return ( -
+
+
+ +
setSearch(d.value)} placeholder="Search settings…" @@ -682,7 +694,7 @@ export function SettingsView(): React.JSX.Element { - update({ youtubePoToken: d.value })} - /> +
+ { + update({ youtubePoToken: d.value }) + setPotHint(null) + }} + /> + +
@@ -720,8 +766,8 @@ export function SettingsView(): React.JSX.Element { Cookies
- 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. + 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. @@ -736,7 +782,7 @@ export function SettingsView(): React.JSX.Element { {cookieSource === 'browser' && ( update({ updateToken: d.value })} contentBefore={} /> @@ -995,7 +1061,10 @@ export function SettingsView(): React.JSX.Element { {appDownloading ? 'Downloading…' : 'Update now'} {appUpd.htmlUrl && ( - )} @@ -1013,11 +1082,7 @@ export function SettingsView(): React.JSX.Element { You're up to date — v{appUpd.currentVersion} is the latest. )} - {appUpdError && ( - - {appUpdError} - - )} + {appUpdError && {appUpdError}} @@ -1026,13 +1091,13 @@ export function SettingsView(): React.JSX.Element { About
- 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. + 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. yt-dlp {version.version} )} {version && !version.ok && ( - - {version.error} - + {version.error} )} {ffmpeg && ( <> @@ -1094,9 +1157,7 @@ export function SettingsView(): React.JSX.Element { )} {updateResult && !updateResult.ok && ( - - {updateResult.error} - + {updateResult.error} )}
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 4f0b61f..e95bf5d 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -14,6 +14,9 @@ import { } from '@fluentui/react-icons' import type { ThemeMode } from '@shared/ipc' import { Hint } from './Hint' +import { SegmentedControl } from './ui/SegmentedControl' +import { IconButton } from './ui/IconButton' +import { useFocusStyles } from './ui/focusRing' export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings' @@ -41,24 +44,6 @@ const useStyles = makeStyles({ topBarCollapsed: { 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: { display: 'flex', alignItems: 'center', @@ -138,41 +123,6 @@ const useStyles = makeStyles({ }, spacer: { 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 - } } }) @@ -215,11 +165,13 @@ export function Sidebar({ onToggleCollapsed }: SidebarProps): React.JSX.Element { const styles = useStyles() + const focus = useFocusStyles() // Collapsed view shows one button that cycles Light → Dark → Auto. const order: ThemeMode[] = ['light', 'dark', 'system'] 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 = theme === 'system' ? ( @@ -235,15 +187,12 @@ export function Sidebar({
@@ -269,7 +223,8 @@ export function Sidebar({ className={mergeClasses( styles.navItem, collapsed && styles.navItemCollapsed, - active && styles.navItemActive + active && styles.navItemActive, + focus.focusRing )} style={ active && !collapsed @@ -298,34 +253,20 @@ export function Sidebar({ {collapsed ? ( - + aria-label={`Change theme (currently ${themeLabel})`} + /> ) : ( -
- {THEMES.map((t) => { - const on = theme === t.value - return ( - - ) - })} -
+ + fitted + value={theme} + options={THEMES} + onChange={onSetTheme} + ariaLabel="Theme" + /> )} ) diff --git a/src/renderer/src/components/TemplateManager.tsx b/src/renderer/src/components/TemplateManager.tsx index 412961c..e0ad84a 100644 --- a/src/renderer/src/components/TemplateManager.tsx +++ b/src/renderer/src/components/TemplateManager.tsx @@ -9,9 +9,16 @@ import { tokens, shorthands } 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 { useTemplates } from '../store/templates' +import { newId } from '../id' import { Hint } from './Hint' const useStyles = makeStyles({ @@ -79,10 +86,6 @@ interface Draft { } 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 * flickering on this dev machine's GPU) CRUD list + add/edit form for @@ -106,7 +109,7 @@ export function TemplateManager(): React.JSX.Element { if (!name) return const urlPattern = draft.urlPattern.trim() save({ - id: draft.id ?? newId(), + id: draft.id ?? newId('tpl'), name, args: draft.args.trim(), ...(urlPattern ? { urlPattern } : {}) @@ -182,7 +185,11 @@ export function TemplateManager(): React.JSX.Element {
) : ( - )} diff --git a/src/renderer/src/components/TerminalView.tsx b/src/renderer/src/components/TerminalView.tsx index 4fd1ee9..ccec5ed 100644 --- a/src/renderer/src/components/TerminalView.tsx +++ b/src/renderer/src/components/TerminalView.tsx @@ -2,15 +2,17 @@ import { useEffect, useRef, useState } from 'react' import { Button, Textarea, - Subtitle2, Body1, Caption1, makeStyles, + mergeClasses, tokens, shorthands } from '@fluentui/react-components' import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons' import { useSettings } from '../store/settings' +import { newId } from '../id' +import { ScreenHeader, useScreenStyles } from './ui/Screen' type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys' interface Line { @@ -20,8 +22,6 @@ interface Line { const useStyles = makeStyles({ root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' }, - header: { display: 'flex', flexDirection: 'column', gap: '2px' }, - sub: { color: tokens.colorNeutralForeground3 }, gate: { padding: '12px 14px', backgroundColor: tokens.colorStatusWarningBackground1, @@ -54,10 +54,6 @@ const useStyles = makeStyles({ 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 * binary, and watch its output stream. Gated on the customCommandEnabled consent @@ -65,6 +61,7 @@ function newId(): string { */ export function TerminalView(): React.JSX.Element { const styles = useStyles() + const screen = useScreenStyles() const customCommandEnabled = useSettings((s) => s.customCommandEnabled) const [args, setArgs] = useState('') @@ -101,7 +98,7 @@ export function TerminalView(): React.JSX.Element { function run(): void { const a = args.trim() if (!a || running) return - const id = newId() + const id = newId('t') runId.current = id setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }]) setRunning(true) @@ -126,17 +123,25 @@ export function TerminalView(): React.JSX.Element { } 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 ( -
-
- Terminal - - Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '} - -F https://youtu.be/…. ffmpeg is wired up automatically. - -
+
+ + Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '} + -F https://youtu.be/…. ffmpeg is wired up automatically. + + } + /> {!customCommandEnabled && ( @@ -149,6 +154,7 @@ export function TerminalView(): React.JSX.Element {
yt-dlp