feat: Library bulk download + list virtualization for large channels
Add a source-wide "Select all" across all playlist groups, and make one click queue every indexed video (the previous 100-per-click cap is gone). Bulk download skips items that are already downloaded or in flight, so it can't enqueue duplicates, and it includes failed/canceled ones so it also retries them. Make the UI hold up at channel scale (1000s of videos): virtualize both the Downloads queue and the Library item list with @tanstack/react-virtual so only on-screen rows hit the DOM, memoize the queue row so they don't all reconcile on every progress tick, and batch the enqueue so queuing a whole channel stays O(n) instead of O(n^2). - VirtualList: reusable windowed list that owns its own scroll container - DownloadsView: virtualized, flex-filled queue - LibraryView: groups flattened to rows; virtualized panel above 100 rows, inline below so small sources are unchanged - QueueItem: React.memo - downloads store: addMany (one state update + one pump); sources: enqueue the whole selection via addMany, no cap Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+30
-2
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.1.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aerofetch",
|
"name": "aerofetch",
|
||||||
"version": "0.1.0",
|
"version": "0.4.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@fluentui/react-components": "^9.74.1",
|
"@fluentui/react-components": "^9.74.1",
|
||||||
"@fluentui/react-icons": "^2.0.330",
|
"@fluentui/react-icons": "^2.0.330",
|
||||||
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -3373,6 +3374,33 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.14.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
|
||||||
|
"integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.17.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
|
||||||
|
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@fluentui/react-components": "^9.74.1",
|
"@fluentui/react-components": "^9.74.1",
|
||||||
"@fluentui/react-icons": "^2.0.330",
|
"@fluentui/react-icons": "^2.0.330",
|
||||||
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|||||||
@@ -9,22 +9,28 @@ import { ArrowDownloadRegular } from '@fluentui/react-icons'
|
|||||||
import { useDownloads } from '../store/downloads'
|
import { useDownloads } from '../store/downloads'
|
||||||
import { DownloadBar } from './DownloadBar'
|
import { DownloadBar } from './DownloadBar'
|
||||||
import { QueueItem } from './QueueItem'
|
import { QueueItem } from './QueueItem'
|
||||||
|
import { VirtualList } from './VirtualList'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
|
// Fill the scroll area so the queue list below can flex to the remaining
|
||||||
|
// height and scroll internally (it's virtualized), instead of the whole page
|
||||||
|
// growing to thousands of rows.
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '20px'
|
gap: '20px',
|
||||||
|
height: '100%'
|
||||||
},
|
},
|
||||||
queueHeader: {
|
queueHeader: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between'
|
justifyContent: 'space-between'
|
||||||
},
|
},
|
||||||
list: {
|
listScroll: {
|
||||||
display: 'flex',
|
flexGrow: 1,
|
||||||
flexDirection: 'column',
|
// min-height:0 lets this flex child shrink below its content height so it,
|
||||||
gap: '10px'
|
// not the page, owns the scrolling.
|
||||||
|
minHeight: 0
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -65,11 +71,15 @@ export function DownloadsView(): React.JSX.Element {
|
|||||||
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
|
<Body1>Nothing queued yet. Paste a URL above to get started.</Body1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.list}>
|
<VirtualList
|
||||||
{items.map((item) => (
|
items={items}
|
||||||
<QueueItem key={item.id} item={item} />
|
className={styles.listScroll}
|
||||||
))}
|
gap={10}
|
||||||
</div>
|
overscan={6}
|
||||||
|
estimateSize={() => 100}
|
||||||
|
getKey={(item) => item.id}
|
||||||
|
renderItem={(item) => <QueueItem item={item} />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ import {
|
|||||||
LibraryRegular
|
LibraryRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type { MediaItem, Source } from '@shared/ipc'
|
import type { MediaItem, Source } from '@shared/ipc'
|
||||||
import { useSources, MAX_ENQUEUE_BATCH } from '../store/sources'
|
import { useSources } from '../store/sources'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||||
import { thumbUrl } from '../thumb'
|
import { thumbUrl } from '../thumb'
|
||||||
import { MediaThumb } from './MediaThumb'
|
import { MediaThumb } from './MediaThumb'
|
||||||
|
import { VirtualList } from './VirtualList'
|
||||||
|
|
||||||
// True in the standalone browser preview (no Electron preload).
|
// True in the standalone browser preview (no Electron preload).
|
||||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||||
@@ -40,6 +41,18 @@ const PREVIEW = typeof window === 'undefined' || !window.electron
|
|||||||
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
|
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
|
||||||
type ItemStatus = DownloadStatus | 'pending'
|
type ItemStatus = DownloadStatus | 'pending'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flattened row for the virtualized item list: either a playlist header or a
|
||||||
|
* single video. Flattening the groups into one list lets a 1000s-video source
|
||||||
|
* window cleanly (one virtualizer over a flat array, headers included).
|
||||||
|
*/
|
||||||
|
type LibRow =
|
||||||
|
| { 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> = {
|
const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
queued: 'Queued',
|
queued: 'Queued',
|
||||||
@@ -134,7 +147,6 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
||||||
actionSpacer: { flexGrow: 1 },
|
actionSpacer: { flexGrow: 1 },
|
||||||
group: { display: 'flex', flexDirection: 'column' },
|
|
||||||
groupHead: {
|
groupHead: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -143,13 +155,13 @@ const useStyles = makeStyles({
|
|||||||
color: tokens.colorNeutralForeground2
|
color: tokens.colorNeutralForeground2
|
||||||
},
|
},
|
||||||
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
|
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
|
||||||
rows: { display: 'flex', flexDirection: 'column' },
|
|
||||||
row: {
|
row: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '10px',
|
gap: '10px',
|
||||||
padding: '5px 4px 5px 18px'
|
padding: '5px 4px 5px 18px'
|
||||||
},
|
},
|
||||||
|
plainList: { display: 'flex', flexDirection: 'column' },
|
||||||
rowThumb: {
|
rowThumb: {
|
||||||
width: '60px',
|
width: '60px',
|
||||||
height: '34px',
|
height: '34px',
|
||||||
@@ -236,6 +248,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [batchNote, setBatchNote] = useState<string | null>(null)
|
||||||
const [syncNote, setSyncNote] = useState<string | null>(null)
|
const [syncNote, setSyncNote] = useState<string | null>(null)
|
||||||
const [scheduled, setScheduled] = useState(false)
|
const [scheduled, setScheduled] = useState(false)
|
||||||
|
|
||||||
@@ -261,8 +274,11 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
if (res.error) setError(res.error)
|
if (res.error) setError(res.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the selection whenever the expanded source changes.
|
// Reset the selection (and any batch note) whenever the expanded source changes.
|
||||||
useEffect(() => setSelected(new Set()), [selectedSourceId])
|
useEffect(() => {
|
||||||
|
setSelected(new Set())
|
||||||
|
setBatchNote(null)
|
||||||
|
}, [selectedSourceId])
|
||||||
|
|
||||||
// Live per-URL queue status so a video row reflects its real download state.
|
// Live per-URL queue status so a video row reflects its real download state.
|
||||||
const statusByUrl = useMemo(() => {
|
const statusByUrl = useMemo(() => {
|
||||||
@@ -273,12 +289,34 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
|
|
||||||
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
|
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
|
||||||
const groups = useMemo(() => groupByPlaylist(items), [items])
|
const groups = useMemo(() => groupByPlaylist(items), [items])
|
||||||
|
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
|
||||||
|
const flatRows = useMemo<LibRow[]>(() => {
|
||||||
|
const rows: LibRow[] = []
|
||||||
|
for (const g of groups) {
|
||||||
|
rows.push({ kind: 'header', title: g.title, items: g.items })
|
||||||
|
for (const it of g.items) rows.push({ kind: 'item', item: it })
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}, [groups])
|
||||||
|
|
||||||
const effStatus = (it: MediaItem): ItemStatus =>
|
const effStatus = (it: MediaItem): ItemStatus =>
|
||||||
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
|
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
|
||||||
|
|
||||||
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
|
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
|
||||||
|
|
||||||
|
// An item is worth (re)queuing when it isn't already downloaded or in flight:
|
||||||
|
// pending, or a previous failure/cancel to retry. Skipping completed/queued/
|
||||||
|
// downloading items keeps "Select all → Download" from enqueuing duplicates,
|
||||||
|
// since addFromUrl doesn't dedupe by URL.
|
||||||
|
const actionable = (it: MediaItem): boolean => {
|
||||||
|
const st = effStatus(it)
|
||||||
|
return st === 'pending' || st === 'error' || st === 'canceled'
|
||||||
|
}
|
||||||
|
const actionableItems = items.filter(actionable)
|
||||||
|
const selectedActionable = actionableItems.filter((it) => selected.has(it.id))
|
||||||
|
const allActionableSelected =
|
||||||
|
actionableItems.length > 0 && actionableItems.every((it) => selected.has(it.id))
|
||||||
|
|
||||||
async function onIndex(): Promise<void> {
|
async function onIndex(): Promise<void> {
|
||||||
setError(null)
|
setError(null)
|
||||||
const u = url.trim()
|
const u = url.trim()
|
||||||
@@ -308,16 +346,24 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Select / clear every downloadable item across all groups in this source.
|
||||||
|
function toggleAll(on: boolean): void {
|
||||||
|
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
// One click queues every chosen item — maxConcurrent gates how many actually
|
||||||
|
// run, the rest wait in the queue. Selection clears since nothing is held back.
|
||||||
function downloadSelected(): void {
|
function downloadSelected(): void {
|
||||||
if (!selectedSourceId) return
|
if (!selectedSourceId || selectedActionable.length === 0) return
|
||||||
const chosen = items.filter((it) => selected.has(it.id))
|
const n = enqueueItems(selectedSourceId, selectedActionable)
|
||||||
enqueueItems(selectedSourceId, chosen)
|
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadPending(): void {
|
function downloadPending(): void {
|
||||||
if (!selectedSourceId) return
|
if (!selectedSourceId || pendingItems.length === 0) return
|
||||||
enqueueItems(selectedSourceId, pendingItems)
|
const n = enqueueItems(selectedSourceId, pendingItems)
|
||||||
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function pillClass(status: ItemStatus): string {
|
function pillClass(status: ItemStatus): string {
|
||||||
@@ -327,6 +373,52 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
return styles.pill
|
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 {
|
||||||
|
return row.kind === 'header' ? `h:${row.title}` : row.item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRow(row: LibRow): React.JSX.Element {
|
||||||
|
if (row.kind === 'header') {
|
||||||
|
const allOn = row.items.every((it) => selected.has(it.id))
|
||||||
|
return (
|
||||||
|
<div className={styles.groupHead}>
|
||||||
|
<AppsListRegular />
|
||||||
|
<span className={styles.groupTitle}>{row.title}</span>
|
||||||
|
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
|
||||||
|
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
|
||||||
|
{allOn ? 'None' : 'All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const it = row.item
|
||||||
|
const status = effStatus(it)
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(it.id)}
|
||||||
|
onChange={(_, d) => toggle(it.id, !!d.checked)}
|
||||||
|
aria-label={`Select ${it.title}`}
|
||||||
|
/>
|
||||||
|
<MediaThumb
|
||||||
|
className={styles.rowThumb}
|
||||||
|
src={thumbUrl({ url: it.url, videoId: it.videoId })}
|
||||||
|
kind="video"
|
||||||
|
iconSize={16}
|
||||||
|
/>
|
||||||
|
<div className={styles.rowMain}>
|
||||||
|
<span className={styles.rowTitle}>
|
||||||
|
{it.playlistIndex}. {it.title}
|
||||||
|
</span>
|
||||||
|
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
|
||||||
|
</div>
|
||||||
|
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
@@ -418,6 +510,12 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<div className={styles.detail}>
|
<div className={styles.detail}>
|
||||||
<div className={styles.actionRow}>
|
<div className={styles.actionRow}>
|
||||||
|
<Checkbox
|
||||||
|
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
|
||||||
|
onChange={(_, d) => toggleAll(!!d.checked)}
|
||||||
|
label="Select all"
|
||||||
|
disabled={actionableItems.length === 0}
|
||||||
|
/>
|
||||||
<Caption1 className={styles.srcSub}>
|
<Caption1 className={styles.srcSub}>
|
||||||
{items.length} videos · {pendingItems.length} pending · indexed{' '}
|
{items.length} videos · {pendingItems.length} pending · indexed{' '}
|
||||||
{relTime(src.lastIndexedAt)}
|
{relTime(src.lastIndexedAt)}
|
||||||
@@ -433,8 +531,9 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
appearance="primary"
|
appearance="primary"
|
||||||
icon={<ArrowDownloadRegular />}
|
icon={<ArrowDownloadRegular />}
|
||||||
onClick={downloadSelected}
|
onClick={downloadSelected}
|
||||||
|
disabled={selectedActionable.length === 0}
|
||||||
>
|
>
|
||||||
Download {Math.min(selected.size, MAX_ENQUEUE_BATCH)} selected
|
Download {selectedActionable.length} selected
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -445,7 +544,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
onClick={downloadPending}
|
onClick={downloadPending}
|
||||||
disabled={pendingItems.length === 0}
|
disabled={pendingItems.length === 0}
|
||||||
>
|
>
|
||||||
Download {Math.min(pendingItems.length, MAX_ENQUEUE_BATCH)} pending
|
Download {pendingItems.length} pending
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<span className={styles.switchRow}>
|
<span className={styles.switchRow}>
|
||||||
@@ -475,55 +574,29 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groups.map((g) => {
|
{batchNote && <Caption1 className={styles.srcSub}>{batchNote}</Caption1>}
|
||||||
const allOn = g.items.every((it) => selected.has(it.id))
|
|
||||||
return (
|
{flatRows.length > VIRTUALIZE_AT ? (
|
||||||
<div key={g.title} className={styles.group}>
|
// Big source: a fixed-height, internally-scrolling virtualized
|
||||||
<div className={styles.groupHead}>
|
// panel so 1000s of rows stay light. A definite height (not
|
||||||
<AppsListRegular />
|
// max-height) gives the virtualizer a viewport to measure.
|
||||||
<span className={styles.groupTitle}>{g.title}</span>
|
<VirtualList
|
||||||
<Caption1 className={styles.srcSub}>{g.items.length}</Caption1>
|
items={flatRows}
|
||||||
<Button
|
style={{ height: '58vh' }}
|
||||||
size="small"
|
overscan={10}
|
||||||
appearance="subtle"
|
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
|
||||||
onClick={() => toggleGroup(g.items, !allOn)}
|
getKey={(row) => rowKey(row)}
|
||||||
>
|
renderItem={(row) => renderRow(row)}
|
||||||
{allOn ? 'None' : 'All'}
|
/>
|
||||||
</Button>
|
) : (
|
||||||
|
// Small source: render inline so the card grows naturally.
|
||||||
|
<div className={styles.plainList}>
|
||||||
|
{flatRows.map((row) => (
|
||||||
|
<div key={rowKey(row)}>{renderRow(row)}</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.rows}>
|
|
||||||
{g.items.map((it) => {
|
|
||||||
const status = effStatus(it)
|
|
||||||
return (
|
|
||||||
<div key={it.id} className={styles.row}>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.has(it.id)}
|
|
||||||
onChange={(_, d) => toggle(it.id, !!d.checked)}
|
|
||||||
aria-label={`Select ${it.title}`}
|
|
||||||
/>
|
|
||||||
<MediaThumb
|
|
||||||
className={styles.rowThumb}
|
|
||||||
src={thumbUrl({ url: it.url, videoId: it.videoId })}
|
|
||||||
kind="video"
|
|
||||||
iconSize={16}
|
|
||||||
/>
|
|
||||||
<div className={styles.rowMain}>
|
|
||||||
<span className={styles.rowTitle}>
|
|
||||||
{it.playlistIndex}. {it.title}
|
|
||||||
</span>
|
|
||||||
{it.durationLabel && (
|
|
||||||
<Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</SourceCard>
|
</SourceCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
@@ -99,7 +100,11 @@ function pct(progress: number): string {
|
|||||||
return `${Math.round(progress * 100)}%`
|
return `${Math.round(progress * 100)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
|
export const QueueItem = memo(function QueueItem({
|
||||||
|
item
|
||||||
|
}: {
|
||||||
|
item: DownloadItem
|
||||||
|
}): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const cancel = useDownloads((s) => s.cancel)
|
const cancel = useDownloads((s) => s.cancel)
|
||||||
const remove = useDownloads((s) => s.remove)
|
const remove = useDownloads((s) => s.remove)
|
||||||
@@ -234,4 +239,4 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useRef, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windowed list: renders only the rows near the viewport, so a list thousands of
|
||||||
|
* items long stays light — one DOM subtree per visible row, not per item. This
|
||||||
|
* keeps the Downloads queue and a big channel's Library list responsive when a
|
||||||
|
* source has 1000s of videos.
|
||||||
|
*
|
||||||
|
* It owns its own scroll container, so size that container via `style`/`className`
|
||||||
|
* (e.g. `flex: 1` + `minHeight: 0`, or a `maxHeight`). Owning the scroller means
|
||||||
|
* the virtualizer anchors at scroll offset 0 and never has to track where the
|
||||||
|
* list sits within the page — robust even though the app shares one outer
|
||||||
|
* scroll area with content above each list.
|
||||||
|
*
|
||||||
|
* Row heights are measured (measureElement), so variable-height rows are fine;
|
||||||
|
* `estimateSize` only needs to be a rough first guess before measurement.
|
||||||
|
*/
|
||||||
|
export function VirtualList<T>({
|
||||||
|
items,
|
||||||
|
estimateSize,
|
||||||
|
getKey,
|
||||||
|
renderItem,
|
||||||
|
overscan = 8,
|
||||||
|
gap = 0,
|
||||||
|
className,
|
||||||
|
style
|
||||||
|
}: {
|
||||||
|
items: T[]
|
||||||
|
estimateSize: (index: number) => number
|
||||||
|
getKey: (item: T, index: number) => string
|
||||||
|
renderItem: (item: T, index: number) => ReactNode
|
||||||
|
overscan?: number
|
||||||
|
gap?: number
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize,
|
||||||
|
overscan,
|
||||||
|
gap,
|
||||||
|
getItemKey: (index) => getKey(items[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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -54,17 +54,8 @@ export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
|
|||||||
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
|
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when running in the standalone browser preview (no Electron preload).
|
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
|
||||||
// The real preload injects window.electron; the browser stub does not.
|
export interface AddOptions {
|
||||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
|
||||||
|
|
||||||
interface DownloadState {
|
|
||||||
items: DownloadItem[]
|
|
||||||
addFromUrl: (
|
|
||||||
url: string,
|
|
||||||
kind: MediaKind,
|
|
||||||
quality: string,
|
|
||||||
opts?: {
|
|
||||||
format?: ChosenFormat
|
format?: ChosenFormat
|
||||||
title?: string
|
title?: string
|
||||||
channel?: string
|
channel?: string
|
||||||
@@ -75,8 +66,29 @@ interface DownloadState {
|
|||||||
incognito?: boolean
|
incognito?: boolean
|
||||||
collection?: CollectionContext
|
collection?: CollectionContext
|
||||||
mediaItemId?: string
|
mediaItemId?: string
|
||||||
}
|
}
|
||||||
) => void
|
|
||||||
|
/** One URL to enqueue in a single addMany batch. */
|
||||||
|
export interface AddEntry {
|
||||||
|
url: string
|
||||||
|
kind: MediaKind
|
||||||
|
quality: string
|
||||||
|
opts?: AddOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when running in the standalone browser preview (no Electron preload).
|
||||||
|
// The real preload injects window.electron; the browser stub does not.
|
||||||
|
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||||
|
|
||||||
|
interface DownloadState {
|
||||||
|
items: DownloadItem[]
|
||||||
|
addFromUrl: (url: string, kind: MediaKind, quality: string, opts?: AddOptions) => void
|
||||||
|
/**
|
||||||
|
* Enqueue many URLs at once with a single state update and a single pump, so
|
||||||
|
* queuing an entire channel (1000s of items) doesn't churn the store O(n²) the
|
||||||
|
* way looping addFromUrl would.
|
||||||
|
*/
|
||||||
|
addMany: (entries: AddEntry[]) => void
|
||||||
retry: (id: string) => void
|
retry: (id: string) => void
|
||||||
cancel: (id: string) => void
|
cancel: (id: string) => void
|
||||||
remove: (id: string) => void
|
remove: (id: string) => void
|
||||||
@@ -95,6 +107,38 @@ function newId(): string {
|
|||||||
return `item-${++idCounter}`
|
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
|
||||||
|
* metadata is carried as probedMeta so main can skip a redundant probe (audit P2).
|
||||||
|
*/
|
||||||
|
function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOptions): DownloadItem {
|
||||||
|
const probedMeta: DownloadMeta | undefined =
|
||||||
|
opts?.title || opts?.channel || opts?.durationLabel
|
||||||
|
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
|
||||||
|
: undefined
|
||||||
|
return {
|
||||||
|
id: newId(),
|
||||||
|
url,
|
||||||
|
title: opts?.title ?? titleFromUrl(url),
|
||||||
|
channel: opts?.channel ?? 'Resolving…',
|
||||||
|
durationLabel: opts?.durationLabel,
|
||||||
|
thumbnail: opts?.thumbnail,
|
||||||
|
kind,
|
||||||
|
quality,
|
||||||
|
status: 'queued',
|
||||||
|
progress: 0,
|
||||||
|
formatId: opts?.format?.id,
|
||||||
|
formatHasAudio: opts?.format?.hasAudio,
|
||||||
|
options: opts?.options,
|
||||||
|
extraArgs: opts?.extraArgs,
|
||||||
|
incognito: opts?.incognito,
|
||||||
|
collection: opts?.collection,
|
||||||
|
mediaItemId: opts?.mediaItemId,
|
||||||
|
probedMeta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function titleFromUrl(url: string): string {
|
function titleFromUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
@@ -291,35 +335,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
pump,
|
pump,
|
||||||
|
|
||||||
addFromUrl: (url, kind, quality, opts) => {
|
addFromUrl: (url, kind, quality, opts) => {
|
||||||
const id = newId()
|
set((s) => ({ items: [buildItem(url, kind, quality, opts), ...s.items] }))
|
||||||
// If the caller already probed the URL, carry that metadata so main can skip
|
pump()
|
||||||
// its own redundant probe (audit P2). Only set it when something real was
|
},
|
||||||
// provided — a bare paste with no probe leaves it undefined.
|
|
||||||
const probedMeta: DownloadMeta | undefined =
|
addMany: (entries) => {
|
||||||
opts?.title || opts?.channel || opts?.durationLabel
|
if (entries.length === 0) return
|
||||||
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
|
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts))
|
||||||
: undefined
|
set((s) => ({ items: [...built, ...s.items] }))
|
||||||
const item: DownloadItem = {
|
|
||||||
id,
|
|
||||||
url,
|
|
||||||
title: opts?.title ?? titleFromUrl(url),
|
|
||||||
channel: opts?.channel ?? 'Resolving…',
|
|
||||||
durationLabel: opts?.durationLabel,
|
|
||||||
thumbnail: opts?.thumbnail,
|
|
||||||
kind,
|
|
||||||
quality,
|
|
||||||
status: 'queued',
|
|
||||||
progress: 0,
|
|
||||||
formatId: opts?.format?.id,
|
|
||||||
formatHasAudio: opts?.format?.hasAudio,
|
|
||||||
options: opts?.options,
|
|
||||||
extraArgs: opts?.extraArgs,
|
|
||||||
incognito: opts?.incognito,
|
|
||||||
collection: opts?.collection,
|
|
||||||
mediaItemId: opts?.mediaItemId,
|
|
||||||
probedMeta
|
|
||||||
}
|
|
||||||
set((s) => ({ items: [item, ...s.items] }))
|
|
||||||
pump()
|
pump()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ import { useSettings } from './settings'
|
|||||||
// True in the standalone browser preview (no Electron preload).
|
// True in the standalone browser preview (no Electron preload).
|
||||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||||
|
|
||||||
// Cap on how many items one "Download" click enqueues, so an entire channel
|
|
||||||
// can't flood the live queue at once — the rest stay pending for a follow-up
|
|
||||||
// click. (The automatic incremental feeder is Phase I; see ROADMAP-PINCHFLAT.md.)
|
|
||||||
export const MAX_ENQUEUE_BATCH = 100
|
|
||||||
|
|
||||||
// --- Preview seed data ------------------------------------------------------
|
// --- Preview seed data ------------------------------------------------------
|
||||||
|
|
||||||
function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
|
function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
|
||||||
@@ -87,8 +82,10 @@ interface SourcesState {
|
|||||||
reindexSource: (id: string) => Promise<void>
|
reindexSource: (id: string) => Promise<void>
|
||||||
removeSource: (id: string) => void
|
removeSource: (id: string) => void
|
||||||
/**
|
/**
|
||||||
* Enqueue the given media items into the download queue with folder context,
|
* Enqueue the given media items into the download queue with folder context.
|
||||||
* capped at MAX_ENQUEUE_BATCH. Returns how many were actually enqueued.
|
* Queues all of them — one click can fill the queue with an entire channel,
|
||||||
|
* which maxConcurrent then gates into download slots. Returns how many were
|
||||||
|
* enqueued.
|
||||||
*/
|
*/
|
||||||
enqueueItems: (sourceId: string, items: MediaItem[]) => number
|
enqueueItems: (sourceId: string, items: MediaItem[]) => number
|
||||||
/** mark an item downloaded once its queue download completes (called by the downloads store) */
|
/** mark an item downloaded once its queue download completes (called by the downloads store) */
|
||||||
@@ -185,10 +182,13 @@ export const useSources = create<SourcesState>((set, get) => ({
|
|||||||
const settings = useSettings.getState()
|
const settings = useSettings.getState()
|
||||||
const kind = settings.defaultKind
|
const kind = settings.defaultKind
|
||||||
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
||||||
const batch = items.slice(0, MAX_ENQUEUE_BATCH)
|
// One batched state update + pump, so queuing a whole channel stays O(n).
|
||||||
const add = useDownloads.getState().addFromUrl
|
useDownloads.getState().addMany(
|
||||||
for (const it of batch) {
|
items.map((it) => ({
|
||||||
add(it.url, kind, quality, {
|
url: it.url,
|
||||||
|
kind,
|
||||||
|
quality,
|
||||||
|
opts: {
|
||||||
title: it.title,
|
title: it.title,
|
||||||
channel: src?.channel,
|
channel: src?.channel,
|
||||||
durationLabel: it.durationLabel,
|
durationLabel: it.durationLabel,
|
||||||
@@ -198,9 +198,10 @@ export const useSources = create<SourcesState>((set, get) => ({
|
|||||||
index: it.playlistIndex
|
index: it.playlistIndex
|
||||||
},
|
},
|
||||||
mediaItemId: it.id
|
mediaItemId: it.id
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return batch.length
|
}))
|
||||||
|
)
|
||||||
|
return items.length
|
||||||
},
|
},
|
||||||
|
|
||||||
markDownloaded: (itemId, filePath) => {
|
markDownloaded: (itemId, filePath) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user