Files
AeroFetch/src/renderer/src/components/HistoryView.tsx
T
debont80 8ab85da67e Fix H5/H6/M21/L37-40/L47/L55/L68/L72/L76/L91/L98/L108/L165/L166 audit items
H5: HistoryEntry now stores formatId+formatHasAudio; redownload passes them
    back to addFromUrl so the original yt-dlp format is reused, not guessed.
    Compound quality labels stripped to the preset prefix for old history.
L72: thumbnail now passed in redownload so re-queued rows keep their art.
H6: TerminalView log capped at MAX_LOG_LINES=2000 to prevent unbounded growth.
M21: --audio-quality omitted for lossless audioFormats (flac/wav).
L166: fmtEta hour rollover fixed in all 3 locations -- 2h00:00 not 120:00.
L165: queueStats formatSpeed precision aligned with fmtBytes.
L55: QueueItem suppresses sizeLabel when already in probed-format quality label.
L47: probeMeta null sends empty meta event so Resolving clears via SR6.
L68: notifiedBackground resets on window show for subsequent close-while-downloading.
L76: dup warning falls back to URL when title is still a placeholder.
L91: SettingsView onFormatSelect uses indexOf instead of unsafe tuple cast.
L98: Embed chapters field gets a hint text.
L108: BrowserWindow created with explicit title AeroFetch.
L37: pure formatters extracted to src/main/lib/formatters.ts and unit-tested.
L38: buildArgs test covers webm thumbnail suppression.
L39: CROP_SQUARE_PPA test is structural not exact-string.
L40: looksLikeUrl/looksLikeSingleVideo extracted to src/renderer/src/lib/urlHelpers.ts.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:29:35 -04:00

439 lines
13 KiB
TypeScript

import { useMemo, useState } from 'react'
import {
Text,
Caption1,
Button,
Checkbox,
Input,
Body1,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
OpenRegular,
FolderRegular,
DeleteRegular,
HistoryRegular,
SearchRegular,
ArrowClockwiseRegular,
CheckmarkSquareRegular,
DismissRegular
} 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'
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: {
display: 'flex',
flexDirection: 'column',
gap: '12px'
},
header: {
display: 'flex',
alignItems: 'center',
gap: '8px',
flexWrap: 'wrap'
},
count: {
color: tokens.colorNeutralForeground3,
flexShrink: 0
},
search: {
minWidth: '180px',
flexGrow: 1,
maxWidth: '320px'
},
kindFilter: {
width: '130px',
flexShrink: 0
},
spacer: {
flexGrow: 1
},
list: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
row: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
thumb: {
flexShrink: 0,
width: '72px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
body: {
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column'
},
title: {
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
meta: {
color: tokens.colorNeutralForeground3
},
actions: {
display: 'flex',
gap: '4px',
flexShrink: 0
},
empty: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '10px',
padding: '56px 16px',
textAlign: 'center'
},
emptyBadge: {
width: '56px',
height: '56px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: tokens.borderRadiusCircular,
fontSize: '26px'
},
emptyHint: {
color: tokens.colorNeutralForeground3
},
noMatches: {
padding: '32px 16px',
textAlign: 'center',
color: tokens.colorNeutralForeground3
}
})
const KIND_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]
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)
const openFile = useHistory((s) => s.openFile)
const showInFolder = useHistory((s) => s.showInFolder)
const remove = useHistory((s) => s.remove)
const removeMany = useHistory((s) => s.removeMany)
const clear = useHistory((s) => s.clear)
const addFromUrl = useDownloads((s) => s.addFromUrl)
const [query, setQuery] = useState('')
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()
return entries.filter((h) => {
if (kindFilter !== 'all' && h.kind !== kindFilter) return false
if (!q) return true
return (
h.title.toLowerCase().includes(q) ||
(h.channel?.toLowerCase().includes(q) ?? false) ||
h.url.toLowerCase().includes(q)
)
})
}, [entries, query, kindFilter])
function redownload(h: HistoryEntry): void {
// Strip compound format labels ("720p · mp4 · 184 MB" → "720p") when there is
// no stored formatId, so videoFormat() can still match the preset (H5).
const quality = (h.quality.split(' · ')[0] ?? h.quality).trim()
addFromUrl(h.url, h.kind, quality, {
title: h.title,
channel: h.channel,
thumbnail: h.thumbnail,
format: h.formatId
? { id: h.formatId, hasAudio: h.formatHasAudio ?? false, label: h.quality }
: undefined
})
}
function toggleSelected(id: string, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
}
const allFilteredSelected = filtered.length > 0 && filtered.every((h) => selected.has(h.id))
function toggleAll(): void {
setSelected(allFilteredSelected ? new Set() : new Set(filtered.map((h) => h.id)))
}
function exitSelectMode(): void {
setSelectMode(false)
setSelected(new Set())
setConfirmDelete(false)
}
function deleteSelected(): void {
removeMany([...selected])
exitSelectMode()
}
if (entries.length === 0) {
return (
<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>
</div>
)
}
return (
<div className={mergeClasses(styles.root, screen.width)}>
<ScreenHeader title="History" description="Files you've finished downloading." />
<div className={styles.header}>
{selectMode ? (
<>
<Caption1 className={styles.count}>{selected.size} selected</Caption1>
<Button size="small" appearance="subtle" onClick={toggleAll}>
{allFilteredSelected ? 'Select none' : 'Select all'}
</Button>
<div className={styles.spacer} />
{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>
</>
)}
</>
) : (
<>
<Caption1 className={styles.count}>
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
</Caption1>
<Input
className={styles.search}
input={{ 'aria-label': 'Search history' }}
size="small"
value={query}
onChange={(_, d) => setQuery(d.value)}
placeholder="Search title, channel, URL…"
contentBefore={<SearchRegular />}
/>
<Select
className={styles.kindFilter}
aria-label="Filter by type"
value={kindFilter}
options={KIND_FILTER_OPTIONS}
onChange={(v) => setKindFilter(v as 'all' | MediaKind)}
/>
<div className={styles.spacer} />
<Button
size="small"
appearance="subtle"
icon={<CheckmarkSquareRegular />}
onClick={() => setSelectMode(true)}
>
Select
</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>
{filtered.length === 0 ? (
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
) : (
<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={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)}
onChange={(_, d) => toggleSelected(h.id, !!d.checked)}
aria-label={`Select ${h.title}`}
/>
)}
<MediaThumb
className={styles.thumb}
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
kind={h.kind}
iconSize={22}
/>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
.filter(Boolean)
.join(' • ')}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Re-download" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={() => redownload(h)}
aria-label="Re-download"
/>
</Hint>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(h.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(h.id)}
aria-label="Show in folder"
/>
</Hint>
<Hint label="Remove from history" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(h.id)}
aria-label="Remove from history"
/>
</Hint>
</div>
</div>
)
})}
</div>
)}
</div>
)
}