From 86c78689dfd8bde7073d2ce72a5e2af5a71a93df Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 21 Apr 2026 10:57:29 -0500 Subject: [PATCH] Feat: Extension of Download Features, Batch Select, Error/Retry (#38) --- Todo | 3 - src/api/mutations/downloads.ts | 20 +- .../downloads/components/DownloadItem.svelte | 277 +++++++++++++++--- .../downloads/components/DownloadQueue.svelte | 49 +++- .../downloads/components/Downloads.svelte | 102 ++++++- src/features/downloads/lib/downloadQueue.ts | 41 ++- .../downloads/store/downloadState.svelte.ts | 191 +++++++++++- src/shared/ui/ContextMenu.svelte | 8 +- src/types/api.ts | 3 +- 9 files changed, 626 insertions(+), 68 deletions(-) diff --git a/Todo b/Todo index a9a9593..60ecfce 100644 --- a/Todo +++ b/Todo @@ -30,9 +30,6 @@ In-Progress: - Folders Slide - Dropdown Formatting (Repositories, Etc) - Extensions Revamps - - Notification on Extension Added - - Notification on Extension Refresh - - Notification on Extension Update - Fix Pill-Shaped Language Filter - Fix ALL ALL EN Tag Issue - Search QOL Animations diff --git a/src/api/mutations/downloads.ts b/src/api/mutations/downloads.ts index 5dcff0f..faeb49f 100644 --- a/src/api/mutations/downloads.ts +++ b/src/api/mutations/downloads.ts @@ -1,7 +1,7 @@ const QUEUE_FRAGMENT = ` state queue { - progress state + progress state tries chapter { id name pageCount mangaId manga { id title thumbnailUrl } @@ -33,6 +33,22 @@ export const DEQUEUE_DOWNLOAD = ` } `; +export const DEQUEUE_CHAPTERS_DOWNLOAD = ` + mutation DequeueChaptersDownload($chapterIds: [Int!]!) { + dequeueChapterDownloads(input: { ids: $chapterIds }) { + downloadStatus { ${QUEUE_FRAGMENT} } + } + } +`; + +export const REORDER_DOWNLOAD = ` + mutation ReorderDownload($chapterId: Int!, $to: Int!) { + reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) { + downloadStatus { ${QUEUE_FRAGMENT} } + } + } +`; + export const START_DOWNLOADER = ` mutation StartDownloader { startDownloader(input: {}) { @@ -80,4 +96,4 @@ export const SET_LOCAL_SOURCE_PATH = ` settings { localSourcePath } } } -`; +`; \ No newline at end of file diff --git a/src/features/downloads/components/DownloadItem.svelte b/src/features/downloads/components/DownloadItem.svelte index dd7e377..a86b1e0 100644 --- a/src/features/downloads/components/DownloadItem.svelte +++ b/src/features/downloads/components/DownloadItem.svelte @@ -1,51 +1,224 @@ -
+
{ e.stopPropagation(); onSelect(item.chapter.id, e); }} + oncontextmenu={openMenu} + ontouchstart={onTouchStart} + ontouchend={cancelLongPress} + ontouchmove={onTouchMove} +> {#if manga?.thumbnailUrl}
{/if} +
{#if manga?.title}{manga.title}{/if} {item.chapter.name} {#if pages > 0} - {isActive ? `${prog.done} / ${prog.total} pages` : `${prog.total} pages`} - {/if} - {#if isActive} -
-
+
+
+
+
+ + {#if isActive} + {prog.done}/{prog.total} + {:else if isError} + failed · {item.tries} {item.tries === 1 ? "try" : "tries"} + {:else} + {prog.total}p + {/if} +
{/if}
+
- {item.state} - {#if !isActive} - - {/if} + {item.state} +
+ {#if isError} + + {/if} + {#if !isActive} + + + + {/if} +
+{#if menuOpen} + (menuOpen = false)} /> +{/if} + + .action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); } + .action-btn:disabled { opacity: 0.25; cursor: default; } + .action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); } + .action-btn.retry:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); } + \ No newline at end of file diff --git a/src/features/downloads/components/DownloadQueue.svelte b/src/features/downloads/components/DownloadQueue.svelte index 6311a4a..ce2be98 100644 --- a/src/features/downloads/components/DownloadQueue.svelte +++ b/src/features/downloads/components/DownloadQueue.svelte @@ -4,18 +4,37 @@ import type { DownloadQueueItem } from "@types/index"; interface Props { - queue: DownloadQueueItem[]; - loading: boolean; - isRunning: boolean; - dequeueing: Set; - onRemove: (chapterId: number) => void; + queue: DownloadQueueItem[]; + loading: boolean; + isRunning: boolean; + dequeueing: Set; + selected: Set; + batchWorking: boolean; + onRemove: (chapterId: number) => void; + onRetry: (chapterId: number) => void; + onReorder: (chapterId: number, dir: "up" | "down") => void; + onSelect: (chapterId: number, e: MouseEvent) => void; + onClearSelect: () => void; + onBatchRemove: () => void; + onBatchRetry: () => void; + onBatchReorder: (dir: "up" | "down") => void; } - const { queue, loading, isRunning, dequeueing, onRemove }: Props = $props(); + const { + queue, loading, isRunning, dequeueing, selected, batchWorking, + onRemove, onRetry, onReorder, onSelect, onClearSelect, + onBatchRemove, onBatchRetry, onBatchReorder, + }: Props = $props(); + + const selectedErrorCount = $derived( + queue.filter((i) => selected.has(i.chapter.id) && i.state === "ERROR").length, + ); {#if loading} -
+
+ +
{:else if queue.length === 0}
Queue is empty.
{:else} @@ -23,9 +42,23 @@ {#each queue as item, i (item.chapter.id)} {/each}
@@ -48,4 +81,4 @@ font-size: var(--text-xs); letter-spacing: var(--tracking-wide); } - + \ No newline at end of file diff --git a/src/features/downloads/components/Downloads.svelte b/src/features/downloads/components/Downloads.svelte index d54aeda..20990f0 100644 --- a/src/features/downloads/components/Downloads.svelte +++ b/src/features/downloads/components/Downloads.svelte @@ -1,38 +1,90 @@

Downloads

- + {/if} + -
-
+
@@ -40,7 +92,12 @@ ? (downloadStore.isRunning ? "Pausing…" : "Starting…") : downloadStore.isRunning ? "Downloading" : "Paused"} - {downloadStore.queue.length} queued +
+ {#if downloadStore.isRunning && downloadStore.eta !== null} + {formatEta(downloadStore.eta)} left + {/if} + {downloadStore.queue.length} queued +
downloadStore.dequeue(id)} + onRetry={(id) => downloadStore.retryOne(id)} + onReorder={(id, dir) => downloadStore.reorder(id, dir)} + onSelect={handleSelect} + onClearSelect={() => { downloadStore.clearSelection(); selectAnchor = null; }} + onBatchRemove={() => downloadStore.dequeueSelected()} + onBatchRetry={() => downloadStore.retrySelected()} + onBatchReorder={(dir) => downloadStore.reorderSelected(dir)} />
@@ -70,6 +136,7 @@ border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } + .heading { font-family: var(--font-ui); font-size: var(--text-xs); @@ -78,6 +145,7 @@ letter-spacing: var(--tracking-wider); text-transform: uppercase; } + .header-actions { display: flex; gap: var(--sp-2); } .content { @@ -98,6 +166,8 @@ border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); + background: none; + cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } @@ -113,6 +183,7 @@ border: 1px solid var(--border-dim); border-radius: var(--radius-md); } + .status-dot { width: 6px; height: 6px; @@ -130,6 +201,21 @@ flex: 1; letter-spacing: var(--tracking-wide); } + + .status-right { + display: flex; + align-items: center; + gap: var(--sp-3); + } + + .status-eta { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--accent-fg); + letter-spacing: var(--tracking-wide); + opacity: 0.8; + } + .status-count { font-family: var(--font-ui); font-size: var(--text-xs); @@ -139,4 +225,4 @@ @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } - + \ No newline at end of file diff --git a/src/features/downloads/lib/downloadQueue.ts b/src/features/downloads/lib/downloadQueue.ts index 66d2ff2..83ed400 100644 --- a/src/features/downloads/lib/downloadQueue.ts +++ b/src/features/downloads/lib/downloadQueue.ts @@ -12,14 +12,51 @@ export function optimisticRemove(queue: DownloadQueueItem[], chapterId: number): return queue.filter((i) => i.chapter.id !== chapterId); } -export function optimisticToggle(state: string, wasRunning: boolean): string { - return wasRunning ? "STOPPED" : "STARTED"; +export function optimisticRemoveMany(queue: DownloadQueueItem[], chapterIds: Set): DownloadQueueItem[] { + return queue.filter((i) => !chapterIds.has(i.chapter.id)); } export function isRunning(state: string | undefined): boolean { return state === "STARTED"; } +export function getErrored(queue: DownloadQueueItem[]): DownloadQueueItem[] { + return queue.filter((i) => i.state === "ERROR"); +} + export function pageProgress(progress: number, pageCount: number): { done: number; total: number } { return { done: Math.round(progress * pageCount), total: pageCount }; } + +export interface SpeedSample { + ts: number; + progress: number; + pages: number; +} + +export function calcSpeed(prev: SpeedSample | null, current: SpeedSample): number | null { + if (!prev) return null; + const dt = (current.ts - prev.ts) / 1000; + if (dt <= 0) return null; + const prevDone = Math.round(prev.progress * prev.pages); + const curDone = Math.round(current.progress * current.pages); + const delta = curDone - prevDone; + if (delta <= 0) return null; + return delta / dt; +} + +export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): number | null { + if (pagesPerSec <= 0 || queue.length === 0) return null; + let remaining = 0; + for (const item of queue) { + const pages = item.chapter.pageCount ?? 0; + remaining += pages - Math.round(item.progress * pages); + } + return remaining / pagesPerSec; +} + +export function formatEta(seconds: number): string { + if (seconds < 60) return `~${Math.ceil(seconds)}s`; + if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`; + return `~${(seconds / 3600).toFixed(1)}h`; +} \ No newline at end of file diff --git a/src/features/downloads/store/downloadState.svelte.ts b/src/features/downloads/store/downloadState.svelte.ts index 829c54c..c7d3677 100644 --- a/src/features/downloads/store/downloadState.svelte.ts +++ b/src/features/downloads/store/downloadState.svelte.ts @@ -1,30 +1,68 @@ import { gql } from "@api/client"; import { GET_DOWNLOAD_STATUS } from "@api/queries"; -import { START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "@api/mutations"; +import { + START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, + DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD, + ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD, +} from "@api/mutations"; import { setActiveDownloads } from "@store/state.svelte"; import type { DownloadStatus } from "@types/index"; -import { toActiveDownloads, optimisticRemove, isRunning } from "../lib/downloadQueue"; +import { + toActiveDownloads, optimisticRemove, optimisticRemoveMany, + isRunning, getErrored, calcSpeed, estimateEta, + type SpeedSample, +} from "../lib/downloadQueue"; class DownloadStore { - status: DownloadStatus | null = $state(null); - loading = $state(true); - togglingPlay = $state(false); - clearing = $state(false); - dequeueing = $state(new Set()); + status: DownloadStatus | null = $state(null); + loading = $state(true); + togglingPlay = $state(false); + clearing = $state(false); + dequeueing = $state(new Set()); + selected = $state(new Set()); + batchWorking = $state(false); + pagesPerSec: number | null = $state(null); + eta: number | null = $state(null); + + private lastSample: SpeedSample | null = null; get queue() { return this.status?.queue ?? []; } get isRunning() { return isRunning(this.status?.state); } + get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); } + get hasErrored() { return this.erroredIds.size > 0; } applyStatus(ds: DownloadStatus) { this.status = ds; setActiveDownloads(toActiveDownloads(ds.queue)); + this.updateSpeed(ds); + } + + private updateSpeed(ds: DownloadStatus) { + const active = ds.queue[0]; + if (!active || active.state !== "DOWNLOADING") { + this.lastSample = null; + this.pagesPerSec = null; + this.eta = null; + return; + } + const sample: SpeedSample = { + ts: Date.now(), + progress: active.progress, + pages: active.chapter.pageCount ?? 0, + }; + const speed = calcSpeed(this.lastSample, sample); + this.lastSample = sample; + if (speed !== null) { + this.pagesPerSec = speed; + this.eta = estimateEta(speed, ds.queue); + } } async poll() { gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) .then((d) => this.applyStatus(d.downloadStatus)) .catch(console.error) - .finally(() => this.loading = false); + .finally(() => { this.loading = false; }); } async togglePlay() { @@ -47,6 +85,7 @@ class DownloadStore { async clear() { if (this.clearing) return; this.clearing = true; + this.selected = new Set(); if (this.status) this.status = { ...this.status, queue: [] }; setActiveDownloads([]); try { @@ -60,10 +99,144 @@ class DownloadStore { if (this.dequeueing.has(chapterId)) return; this.dequeueing = new Set(this.dequeueing).add(chapterId); if (this.status) this.status = { ...this.status, queue: optimisticRemove(this.status.queue, chapterId) }; + this.selected.delete(chapterId); + this.selected = new Set(this.selected); try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); } catch (e) { console.error(e); this.poll(); } finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); } } + + async dequeueSelected() { + if (this.batchWorking || this.selected.size === 0) return; + this.batchWorking = true; + const ids = [...this.selected]; + if (this.status) this.status = { ...this.status, queue: optimisticRemoveMany(this.status.queue, this.selected) }; + this.selected = new Set(); + try { + await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); + this.poll(); + } catch (e) { console.error(e); this.poll(); } + finally { this.batchWorking = false; } + } + + async retryOne(chapterId: number) { + if (this.dequeueing.has(chapterId)) return; + this.dequeueing = new Set(this.dequeueing).add(chapterId); + try { + await gql(DEQUEUE_DOWNLOAD, { chapterId }); + await gql(ENQUEUE_DOWNLOAD, { chapterId }); + this.poll(); + } catch (e) { console.error(e); this.poll(); } + finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); } + } + + async retryAllErrored() { + if (this.batchWorking || !this.hasErrored) return; + this.batchWorking = true; + const ids = [...this.erroredIds]; + try { + await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); + for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); + this.poll(); + } catch (e) { console.error(e); this.poll(); } + finally { this.batchWorking = false; } + } + + async retrySelected() { + if (this.batchWorking || this.selected.size === 0) return; + this.batchWorking = true; + const ids = [...this.selected].filter((id) => this.erroredIds.has(id)); + this.selected = new Set(); + try { + if (ids.length > 0) { + await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); + for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); + } + this.poll(); + } catch (e) { console.error(e); this.poll(); } + finally { this.batchWorking = false; } + } + + async reorder(chapterId: number, direction: "up" | "down") { + const idx = this.queue.findIndex((i) => i.chapter.id === chapterId); + if (idx === -1) return; + const to = direction === "up" ? idx - 1 : idx + 1; + if (to < 0 || to >= this.queue.length) return; + const newQueue = [...this.queue]; + [newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]]; + if (this.status) this.status = { ...this.status, queue: newQueue }; + try { + const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( + REORDER_DOWNLOAD, { chapterId, to }, + ); + this.applyStatus(d.reorderChapterDownload.downloadStatus); + } catch (e) { console.error(e); this.poll(); } + } + + selectOnly(chapterId: number) { + this.selected = new Set([chapterId]); + } + + async reorderSelected(direction: "up" | "down") { + if (this.batchWorking || this.selected.size === 0) return; + this.batchWorking = true; + + const queue = [...this.queue]; + const selectedIndices = queue + .map((item, i) => ({ id: item.chapter.id, i })) + .filter(({ id }) => this.selected.has(id)) + .map(({ i }) => i) + .sort((a, b) => direction === "up" ? a - b : b - a); + + if (direction === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; } + if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; } + + const newQueue = [...queue]; + for (const idx of selectedIndices) { + const to = direction === "up" ? idx - 1 : idx + 1; + if (to < 0 || to >= newQueue.length) break; + [newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]]; + } + if (this.status) this.status = { ...this.status, queue: newQueue }; + + try { + for (const idx of selectedIndices) { + const to = direction === "up" ? idx - 1 : idx + 1; + if (to < 0 || to >= queue.length) break; + const chapterId = queue[idx].chapter.id; + await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( + REORDER_DOWNLOAD, { chapterId, to }, + ); + } + this.poll(); + } catch (e) { console.error(e); this.poll(); } + finally { this.batchWorking = false; } + } + + toggleSelect(chapterId: number) { + const next = new Set(this.selected); + if (next.has(chapterId)) next.delete(chapterId); + else next.add(chapterId); + this.selected = next; + } + + selectRange(fromId: number, toId: number) { + const ids = this.queue.map((i) => i.chapter.id); + const a = ids.indexOf(fromId), b = ids.indexOf(toId); + if (a === -1 || b === -1) return; + const [lo, hi] = a < b ? [a, b] : [b, a]; + const next = new Set(this.selected); + for (let i = lo; i <= hi; i++) next.add(ids[i]); + this.selected = next; + } + + selectAll() { + this.selected = new Set(this.queue.map((i) => i.chapter.id)); + } + + clearSelection() { + this.selected = new Set(); + } } -export const downloadStore = new DownloadStore(); +export const downloadStore = new DownloadStore(); \ No newline at end of file diff --git a/src/shared/ui/ContextMenu.svelte b/src/shared/ui/ContextMenu.svelte index c1f9592..2943409 100644 --- a/src/shared/ui/ContextMenu.svelte +++ b/src/shared/ui/ContextMenu.svelte @@ -45,6 +45,10 @@ if (el && !el.contains(e.target as Node)) onClose(); } + function onTouchStartOutside(e: TouchEvent) { + if (el && !el.contains(e.target as Node)) onClose(); + } + function onKey(e: KeyboardEvent) { if (e.key === "Escape") { e.stopPropagation(); onClose(); return; } if (e.key === "ArrowDown") { @@ -68,9 +72,11 @@ $effect(() => { document.addEventListener("mousedown", onMouseDown, true); + document.addEventListener("touchstart", onTouchStartOutside, true); document.addEventListener("keydown", onKey, true); return () => { document.removeEventListener("mousedown", onMouseDown, true); + document.removeEventListener("touchstart", onTouchStartOutside, true); document.removeEventListener("keydown", onKey, true); }; }); @@ -128,4 +134,4 @@ .sep { height: 1px; background: var(--border-dim); margin: 3px var(--sp-1); } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } - + \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts index a9344f5..8f5d01f 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,6 +1,7 @@ export interface DownloadQueueItem { progress: number; state: "QUEUED" | "DOWNLOADING" | "FINISHED" | "ERROR"; + tries: number; chapter: { id: number; name: string; @@ -17,4 +18,4 @@ export interface DownloadStatus { export interface Connection { nodes: T[]; -} +} \ No newline at end of file