Feat: Downloads Auto-Retry Button & Toast Enhancements

This commit is contained in:
Youwes09
2026-04-25 15:16:50 -05:00
parent 544792a7ad
commit f6118077fb
4 changed files with 91 additions and 14 deletions
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, ArrowUp, ArrowDown, ArrowLineUp, ArrowLineDown, ArrowClockwise, X } from "phosphor-svelte"; import { CircleNotch, ArrowLineUp, ArrowLineDown, ArrowClockwise, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte"; import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte"; import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
@@ -229,12 +229,6 @@
</button> </button>
{/if} {/if}
{#if !isActive} {#if !isActive}
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "up"); }} disabled={isFirst} title="Move up">
<ArrowUp size={11} weight="light" />
</button>
<button class="action-btn" onclick={(e) => { e.stopPropagation(); onReorder(item.chapter.id, "down"); }} disabled={isLast} title="Move down">
<ArrowDown size={11} weight="light" />
</button>
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove"> <button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if} {#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button> </button>
@@ -263,6 +257,11 @@
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
.row:hover:not(.row-active):not(.row-removing) {
border-color: var(--border-strong);
background: var(--bg-elevated);
}
.row.row-active { border-color: var(--accent-dim); } .row.row-active { border-color: var(--accent-dim); }
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); } .row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); } .row.row-selected { background: var(--bg-elevated); border-color: var(--border-strong); }
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash } from "phosphor-svelte"; import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash, Repeat } from "phosphor-svelte";
import DownloadQueue from "./DownloadQueue.svelte"; import DownloadQueue from "./DownloadQueue.svelte";
import { downloadStore } from "../store/downloadState.svelte"; import { downloadStore } from "../store/downloadState.svelte";
import { formatEta } from "../lib/downloadQueue"; import { formatEta } from "../lib/downloadQueue";
@@ -45,6 +45,14 @@
<div class="header"> <div class="header">
<h1 class="heading">Downloads</h1> <h1 class="heading">Downloads</h1>
<div class="header-actions"> <div class="header-actions">
<button
class="icon-btn"
class:active={downloadStore.autoRetryEnabled}
onclick={() => downloadStore.toggleAutoRetry()}
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
>
<Repeat size={14} weight="regular" />
</button>
{#if downloadStore.hasErrored} {#if downloadStore.hasErrored}
<button <button
class="icon-btn" class="icon-btn"
@@ -183,7 +191,8 @@
cursor: pointer; cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base); 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); } .icon-btn:hover:not(:disabled):not(.active) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); } .icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); } .icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
+39
View File
@@ -0,0 +1,39 @@
import type { DownloadQueueItem } from "@types/index";
const RETRY_DELAY_MS = 20_000;
export interface AutoRetryHandle {
stop: () => void;
}
export function startAutoRetry(
getQueue: () => DownloadQueueItem[],
isRunning: () => boolean,
retryErrored: () => Promise<void>,
): AutoRetryHandle {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
async function tick() {
if (stopped) return;
const queue = getQueue();
const errored = queue.filter(i => i.state === "ERROR");
const active = queue.filter(i => i.state !== "ERROR");
if (errored.length > 0 && active.length === 0 && !isRunning()) {
await retryErrored().catch(() => {});
}
if (!stopped) timer = setTimeout(tick, RETRY_DELAY_MS);
}
timer = setTimeout(tick, RETRY_DELAY_MS);
return {
stop() {
stopped = true;
if (timer !== null) { clearTimeout(timer); timer = null; }
},
};
}
@@ -12,6 +12,7 @@ import {
isRunning, getErrored, calcSpeed, estimateEta, isRunning, getErrored, calcSpeed, estimateEta,
type SpeedSample, type SpeedSample,
} from "../lib/downloadQueue"; } from "../lib/downloadQueue";
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
class DownloadStore { class DownloadStore {
status: DownloadStatus | null = $state(null); status: DownloadStatus | null = $state(null);
@@ -24,17 +25,39 @@ class DownloadStore {
pagesPerSec: number | null = $state(null); pagesPerSec: number | null = $state(null);
eta: number | null = $state(null); eta: number | null = $state(null);
toastsEnabled = $state(true); toastsEnabled = $state(true);
autoRetryEnabled = $state(false);
private lastSample: SpeedSample | null = null; private lastSample: SpeedSample | null = null;
private prevQueue: DownloadQueueItem[] = []; private prevQueue: DownloadQueueItem[] = [];
private autoRetryHnd: AutoRetryHandle | null = null;
get queue() { return this.status?.queue ?? []; } get queue() { return this.status?.queue ?? []; }
get isRunning() { return isRunning(this.status?.state); } get isRunning() { return isRunning(this.status?.state); }
get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); } get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); }
get hasErrored() { return this.erroredIds.size > 0; } get hasErrored() { return this.erroredIds.size > 0; }
toggleToasts() { this.toastsEnabled = !this.toastsEnabled; } toggleToasts() {
this.toastsEnabled = !this.toastsEnabled;
addToast({ kind: "info", title: this.toastsEnabled ? "Notifications enabled" : "Notifications muted", body: this.toastsEnabled ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 });
}
toggleAutoRetry() {
if (this.autoRetryEnabled) {
this.autoRetryHnd?.stop();
this.autoRetryHnd = null;
this.autoRetryEnabled = false;
addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 });
} else {
this.autoRetryEnabled = true;
this.autoRetryHnd = startAutoRetry(
() => this.queue,
() => this.isRunning,
() => this.retryAllErrored(),
);
addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 });
}
}
detectTransitions(next: DownloadQueueItem[]) { detectTransitions(next: DownloadQueueItem[]) {
if (!this.toastsEnabled) return; if (!this.toastsEnabled) return;
@@ -101,7 +124,10 @@ class DownloadStore {
this.applyStatus(d.startDownloader.downloadStatus); this.applyStatus(d.startDownloader.downloadStatus);
} }
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
finally { this.togglingPlay = false; } finally {
this.togglingPlay = false;
addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 });
}
} }
async clear() { async clear() {
@@ -113,6 +139,7 @@ class DownloadStore {
try { try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER); const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
this.applyStatus(d.clearDownloader.downloadStatus); this.applyStatus(d.clearDownloader.downloadStatus);
addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 });
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
finally { this.clearing = false; } finally { this.clearing = false; }
} }
@@ -137,6 +164,7 @@ class DownloadStore {
try { try {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
this.poll(); this.poll();
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 });
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; } finally { this.batchWorking = false; }
} }
@@ -160,6 +188,7 @@ class DownloadStore {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
this.poll(); this.poll();
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }
finally { this.batchWorking = false; } finally { this.batchWorking = false; }
} }
@@ -173,6 +202,7 @@ class DownloadStore {
if (ids.length > 0) { if (ids.length > 0) {
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
} }
this.poll(); this.poll();
} catch (e) { console.error(e); this.poll(); } } catch (e) { console.error(e); this.poll(); }