diff --git a/src/components/download/DownloadStatusBar.svelte b/src/components/download/DownloadStatusBar.svelte new file mode 100644 index 00000000..3a461499 --- /dev/null +++ b/src/components/download/DownloadStatusBar.svelte @@ -0,0 +1,284 @@ + + +{#if visible} +
+
+
+
+ +
+ + {#if showComplete} + {$t("downloads.status_bar.complete")} + {:else} + {countLabel} + {#if agg.activeCount > 0} + + {formatSpeed(agg.speedBps)} + {/if} + {#if etaText} + + {$t("downloads.status_bar.eta", { eta: etaText })} + {/if} + {#if agg.speedBps > 0} + + + + {/if} + {#if agg.activeCount > 0 && agg.queuedCount > 0} + + + {$t("downloads.status_bar.queued", { count: agg.queuedCount })} + + {/if} + {/if} + + {#if !showComplete} + {sizeLabel} + {/if} + {$t("downloads.status_bar.open")} +
+ + {announceText} +
+{/if} + + diff --git a/src/lib/i18n/el.json b/src/lib/i18n/el.json index 5ecb6c95..8b84cd98 100644 --- a/src/lib/i18n/el.json +++ b/src/lib/i18n/el.json @@ -567,6 +567,16 @@ "copied": "Το αρχείο καταγραφής αντιγράφηκε", "copy_failed": "Αποτυχία αντιγραφής αρχείου καταγραφής", "empty": "Δεν υπάρχουν ακόμη γραμμές" + }, + "status_bar": { + "region_label": "Πρόοδος λήψεων", + "active": "{{count}} σε λήψη", + "queued": "{{count}} σε αναμονή", + "paused_all": "Όλες σε παύση", + "eta": "Απομένουν {{eta}}", + "open": "Προβολή λήψεων", + "indeterminate": "Λήψη σε εξέλιξη, άγνωστο συνολικό μέγεθος", + "complete": "Οι λήψεις ολοκληρώθηκαν" } }, "toast": { diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 41efb90d..c66dcbb0 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -567,6 +567,16 @@ "copied": "Log copied to clipboard", "copy_failed": "Couldn't copy log", "empty": "No log lines yet" + }, + "status_bar": { + "region_label": "Download progress", + "active": "{{count}} downloading", + "queued": "{{count}} queued", + "paused_all": "All paused", + "eta": "ETA {{eta}}", + "open": "View downloads", + "indeterminate": "Downloading, total size unknown", + "complete": "Downloads complete" } }, "toast": { diff --git a/src/lib/i18n/es.json b/src/lib/i18n/es.json index 68fe53b7..b48d0153 100644 --- a/src/lib/i18n/es.json +++ b/src/lib/i18n/es.json @@ -567,6 +567,16 @@ "copied": "Registro copiado al portapapeles", "copy_failed": "No se pudo copiar el registro", "empty": "Aún no hay líneas en el registro" + }, + "status_bar": { + "region_label": "Progreso de descargas", + "active": "{{count}} descargando", + "queued": "{{count}} en cola", + "paused_all": "Todas en pausa", + "eta": "Quedan {{eta}}", + "open": "Ver descargas", + "indeterminate": "Descargando, tamaño total desconocido", + "complete": "Descargas completadas" } }, "toast": { diff --git a/src/lib/i18n/fa.json b/src/lib/i18n/fa.json index beb36529..c08f4f8e 100644 --- a/src/lib/i18n/fa.json +++ b/src/lib/i18n/fa.json @@ -567,6 +567,16 @@ "copied": "Log copied to clipboard", "copy_failed": "Couldn't copy log", "empty": "No log lines yet" + }, + "status_bar": { + "region_label": "پیشرفت دانلود", + "active": "{{count}} در حال دانلود", + "queued": "{{count}} در صف", + "paused_all": "همه متوقف شده", + "eta": "{{eta}} باقی‌مانده", + "open": "مشاهده دانلودها", + "indeterminate": "در حال دانلود، حجم کل نامشخص", + "complete": "دانلودها کامل شد" } }, "toast": { diff --git a/src/lib/i18n/fr.json b/src/lib/i18n/fr.json index d4456d52..36237692 100644 --- a/src/lib/i18n/fr.json +++ b/src/lib/i18n/fr.json @@ -567,6 +567,16 @@ "copied": "Journal copié", "copy_failed": "Impossible de copier le journal", "empty": "Aucune ligne dans le journal pour l'instant" + }, + "status_bar": { + "region_label": "Progression des téléchargements", + "active": "{{count}} en cours", + "queued": "{{count}} en file", + "paused_all": "Tout en pause", + "eta": "{{eta}} restant", + "open": "Voir les téléchargements", + "indeterminate": "Téléchargement, taille totale inconnue", + "complete": "Téléchargements terminés" } }, "toast": { diff --git a/src/lib/i18n/it.json b/src/lib/i18n/it.json index 029cf4f4..a0b79ab2 100644 --- a/src/lib/i18n/it.json +++ b/src/lib/i18n/it.json @@ -567,6 +567,16 @@ "copied": "Log copiato", "copy_failed": "Impossibile copiare il log", "empty": "Nessuna riga di log ancora" + }, + "status_bar": { + "region_label": "Avanzamento download", + "active": "{{count}} in download", + "queued": "{{count}} in coda", + "paused_all": "Tutti in pausa", + "eta": "{{eta}} rimanenti", + "open": "Vedi download", + "indeterminate": "Download in corso, dimensione totale sconosciuta", + "complete": "Download completati" } }, "toast": { diff --git a/src/lib/i18n/ja.json b/src/lib/i18n/ja.json index bba82dbe..b693fd27 100644 --- a/src/lib/i18n/ja.json +++ b/src/lib/i18n/ja.json @@ -567,6 +567,16 @@ "copied": "ログをコピーしました", "copy_failed": "ログをコピーできませんでした", "empty": "まだログがありません" + }, + "status_bar": { + "region_label": "ダウンロードの進行状況", + "active": "{{count}} 件ダウンロード中", + "queued": "{{count}} 件キュー待ち", + "paused_all": "すべて一時停止中", + "eta": "残り {{eta}}", + "open": "ダウンロードを表示", + "indeterminate": "ダウンロード中、合計サイズ不明", + "complete": "ダウンロード完了" } }, "toast": { diff --git a/src/lib/i18n/keys.ts b/src/lib/i18n/keys.ts index 1999db3c..b76ebf9e 100644 --- a/src/lib/i18n/keys.ts +++ b/src/lib/i18n/keys.ts @@ -230,6 +230,14 @@ export type TranslationKeys = | 'downloads.status.paused' | 'downloads.status.queued' | 'downloads.status.seeding' + | 'downloads.status_bar.active' + | 'downloads.status_bar.complete' + | 'downloads.status_bar.eta' + | 'downloads.status_bar.indeterminate' + | 'downloads.status_bar.open' + | 'downloads.status_bar.paused_all' + | 'downloads.status_bar.queued' + | 'downloads.status_bar.region_label' | 'downloads.stop' | 'downloads.sw.autofix' | 'downloads.sw.autofix_none' diff --git a/src/lib/i18n/pt.json b/src/lib/i18n/pt.json index 763edb6e..0a6a1765 100644 --- a/src/lib/i18n/pt.json +++ b/src/lib/i18n/pt.json @@ -567,6 +567,16 @@ "copied": "Log copiado", "copy_failed": "Não foi possível copiar o log", "empty": "Nenhuma linha no log ainda" + }, + "status_bar": { + "region_label": "Progresso dos downloads", + "active": "{{count}} baixando", + "queued": "{{count}} na fila", + "paused_all": "Todos pausados", + "eta": "Faltam {{eta}}", + "open": "Ver downloads", + "indeterminate": "Baixando, tamanho total desconhecido", + "complete": "Downloads concluídos" } }, "toast": { diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json index 94aac135..b51dfda3 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -567,6 +567,16 @@ "copied": "Лог скопирован в буфер обмена", "copy_failed": "Не удалось скопировать лог", "empty": "Пока нет строк лога" + }, + "status_bar": { + "region_label": "Прогресс загрузок", + "active": "Загружается: {{count}}", + "queued": "В очереди: {{count}}", + "paused_all": "Все на паузе", + "eta": "Осталось {{eta}}", + "open": "Открыть загрузки", + "indeterminate": "Загрузка, общий размер неизвестен", + "complete": "Загрузки завершены" } }, "toast": { diff --git a/src/lib/i18n/zh-TW.json b/src/lib/i18n/zh-TW.json index bf63649a..97290092 100644 --- a/src/lib/i18n/zh-TW.json +++ b/src/lib/i18n/zh-TW.json @@ -567,6 +567,16 @@ "copied": "日誌已複製", "copy_failed": "無法複製日誌", "empty": "尚無日誌" + }, + "status_bar": { + "region_label": "下載進度", + "active": "{{count}} 個下載中", + "queued": "{{count}} 個佇列中", + "paused_all": "全部已暫停", + "eta": "剩餘 {{eta}}", + "open": "檢視下載", + "indeterminate": "正在下載,總大小未知", + "complete": "下載完成" } }, "toast": { diff --git a/src/lib/i18n/zh.json b/src/lib/i18n/zh.json index e0d9049c..6688246c 100644 --- a/src/lib/i18n/zh.json +++ b/src/lib/i18n/zh.json @@ -567,6 +567,16 @@ "copied": "日志已复制", "copy_failed": "无法复制日志", "empty": "暂无日志" + }, + "status_bar": { + "region_label": "下载进度", + "active": "{{count}} 个下载中", + "queued": "{{count}} 个排队中", + "paused_all": "全部已暂停", + "eta": "剩余 {{eta}}", + "open": "查看下载", + "indeterminate": "正在下载,总大小未知", + "complete": "下载完成" } }, "toast": { diff --git a/src/lib/stores/download-store.svelte.ts b/src/lib/stores/download-store.svelte.ts index 28969fa1..91b40065 100644 --- a/src/lib/stores/download-store.svelte.ts +++ b/src/lib/stores/download-store.svelte.ts @@ -56,12 +56,16 @@ export type SpeedPoint = { t: number; bps: number }; const SPEED_SMOOTHING = 0.3; const SPEED_HISTORY_MAX = 60; +const AGGREGATE_SAMPLE_MS = 900; let downloads = $state(new Map()); const speedHistory = new Map(); const suppressedGenericIds = new Set(); let flushScheduled = false; +let aggregateSpeedHistory = $state([]); +let lastAggregateSampleAt = 0; + function pushSpeedPoint(id: number, bps: number) { let arr = speedHistory.get(id); if (!arr) { @@ -82,17 +86,53 @@ function clearSpeedHistory(id: number) { speedHistory.delete(id); } +function sampleAggregateSpeed(now: number) { + let bps = 0; + let anyActive = false; + let anyPending = false; + for (const item of downloads.values()) { + if (item.status === "downloading") { + bps += Math.max(0, item.speed); + anyActive = true; + } else if (item.status === "queued" || item.status === "paused") { + anyPending = true; + } + } + + if (!anyActive && !anyPending) { + if (aggregateSpeedHistory.length > 0) aggregateSpeedHistory = []; + lastAggregateSampleAt = 0; + return; + } + + if (now - lastAggregateSampleAt < AGGREGATE_SAMPLE_MS) return; + if (!anyActive && aggregateSpeedHistory.length === 0) return; + + lastAggregateSampleAt = now; + const next = aggregateSpeedHistory.length >= SPEED_HISTORY_MAX + ? aggregateSpeedHistory.slice(aggregateSpeedHistory.length - SPEED_HISTORY_MAX + 1) + : aggregateSpeedHistory.slice(); + next.push({ t: now, bps }); + aggregateSpeedHistory = next; +} + +export function getAggregateSpeedHistory(): SpeedPoint[] { + return aggregateSpeedHistory; +} + function scheduleFlush() { if (flushScheduled) return; flushScheduled = true; requestAnimationFrame(() => { flushScheduled = false; + sampleAggregateSpeed(Date.now()); downloads = new Map(downloads); }); } function flushNow() { flushScheduled = false; + sampleAggregateSpeed(Date.now()); downloads = new Map(downloads); } @@ -139,6 +179,81 @@ export function getPausedCount(): number { return getCounts().paused; } +export type DownloadAggregate = { + activeCount: number; + queuedCount: number; + pausedCount: number; + speedBps: number; + downloadedBytes: number; + totalBytes: number | null; + percent: number | null; + etaSeconds: number | null; +}; + +export function getAggregate(): DownloadAggregate { + let activeCount = 0; + let queuedCount = 0; + let pausedCount = 0; + let speedBps = 0; + let downloadedBytes = 0; + let knownTotal = 0; + let allTotalsKnown = true; + let sawDownloading = false; + let maxItemEta: number | null = null; + + for (const item of downloads.values()) { + if (item.status === "queued") { queuedCount++; continue; } + if (item.status === "paused") { pausedCount++; continue; } + if (item.status !== "downloading") continue; + + activeCount++; + sawDownloading = true; + speedBps += Math.max(0, item.speed); + + if (item.kind === "generic") { + downloadedBytes += Math.max(0, item.downloadedBytes); + if (item.totalBytes != null && item.totalBytes > 0) { + knownTotal += item.totalBytes; + } else { + allTotalsKnown = false; + } + const eta = item.etaSeconds; + if (eta != null && Number.isFinite(eta) && eta > 0) { + maxItemEta = maxItemEta === null ? eta : Math.max(maxItemEta, eta); + } + } else { + downloadedBytes += Math.max(0, item.bytesDownloaded); + allTotalsKnown = false; + } + } + + const totalBytes = sawDownloading && allTotalsKnown && knownTotal > 0 ? knownTotal : null; + const percent = totalBytes !== null + ? Math.min(100, Math.max(0, (downloadedBytes / totalBytes) * 100)) + : null; + + let etaSeconds: number | null = null; + if (speedBps > 0) { + if (totalBytes !== null) { + const eta = Math.max(0, totalBytes - downloadedBytes) / speedBps; + etaSeconds = Number.isFinite(eta) && eta > 0 ? eta : null; + } else { + etaSeconds = maxItemEta; + } + } + + return { + activeCount, + queuedCount, + pausedCount, + speedBps, + downloadedBytes, + totalBytes, + percent, + etaSeconds, + }; +} + export function upsertProgress( courseId: number, courseName: string, diff --git a/src/lib/stores/download-store.test.ts b/src/lib/stores/download-store.test.ts index c2b21dc4..d0286dd7 100644 --- a/src/lib/stores/download-store.test.ts +++ b/src/lib/stores/download-store.test.ts @@ -4,12 +4,29 @@ type DownloadStore = typeof import("./download-store.svelte"); let store: DownloadStore; -const queueItem = (id: number) => ({ +type QueueItem = { + id: number; + url: string; + platform: string; + title: string; + status: { type: string; data?: unknown }; + percent: number; + speed_bytes_per_sec: number; + downloaded_bytes: number; + total_bytes: number | null; + file_path: string | null; + file_size_bytes: number | null; + file_count: number | null; + thumbnail_url: string | null; + eta_seconds?: number | null; +}; + +const queueItem = (id: number, overrides: Partial = {}): QueueItem => ({ id, url: "https://example.com/video", platform: "youtube", title: "Example video", - status: { type: "Active" as const }, + status: { type: "Active" }, percent: 0, speed_bytes_per_sec: 0, downloaded_bytes: 0, @@ -18,6 +35,8 @@ const queueItem = (id: number) => ({ file_size_bytes: null, file_count: null, thumbnail_url: null, + eta_seconds: null, + ...overrides, }); beforeAll(async () => { @@ -64,3 +83,163 @@ describe("generic download progress", () => { }); }); }); + +describe("getAggregate", () => { + it("reports an empty queue as idle with nothing knowable", () => { + expect(store.getAggregate()).toMatchObject({ + activeCount: 0, + queuedCount: 0, + pausedCount: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + percent: null, + etaSeconds: null, + }); + }); + + it("sums speed and bytes across downloading items with known totals", () => { + store.syncQueueState([ + queueItem(1, { speed_bytes_per_sec: 1000, downloaded_bytes: 200, total_bytes: 1000 }), + queueItem(2, { speed_bytes_per_sec: 3000, downloaded_bytes: 300, total_bytes: 3000 }), + ]); + + const agg = store.getAggregate(); + expect(agg.activeCount).toBe(2); + expect(agg.speedBps).toBe(4000); + expect(agg.downloadedBytes).toBe(500); + expect(agg.totalBytes).toBe(4000); + expect(agg.percent).toBeCloseTo(12.5); + expect(agg.etaSeconds).toBeCloseTo(0.875); + }); + + it("falls back to the largest per-item ETA when a total size is unknown", () => { + store.syncQueueState([ + queueItem(1, { speed_bytes_per_sec: 1000, downloaded_bytes: 200, total_bytes: 1000, eta_seconds: 4 }), + queueItem(2, { speed_bytes_per_sec: 500, downloaded_bytes: 100, total_bytes: null, eta_seconds: 42 }), + ]); + + const agg = store.getAggregate(); + expect(agg.totalBytes).toBeNull(); + expect(agg.percent).toBeNull(); + expect(agg.speedBps).toBe(1500); + expect(agg.etaSeconds).toBe(42); + }); + + it("never emits Infinity or NaN when everything is stalled", () => { + store.syncQueueState([ + queueItem(1, { speed_bytes_per_sec: 0, downloaded_bytes: 200, total_bytes: 1000 }), + ]); + + const agg = store.getAggregate(); + expect(agg.speedBps).toBe(0); + expect(agg.percent).toBeCloseTo(20); + expect(agg.etaSeconds).toBeNull(); + }); + + it("excludes queued, paused, complete and seeding items from the maths", () => { + store.syncQueueState([ + queueItem(1, { status: { type: "Active" }, speed_bytes_per_sec: 1000, downloaded_bytes: 500, total_bytes: 1000 }), + queueItem(2, { status: { type: "Queued" }, speed_bytes_per_sec: 9000, downloaded_bytes: 999, total_bytes: 9999 }), + queueItem(3, { status: { type: "Paused" }, speed_bytes_per_sec: 9000, downloaded_bytes: 999, total_bytes: 9999 }), + queueItem(4, { status: { type: "Complete" }, speed_bytes_per_sec: 9000, downloaded_bytes: 999, total_bytes: 9999 }), + queueItem(5, { status: { type: "Seeding" }, speed_bytes_per_sec: 9000, downloaded_bytes: 999, total_bytes: 9999 }), + ]); + + const agg = store.getAggregate(); + expect(agg.queuedCount).toBe(1); + expect(agg.pausedCount).toBe(1); + expect(agg.activeCount).toBe(1); + expect(agg.speedBps).toBe(1000); + expect(agg.downloadedBytes).toBe(500); + expect(agg.totalBytes).toBe(1000); + expect(agg.percent).toBeCloseTo(50); + }); + + it("reports a seeding-only queue as idle so the bar does not stay pinned", () => { + store.syncQueueState([ + queueItem(1, { + status: { type: "Seeding" }, + percent: 100, + speed_bytes_per_sec: 0, + downloaded_bytes: 4096, + total_bytes: 4096, + }), + ]); + + expect(store.getAggregate()).toMatchObject({ + activeCount: 0, + queuedCount: 0, + pausedCount: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + percent: null, + etaSeconds: null, + }); + }); + + it("keeps counting a seeding torrent in the sidebar badge", () => { + store.syncQueueState([ + queueItem(1, { status: { type: "Seeding" }, percent: 100, total_bytes: 4096 }), + ]); + + const counts = store.getCounts(); + expect(counts.active).toBe(1); + expect(counts.badge).toBe(1); + }); + + it("drops stale samples once the queue drains", () => { + let clock = Date.now(); + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => clock); + try { + for (let i = 0; i < 3; i++) { + clock += 1000; + store.syncQueueState([ + queueItem(1, { speed_bytes_per_sec: 1000, downloaded_bytes: i * 100, total_bytes: 10_000 }), + ]); + } + expect(store.getAggregateSpeedHistory().length).toBeGreaterThan(0); + + clock += 1000; + store.syncQueueState([ + queueItem(1, { status: { type: "Complete" }, percent: 100, total_bytes: 10_000 }), + ]); + expect(store.getAggregateSpeedHistory()).toHaveLength(0); + } finally { + nowSpy.mockRestore(); + } + }); + + it("lets a course item contribute speed but forces percent to null", () => { + store.syncQueueState([ + queueItem(1, { speed_bytes_per_sec: 1000, downloaded_bytes: 200, total_bytes: 1000, eta_seconds: 5 }), + ]); + store.upsertProgress(77, "Some course", 30, "Module 1", "Page 2", 4096, 10, 3, 2, 0); + + const agg = store.getAggregate(); + expect(agg.activeCount).toBe(2); + expect(agg.totalBytes).toBeNull(); + expect(agg.percent).toBeNull(); + expect(agg.downloadedBytes).toBe(200 + 4096); + expect(agg.etaSeconds).toBe(5); + + store.removeDownload(77); + }); + + it("caps the aggregate speed history at the history limit", () => { + let clock = Date.now(); + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => clock); + try { + for (let i = 0; i < 80; i++) { + clock += 1000; + store.syncQueueState([ + queueItem(1, { speed_bytes_per_sec: 1000 + i, downloaded_bytes: i, total_bytes: 10_000 }), + ]); + } + expect(store.getAggregateSpeedHistory()).toHaveLength(60); + } finally { + nowSpy.mockRestore(); + } + }); +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5110c790..5b4f9fa8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -21,6 +21,7 @@ import AppSidebar from "$components/shell/AppSidebar.svelte"; import AppToolbar from "$components/shell/AppToolbar.svelte"; import CommandPalette from "$components/shell/CommandPalette.svelte"; + import DownloadStatusBar from "$components/download/DownloadStatusBar.svelte"; import { setCommandPaletteItems } from "$lib/stores/command-palette-store.svelte"; import { refreshUpdateInfo } from "$lib/stores/update-store.svelte"; import { startClipboardMonitor, stopClipboardMonitor, onClipboardUrl } from "$lib/stores/clipboard-monitor"; @@ -362,6 +363,10 @@ {@render children()} {/if} + + {#if !hideAppSidebar} + + {/if} {/if}