Harden audit findings: correctness, type-safety, Windows conventions & polish

Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified —
typecheck + 234 tests + eslint + prettier green).

Correctness / bugs:
- B3: match the release checksum to the asset's filename line (no wrong-hash verify)
- B4: newline-safe metadata probe (one --print with a unit-separator delimiter)
- B5 / L88: guard the meta event against canceled items; progress no longer promotes
  a queued item outside pump()
- B7: cookie-login promise always resolves (handles destroy-without-close)
- L146: trim parser rejects >2 colon-group times; M36: Library selection counts only
  actionable rows
- L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message,
  schedule picker min, dead-code/comment cleanup

Type safety:
- Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed)

Resilience / Windows / metadata:
- R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth)
- W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window;
  L147 dead macOS branches removed
- CL1: shared stdout markers; package/builder metadata (license, homepage, repository,
  copyright, tsbuildinfo glob)

Copy / docs / tests:
- M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled
- New unit tests for L35 (isValidMediaItem) and L36 (compareVersions)

This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard
work it builds on: background running + auto-download, library clipboard detection,
tray, binary management & library scale, credential encryption at rest, the shared
jsonStore and ui/ primitives, and the eslint/prettier tooling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:02:54 -04:00
parent a6a8c5f578
commit 1376c2dee8
82 changed files with 4571 additions and 1276 deletions
+11 -3
View File
@@ -8,11 +8,13 @@ import { TerminalView } from './components/TerminalView'
import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { LiveRegion } from './components/LiveRegion'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useDownloads } from './store/downloads'
import { summarizeQueue } from './store/queueStats'
import { useResolvedDark } from './store/systemTheme'
import { logError } from './reportError'
const useStyles = makeStyles({
provider: {
@@ -58,14 +60,19 @@ function App(): React.JSX.Element {
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
const s = summarizeQueue(items)
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode })
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode, badgeCount: s.downloading })
}
push(useDownloads.getState().items)
return useDownloads.subscribe((st) => push(st.items))
}, [])
const paletteActions: PaletteAction[] = [
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
{
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-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
@@ -91,7 +98,7 @@ function App(): React.JSX.Element {
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('')
useEffect(() => {
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
}, [])
// Sidebar collapse, persisted across launches in localStorage.
@@ -151,6 +158,7 @@ function App(): React.JSX.Element {
)}
</>
)}
<LiveRegion />
</div>
</FluentProvider>
)
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
import { useFocusStyles } from './ui/focusRing'
export interface PaletteAction {
id: string
@@ -35,7 +36,6 @@ const useStyles = makeStyles({
input: {
appearance: 'none',
border: 'none',
outline: 'none',
padding: '14px 16px',
fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase,
@@ -91,6 +91,7 @@ export function CommandPalette({
onClose: () => void
}): React.JSX.Element {
const styles = useStyles()
const focus = useFocusStyles()
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
@@ -133,7 +134,7 @@ export function CommandPalette({
>
<input
ref={inputRef}
className={styles.input}
className={mergeClasses(styles.input, focus.focusRing)}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
@@ -148,7 +149,11 @@ export function CommandPalette({
<button
key={a.id}
type="button"
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
className={mergeClasses(
styles.item,
i === sel && styles.itemActive,
focus.focusRing
)}
onClick={() => {
a.run()
onClose()
+39 -72
View File
@@ -33,7 +33,10 @@ import { sameVideo } from '../store/queueStats'
import { useSettings } from '../store/settings'
import { Select } from './Select'
import { Hint } from './Hint'
import { SegmentedControl } from './ui/SegmentedControl'
import { IconButton } from './ui/IconButton'
import { useClipboardLink, looksLikeUrl } from '../useClipboardLink'
import { logError } from '../reportError'
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
function firstUrl(text: string): string | null {
@@ -148,34 +151,6 @@ const useStyles = makeStyles({
flexDirection: 'column',
gap: '4px'
},
segmented: {
display: 'inline-flex',
width: 'fit-content',
border: `1px solid ${tokens.colorNeutralStroke1}`,
...shorthands.borderRadius(tokens.borderRadiusMedium),
overflow: 'hidden'
},
segment: {
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground2,
padding: '7px 16px',
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
cursor: 'pointer',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover
}
},
segmentActive: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
fontWeight: tokens.fontWeightSemibold,
':hover': {
backgroundColor: tokens.colorBrandBackgroundHover
}
},
quality: {
minWidth: '220px'
},
@@ -224,24 +199,6 @@ const useStyles = makeStyles({
flexGrow: 1,
minWidth: 0
},
plKindBtn: {
flexShrink: 0,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
}
},
plItemLabel: {
display: 'flex',
flexDirection: 'column',
@@ -296,6 +253,14 @@ const useStyles = makeStyles({
}
})
// 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).
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())}`
}
export function DownloadBar(): React.JSX.Element {
const styles = useStyles()
const addFromUrl = useDownloads((s) => s.addFromUrl)
@@ -307,7 +272,7 @@ export function DownloadBar(): React.JSX.Element {
const [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
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)
@@ -346,7 +311,7 @@ export function DownloadBar(): React.JSX.Element {
const u = parseUrlFile(content)
if (u) onUrlChange(u)
})
.catch(() => {})
.catch(logError('dropped .url file read'))
}
}
@@ -384,7 +349,10 @@ export function DownloadBar(): React.JSX.Element {
dismiss: dismissSuggestion,
offer: offerLink
} = useClipboardLink(url)
useEffect(() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')), [offerLink])
useEffect(
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
[offerLink]
)
function acceptSuggestion(): void {
const link = acceptLink()
@@ -393,7 +361,7 @@ export function DownloadBar(): React.JSX.Element {
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]
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
: undefined
function clearProbe(): void {
@@ -573,7 +541,7 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.urlRow}>
<Input
className={styles.url}
input={{ id: 'aerofetch-url' }}
input={{ id: 'aerofetch-url', 'aria-label': 'Video or playlist URL' }}
value={url}
onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
@@ -716,19 +684,22 @@ export function DownloadBar(): React.JSX.Element {
/>
<Hint
label={
effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
effKind(e.index) === 'audio'
? 'Audio — click for video'
: 'Video — click for audio'
}
placement="top"
align="end"
>
<button
type="button"
className={styles.plKindBtn}
<IconButton
size="sm"
style={{ flexShrink: 0 }}
icon={
effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />
}
onClick={() => toggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
>
{effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
</button>
/>
</Hint>
</div>
))}
@@ -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)}
/>
</Field>
@@ -792,20 +764,15 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.controls}>
<div className={styles.control}>
<Caption1>Format</Caption1>
<div className={styles.segmented} role="radiogroup" aria-label="Format">
{(['video', 'audio'] as MediaKind[]).map((k) => (
<button
key={k}
type="button"
role="radio"
aria-checked={kind === k}
className={mergeClasses(styles.segment, kind === k && styles.segmentActive)}
onClick={() => onKindChange(k)}
>
{k === 'video' ? 'Video' : 'Audio'}
</button>
))}
</div>
<SegmentedControl<MediaKind>
value={kind}
options={[
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]}
onChange={onKindChange}
ariaLabel="Format"
/>
</div>
<div className={styles.control}>
@@ -45,7 +45,7 @@ const VIDEO_CODEC_LABELS: Record<VideoCodecPref, string> = {
const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = {
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)}
/>
</Field>
<Field label="Preferred codec" hint="Tiebreaker, not a hard filter.">
<Field
label="Preferred codec"
hint="A preference when several formats match — not a strict filter."
>
<Select
aria-label="Preferred video codec"
value={value.preferredVideoCodec}
@@ -144,7 +147,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
/>
</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
checked={value.embedSubtitles}
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
@@ -232,6 +235,28 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
label={value.embedMetadata ? 'On' : 'Off'}
/>
</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.">
<Switch
checked={value.embedThumbnail}
+16 -2
View File
@@ -5,6 +5,7 @@ import {
Button,
ProgressBar,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -14,6 +15,7 @@ import { summarizeQueue } from '../store/queueStats'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
const useStyles = makeStyles({
root: {
@@ -70,6 +72,7 @@ const useStyles = makeStyles({
export function DownloadsView(): React.JSX.Element {
const styles = useStyles()
const screen = useScreenStyles()
const items = useDownloads((s) => 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 (
<div className={styles.root}>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." />
<DownloadBar />
{summary.active && (
@@ -96,7 +110,7 @@ export function DownloadsView(): React.JSX.Element {
)}
<div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2>
<Subtitle2>Queue ({queueCount})</Subtitle2>
<div className={styles.headerActions}>
{summary.failed > 0 && (
<Button
@@ -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 couldnt 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>
)
}
}
+117 -38
View File
@@ -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<Set<string>>(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 (
<div className={styles.empty}>
<div
className={styles.emptyBadge}
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
>
<HistoryRegular />
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="History" description="Files you've finished downloading." />
<div className={styles.empty}>
<div
className={styles.emptyBadge}
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
>
<HistoryRegular />
</div>
<Body1>No downloads yet.</Body1>
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
</div>
<Body1>No downloads yet.</Body1>
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
</div>
)
}
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}>
{selectMode ? (
<>
@@ -228,18 +230,44 @@ export function HistoryView(): React.JSX.Element {
{allFilteredSelected ? 'Select none' : 'Select all'}
</Button>
<div className={styles.spacer} />
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={deleteSelected}
disabled={selected.size === 0}
>
Delete selected
</Button>
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={exitSelectMode}>
Cancel
</Button>
{confirmDelete ? (
<>
<Caption1 className={styles.count}>
Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
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}
>
Delete selected
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={exitSelectMode}
>
Cancel
</Button>
</>
)}
</>
) : (
<>
@@ -248,6 +276,7 @@ export function HistoryView(): React.JSX.Element {
</Caption1>
<Input
className={styles.search}
input={{ 'aria-label': 'Search history' }}
size="small"
value={query}
onChange={(_, d) => setQuery(d.value)}
@@ -270,9 +299,36 @@ export function HistoryView(): React.JSX.Element {
>
Select
</Button>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
Clear history
</Button>
{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
</Button>
)}
</>
)}
</div>
@@ -280,10 +336,33 @@ export function HistoryView(): React.JSX.Element {
{filtered.length === 0 ? (
<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) => {
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 && (
<Checkbox
checked={selected.has(h.id)}
+80 -84
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import {
Subtitle2,
Body1,
Caption1,
Text,
@@ -33,10 +32,15 @@ import type { MediaItem, Source } from '@shared/ipc'
import { useSources } from '../store/sources'
import { useSettings } from '../store/settings'
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
import { logError } from '../reportError'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { thumbUrl } from '../thumb'
import { relTime } from '../datetime'
import { MediaThumb } from './MediaThumb'
import { VirtualList } from './VirtualList'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { StatusChip } from './ui/StatusChip'
import { useFocusStyles } from './ui/focusRing'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -50,26 +54,13 @@ type ItemStatus = DownloadStatus | 'pending'
* window cleanly (one virtualizer over a flat array, headers included).
*/
type LibRow =
| { kind: 'header'; title: string; items: MediaItem[] }
| { kind: 'item'; item: MediaItem }
{ kind: 'header'; title: string; items: MediaItem[] } | { kind: 'item'; item: MediaItem }
/** Above this many flattened rows, the item list switches to a virtualized panel. */
const VIRTUALIZE_AT = 100
const STATUS_LABEL: Record<ItemStatus, string> = {
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<string | null>(null)
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(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<void> {
@@ -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<LibRow[]>(() => {
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 (
<div
className={styles.groupHead}
className={mergeClasses(styles.groupHead, focus.focusRing)}
onClick={() => 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 ? <ChevronDownRegular /> : <ChevronRightRegular />}
@@ -486,6 +455,7 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.row}>
<Checkbox
checked={selected.has(it.id)}
disabled={!actionable(it)}
onChange={(_, d) => toggle(it.id, !!d.checked)}
aria-label={`Select ${it.title}`}
/>
@@ -501,23 +471,22 @@ export function LibraryView(): React.JSX.Element {
</span>
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
</div>
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
<StatusChip status={status} />
</div>
)
}
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Library</Subtitle2>
<Caption1 className={styles.sub}>
Index a channel or playlist once, then download it into organized folders.
</Caption1>
</div>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader
title="Library"
description="Index a channel or playlist once, then download it into organized folders."
/>
<div className={styles.addRow}>
<Input
className={styles.addInput}
input={{ 'aria-label': 'Channel or playlist URL' }}
value={url}
onChange={(_, d) => 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)}
>
<div className={styles.detail}>
<div className={styles.actionRow}>
@@ -634,7 +601,11 @@ export function LibraryView(): React.JSX.Element {
<div className={styles.actionSpacer} />
{selected.size > 0 ? (
<>
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
<Button
size="small"
appearance="subtle"
onClick={() => setSelected(new Set())}
>
Clear
</Button>
<Button
@@ -675,14 +646,38 @@ export function LibraryView(): React.JSX.Element {
>
Re-index
</Button>
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => removeSource(src.id)}
>
Remove
</Button>
{confirmRemoveId === src.id ? (
<>
<Caption1 className={styles.sub}>Remove {src.title}?</Caption1>
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={() => {
removeSource(src.id)
setConfirmRemoveId(null)
}}
>
Remove
</Button>
<Button
size="small"
appearance="subtle"
onClick={() => setConfirmRemoveId(null)}
>
Cancel
</Button>
</>
) : (
<Button
size="small"
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setConfirmRemoveId(src.id)}
>
Remove
</Button>
)}
</div>
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
@@ -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 (
<div className={styles.card}>
<div
className={styles.cardHead}
className={mergeClasses(styles.cardHead, focus.focusRing)}
onClick={onToggleExpand}
role="button"
tabIndex={0}
@@ -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>
)
}
+1 -1
View File
@@ -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<string | null>(null)
const showImg = !!src && failedSrc !== src
+2 -2
View File
@@ -112,8 +112,8 @@ export function Onboarding(): React.JSX.Element {
<Field label="Where downloads go">
<Caption1 className={styles.folderNote}>
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
<strong>Documents\Audio</strong>. You can point each to a different folder any time
in Settings.
<strong>Documents\Audio</strong>. You can point each to a different folder any time in
Settings.
</Caption1>
</Field>
+39 -30
View File
@@ -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<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 {
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 (
<div className={styles.root}>
<div className={mergeClasses(styles.root, focus.focusRing)} tabIndex={0} onKeyDown={onKeyDown}>
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
@@ -167,9 +163,7 @@ export const QueueItem = memo(function QueueItem({
/>
)}
<Text className={styles.title}>{item.title}</Text>
<Badge appearance="tint" color={badge.color}>
{badge.label}
</Badge>
<StatusChip status={item.status} />
{item.incognito && (
<Hint label="Private — not saved to history" placement="top" align="start">
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
@@ -181,11 +175,24 @@ export const QueueItem = memo(function QueueItem({
{item.status === 'downloading' && (
<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"
/>
<Caption1 className={styles.stats}>
{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` : ''}
</>
)}
</Caption1>
</div>
)}
@@ -206,7 +213,9 @@ export const QueueItem = memo(function QueueItem({
{item.status === 'saved' && (
<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>
)}
+125 -64
View File
@@ -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<YtdlpVersionResult | null>(null)
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
@@ -273,22 +274,23 @@ export function SettingsView(): React.JSX.Element {
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<BackupImportResult | null>(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<void> {
@@ -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 (
<div className={styles.root} ref={rootRef}>
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
<div data-settings-search>
<ScreenHeader
title="Settings"
description="Folders, formats, network, cookies, and more."
/>
</div>
<div data-settings-search>
<Input
input={{ 'aria-label': 'Search settings' }}
value={search}
onChange={(_, d) => setSearch(d.value)}
placeholder="Search settings…"
@@ -682,7 +694,7 @@ export function SettingsView(): React.JSX.Element {
<Field
label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Requires aria2c.exe in resources/bin (see the README there) — falls back to yt-dlp's downloader if it's missing."
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}
@@ -704,13 +716,47 @@ export function SettingsView(): React.JSX.Element {
<Field
label="YouTube PO token (advanced)"
hint="A manually-obtained Proof-of-Origin token for YouTube's bot check, sent via --extractor-args. Usually cookies (above) are the easier fix; automatic minting is planned."
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.'
}
>
<Input
value={youtubePoToken}
placeholder="(none)"
onChange={(_, d) => update({ youtubePoToken: d.value })}
/>
<div className={styles.folderRow}>
<Input
style={{ flexGrow: 1 }}
value={youtubePoToken}
placeholder="(none)"
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>
@@ -720,8 +766,8 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>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.
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">
@@ -736,7 +782,7 @@ export function SettingsView(): React.JSX.Element {
{cookieSource === 'browser' && (
<Field
label="Browser"
hint="Reads that browser's saved cookies directly. Close it first if yt-dlp can't open a locked cookie database."
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
>
<Select
aria-label="Browser"
@@ -779,9 +825,7 @@ export function SettingsView(): React.JSX.Element {
)}
</div>
{loginError && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>{loginError}</Caption1>
)}
{loginError && <Caption1 className={errText.error}>{loginError}</Caption1>}
</>
)}
</Card>
@@ -872,8 +916,9 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>Backup &amp; 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.
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}>
@@ -888,17 +933,13 @@ export function SettingsView(): React.JSX.Element {
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
)}
{exportResult && !exportResult.ok && exportResult.error && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
{exportResult.error}
</Caption1>
<Caption1 className={errText.error}>{exportResult.error}</Caption1>
)}
{importResult?.ok && (
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
)}
{importResult && !importResult.ok && importResult.error && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
{importResult.error}
</Caption1>
<Caption1 className={errText.error}>{importResult.error}</Caption1>
)}
</Card>
@@ -920,9 +961,34 @@ export function SettingsView(): React.JSX.Element {
>
Copy full report
</Button>
<Button icon={<DeleteRegular />} onClick={clearErrorLog} disabled={errorEntries.length === 0}>
Clear log
</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 ? (
@@ -937,7 +1003,7 @@ export function SettingsView(): React.JSX.Element {
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
</div>
<Caption1 className={styles.errorRowText}>{e.error}</Caption1>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
</div>
))}
</div>
@@ -968,12 +1034,12 @@ export function SettingsView(): React.JSX.Element {
<Field
label="Update access token"
hint="Only needed if the release server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only Gitea token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
hint="Only needed if the update server requires sign-in (you'll see “could not reach the update server” without it). 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 — Gitea access token"
placeholder="Optional — access token"
onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />}
/>
@@ -995,7 +1061,10 @@ export function SettingsView(): React.JSX.Element {
{appDownloading ? 'Downloading…' : 'Update now'}
</Button>
{appUpd.htmlUrl && (
<Button icon={<OpenRegular />} onClick={() => window.open(appUpd.htmlUrl, '_blank')}>
<Button
icon={<OpenRegular />}
onClick={() => window.open(appUpd.htmlUrl, '_blank')}
>
View release
</Button>
)}
@@ -1013,11 +1082,7 @@ export function SettingsView(): React.JSX.Element {
<Text>You&apos;re up to date v{appUpd.currentVersion} is the latest.</Text>
)}
{appUpdError && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{appUpdError}
</Caption1>
)}
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
</Card>
<Card className={styles.card}>
@@ -1026,13 +1091,13 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>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.
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>
<Field
label="Keep yt-dlp updated automatically"
hint="Checks once a day on launch and updates yt-dlp in the background, so YouTube changes don't start breaking downloads with 403 errors."
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}
@@ -1045,9 +1110,7 @@ export function SettingsView(): React.JSX.Element {
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
)}
{version && !version.ok && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{version.error}
</Caption1>
<Caption1 className={errText.errorPre}>{version.error}</Caption1>
)}
{ffmpeg && (
<>
@@ -1094,9 +1157,7 @@ export function SettingsView(): React.JSX.Element {
</Text>
)}
{updateResult && !updateResult.ok && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{updateResult.error}
</Caption1>
<Caption1 className={errText.errorPre}>{updateResult.error}</Caption1>
)}
</Card>
</div>
+28 -87
View File
@@ -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({
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
<button
type="button"
className={styles.iconBtn}
<IconButton
icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
onClick={onToggleCollapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
>
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
</button>
/>
</Hint>
</div>
@@ -254,7 +203,12 @@ export function Sidebar({
{!collapsed && (
<div className={styles.brandText}>
<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={styles.caption} title={version ? `Version ${version}` : undefined}>
yt-dlp frontend
</Caption1>
</div>
)}
</div>
@@ -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 ? (
<Hint label={`Theme: ${themeLabel}`} placement="right">
<button
type="button"
className={styles.iconBtn}
<IconButton
icon={themeIcon}
onClick={cycleTheme}
aria-label={`Theme: ${themeLabel}. Click to change.`}
>
{themeIcon}
</button>
aria-label={`Change theme (currently ${themeLabel})`}
/>
</Hint>
) : (
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
{THEMES.map((t) => {
const on = theme === t.value
return (
<button
key={t.value}
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>
<SegmentedControl<ThemeMode>
fitted
value={theme}
options={THEMES}
onChange={onSetTheme}
ariaLabel="Theme"
/>
)}
</nav>
)
@@ -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 {
</div>
</div>
) : (
<Button appearance="subtle" icon={<AddRegular />} onClick={() => setDraft({ ...BLANK_DRAFT })}>
<Button
appearance="subtle"
icon={<AddRegular />}
onClick={() => setDraft({ ...BLANK_DRAFT })}
>
Add template
</Button>
)}
+23 -17
View File
@@ -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 (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Terminal</Subtitle2>
<Caption1 className={styles.sub}>
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.
</Caption1>
</div>
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader
title="Terminal"
description={
<>
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.
</>
}
/>
{!customCommandEnabled && (
<Body1 className={styles.gate}>
@@ -149,6 +154,7 @@ export function TerminalView(): React.JSX.Element {
<div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
textarea={{ 'aria-label': 'yt-dlp arguments' }}
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
+24 -17
View File
@@ -42,28 +42,35 @@ export function VirtualList<T>({
estimateSize,
overscan,
gap,
getItemKey: (index) => getKey(items[index], index)
getItemKey: (index) => {
const it = items[index]
return it !== undefined ? getKey(it, index) : index
}
})
return (
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{virtualizer.getVirtualItems().map((vrow) => (
<div
key={vrow.key}
data-index={vrow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vrow.start}px)`
}}
>
{renderItem(items[vrow.index], vrow.index)}
</div>
))}
{virtualizer.getVirtualItems().map((vrow) => {
const item = items[vrow.index]
if (item === undefined) return null
return (
<div
key={vrow.key}
data-index={vrow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vrow.start}px)`
}}
>
{renderItem(item, vrow.index)}
</div>
)
})}
</div>
</div>
)
@@ -0,0 +1,58 @@
import { forwardRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing'
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: '18px' },
sm: { width: '28px', height: '28px', fontSize: '16px' }
})
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>
)
})
+74
View File
@@ -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>{title}</Subtitle2>
{description && <Caption1 className={styles.description}>{description}</Caption1>}
</div>
{actions && <div className={styles.actions}>{actions}</div>}
</div>
)
}
@@ -0,0 +1,139 @@
import { useRef } from 'react'
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
import { useFocusStyles } from './focusRing'
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
}
},
icon: {
display: 'flex',
fontSize: '16px',
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). Active treatment is solid
* brand (matching both former implementations). `fitted` makes the segments split
* the full width (sidebar); the default is fit-content (download bar).
*/
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: 'warning' }
}
/** 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,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'
}
}
})
+38
View File
@@ -0,0 +1,38 @@
// 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, e.g. "just now" / "5 min ago" / "3 h ago" /
* "2 d 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`
return `${Math.round(hrs / 24)} d ago`
}
/** Absolute "when" label that stays short for recent items: "Today, 3:04 PM" /
* "Yesterday, 3:04 PM" / "Jun 5, 2025". (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}`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', 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 ''
}
}
+14
View File
@@ -0,0 +1,14 @@
// Single id generator for queue items, terminal runs, and command templates.
// Replaces three near-identical `newId()` copies that had divergent fallback
// prefixes and could collide within a millisecond (M7 / L33 / L70).
//
// `crypto.randomUUID` is the real path in Electron/Node; the counter-suffixed
// fallback is effectively unreachable but kept so the function is total in any
// host, and the monotonic counter makes two ids minted in the same millisecond
// distinct (the old `Date.now()`-only fallbacks could collide).
let counter = 0
export function newId(prefix = 'id'): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `${prefix}-${Date.now()}-${++counter}`
}
+55 -12
View File
@@ -3,6 +3,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings, type CommandTemplate } from '@shared/ipc'
import App from './App'
import { ErrorBoundary } from './components/ErrorBoundary'
// In the standalone UI preview (browser, no Electron preload) window.api isn't
// injected. Stub the full surface so the UI renders and stays interactive during
@@ -18,7 +19,7 @@ if (import.meta.env.DEV && !window.api) {
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
theme: 'system',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
@@ -75,7 +76,10 @@ if (import.meta.env.DEV && !window.api) {
runAppUpdate: async () => ({ ok: true }),
onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
getFfmpegVersions: async () => ({ ffmpeg: '7.1-full_build (UI preview mock)', ffprobe: '7.1-full_build (UI preview mock)' }),
getFfmpegVersions: async () => ({
ffmpeg: '7.1-full_build (UI preview mock)',
ffprobe: '7.1-full_build (UI preview mock)'
}),
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
@@ -108,10 +112,38 @@ if (import.meta.env.DEV && !window.api) {
thumbnail: undefined,
formats: [
{ id: '__best__', label: 'Best available', ext: 'mp4', hasAudio: true },
{ id: '299', label: '1080p60 · mp4 · 248 MB', height: 1080, ext: 'mp4', filesizeLabel: '248 MB', hasAudio: false },
{ id: '136', label: '720p · mp4 · 124 MB', height: 720, ext: 'mp4', filesizeLabel: '124 MB', hasAudio: false },
{ id: '135', label: '480p · mp4 · 72 MB', height: 480, ext: 'mp4', filesizeLabel: '72 MB', hasAudio: false },
{ id: '134', label: '360p · mp4 · 38 MB', height: 360, ext: 'mp4', filesizeLabel: '38 MB', hasAudio: false }
{
id: '299',
label: '1080p60 · mp4 · 248 MB',
height: 1080,
ext: 'mp4',
filesizeLabel: '248 MB',
hasAudio: false
},
{
id: '136',
label: '720p · mp4 · 124 MB',
height: 720,
ext: 'mp4',
filesizeLabel: '124 MB',
hasAudio: false
},
{
id: '135',
label: '480p · mp4 · 72 MB',
height: 480,
ext: 'mp4',
filesizeLabel: '72 MB',
hasAudio: false
},
{
id: '134',
label: '360p · mp4 · 38 MB',
height: 360,
ext: 'mp4',
filesizeLabel: '38 MB',
hasAudio: false
}
]
}
}
@@ -119,7 +151,6 @@ if (import.meta.env.DEV && !window.api) {
startDownload: async () => ({ ok: true }),
cancelDownload: async () => {},
pauseDownload: async () => {},
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
chooseFolder: async () => null,
openPath: async () => '',
showInFolder: async () => {},
@@ -162,16 +193,25 @@ if (import.meta.env.DEV && !window.api) {
},
updateYtdlp: async (channel) => {
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
return {
ok: true,
output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)`
}
},
// No background updater in the browser preview — hand back an inert unsubscribe.
onYtdlpAutoUpdateStatus: () => () => {},
listErrorLog: async () => [],
clearErrorLog: async () => [],
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
exportBackup: async () => ({
ok: true,
path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json'
}),
importBackup: async () => ({ ok: true }),
onDownloadEvent: () => () => {},
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
getSystemTheme: async () => ({
shouldUseDarkColors: false,
shouldUseHighContrastColors: false
}),
onSystemThemeUpdate: () => () => {},
openHighContrastSettings: async () => {},
onExternalUrl: () => () => {},
@@ -229,12 +269,15 @@ if (import.meta.env.DEV && !window.api) {
runTerminal: async () => ({ ok: true }),
cancelTerminal: async () => {},
onTerminalOutput: () => () => {},
setTaskbarProgress: () => {}
setTaskbarProgress: () => {},
mintPoToken: async () => null
}
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
)
+13
View File
@@ -0,0 +1,13 @@
// Centralized handler for fire-and-forget IPC (and similar async) calls whose
// failure we want recorded but can't surface to the user — the optimistic store
// update has already happened, so there's nothing to roll back or prompt about.
// Replaces the swallowed `.catch(() => {})` sites the audit flagged (M29) with a
// single, consistent log format. `op` names the operation, e.g. 'addHistory'.
//
// Note: in production the DevTools console isn't reachable (the app menu is
// suppressed), so this is primarily a development/`--inspect` diagnostic until a
// persistent renderer log sink lands (CC8). It is still strictly better than
// discarding the error.
export function logError(op: string): (e: unknown) => void {
return (e) => console.error(`[AeroFetch] ${op} failed:`, e)
}
+93 -35
View File
@@ -1,19 +1,24 @@
import { create } from 'zustand'
import type { DownloadEvent, DownloadMeta, DownloadOptions, CollectionContext } from '@shared/ipc'
import {
VIDEO_QUALITY_OPTIONS,
AUDIO_QUALITY_OPTIONS,
type DownloadEvent,
type DownloadMeta,
type DownloadOptions,
type CollectionContext,
type MediaKind
} from '@shared/ipc'
import { useSettings } from './settings'
import { useHistory } from './history'
import { useSources } from './sources'
import { newId } from '../id'
export type MediaKind = 'video' | 'audio'
// Single-sourced from the IPC contract (L105) and re-exported so existing
// `import { MediaKind } from '../store/downloads'` sites keep working.
export type { MediaKind }
export type DownloadStatus =
| 'queued'
| 'downloading'
| 'paused'
| 'saved'
| 'completed'
| 'error'
| 'canceled'
'queued' | 'downloading' | 'paused' | 'saved' | 'completed' | 'error' | 'canceled'
/** An exact probed format chosen for a download (omitted for the preset path). */
export interface ChosenFormat {
@@ -37,6 +42,12 @@ export interface DownloadItem {
speed?: string
eta?: string
sizeLabel?: string
/** yt-dlp reported no total size — render the bar indeterminate, not 0% (L137) */
sizeUnknown?: boolean
/** true once the main download stream finished and yt-dlp is fetching the
* remaining stream / merging / post-processing — the bar goes indeterminate
* instead of visibly restarting 0→100% for the second stream (SR7) */
finishing?: boolean
filePath?: string
error?: string
/** exact format carried so retry re-downloads the same thing */
@@ -60,10 +71,18 @@ export interface DownloadItem {
mediaItemId?: string
}
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
video: ['Best available', '1080p', '720p', '480p', '360p'],
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
}
// `satisfies` (not an annotation) so the tuple element types survive: under
// noUncheckedIndexedAccess a `readonly string[]` index yields `string | undefined`,
// but the const tuples keep `QUALITY_OPTIONS[kind][0]` known-defined (L168).
export const QUALITY_OPTIONS = {
video: VIDEO_QUALITY_OPTIONS,
audio: AUDIO_QUALITY_OPTIONS
} satisfies Record<MediaKind, readonly string[]>
// Placeholder channel shown while metadata is still being fetched. Tracked as a
// constant so the meta/error/done handlers can recognise and clear it instead of
// letting it stick forever when a probe returns no channel (SR6).
const RESOLVING = 'Resolving…'
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
export interface AddOptions {
@@ -129,12 +148,6 @@ interface DownloadState {
applyEvent: (ev: DownloadEvent) => void
}
let idCounter = 0
function newId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `item-${++idCounter}`
}
/**
* Build a fresh queued DownloadItem from a URL + options. Pure (no store access)
* so addFromUrl and addMany share it. If the caller already probed the URL, its
@@ -150,10 +163,10 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
const scheduledFor =
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
return {
id: newId(),
id: newId('item'),
url,
title: opts?.title ?? titleFromUrl(url),
channel: opts?.channel ?? 'Resolving…',
channel: opts?.channel ?? RESOLVING,
durationLabel: opts?.durationLabel,
thumbnail: opts?.thumbnail,
kind,
@@ -196,7 +209,7 @@ function fmtEta(seconds: number): string {
const seed: DownloadItem[] = PREVIEW
? [
{
id: newId(),
id: newId('item'),
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
title: 'Building a Desktop App with Electron — Full Course',
channel: 'DevChannel',
@@ -210,7 +223,7 @@ const seed: DownloadItem[] = PREVIEW
sizeLabel: '512 MB'
},
{
id: newId(),
id: newId('item'),
url: 'https://youtube.com/watch?v=abcd1234',
title: 'Lo-fi beats to code to (1 hour mix)',
channel: 'ChillStudio',
@@ -221,7 +234,7 @@ const seed: DownloadItem[] = PREVIEW
progress: 0
},
{
id: newId(),
id: newId('item'),
url: 'https://youtube.com/watch?v=zzz9999',
title: 'How yt-dlp Works Under the Hood',
channel: 'Open Source Weekly',
@@ -234,7 +247,7 @@ const seed: DownloadItem[] = PREVIEW
filePath: 'C:\\Users\\you\\Downloads\\How yt-dlp Works Under the Hood.mp4'
},
{
id: newId(),
id: newId('item'),
url: 'https://youtube.com/watch?v=err0r',
title: 'Private video',
channel: undefined,
@@ -376,7 +389,11 @@ export const useDownloads = create<DownloadState>((set, get) => {
addMany: (entries) => {
if (entries.length === 0) return
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts))
// The queue is a newest-first array and pump() promotes the highest-index
// (oldest) queued item first, so a batch must be stored last-entry-first for
// the playlist/channel to download 1→N instead of N→1 (M32). Entry 1 then
// sits at the bottom of the list — the same way repeated single adds stack.
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts)).reverse()
set((s) => ({ items: [...built, ...s.items] }))
pump()
},
@@ -385,7 +402,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
? {
...i,
status: 'queued',
progress: 0,
error: undefined,
speed: undefined,
eta: undefined
}
: i
)
}))
@@ -396,7 +420,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
set((s) => ({
items: s.items.map((i) =>
i.status === 'error'
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
? {
...i,
status: 'queued',
progress: 0,
error: undefined,
speed: undefined,
eta: undefined
}
: i
)
}))
@@ -435,11 +466,12 @@ export const useDownloads = create<DownloadState>((set, get) => {
// item first (oldest-first over a newest-first array) — picks it next.
set((s) => {
const idx = s.items.findIndex((i) => i.id === id)
if (idx < 0 || s.items[idx].status !== 'queued') return {}
if (idx < 0 || s.items[idx]?.status !== 'queued') return {}
const items = s.items.slice()
const [it] = items.splice(idx, 1)
if (!it) return {}
let after = -1
for (let k = 0; k < items.length; k++) if (items[k].status === 'queued') after = k
for (let k = 0; k < items.length; k++) if (items[k]?.status === 'queued') after = k
items.splice(after + 1, 0, it)
return { items }
})
@@ -526,21 +558,35 @@ export const useDownloads = create<DownloadState>((set, get) => {
if (i.id !== ev.id) return i
switch (ev.type) {
case 'meta':
// A late meta event can arrive between cancel() and the child's
// close — don't overwrite a canceled item's fields (B5; mirrors the
// canceled guard on the done/error cases below).
if (i.status === 'canceled') return i
return {
...i,
title: ev.meta.title ?? i.title,
channel: ev.meta.channel ?? i.channel,
// Clear the 'Resolving…' placeholder even when the probe returns
// no channel, so it can't stick forever (SR6); a real channel is
// always preserved.
channel: ev.meta.channel ?? (i.channel === RESOLVING ? undefined : i.channel),
durationLabel: ev.meta.durationLabel ?? i.durationLabel
}
case 'progress':
// Ignore late events once the item has left the active state.
if (i.status !== 'downloading' && i.status !== 'queued') return i
// Only a launched (downloading) item should take progress. Never
// promote a 'queued' item here — pump() owns the queued→downloading
// transition and the concurrency cap (L88); a stray progress event
// for a non-downloading item is ignored.
if (i.status !== 'downloading') return i
return {
...i,
status: 'downloading',
progress: ev.progress.progress,
// Main owns the latch and resends it on every tick, so reflecting
// it directly resets cleanly on a retry's fresh spawn (SR7).
finishing: ev.progress.finishing,
speed: ev.progress.speed,
eta: ev.progress.eta,
sizeUnknown: ev.progress.sizeUnknown,
sizeLabel: ev.progress.sizeLabel ?? i.sizeLabel
}
case 'done':
@@ -549,13 +595,23 @@ export const useDownloads = create<DownloadState>((set, get) => {
...i,
status: 'completed',
progress: 1,
finishing: false,
speed: undefined,
eta: undefined,
channel: i.channel === RESOLVING ? undefined : i.channel,
filePath: ev.filePath ?? i.filePath
}
case 'error':
if (i.status === 'canceled') return i
return { ...i, status: 'error', speed: undefined, eta: undefined, error: ev.error }
return {
...i,
status: 'error',
finishing: false,
speed: undefined,
eta: undefined,
channel: i.channel === RESOLVING ? undefined : i.channel,
error: ev.error
}
default:
return i
}
@@ -595,7 +651,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)) {
if (
st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)
) {
st.promoteDueScheduled()
}
}, 15_000)
+2 -1
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { ErrorLogEntry } from '@shared/ipc'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -28,7 +29,7 @@ export const useErrorLog = create<ErrorLogState>((set) => ({
clear: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearErrorLog().catch(() => {})
if (!PREVIEW) window.api.clearErrorLog().catch(logError('clearErrorLog'))
}
}))
+5 -4
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { HistoryEntry } from '@shared/ipc'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -58,23 +59,23 @@ export const useHistory = create<HistoryState>((set, get) => ({
add: (entry) => {
set((s) => ({ entries: [entry, ...s.entries.filter((e) => e.id !== entry.id)] }))
if (!PREVIEW) window.api.addHistory(entry).catch(() => {})
if (!PREVIEW) window.api.addHistory(entry).catch(logError('addHistory'))
},
remove: (id) => {
set((s) => ({ entries: s.entries.filter((e) => e.id !== id) }))
if (!PREVIEW) window.api.removeHistory(id).catch(() => {})
if (!PREVIEW) window.api.removeHistory(id).catch(logError('removeHistory'))
},
removeMany: (ids) => {
const remove = new Set(ids)
set((s) => ({ entries: s.entries.filter((e) => !remove.has(e.id)) }))
if (!PREVIEW) window.api.removeManyHistory(ids).catch(() => {})
if (!PREVIEW) window.api.removeManyHistory(ids).catch(logError('removeManyHistory'))
},
clear: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearHistory().catch(() => {})
if (!PREVIEW) window.api.clearHistory().catch(logError('clearHistory'))
},
openFile: (id) => {
+2 -2
View File
@@ -13,10 +13,10 @@ function parseSpeed(s?: string): number {
if (!s) return 0
const m = /([\d.]+)\s*([KMGT]?)i?B\/s/i.exec(s)
if (!m) return 0
const n = parseFloat(m[1])
const n = parseFloat(m[1] ?? '')
if (!isFinite(n)) return 0
const mult: Record<string, number> = { '': 1, K: 1e3, M: 1e6, G: 1e9, T: 1e12 }
return n * (mult[m[2].toUpperCase()] ?? 1)
return n * (mult[(m[2] ?? '').toUpperCase()] ?? 1)
}
function formatSpeed(bytesPerSec: number): string {
+27 -7
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -12,7 +13,9 @@ const FALLBACK: Settings = {
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
// Mirror main's DEFAULTS (SR1): follow the OS theme until real settings load,
// so there's no white-on-first-paint flash for a dark-mode user.
theme: 'system',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
@@ -26,12 +29,12 @@ const FALLBACK: Settings = {
restrictFilenames: false,
downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpChannel: 'stable',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
autoDownloadNew: false,
// True in preview so design work isn't blocked behind the welcome screen;
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
hasCompletedOnboarding: PREVIEW,
@@ -56,14 +59,31 @@ export const useSettings = create<SettingsState>((set, get) => ({
update: (partial) => {
set(partial) // optimistic local update
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
if (PREVIEW) return
// M34: reconcile with main's authoritative, validated result — for exactly the
// keys we changed — so a value main clamps, sanitizes, or rejects (e.g. an
// unsafe filenameTemplate, an out-of-range maxConcurrent, a malformed
// rateLimit) stops showing as accepted in the UI until restart.
window.api
.setSettings(partial)
.then((saved) => {
const reconciled = Object.fromEntries(
(Object.keys(partial) as (keyof Settings)[]).map((k) => [k, saved[k]])
) as Partial<Settings>
set(reconciled)
})
.catch(logError('setSettings'))
},
chooseDir: (target) => {
if (PREVIEW) return
window.api.chooseFolder().then((dir) => {
if (dir) get().update({ [target]: dir })
})
// Seed the OS picker with the folder this target currently points at (W5).
window.api
.chooseFolder(get()[target])
.then((dir) => {
if (dir) get().update({ [target]: dir })
})
.catch(logError('chooseFolder'))
},
clearDir: (target) => get().update({ [target]: '' })
+12 -7
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand'
import type { Source, MediaItem, IndexProgress } from '@shared/ipc'
import { useDownloads } from './downloads'
import { useSettings } from './settings'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -113,7 +114,7 @@ export const useSources = create<SourcesState>((set, get) => ({
window.api
.listSources()
.then((sources) => set({ sources }))
.catch(() => {})
.catch(logError('listSources'))
},
selectSource: (id) => {
@@ -123,7 +124,7 @@ export const useSources = create<SourcesState>((set, get) => ({
window.api
.listSourceItems(id)
.then((items) => set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } })))
.catch(() => {})
.catch(logError('listSourceItems'))
}
},
@@ -164,7 +165,8 @@ export const useSources = create<SourcesState>((set, get) => ({
const items = await window.api.listSourceItems(id)
set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } }))
}
} catch {
} catch (e) {
logError('reindexSource')(e)
set({ indexing: { active: false } })
}
},
@@ -174,7 +176,7 @@ export const useSources = create<SourcesState>((set, get) => ({
sources: s.sources.filter((x) => x.id !== id),
selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId
}))
if (!PREVIEW) window.api.removeSource(id).catch(() => {})
if (!PREVIEW) window.api.removeSource(id).catch(logError('removeSource'))
},
enqueueItems: (sourceId, items) => {
@@ -220,12 +222,15 @@ export const useSources = create<SourcesState>((set, get) => ({
}
return changed ? { itemsBySource: next } : {}
})
if (!PREVIEW) window.api.markSourceItemDownloaded(itemId, filePath).catch(() => {})
if (!PREVIEW)
window.api
.markSourceItemDownloaded(itemId, filePath)
.catch(logError('markSourceItemDownloaded'))
},
setWatched: (id, watched) => {
set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(() => {})
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(logError('setSourceWatched'))
},
syncWatched: async () => {
@@ -274,7 +279,7 @@ if (!PREVIEW) {
setTimeout(() => useSources.getState().syncWatched(), 1500)
}
})
.catch(() => {})
.catch(logError('startup listSources'))
window.api.onIndexProgress((p: IndexProgress) => {
useSources.setState({
indexing: {
+3 -2
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { CommandTemplate } from '@shared/ipc'
import { logError } from '../reportError'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -24,12 +25,12 @@ export const useTemplates = create<TemplatesState>((set) => ({
save: (template) => {
set((s) => ({ templates: [template, ...s.templates.filter((t) => t.id !== template.id)] }))
if (!PREVIEW) window.api.saveTemplate(template).catch(() => {})
if (!PREVIEW) window.api.saveTemplate(template).catch(logError('saveTemplate'))
},
remove: (id) => {
set((s) => ({ templates: s.templates.filter((t) => t.id !== id) }))
if (!PREVIEW) window.api.removeTemplate(id).catch(() => {})
if (!PREVIEW) window.api.removeTemplate(id).catch(logError('removeTemplate'))
}
}))
+1 -1
View File
@@ -24,7 +24,7 @@ export function youtubeId(url: string | undefined): string | null {
const v = u.searchParams.get('v')
if (v) return v
const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/i)
if (m) return m[1]
if (m?.[1]) return m[1]
}
return null
}