Fix L9/L28/M11: shared thumb sizes, live defaults, clipboard IPC

L9: Extract four thumbnail box dimensions into src/renderer/src/thumbSizes.ts
    (THUMB_LG 120x68, THUMB_MD 108x64, THUMB_SM 72x44, THUMB_XS 60x34).
    DownloadBar, QueueItem, HistoryView, and LibraryView all import and use
    them instead of hard-coded strings -- one place to change the 16:9 sizing.

L28: DownloadBar now reflects Settings changes to defaultKind/defaultQuality
     in real time, but only while the URL field is empty (idle state). A URL
     typed or probed in progress is never discarded by a settings change.

M11: Clipboard reads now consistently use window.api.readClipboard (IPC) in
     the paste button, matching the clipboard-watcher hook. navigator.clipboard
     is no longer used for reads anywhere in the renderer.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 14:20:21 -04:00
parent 3df32d0505
commit 6a8452d6c3
6 changed files with 34 additions and 27 deletions
+3 -3
View File
@@ -248,7 +248,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
[`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
- [x] **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.
- [x] **M12 — Shared `errorText` style.** `tokens.colorPaletteRedForeground1` is applied inline
@@ -392,7 +392,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
ambiguous.
- [x] **L8 — Index/compound list keys.** TerminalView keys log lines by array index; Settings
Diagnostics keys by `id + occurredAt` while every other list keys by `id` alone.
- [ ] **L9 — Thumbnail box sizes not shared.** Four hand-tuned 16:9 boxes (120×68, 108×64, 72×44,
- [x] **L9 — Thumbnail box sizes not shared.** Four hand-tuned 16:9 boxes (120×68, 108×64, 72×44,
60×34) with no shared aspect/size constant.
- [ ] **L10 — Scattered magic numbers.** Timeouts/caps spread across modules (probe 60s, indexer
180s, probeMeta 30s, update-idle 60s; MAX_ENTRIES 500/200, MAX_TEMPLATES 100, MAX_ITEMS 20000).
@@ -434,7 +434,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
- [x] **L27 — DownloadBar Enter probes, not downloads.** Enter in the URL field triggers
`fetchFormats`; there's no keyboard path to actually start the download (LibraryView's Enter
runs its primary action). Inconsistent.
- [ ] **L28 — Default kind/quality applied once.** DownloadBar seeds from settings via a one-shot
- [x] **L28 — Default kind/quality applied once.** DownloadBar seeds from settings via a one-shot
ref; changing the default in Settings while on the Downloads tab isn't reflected until reload.
- [ ] **L29 — UI constant in the store.** `QUALITY_OPTIONS` lives in `store/downloads.ts` yet is a
presentational constant imported by views — couples UI to the store module.
+9 -10
View File
@@ -37,6 +37,7 @@ import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton'
import { useClipboardLink, looksLikeUrl } from '../useClipboardLink'
import { logError } from '../reportError'
import { THUMB_LG } from '../thumbSizes'
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
function firstUrl(text: string): string | null {
@@ -104,8 +105,8 @@ const useStyles = makeStyles({
},
previewThumb: {
flexShrink: 0,
width: '120px',
height: '68px',
width: `${THUMB_LG.w}px`,
height: `${THUMB_LG.h}px`,
objectFit: 'cover',
display: 'flex',
alignItems: 'center',
@@ -315,15 +316,13 @@ export function DownloadBar(): React.JSX.Element {
}
}
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
// 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 && !appliedDefaults.current) {
appliedDefaults.current = true
if (!settingsLoaded || url.trim()) return
setKind(defaultKind)
setQuality(defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality)
}
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality])
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality, url])
// Probe state for the single-video format picker.
const [info, setInfo] = useState<MediaInfo | null>(null)
@@ -415,10 +414,10 @@ export function DownloadBar(): React.JSX.Element {
async function paste(): Promise<void> {
try {
const text = await navigator.clipboard.readText()
const text = (await window.api?.readClipboard?.()) ?? ''
if (text) onUrlChange(text.trim())
} catch {
/* clipboard blocked -- ignore in preview */
/* ignore in preview */
}
}
+4 -3
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import {
Text,
Caption1,
@@ -33,6 +33,7 @@ import { Hint } from './Hint'
import { Select } from './Select'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { useFocusStyles } from './ui/focusRing'
import { THUMB_SM } from '../thumbSizes'
const useStyles = makeStyles({
root: {
@@ -78,8 +79,8 @@ const useStyles = makeStyles({
},
thumb: {
flexShrink: 0,
width: '72px',
height: '44px',
width: `${THUMB_SM.w}px`,
height: `${THUMB_SM.h}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
+8 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import {
Body1,
Caption1,
@@ -41,6 +41,7 @@ import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing'
import { THUMB_XS } from '../thumbSizes'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -178,8 +179,8 @@ const useStyles = makeStyles({
},
plainList: { display: 'flex', flexDirection: 'column' },
rowThumb: {
width: '60px',
height: '34px',
width: `${THUMB_XS.w}px`,
height: `${THUMB_XS.h}px`,
...shorthands.borderRadius(tokens.borderRadiusSmall)
},
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
@@ -242,7 +243,7 @@ export function LibraryView(): React.JSX.Element {
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.
// videos -- a library source is a channel/playlist to sync, not a one-off.
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchNote, setBatchNote] = useState<string | null>(null)
@@ -372,7 +373,7 @@ export function LibraryView(): React.JSX.Element {
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.
function downloadSelected(): void {
if (!selectedSourceId || selectedActionable.length === 0) return
@@ -397,7 +398,7 @@ export function LibraryView(): React.JSX.Element {
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
// One row of the item list a playlist header or a video shared by the
// 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 {
return row.kind === 'header' ? `h:${row.title}` : row.item.id
@@ -406,7 +407,7 @@ export function LibraryView(): React.JSX.Element {
function renderRow(row: LibRow): React.JSX.Element {
if (row.kind === 'header') {
const open = isGroupOpen(row.title)
// "All on" is judged over the ACTIONABLE rows only downloaded rows aren't
// "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
+3 -2
View File
@@ -32,6 +32,7 @@ import { MediaThumb } from './MediaThumb'
import { Hint } from './Hint'
import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing'
import { THUMB_MD } from '../thumbSizes'
const useStyles = makeStyles({
root: {
@@ -44,8 +45,8 @@ const useStyles = makeStyles({
},
thumb: {
flexShrink: 0,
width: '108px',
height: '64px',
width: `${THUMB_MD.w}px`,
height: `${THUMB_MD.h}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
+5
View File
@@ -0,0 +1,5 @@
/** Shared 16:9 thumbnail box dimensions used across queue, history, and library views. */
export const THUMB_LG = { w: 120, h: 68 } as const
export const THUMB_MD = { w: 108, h: 64 } as const
export const THUMB_SM = { w: 72, h: 44 } as const
export const THUMB_XS = { w: 60, h: 34 } as const