8ab85da67e
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>
439 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|