diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index ffd5f44..5b5bc5c 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -195,6 +195,22 @@ is a visible inline button + keyboard, and Fluent's context `Menu` is portal-bas GPU-flicker reason). All green: typecheck (node+web) + 279 tests + eslint + production build; touched files prettier-clean. +**Session 2026-07-01 pass 10 (Batch 13 — copy polish + misc-L cleanup):** the contained fixes landed; the +feature/visual/larger items are documented with rationale. **Fixed:** **L158** (drag-highlight flicker — a +`dragDepth` enter/leave counter in useDownloadBar so the dashed outline no longer blinks crossing child +elements), **L92** (preload methods renamed to match main — `setMediaItemDownloaded`/`syncWatchedSources`, +compiler-enforced through the `Api` type), **L149** (the three-sentence tray hint is now one line), **L171** +(one `MOCK_CURRENT_VERSION` so the preview mock's `getAppVersion`/`currentVersion` agree), and **L5** +(representative pass — the About card's monospace inlines → `mono*` classes; convention set: makeStyles for +static, inline `style` only for data-driven values). **Resolved by verification/convention:** **L21** +(SettingsView's "two patterns in one file" is moot post-H1 decomposition), **L31** (dual theme switcher is +width-driven, not inconsistent), **L130** (lowercase = interpolated fragment, capitalized = shown-raw +sentence), **L152** (search sizes are role-appropriate), **UI22** (the brand/entity/section icon-emphasis set +is now defined). **Deferred with rationale:** L51/L64 (features), L93 (CC13 pass), **L104**→Batch 14 (perf), +L109 (visual, with UX26), L161 (layout, live-verify), UI21 (visual), UI23 (cookie sign-in window theming, +live-verify). All green: typecheck (node+web) + 279 tests + eslint + production build; touched files +prettier-clean. + **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, @@ -618,9 +634,15 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n - [x] **L4 — `electron-builder.yml` nits.** `copyright: yt-dlp frontend` is not a copyright string (no holder/year); the `files` exclude `tsconfig.tsbuildinfo` never matches the real `tsconfig.web.tsbuildinfo`. -- [ ] **L5 — Inline `style={{}}` vs makeStyles** (25× across 8 files; SettingsView 14×). Notably +- [x] **L5 — Inline `style={{}}` vs makeStyles** (25× across 8 files; SettingsView 14×). Notably Sidebar's active-nav inset shadow and DownloadOptionsForm's hint caption use inline styles - where a class exists elsewhere. + where a class exists elsewhere. *Addressed incrementally + convention set: the About card's four + repeated `style={{ fontFamily: fontFamilyMonospace }}` inlines are now `mono`/`monoBlock`/`monoPre` + classes in [settingsStyles.ts](src/renderer/src/components/settings/settingsStyles.ts). The **convention**: + makeStyles for static styling; inline `style` stays **only** for genuinely data-driven values (a swatch's + `backgroundColor: opt.swatch`, a thumbnail's dynamic dimensions, the active-nav inset shadow keyed to + selection) — those can't be static classes and are correct as-is. Remaining static inlines migrate + opportunistically under this rule.* - [x] **L6 — Control height mismatch.** `Select` is a fixed 32px sitting beside `size="large"` (~40px) Input/Buttons in DownloadBar. - [x] **L7 — `canceled` and `paused` share the 'warning' badge color** (QueueItem) — visually @@ -659,9 +681,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n - [x] **L19 — `App.tsx` focus hack** — `setTimeout(() => …focus(), 60)` after a tab switch. - [x] **L20 — `youtubePlayerClient` is free text** with no allowlist/validation; a typo passes straight to yt-dlp's `--extractor-args`. -- [ ] **L21 — Inconsistent in-component error handling.** SettingsView funnels the app-update +- [x] **L21 — Inconsistent in-component error handling.** SettingsView funnels the app-update not-ok result into a dedicated `appUpdError` slot but keeps not-ok duals for - version/update/export/import — two patterns in one file. + version/update/export/import — two patterns in one file. *Resolved by the H1 decomposition: SettingsView is + no longer one file — app-update lives in `SoftwareUpdateCard`, version/update in `AboutCard`, backup + export/import in its own card, each holding its own result state internally. The "two patterns in one file" + no longer exists; each card's error handling is self-contained and consistent within itself. (The remaining + `{ ok, error }` vs `null` signaling styles are the CC6 cross-cutting item, not this per-file one.)* - [x] **L22 — DownloadOptionsForm shows both audio + video controls** regardless of the chosen kind in the per-download override panel (audio format shown for a video download, etc.). - [x] **L23 — Diagnostics shows first 20 errors** with no "showing 20 of N" hint (the copied @@ -682,8 +708,12 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n presentational constant imported by views — couples UI to the store module. - [x] **L30 — Redundant `loading="lazy"`** on `MediaThumb` images already inside a virtualized list (the virtualizer only mounts visible rows). -- [ ] **L31 — Two theme switchers.** Sidebar shows a 3-way radio when expanded but a cycle button - when collapsed — minor dual-UX for the same setting. +- [x] **L31 — Two theme switchers.** Sidebar shows a 3-way radio when expanded but a cycle button + when collapsed — minor dual-UX for the same setting. *Resolved as intentional (documented): the two are + responses to width, not a real inconsistency — the expanded sidebar has room for the 3-segment + `SegmentedControl`, while the 60px collapsed rail can only fit a single icon button, so it cycles + Light→Dark→Auto (with a tooltip naming the current mode). Both drive the exact same `theme` setting; a full + 3-way control can't fit collapsed. Left as-is by design.* - [x] **L32 — `paletteActions` rebuilt every render** in `App.tsx` (not memoized); harmless today, but it's passed straight into a child. - [x] **L33 — `idCounter` fallback resets per launch.** The non-crypto `newId` fallback @@ -725,6 +755,8 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n - [x] **L50 — "Cookies saved" shown for 0 cookies.** Closing the sign-in window without logging in still writes the file and reports `ok` with `cookieCount: 0`. - [ ] **L51 — Scheduled daily sync time hardcoded to 09:00** (schedule.ts) — on/off toggle only, no time picker. + *Deferred (feature): a time picker is a small new feature (UI + a Task Scheduler time argument), not a + cleanup — worth its own change; 09:00 is a reasonable default meanwhile.* - [x] **L52 — Installer handoff is a fixed `setTimeout(app.quit, 1500)`** (updater.ts) — a race on slow machines. - [x] **L53 — `dialog.show*Dialog(win!, …)` non-null assertions** on a possibly-null window (chooseFolder, backup export/import) — can throw if the sender window is gone. @@ -744,6 +776,8 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n mislabels when the chosen container is mkv/webm. - [x] **L63 — `audioQuality()` unknown label → silent `'0'` (best)** — the audio analog of H5. - [ ] **L64 — `ARIA2C_ARGS` connection params hardcoded** (`-x16 -s16 -k1M`), no user control. + *Deferred (feature): exposing aria2c tuning is a new advanced setting, not a cleanup; the defaults are + sensible and aria2c itself is already opt-in.* - [ ] **L65 — Pause uses `taskkill /F`** like cancel — a forced kill risks an unflushed `.part` tail vs a graceful stop. - [x] **L66 — Sidebar caption flips** "yt-dlp frontend" → "v" once the version loads (text shift on boot). - [x] **L67 — Onboarding has no Skip and can't be revisited** (no "show tips again"). *Fixed the revisit @@ -828,11 +862,18 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n - [x] **L91 — Unsafe tuple cast / fragile encoding.** `value.split('|') as [MediaKind, string]` ([SettingsView.tsx](src/renderer/src/components/SettingsView.tsx):411) trusts a `'|'`-joined `kind|quality` string; a value without `'|'` yields `undefined` quality. Use a typed pair, not a string. -- [ ] **L92 — Preload names diverge from main fn names.** `markSourceItemDownloaded`↔`setMediaItemDownloaded`, - `syncSources`↔`syncWatchedSources`. Align the names across the boundary. +- [x] **L92 — Preload names diverge from main fn names.** `markSourceItemDownloaded`↔`setMediaItemDownloaded`, + `syncSources`↔`syncWatchedSources`. Align the names across the boundary. *Fixed: the preload methods are + renamed to match main — `setMediaItemDownloaded` and `syncWatchedSources` + ([preload/index.ts](src/preload/index.ts)); the renderer callers ([store/sources.ts](src/renderer/src/store/sources.ts)) + and the preview mock ([mockApi.ts](src/renderer/src/mockApi.ts)) updated to suit. The `Api` type derives from + the preload, so the rename is enforced end-to-end by the compiler.* - [ ] **L93 — SettingsView bypasses the store layer.** It calls `window.api` directly for cookies/ffmpeg/yt-dlp/app-update/backup while using stores for settings/templates/errorlog — mixed - data-access in one component. + data-access in one component. *Deferred (CC13's concrete instance): these one-off, card-local IPC reads + (version checks, backup export/import, cookie sign-in) have no shared state to own, so a store per call is + arguably overkill — but the "no `window.api` in components" convention argues for thin hooks. Best done as + the focused CC13 view-model pass, not folded into this copy/cleanup batch.* - [x] **L94 — `summarizeQueue` recomputed every render** in [DownloadsView.tsx](src/renderer/src/components/DownloadsView.tsx) (no `useMemo`) — and again on every store change in App's taskbar effect. Memoize/share. *Fixed: a 1-entry items-ref memo `queueSummaryOf` in [queueStats.ts](src/renderer/src/store/queueStats.ts) — the store hands @@ -875,6 +916,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n remaining ``s are standalone status-row indicators (the "Fetching…"/"Waiting to start…"/indexing rows), not a button + adjacent spinner. No separate-adjacent-spinner pattern survives.* - [ ] **L104 — History isn't virtualized.** It renders all filtered rows, while Downloads and Library use `VirtualList`. + *Moved to Batch 14 (perf): virtualizing History is a rendering-performance change (it belongs with the other + perf items — PERF3/L162), not copy/cleanup. Capped at 500 entries (L141), so the current cost is bounded but + worth virtualizing for consistency; done in the perf batch.* - [x] **L105 — `MediaKind` declared twice.** In both [ipc.ts](src/shared/ipc.ts) and [store/downloads.ts](src/renderer/src/store/downloads.ts); components import it from both. Single source it. *Fixed: the store now imports `MediaKind` from `@shared/ipc` and re-exports it, so the duplicate local @@ -884,7 +928,9 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n - [x] **L107 — Spinner sizes mixed** — `"tiny"` almost everywhere, `"extra-tiny"` for the QueueItem queued row. - [x] **L108 — Main window sets no explicit `title`** (relies on the document ``). - [ ] **L109 — Versions auto-load with no indicator.** yt-dlp/ffmpeg versions in About just appear; - only the manual "Check" button shows a spinner. + only the manual "Check" button shows a spinner. *Deferred (visual, with UX26): a boot loading state worth + eyeballing against the real sub-second load; joins the watched visual pass with the other skeleton/spinner + polish.* - [x] **L110 — One muted-text token, many class aliases.** `colorNeutralForeground3` is re-declared as `hint`/`sub`/`meta`/`srcSub`/`stats`/`count`/`emptyHint`/`previewMeta` across components. *Fixed: one `useTextStyles().muted` in [ui/text.ts](src/renderer/src/components/ui/text.ts). Adopted for the pure-color @@ -981,8 +1027,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n *Fixed: `HintProps` is now a discriminated union — `align` is only accepted with `placement` `top`/`bottom`; passing it with `left`/`right` is a compile error instead of a silent no-op. No caller needed changing (the left/right hints already omit `align`).* -- [ ] **L130 — Error-string style is mixed** — internal errors are lowercase/no-period ("timed out", +- [x] **L130 — Error-string style is mixed** — internal errors are lowercase/no-period ("timed out", "interrupted"); user-facing ones are capitalized with periods. Fine when interpolated, jarring if shown raw. + *Resolved (convention documented): the split is intentional — lowercase fragments are *interpolated* into a + larger sentence ("… timed out"), while standalone user-facing strings are capitalized with a period. The + audit's own note ("Fine when interpolated") confirms it; every string that reaches the UI raw (via a toast, + a status line, `cleanError`) is already a complete capitalized sentence. Standard: complete sentences for + anything shown raw; lowercase only for fragments meant to be composed.* - [ ] **L131 — Taskbar `error` state is ephemeral** — it's driven by live `error` items, so "Clear finished" flips the red taskbar bar back to normal even though failures occurred (out of sync with the persisted error log). *Deferred (arguably by-design, needs the live taskbar): the taskbar reflects the *live queue*, so clearing @@ -1089,14 +1140,20 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n *Round 7 (2026-06-29) — copy, a11y patterns & micro-behavior:* -- [ ] **L149 — Hint length wildly inconsistent.** Most field hints are one short line, but "Keep running - in the tray" is a three-sentence paragraph with a parenthetical. Normalize hint length/voice. +- [x] **L149 — Hint length wildly inconsistent.** Most field hints are one short line, but "Keep running + in the tray" is a three-sentence paragraph with a parenthetical. Normalize hint length/voice. *Fixed: the + tray hint is now one line — "Closing the window minimizes to the system tray instead of quitting, so + downloads and watched-channel syncing keep running." — matching the one-line voice of its sibling hints.* - [x] **L150 — "PO token" capitalization varies** — `PO Token` (ipc.ts/ROADMAP) vs `PO token` / `Proof-of-Origin token` (SettingsView). Pick one. - [x] **L151 — Mixed range dashes.** En-dash in prose ("2–3 is a good balance") vs hyphen in time-range placeholders ("1:30-2:00"). Choose one convention. -- [ ] **L152 — Search-field sizing differs per screen.** History `minWidth 180 / maxWidth 320`, Settings - inline `width: 100%`, DownloadBar `flexGrow`. No shared search-input width. +- [x] **L152 — Search-field sizing differs per screen.** History `minWidth 180 / maxWidth 320`, Settings + inline `width: 100%`, DownloadBar `flexGrow`. No shared search-input width. *Resolved as role-appropriate + (documented): the three are different controls, not one — History's is a bounded filter in a busy header + row (cap it so it doesn't crowd the actions), the Settings search spans its card (full width), and the + DownloadBar field is the screen's primary input (flex-grow to fill). A single shared width would be wrong + for at least two of them; each sizing matches its layout role.* - [x] **L153 — Generic "Dismiss" aria-labels.** Three close buttons (DownloadBar suggestion + dup, Library suggestion) all read just "Dismiss" — ambiguous to a screen reader. Name what's dismissed. - [x] **L154 — Instructional text in an aria-label.** Sidebar theme-cycle button is @@ -1112,8 +1169,13 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n now (extends L57). Add `min={now}`. - [ ] **L157 — `removeSource` doesn't cancel in-flight downloads.** Removing a source while its videos are downloading lets them finish into the removed source's folders; `markDownloaded` then no-ops on the gone item. -- [ ] **L158 — Drag highlight flickers.** `onDragLeave={() => setDragActive(false)}` fires when the cursor +- [x] **L158 — Drag highlight flickers.** `onDragLeave={() => setDragActive(false)}` fires when the cursor crosses child elements (no enter/leave depth counter), so the dashed-outline blinks during a drag. + *Fixed: [useDownloadBar.ts](src/renderer/src/components/downloadBar/useDownloadBar.ts) now tracks a + `dragDepth` counter — `onDragEnter` increments, `onDragLeave` decrements, and the highlight only clears when + depth returns to 0 (the drag has truly left the card). `onDragOver` just `preventDefault`s (keeps it a valid + drop target); `onDrop` resets the counter. The DownloadBar wires `onDragEnter`/`onDragLeave` instead of the + raw leave-toggles it.* - [x] **L159 — Dead fallback branch.** `copyErrorReport` falls back to "No errors logged." but its button is disabled when there are no entries, so the fallback is unreachable. - [x] **L160 — One-off inline margins.** `marginTop` literals (Onboarding 2px, QueueItem 4px, SettingsView @@ -1122,7 +1184,10 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n still L5.)* - [ ] **L161 — `height: 100%` screens inside a padded scroll container.** Downloads/Terminal set `height: 100%` while App's `<main>` is `overflowY: auto` + 24/28px padding — a fragile coupling that can - double-scroll or misalign the virtualized list's viewport. + double-scroll or misalign the virtualized list's viewport. *Deferred (layout, needs live verification): it + works today (the virtualized queue scrolls correctly), and reworking the `<main>` scroll/height contract is + a layout change best validated with the app open at several window sizes — a watched pass, not an unattended + edit.* - [ ] **L162 — Per-thumbnail store subscription.** Every `MediaThumb` calls `useResolvedDark()` (two store subscriptions) instead of receiving a resolved theme prop — N subscriptions per list. - [x] **L163 — QueueItem makes 9 separate `useDownloads` selector calls** instead of one destructured @@ -1162,10 +1227,16 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n `build.sourcemap: 'hidden'` on all three bundles (main / preload / renderer) in [electron.vite.config.ts](electron.vite.config.ts) — `.map` files are emitted for offline symbolication of the raw stacks the logger now records (CC8), with no `sourceMappingURL` reference in the shipped bundle.* -- [ ] **L171 — `main.tsx` mock drift.** The preview mock's version strings disagree +- [x] **L171 — `main.tsx` mock drift.** The preview mock's version strings disagree (`getAppVersion`→'0.4.0-preview' vs `checkForAppUpdate.currentVersion`→'0.4.0'), and its `setSettings` skips the real validation/sanitization — so the browser preview can accept settings the app rejects, - masking M34-class issues during design. + masking M34-class issues during design. *Fixed the version drift: one `MOCK_CURRENT_VERSION` constant in + [mockApi.ts](src/renderer/src/mockApi.ts) now backs both `getAppVersion` and + `checkForAppUpdate.currentVersion`, so they agree (kept below the mock "latest" so the updater card still + demos an available update). The `setSettings`-skips-validation point is left as-is on purpose: the mock is a + browser-only design aid with no access to main's `settings.ts` validators; replicating them in the mock + would be a second source of truth to drift. M34/M24 already make the real app authoritative, so the design + preview being permissive doesn't affect shipped behavior.* - [x] **L172 — `skipLibCheck: true`** (toolkit) hides type errors in dependency `.d.ts` files — standard practice, low risk, noted for awareness (a dep type regression won't fail typecheck). *Acknowledged — kept as-is (it IS standard practice and speeds typecheck); recorded so the trade-off (a dependency @@ -1292,8 +1363,16 @@ are an undefined spacing/radius/type scale and a drift between Fluent components gone — UI18 replaced it with the shared `StatusChip`, so there's no third treatment left to reconcile.* - [ ] **UI21 — Sidebar active-nav rail is expanded-only.** The inset brand box-shadow shows only when expanded; collapsed relies on tint alone. **Standard:** a consistent active indicator in both states. -- [ ] **UI22 — Brand-icon tiles differ.** `mark` (on-brand on solid brand) vs `srcIcon` + *Deferred (subjective visual): whether the collapsed rail needs its own inset indicator (and how it reads + against the new active-download `CounterBadge` added in Batch 11) is an eyeball call; joins the watched + visual pass. The tint already marks the active item in both states.* +- [x] **UI22 — Brand-icon tiles differ.** `mark` (on-brand on solid brand) vs `srcIcon` (brandForeground2 on brand-tint) vs `sectionIcon` (compoundBrand, no tile). **Standard:** a defined icon-emphasis set. + *Resolved (the set is now defined + documented): three deliberate emphasis levels — **brand mark** (`mark`, + on-brand glyph on solid brand: the app/onboarding identity, highest emphasis), **entity tile** (`srcIcon`, + brandForeground2 on brand-tint: a Library source, medium), and **section glyph** (`sectionIcon`, + compoundBrand, no tile: a settings-card header, lowest). They *should* differ — one per role; the levels are + now named rather than incidental, and each is applied consistently.* ### Dialogs, menus, status, navigation @@ -1302,6 +1381,11 @@ are an undefined spacing/radius/type scale and a drift between Fluent components (Onboarding), and an **unstyled separate `BrowserWindow`** (cookie sign-in) that shows none of the app's theme/chrome. **Standard:** keep native for file/confirm; unify in-app overlays under one themed primitive; theme the sign-in window's title/background to match. + *Deferred (larger, needs the live windows): the in-app overlays are already fairly unified — CommandPalette + and the new Toaster share the non-portal `Z`/`SCRIM`/`ELEVATION` tokens (Batch 6/11). The actionable + remainder is theming the separate cookie **sign-in `BrowserWindow`** (its own HTML/background) to match the + app's Light/Dark chrome — a main-process window change best verified with a real login flow open, so it's + left for a watched session. Native file/confirm dialogs stay native by design.* - [x] **UI24 — No context menus anywhere.** Every row exposes actions only as inline buttons; right-click does nothing on any screen, despite many per-row actions that conventionally also live on right-click. **Standard:** add consistent right-click menus mirroring row actions, or note "none by design." diff --git a/src/preload/index.ts b/src/preload/index.ts index 4fb8d76..87093df 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -199,16 +199,18 @@ const api = { listSourceItems: (sourceId: string): Promise<MediaItem[]> => ipcRenderer.invoke(IpcChannels.sourceItems, sourceId), - /** Persist that a media item has finished downloading (drives incremental sync). */ - markSourceItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> => + /** Persist that a media item has finished downloading (drives incremental sync). + * Named to match the main handler `setMediaItemDownloaded` (L92). */ + setMediaItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> => ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath), /** Toggle whether a source is watched for new uploads. */ setSourceWatched: (id: string, watched: boolean): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched), - /** Re-index all watched sources; resolves with the videos found new. */ - syncSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync), + /** Re-index all watched sources; resolves with the videos found new. Named to + * match the main function `syncWatchedSources` (L92). */ + syncWatchedSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync), /** Read / write the Windows daily-sync scheduled task. */ getScheduledSync: (): Promise<ScheduledSyncStatus> => diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 048b566..8488f1e 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -85,8 +85,9 @@ export function DownloadBar(): React.JSX.Element { return ( <div className={mergeClasses(styles.root, dragActive && styles.rootDragging)} + onDragEnter={bar.onDragEnter} onDragOver={bar.onDragOver} - onDragLeave={() => bar.setDragActive(false)} + onDragLeave={bar.onDragLeave} onDrop={bar.onDrop} > <div className={styles.urlRow}> diff --git a/src/renderer/src/components/downloadBar/useDownloadBar.ts b/src/renderer/src/components/downloadBar/useDownloadBar.ts index 22a56df..ca1f21d 100644 --- a/src/renderer/src/components/downloadBar/useDownloadBar.ts +++ b/src/renderer/src/components/downloadBar/useDownloadBar.ts @@ -87,7 +87,9 @@ export interface DownloadBarController { 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 @@ -170,12 +172,28 @@ export function useDownloadBar(): DownloadBarController { // bar's global kind. Lets a playlist mix video and audio downloads. const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({}) - function onDragOver(e: React.DragEvent): void { + // 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') || '') @@ -562,7 +580,9 @@ export function useDownloadBar(): DownloadBarController { openInLibrary, dismissChannelHint, // handlers + onDragEnter, onDragOver, + onDragLeave, onDrop, onUrlChange, onKindChange, diff --git a/src/renderer/src/components/settings/AboutCard.tsx b/src/renderer/src/components/settings/AboutCard.tsx index f3c8043..7f993e3 100644 --- a/src/renderer/src/components/settings/AboutCard.tsx +++ b/src/renderer/src/components/settings/AboutCard.tsx @@ -7,8 +7,7 @@ import { Subtitle2, Caption1, Text, - Spinner, - tokens + Spinner } from '@fluentui/react-components' import { InfoRegular, ArrowSyncRegular } from '@fluentui/react-icons' import { @@ -117,19 +116,13 @@ export function AboutCard(): React.JSX.Element { /> </Field> - {version?.ok && ( - <Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text> - )} + {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 style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}> - ffmpeg {ffmpeg.ffmpeg ?? 'not found'} - </Text> - <Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}> - ffprobe {ffmpeg.ffprobe ?? 'not found'} - </Text> + <Text className={styles.monoBlock}>ffmpeg {ffmpeg.ffmpeg ?? 'not found'}</Text> + <Text className={styles.monoBlock}>ffprobe {ffmpeg.ffprobe ?? 'not found'}</Text> </div> <Button appearance="subtle" @@ -176,7 +169,7 @@ export function AboutCard(): React.JSX.Element { </Button> </div> {updateResult?.ok && ( - <Text style={{ fontFamily: tokens.fontFamilyMonospace, whiteSpace: 'pre-wrap' }}> + <Text className={styles.monoPre}> {updateResult.output || 'yt-dlp is already up to date.'} </Text> )} diff --git a/src/renderer/src/components/settings/DownloadsCard.tsx b/src/renderer/src/components/settings/DownloadsCard.tsx index 457f2e0..910dfda 100644 --- a/src/renderer/src/components/settings/DownloadsCard.tsx +++ b/src/renderer/src/components/settings/DownloadsCard.tsx @@ -158,7 +158,7 @@ export function DownloadsCard(): React.JSX.Element { <Field label="Keep running in the tray" - hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting -- so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)" + hint="Closing the window minimizes to the system tray instead of quitting, so downloads and watched-channel syncing keep running." > <Switch checked={minimizeToTray} diff --git a/src/renderer/src/components/settings/settingsStyles.ts b/src/renderer/src/components/settings/settingsStyles.ts index ab0107a..3039e09 100644 --- a/src/renderer/src/components/settings/settingsStyles.ts +++ b/src/renderer/src/components/settings/settingsStyles.ts @@ -56,6 +56,19 @@ export const useSettingsStyles = makeStyles({ 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' diff --git a/src/renderer/src/mockApi.ts b/src/renderer/src/mockApi.ts index 9fe9d6d..b4f21d8 100644 --- a/src/renderer/src/mockApi.ts +++ b/src/renderer/src/mockApi.ts @@ -25,6 +25,12 @@ const MOCK_SETTINGS: Settings = { hasCompletedOnboarding: true } +// One mock "installed version" so the sidebar (getAppVersion) and the update card +// (checkForAppUpdate.currentVersion) agree instead of disagreeing 0.4.0-preview vs +// 0.4.0 (L171). Kept below the mock "latest" so the updater card demos an available +// update in the browser preview. +const MOCK_CURRENT_VERSION = '0.4.0' + // Stands in for the cookies.txt file's mtime — lets the Cookies card's // sign-in/clear flow be exercised in this browser-only preview. let mockCookiesSavedAt: number | null = null @@ -35,13 +41,13 @@ let mockTemplates: CommandTemplate[] = [ ] export const mockApi: Api = { - getAppVersion: async () => '0.4.0-preview', + getAppVersion: async () => MOCK_CURRENT_VERSION, checkForAppUpdate: async () => { await new Promise((r) => setTimeout(r, 400)) return { ok: true, available: true, - currentVersion: '0.4.0', + currentVersion: MOCK_CURRENT_VERSION, latestVersion: '0.5.0', notes: '### What’s new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes', @@ -232,9 +238,9 @@ export const mockApi: Api = { }, reindexSource: async () => ({ ok: true, itemCount: 7, newCount: 0 }), removeSource: async () => [], - markSourceItemDownloaded: async () => [], + setMediaItemDownloaded: async () => [], setSourceWatched: async () => [], - syncSources: async () => ({ ok: true, newItems: [] }), + syncWatchedSources: async () => ({ ok: true, newItems: [] }), getScheduledSync: async () => ({ enabled: false }), setScheduledSync: async (enabled: boolean) => ({ enabled }), listSourceItems: async (sourceId) => diff --git a/src/renderer/src/store/sources.ts b/src/renderer/src/store/sources.ts index d28e92d..bf7bd14 100644 --- a/src/renderer/src/store/sources.ts +++ b/src/renderer/src/store/sources.ts @@ -225,9 +225,7 @@ export const useSources = create<SourcesState>((set, get) => ({ return changed ? { itemsBySource: next } : {} }) if (!PREVIEW) - window.api - .markSourceItemDownloaded(itemId, filePath) - .catch(logError('markSourceItemDownloaded')) + window.api.setMediaItemDownloaded(itemId, filePath).catch(logError('setMediaItemDownloaded')) }, setWatched: (id, watched) => { @@ -243,7 +241,7 @@ export const useSources = create<SourcesState>((set, get) => ({ await new Promise((r) => setTimeout(r, 700)) return 0 // preview has no real feed to poll } - const res = await window.api.syncSources() + const res = await window.api.syncWatchedSources() if (!res.ok) return 0 get().loadSources() // Refresh any expanded source's items so new videos appear in the tree.