Fix: Download Notifications + Control & ContextMenu Icons (#38)

This commit is contained in:
Youwes09
2026-04-21 11:41:02 -05:00
parent 86c78689df
commit c025336a7e
5 changed files with 75 additions and 111 deletions
+9 -7
View File
@@ -5,12 +5,12 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import { store, setActiveDownloads } from "@store/state.svelte";
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { boot, startProbe, stopProbe, retryBoot, bypassBoot } from "@store/boot.svelte";
import { initRpc, setIdle, clearReading, destroyRpc } from "@store/discord";
import { applyTheme } from "@core/theme";
import { applyZoom, mountZoomKey, mountIdleDetection } from "@core/ui";
import { checkForUpdateSilently } from "@core/updater";
import { mountDownloadPoller } from "@features/downloads/lib/downloadPoller";
import Layout from "@shared/chrome/Layout.svelte";
import Reader from "@features/reader/components/Reader.svelte";
import Settings from "@features/settings/components/Settings.svelte";
@@ -72,6 +72,11 @@
if (!store.activeChapter && store.settings.discordRpc) setIdle();
});
$effect(() => {
const next = downloadStore.queue.slice();
downloadStore.detectTransitions(next);
});
onMount(async () => {
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => { devSplash = true; };
@@ -102,15 +107,12 @@
e => setActiveDownloads(e.payload),
);
let unmountPoller: (() => void) | undefined;
$effect(() => {
if (!appReady) return;
mountDownloadPoller().then(cleanup => { unmountPoller = cleanup; });
return () => unmountPoller?.();
});
await downloadStore.poll();
const dlInterval = setInterval(() => downloadStore.poll(), 2000);
return () => {
stopProbe();
clearInterval(dlInterval);
unlistenResize();
unlistenScale();
unlistenDownload();
@@ -1,13 +1,12 @@
<script lang="ts">
import { Play, Pause, Trash, CircleNotch, ArrowClockwise } from "phosphor-svelte";
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash } from "phosphor-svelte";
import DownloadQueue from "./DownloadQueue.svelte";
import { downloadStore } from "../store/downloadState.svelte";
import { formatEta } from "../lib/downloadQueue";
import { onMount } from "svelte";
$effect(() => {
onMount(() => {
downloadStore.poll();
const interval = setInterval(() => downloadStore.poll(), 2000);
return () => clearInterval(interval);
});
let selectAnchor = $state<number | null>(null);
@@ -60,6 +59,18 @@
{/if}
</button>
{/if}
<button
class="icon-btn"
class:active={downloadStore.toastsEnabled}
onclick={() => downloadStore.toggleToasts()}
title={downloadStore.toastsEnabled ? "Mute download notifications" : "Unmute download notifications"}
>
{#if downloadStore.toastsEnabled}
<Bell size={14} weight="regular" />
{:else}
<BellSlash size={14} weight="regular" />
{/if}
</button>
<button
class="icon-btn"
class:loading={downloadStore.togglingPlay}
@@ -173,6 +184,7 @@
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.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.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.status-bar {
display: flex;
@@ -1,61 +0,0 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { gql } from "@api/client";
import { GET_DOWNLOAD_STATUS } from "@api/queries/downloads";
import { addToast, setActiveDownloads } from "@store/state.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
let prevQueue: DownloadQueueItem[] = [];
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
for (const item of prev) {
if (item.state !== "DOWNLOADING") continue;
if (!next.some(q => q.chapter.id === item.chapter.id)) {
const manga = item.chapter.manga;
addToast({
kind: "success",
title: "Chapter downloaded",
body: manga ? `${manga.title}${item.chapter.name}` : item.chapter.name,
duration: 4000,
});
}
}
}
function applyQueue(next: DownloadQueueItem[]) {
detectCompletions(prevQueue, next);
prevQueue = next;
setActiveDownloads(next.map(item => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
})));
}
export async function mountDownloadPoller(): Promise<() => void> {
const win = getCurrentWindow();
let paused = false;
let interval: ReturnType<typeof setInterval>;
const poll = () => {
if (paused) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then(d => applyQueue(d.downloadStatus.queue))
.catch(console.error);
};
poll();
interval = setInterval(poll, 2000);
const onVisibility = () => { paused = document.hidden; };
document.addEventListener("visibilitychange", onVisibility);
const unlistenFocus = await win.onFocusChanged(({ payload: focused }) => {
paused = !focused;
});
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisibility);
unlistenFocus();
};
}
@@ -5,8 +5,8 @@ import {
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 { addToast, setActiveDownloads } from "@store/state.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
import {
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
isRunning, getErrored, calcSpeed, estimateEta,
@@ -24,13 +24,35 @@ class DownloadStore {
pagesPerSec: number | null = $state(null);
eta: number | null = $state(null);
private lastSample: SpeedSample | null = null;
toastsEnabled = $state(true);
private lastSample: SpeedSample | null = null;
private prevQueue: DownloadQueueItem[] = [];
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; }
toggleToasts() { this.toastsEnabled = !this.toastsEnabled; }
detectTransitions(next: DownloadQueueItem[]) {
if (!this.toastsEnabled) return;
const nextMap = new Map(next.map(i => [i.chapter.id, i]));
for (const item of this.prevQueue) {
if (item.state !== "DOWNLOADING") continue;
const nextItem = nextMap.get(item.chapter.id);
const manga = item.chapter.manga;
const label = manga ? `${manga.title}${item.chapter.name}` : item.chapter.name;
if (!nextItem) {
addToast({ kind: "download", title: "Chapter downloaded", body: label, duration: 4000 });
} else if (nextItem.state === "ERROR") {
addToast({ kind: "error", title: "Download failed", body: label, duration: 5000 });
}
}
this.prevQueue = next.slice();
}
applyStatus(ds: DownloadStatus) {
this.status = ds;
setActiveDownloads(toActiveDownloads(ds.queue));
@@ -40,9 +62,9 @@ class DownloadStore {
private updateSpeed(ds: DownloadStatus) {
const active = ds.queue[0];
if (!active || active.state !== "DOWNLOADING") {
this.lastSample = null;
this.lastSample = null;
this.pagesPerSec = null;
this.eta = null;
this.eta = null;
return;
}
const sample: SpeedSample = {
@@ -54,7 +76,7 @@ class DownloadStore {
this.lastSample = sample;
if (speed !== null) {
this.pagesPerSec = speed;
this.eta = estimateEta(speed, ds.queue);
this.eta = estimateEta(speed, ds.queue);
}
}
@@ -145,7 +167,7 @@ class DownloadStore {
async retrySelected() {
if (this.batchWorking || this.selected.size === 0) return;
this.batchWorking = true;
const ids = [...this.selected].filter((id) => this.erroredIds.has(id));
const ids = [...this.selected].filter((id) => this.erroredIds.has(id));
this.selected = new Set();
try {
if (ids.length > 0) {
@@ -173,22 +195,18 @@ class DownloadStore {
} 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 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 === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; }
if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; }
const newQueue = [...queue];
@@ -213,6 +231,7 @@ class DownloadStore {
finally { this.batchWorking = false; }
}
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
toggleSelect(chapterId: number) {
const next = new Set(this.selected);
if (next.has(chapterId)) next.delete(chapterId);
@@ -221,22 +240,17 @@ class DownloadStore {
}
selectRange(fromId: number, toId: number) {
const ids = this.queue.map((i) => i.chapter.id);
const a = ids.indexOf(fromId), b = ids.indexOf(toId);
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);
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();
}
selectAll() { this.selected = new Set(this.queue.map((i) => i.chapter.id)); }
clearSelection() { this.selected = new Set(); }
}
export const downloadStore = new DownloadStore();
@@ -2,7 +2,10 @@
import { onMount, untrack } from "svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { X } from "phosphor-svelte";
import {
X, CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple,
} from "phosphor-svelte";
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga";
@@ -385,21 +388,20 @@
}
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
const { CheckCircle, Circle } = { CheckCircle: null as any, Circle: null as any };
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
return [
{ label: ch.isRead ? "Mark as unread" : "Mark as read", onClick: () => markRead(ch.id, !ch.isRead) },
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
{ separator: true },
{ label: "Mark above as read", onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
{ label: "Mark above as unread", onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
{ label: "Mark above as read", icon: ArrowFatLinesUp, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
{ label: "Mark above as unread", icon: ArrowFatLineUp, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
{ separator: true },
{ label: "Mark below as read", onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
{ label: "Mark below as unread", onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
{ label: "Mark below as read", icon: ArrowFatLinesDown, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
{ label: "Mark below as unread", icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
{ separator: true },
{ label: ch.isDownloaded ? "Delete download" : "Download", danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) },
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) },
{ separator: true },
{ label: "Download next 5 from here", onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
{ label: "Download all from here", onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
{ label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
];
}
@@ -669,7 +671,6 @@
{/if}
<style>
/* ─── Root layout ─────────────────────────────────────────── */
.root {
display: flex;
height: 100%;
@@ -677,10 +678,8 @@
animation: fadeIn 0.14s ease both;
}
/* ─── List area wrapper ───────────────────────────────────── */
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ─── Link picker modal ───────────────────────────────────── */
.link-backdrop {
position: fixed;
inset: 0;
@@ -757,7 +756,6 @@
}
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ─── Markers panel overlay ───────────────────────────────── */
.markers-panel-overlay {
position: fixed; inset: 0; z-index: var(--z-settings);
display: flex; align-items: stretch; justify-content: flex-start;
@@ -771,8 +769,7 @@
animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
}
/* ─── Animations ──────────────────────────────────────────── */
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px) } to { opacity: 1; transform: translateX(0) } }
</style>
</style>