feat: queue UX (Phase M) + media editing (split-chapters, trim)

Phase L (media editing):
- Split by chapters (--split-chapters) as a DownloadOptions toggle
- Trim/cut by time range: parseTrimSections -> --download-sections "*A-B"
  + --force-keyframes-at-cuts (deduped vs SponsorBlock), per-download Trim panel

Phase M (queue & daily-use UX), all 7:
- Pause/resume: main-side killTree + paused flag (silent close), download:pause
  IPC, store pause/resume + paused status, QueueItem controls
- Reorder via "Download next" (prioritize)
- Aggregate progress strip (pure summarizeQueue) + combined speed/ETA
- Drag-and-drop links / .url files onto the download card
- Retry all failed
- Duplicate detection (sameVideo) with "Download anyway" guard
- Per-download scheduling (datetime-local) + save-for-later (saved status,
  15s promotion ticker); session-only (queue not persisted)

New pure modules unit-tested (queueStats); buildArgs trim/split tested.
typecheck + test green (178 passed). Roadmap updated: Phase M COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 10:55:43 -04:00
parent 43a62dc4a2
commit 9ac11ceb6c
18 changed files with 994 additions and 83 deletions
+222 -6
View File
@@ -1,6 +1,8 @@
import { useState, useEffect, useRef } from 'react'
import {
Input,
Textarea,
Field,
Button,
Checkbox,
Spinner,
@@ -20,10 +22,14 @@ import {
ErrorCircleRegular,
LinkRegular,
DismissRegular,
AppsListRegular
AppsListRegular,
CutRegular,
CalendarClockRegular,
WarningRegular
} from '@fluentui/react-icons'
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { sameVideo } from '../store/queueStats'
import { useSettings } from '../store/settings'
import { Select } from './Select'
import { Hint } from './Hint'
@@ -40,6 +46,23 @@ function looksLikeUrl(text: string): boolean {
}
}
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
function firstUrl(text: string): string | null {
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
if (looksLikeUrl(line)) return line
}
return null
}
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
function parseUrlFile(content: string): string | null {
const m = /^\s*URL\s*=\s*(\S+)/im.exec(content)
const url = m?.[1]?.trim()
return url && looksLikeUrl(url) ? url : null
}
const useStyles = makeStyles({
root: {
display: 'flex',
@@ -50,6 +73,11 @@ const useStyles = makeStyles({
...shorthands.borderRadius(tokens.borderRadiusXLarge),
boxShadow: tokens.shadow4
},
// Highlight while a link / .url file is dragged over the card.
rootDragging: {
outline: `2px dashed ${tokens.colorBrandStroke1}`,
outlineOffset: '-2px'
},
urlRow: {
display: 'flex',
gap: '8px'
@@ -212,6 +240,45 @@ const useStyles = makeStyles({
},
plItemMeta: {
color: tokens.colorNeutralForeground3
},
// --- duplicate warning ---
dupRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
// --- trim / schedule panels ---
trimBlock: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'flex-start'
},
optButtons: {
display: 'flex',
gap: '8px'
},
trimPanel: {
alignSelf: 'stretch',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// Native datetime-local input, themed to sit beside the Fluent controls.
dtInput: {
fontFamily: tokens.fontFamilyBase,
fontSize: tokens.fontSizeBase300,
padding: '6px 10px',
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke1}`,
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
colorScheme: 'light dark'
}
})
@@ -227,6 +294,47 @@ export function DownloadBar(): React.JSX.Element {
const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
const [showTrim, setShowTrim] = useState(false)
const [trim, setTrim] = useState('')
// Optional schedule: a datetime-local value; a future time parks the download.
const [showSchedule, setShowSchedule] = useState(false)
const [scheduleAt, setScheduleAt] = useState('')
// Drag-and-drop: highlight while a link / .url file hovers the card.
const [dragActive, setDragActive] = useState(false)
// Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null)
const confirmDup = useRef(false)
function onDragOver(e: React.DragEvent): void {
e.preventDefault()
if (!dragActive) setDragActive(true)
}
function onDrop(e: React.DragEvent): void {
e.preventDefault()
setDragActive(false)
const dt = e.dataTransfer
const fromText = firstUrl(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
if (fromText) {
onUrlChange(fromText)
return
}
// A dropped Windows .url Internet Shortcut — read its URL= line.
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
if (file) {
file
.text()
.then((content) => {
const u = parseUrlFile(content)
if (u) onUrlChange(u)
})
.catch(() => {})
}
}
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
useEffect(() => {
@@ -320,8 +428,10 @@ export function DownloadBar(): React.JSX.Element {
function onUrlChange(next: string): void {
setUrl(next)
// Any probed info is stale once the URL changes.
// Any probed info — or a duplicate warning — is stale once the URL changes.
if (info || probeError || playlist) clearProbe()
if (dup) setDup(null)
confirmDup.current = false
}
function onKindChange(next: MediaKind): void {
@@ -383,6 +493,23 @@ export function DownloadBar(): React.JSX.Element {
const trimmed = url.trim()
if (!trimmed) return
// Duplicate guard: if this URL is already in the queue (and the user hasn't
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
// Canceled/failed items don't count — re-adding those is a legitimate retry.
if (!confirmDup.current) {
const existing = useDownloads
.getState()
.items.find(
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
)
if (existing) {
setDup(existing.title)
return
}
}
confirmDup.current = false
setDup(null)
const meta = info
? {
title: info.title,
@@ -392,9 +519,14 @@ export function DownloadBar(): React.JSX.Element {
}
: {}
const trimSpec = trim.trim() || undefined
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
if (usingFormats && selectedFormat) {
addFromUrl(trimmed, 'video', selectedFormat.label, {
...meta,
trim: trimSpec,
scheduledFor,
format: {
id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio,
@@ -402,13 +534,22 @@ export function DownloadBar(): React.JSX.Element {
}
})
} else {
addFromUrl(trimmed, kind, quality, meta)
addFromUrl(trimmed, kind, quality, { ...meta, trim: trimSpec, scheduledFor })
}
setUrl('')
setTrim('')
setShowTrim(false)
setScheduleAt('')
setShowSchedule(false)
clearProbe()
}
function downloadAnyway(): void {
confirmDup.current = true
download()
}
function addPlaylist(): void {
if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index))
@@ -424,7 +565,12 @@ export function DownloadBar(): React.JSX.Element {
}
return (
<div className={styles.root}>
<div
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
onDragOver={onDragOver}
onDragLeave={() => setDragActive(false)}
onDrop={onDrop}
>
<div className={styles.urlRow}>
<Input
className={styles.url}
@@ -474,6 +620,23 @@ export function DownloadBar(): React.JSX.Element {
</div>
)}
{dup && (
<div className={styles.dupRow}>
<WarningRegular />
<Caption1 className={styles.suggestionText}>Already in your queue: {dup}.</Caption1>
<Button size="small" appearance="primary" onClick={downloadAnyway}>
Download anyway
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => setDup(null)}
aria-label="Dismiss"
/>
</div>
)}
{probing && (
<div className={styles.statusRow}>
<Spinner size="tiny" />
@@ -550,6 +713,59 @@ export function DownloadBar(): React.JSX.Element {
</div>
)}
{!playlist && (
<div className={styles.trimBlock}>
<div className={styles.optButtons}>
<Button
size="small"
appearance="subtle"
icon={<CutRegular />}
onClick={() => setShowTrim((v) => !v)}
>
{trim.trim() ? 'Trim · on' : 'Trim'}
</Button>
<Button
size="small"
appearance="subtle"
icon={<CalendarClockRegular />}
onClick={() => setShowSchedule((v) => !v)}
>
{scheduleAt ? 'Scheduled' : 'Schedule'}
</Button>
</div>
{showTrim && (
<div className={styles.trimPanel}>
<Field
label="Sections to keep"
hint="Time ranges like 1:30-2:00 — one per line or comma-separated. Leave empty to download the whole thing."
>
<Textarea
value={trim}
onChange={(_, d) => setTrim(d.value)}
placeholder={'0:30-1:45\n3:00-3:30'}
resize="vertical"
/>
</Field>
</div>
)}
{showSchedule && (
<div className={styles.trimPanel}>
<Field
label="Start at"
hint="Pick a date and time to start this download. Leave empty to start now. Scheduled downloads only fire while AeroFetch is running."
>
<input
type="datetime-local"
className={styles.dtInput}
value={scheduleAt}
onChange={(e) => setScheduleAt(e.target.value)}
/>
</Field>
</div>
)}
</div>
)}
<div className={styles.controls}>
<div className={styles.control}>
<Caption1>Format</Caption1>
@@ -606,11 +822,11 @@ export function DownloadBar(): React.JSX.Element {
<Button
appearance="primary"
size="large"
icon={<ArrowDownloadRegular />}
icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
onClick={download}
disabled={!url.trim()}
>
Download
{scheduleAt ? 'Schedule' : 'Download'}
</Button>
)}
</div>
@@ -204,6 +204,16 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
label={value.embedChapters ? 'On' : 'Off'}
/>
</Field>
<Field
label="Split by chapters"
hint="Also save each chapter as its own file. The full-length file is kept too."
>
<Switch
checked={value.splitChapters}
onChange={(_, d) => setOpt('splitChapters', d.checked)}
label={value.splitChapters ? 'On' : 'Off'}
/>
</Field>
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch
checked={value.embedMetadata}
+58 -7
View File
@@ -1,12 +1,16 @@
import {
Subtitle2,
Body1,
Caption1,
Button,
ProgressBar,
makeStyles,
tokens
tokens,
shorthands
} from '@fluentui/react-components'
import { ArrowDownloadRegular } from '@fluentui/react-icons'
import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads'
import { summarizeQueue } from '../store/queueStats'
import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList'
@@ -26,6 +30,27 @@ const useStyles = makeStyles({
alignItems: 'center',
justifyContent: 'space-between'
},
headerActions: {
display: 'flex',
gap: '8px'
},
summary: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 14px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
summaryBar: {
flexGrow: 1
},
summaryText: {
flexShrink: 0,
color: tokens.colorNeutralForeground3,
whiteSpace: 'nowrap'
},
listScroll: {
flexGrow: 1,
// min-height:0 lets this flex child shrink below its content height so it,
@@ -47,7 +72,9 @@ export function DownloadsView(): React.JSX.Element {
const styles = useStyles()
const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll)
const summary = summarizeQueue(items)
const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
)
@@ -56,13 +83,37 @@ export function DownloadsView(): React.JSX.Element {
<div className={styles.root}>
<DownloadBar />
{summary.active && (
<div className={styles.summary}>
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
<Caption1 className={styles.summaryText}>
{summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''}
{summary.speedLabel ? `${summary.speedLabel}` : ''}
{summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''}
</Caption1>
</div>
)}
<div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2>
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
</Button>
)}
<div className={styles.headerActions}>
{summary.failed > 0 && (
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={retryAll}
>
Retry all failed ({summary.failed})
</Button>
)}
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
</Button>
)}
</div>
</div>
{items.length === 0 ? (
@@ -57,6 +57,8 @@ const STATUS_LABEL: Record<ItemStatus, string> = {
pending: 'Pending',
queued: 'Queued',
downloading: 'Downloading',
paused: 'Paused',
saved: 'Saved',
completed: 'Downloaded',
error: 'Failed',
canceled: 'Canceled'
+87
View File
@@ -16,6 +16,11 @@ import {
OpenRegular,
FolderRegular,
ArrowClockwiseRegular,
ArrowDownloadRegular,
ArrowUpRegular,
BookmarkRegular,
PauseRegular,
PlayRegular,
CheckmarkCircleFilled,
ErrorCircleFilled,
EyeOffRegular
@@ -91,6 +96,8 @@ 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' }
@@ -100,6 +107,14 @@ 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
}: {
@@ -107,6 +122,11 @@ export const QueueItem = memo(function QueueItem({
}): React.JSX.Element {
const styles = useStyles()
const cancel = useDownloads((s) => s.cancel)
const pause = useDownloads((s) => s.pause)
const resume = useDownloads((s) => s.resume)
const prioritize = useDownloads((s) => s.prioritize)
const saveForLater = useDownloads((s) => s.saveForLater)
const queueNow = useDownloads((s) => s.queueNow)
const remove = useDownloads((s) => s.remove)
const retry = useDownloads((s) => s.retry)
const openFile = useDownloads((s) => s.openFile)
@@ -177,12 +197,79 @@ export const QueueItem = memo(function QueueItem({
</div>
)}
{item.status === 'paused' && (
<div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
<Caption1 className={styles.stats}>Paused {pct(item.progress)}</Caption1>
</div>
)}
{item.status === 'saved' && (
<Caption1 className={styles.stats}>
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
</Caption1>
)}
{item.status === 'error' && item.error && (
<Caption1 className={styles.error}>{item.error}</Caption1>
)}
</div>
<div className={styles.actions}>
{item.status === 'downloading' && (
<Hint label="Pause" placement="top" align="end">
<Button
appearance="subtle"
icon={<PauseRegular />}
onClick={() => pause(item.id)}
aria-label="Pause"
/>
</Hint>
)}
{item.status === 'queued' && (
<>
<Hint label="Download next" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowUpRegular />}
onClick={() => prioritize(item.id)}
aria-label="Download next"
/>
</Hint>
<Hint label="Save for later" placement="top" align="end">
<Button
appearance="subtle"
icon={<BookmarkRegular />}
onClick={() => saveForLater(item.id)}
aria-label="Save for later"
/>
</Hint>
</>
)}
{item.status === 'paused' && (
<Hint label="Resume" placement="top" align="end">
<Button
appearance="subtle"
icon={<PlayRegular />}
onClick={() => resume(item.id)}
aria-label="Resume"
/>
</Hint>
)}
{item.status === 'saved' && (
<Hint label="Add to queue" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowDownloadRegular />}
onClick={() => queueNow(item.id)}
aria-label="Add to queue"
/>
</Hint>
)}
{active && (
<Hint label="Cancel" placement="top" align="end">
<Button
+1
View File
@@ -113,6 +113,7 @@ 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 () => '',
+142 -3
View File
@@ -6,7 +6,14 @@ import { useSources } from './sources'
export type MediaKind = 'video' | 'audio'
export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'error' | 'canceled'
export type DownloadStatus =
| 'queued'
| 'downloading'
| 'paused'
| 'saved'
| 'completed'
| 'error'
| 'canceled'
/** An exact probed format chosen for a download (omitted for the preset path). */
export interface ChosenFormat {
@@ -39,6 +46,10 @@ export interface DownloadItem {
options?: DownloadOptions
/** per-download custom-command override (omitted = use the persisted default template) */
extraArgs?: string
/** raw trim spec — time ranges to keep (parsed to --download-sections in main) */
trim?: string
/** epoch ms to auto-start a 'saved' item; undefined = parked indefinitely (Phase M) */
scheduledFor?: number
/** private download — completion is never recorded to history */
incognito?: boolean
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
@@ -63,6 +74,9 @@ export interface AddOptions {
thumbnail?: string
options?: DownloadOptions
extraArgs?: string
trim?: string
/** when set and in the future, the item starts 'saved' and auto-queues at this time */
scheduledFor?: number
incognito?: boolean
collection?: CollectionContext
mediaItemId?: string
@@ -90,6 +104,20 @@ interface DownloadState {
*/
addMany: (entries: AddEntry[]) => void
retry: (id: string) => void
/** re-queue every failed item at once (Phase M) */
retryAll: () => void
/** stop a running download but keep its partial file for a later resume (Phase M) */
pause: (id: string) => void
/** re-queue a paused download; yt-dlp continues its partial file on relaunch */
resume: (id: string) => void
/** move a queued item to the front of the promotion order ("download next", Phase M) */
prioritize: (id: string) => void
/** park a queued item indefinitely as 'saved' (Phase M) */
saveForLater: (id: string) => void
/** un-park a 'saved' item back into the queue now (clears any schedule) */
queueNow: (id: string) => void
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
promoteDueScheduled: () => void
cancel: (id: string) => void
remove: (id: string) => void
clearFinished: () => void
@@ -117,6 +145,10 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
opts?.title || opts?.channel || opts?.durationLabel
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
: undefined
// A future scheduled time parks the item as 'saved' until its moment arrives;
// a past/absent time starts it queued as usual.
const scheduledFor =
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
return {
id: newId(),
url,
@@ -126,12 +158,14 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
thumbnail: opts?.thumbnail,
kind,
quality,
status: 'queued',
status: scheduledFor ? 'saved' : 'queued',
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options,
extraArgs: opts?.extraArgs,
trim: opts?.trim,
scheduledFor,
incognito: opts?.incognito,
collection: opts?.collection,
mediaItemId: opts?.mediaItemId,
@@ -297,6 +331,7 @@ export const useDownloads = create<DownloadState>((set, get) => {
formatHasAudio: item.formatHasAudio,
options: item.options,
extraArgs: item.extraArgs,
trim: item.trim,
meta: item.probedMeta,
collection: item.collection
})
@@ -357,6 +392,93 @@ export const useDownloads = create<DownloadState>((set, get) => {
pump()
},
retryAll: () => {
set((s) => ({
items: s.items.map((i) =>
i.status === 'error'
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
: i
)
}))
pump()
},
pause: (id) => {
const cur = get().items.find((i) => i.id === id)
if (!cur) return
// Only a running download has a live process to stop; a queued item is
// just parked in place. Either way it becomes 'paused' (pump ignores it).
if (!PREVIEW && cur.status === 'downloading') window.api.pauseDownload(id).catch(() => {})
set((s) => ({
items: s.items.map((i) =>
i.id === id && (i.status === 'downloading' || i.status === 'queued')
? { ...i, status: 'paused', speed: undefined, eta: undefined }
: i
)
}))
pump() // pausing a running item frees a slot
},
resume: (id) => {
// Re-queue WITHOUT resetting progress (unlike retry): pump() promotes it and
// launchItem re-spawns yt-dlp, which continues the partial file by default.
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'paused' ? { ...i, status: 'queued' } : i
)
}))
pump()
},
prioritize: (id) => {
// Reposition the item so pump() — which promotes the highest-index queued
// 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 {}
const items = s.items.slice()
const [it] = items.splice(idx, 1)
let after = -1
for (let k = 0; k < items.length; k++) if (items[k].status === 'queued') after = k
items.splice(after + 1, 0, it)
return { items }
})
pump()
},
saveForLater: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'queued'
? { ...i, status: 'saved', scheduledFor: undefined }
: i
)
}))
},
queueNow: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'saved'
? { ...i, status: 'queued', scheduledFor: undefined }
: i
)
}))
pump()
},
promoteDueScheduled: () => {
const now = Date.now()
set((s) => ({
items: s.items.map((i) =>
i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
? { ...i, status: 'queued', scheduledFor: undefined }
: i
)
}))
pump()
},
cancel: (id) => {
const cur = get().items.find((i) => i.id === id)
if (!cur) return
@@ -379,7 +501,13 @@ export const useDownloads = create<DownloadState>((set, get) => {
clearFinished: () =>
set((s) => ({
items: s.items.filter((i) => i.status === 'downloading' || i.status === 'queued')
items: s.items.filter(
(i) =>
i.status === 'downloading' ||
i.status === 'queued' ||
i.status === 'paused' ||
i.status === 'saved'
)
})),
openFile: (id) => {
@@ -461,6 +589,17 @@ export const useDownloads = create<DownloadState>((set, get) => {
}
})
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
// Session-only: the queue isn't persisted, so a schedule only fires while the app
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)) {
st.promoteDueScheduled()
}
}, 15_000)
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
if (!PREVIEW) {
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
+139
View File
@@ -0,0 +1,139 @@
/**
* Pure helpers for the Downloads view (Phase M): a one-line aggregate of the live
* queue, and same-video detection for the duplicate guard. Kept free of any store
* / window dependency (only a type-only import of DownloadItem) so it can be
* unit-tested without constructing the Zustand store or its preview ticker.
*/
import type { DownloadItem } from './downloads'
// Parse a human speed string ('4.7 MB/s', '512 KiB/s') into bytes/second. Tolerant
// of the SI (KB) and binary (KiB) suffixes yt-dlp may emit; the 1000-vs-1024
// difference is immaterial for a live display estimate.
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])
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)
}
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec <= 0) return ''
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
let v = bytesPerSec
let i = 0
while (v >= 1000 && i < units.length - 1) {
v /= 1000
i++
}
return `${v.toFixed(1)} ${units[i]}`
}
// Parse 'M:SS' / 'H:MM:SS' (or bare seconds) into seconds.
function parseEtaSeconds(s?: string): number {
if (!s) return 0
const parts = s.split(':').map((p) => Number(p))
if (parts.length === 0 || parts.some((p) => !isFinite(p))) return 0
return parts.reduce((acc, p) => acc * 60 + p, 0)
}
function formatEta(totalSeconds: number): string {
if (totalSeconds <= 0) return ''
const s = Math.round(totalSeconds)
const m = Math.floor(s / 60)
return `${m}:${String(s % 60).padStart(2, '0')}`
}
export interface QueueSummary {
downloading: number
queued: number
failed: number
/** true when something is downloading or waiting */
active: boolean
/** 0..1, averaged over downloading + queued items */
progress: number
/** combined download speed, formatted; '' when nothing is downloading */
speedLabel: string
/** rough time remaining (the longest active ETA), formatted; '' when unknown */
etaLabel: string
}
/**
* Aggregate the live queue into a one-line summary for the Downloads header.
* Combined speed is summed across downloading items; the "time left" is the
* longest single ETA — a reasonable proxy for when the current batch finishes
* (we don't track remaining bytes, so a true combined ETA isn't available).
* Queued items count toward the progress average as 0%, so the bar reflects
* "how much of the visible work is done", not just the active downloads.
*/
export function summarizeQueue(items: DownloadItem[]): QueueSummary {
let downloading = 0
let queued = 0
let failed = 0
let progressSum = 0
let progressCount = 0
let speed = 0
let maxEta = 0
for (const i of items) {
if (i.status === 'downloading') {
downloading++
progressSum += i.progress
progressCount++
speed += parseSpeed(i.speed)
maxEta = Math.max(maxEta, parseEtaSeconds(i.eta))
} else if (i.status === 'queued') {
queued++
progressCount++
} else if (i.status === 'error') {
failed++
}
}
return {
downloading,
queued,
failed,
active: downloading > 0 || queued > 0,
progress: progressCount > 0 ? progressSum / progressCount : 0,
speedLabel: formatSpeed(speed),
etaLabel: formatEta(maxEta)
}
}
// --- Duplicate detection ----------------------------------------------------
function youtubeId(url: string): string | null {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./, '')
if (host === 'youtu.be') return u.pathname.slice(1) || null
if (host.endsWith('youtube.com')) return u.searchParams.get('v')
} catch {
/* not a URL */
}
return null
}
function normalizeUrl(url: string): string {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./, '').toLowerCase()
return `${u.protocol}//${host}${u.pathname.replace(/\/$/, '')}${u.search}`
} catch {
return url.trim()
}
}
/**
* True when two URLs point at the same video: a matching YouTube id when both
* are YouTube links, otherwise normalised-URL equality (host lower-cased, `www.`
* and a trailing slash stripped). Powers the "already in your queue" guard.
*/
export function sameVideo(a: string, b: string): boolean {
if (a === b) return true
const ya = youtubeId(a)
const yb = youtubeId(b)
if (ya && yb) return ya === yb
return normalizeUrl(a) === normalizeUrl(b)
}