From b44b12ba866d8aeab3e3fd5b06ec12225a3a7ebb Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 10:53:34 +0530 Subject: [PATCH 01/17] fix: update discord rpc to emit presence when reading chapters --- src/lib/components/reader/Reader.svelte | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/components/reader/Reader.svelte b/src/lib/components/reader/Reader.svelte index c82e1b5..19316c3 100644 --- a/src/lib/components/reader/Reader.svelte +++ b/src/lib/components/reader/Reader.svelte @@ -13,6 +13,7 @@ import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader"; import { historyState } from "$lib/state/history.svelte"; import { getAdapter } from "$lib/request-manager"; + import { setReading, clearReading } from "$lib/core/discord"; import type { ReaderSettings } from "$lib/state/reader.svelte"; import ReaderControls from "$lib/components/reader/ReaderControls.svelte"; import PageView from "$lib/components/reader/PageView.svelte"; @@ -215,10 +216,15 @@ ? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast) : () => goBack(style, adjacent, startAtLast)); + function handleCloseReader() { + clearReading().catch(() => {}); + readerState.closeReader(); + } + const onKey = createReaderKeyHandler({ goNext: () => goNext(), goPrev: () => goPrev(), - closeReader: () => readerState.closeReader(), + closeReader: () => handleCloseReader(), goToPage: (p) => jumpToPage(p, style, lastPage, containerEl), lastPage: () => lastPage, adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); }, @@ -308,6 +314,7 @@ ch.id, ch.name, readerState.pageNumber, ); loadChapter(ch.id, useBlob, abortCtrl, startAtLastPageRef, markedRead, adjacent); + setReading(manga, ch).catch(() => {}); }); } }); From 13e760594d6a16dc40be0e959db024af5a390b66 Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 10:54:20 +0530 Subject: [PATCH 02/17] fix: sync libraryShowAllInSaved setting to library state --- src/lib/state/library.svelte.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/state/library.svelte.ts b/src/lib/state/library.svelte.ts index 4aaf7b3..347bf0c 100644 --- a/src/lib/state/library.svelte.ts +++ b/src/lib/state/library.svelte.ts @@ -204,10 +204,12 @@ class LibraryState { this.tabFilters = { ...this.tabFilters, [tab]: {} }; } - syncFromSettings(s: { hiddenLibraryTabs?: string[]; libraryPinnedTabOrder?: string[]; defaultLibraryCategoryId?: number | null }) { + syncFromSettings(s: { hiddenLibraryTabs?: string[]; libraryPinnedTabOrder?: string[]; defaultLibraryCategoryId?: number | null; libraryShowAllInSaved?: boolean; libraryHideCompletedInSaved?: boolean }) { if (s.hiddenLibraryTabs) this.hiddenTabs = new Set(s.hiddenLibraryTabs); if (s.libraryPinnedTabOrder) this.pinnedTabOrder = s.libraryPinnedTabOrder; if (s.defaultLibraryCategoryId !== undefined) this.defaultCategoryId = s.defaultLibraryCategoryId ?? null; + if (s.libraryShowAllInSaved !== undefined) this.showAllInSaved = s.libraryShowAllInSaved; + if (s.libraryHideCompletedInSaved !== undefined) this.hideCompletedInSaved = s.libraryHideCompletedInSaved; } setCategories(cats: Category[]) { From 294865fe9d136efec9505ec6154ffb4355f74e91 Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 10:55:22 +0530 Subject: [PATCH 03/17] style: use specific imports for discord functions --- src/routes/+layout.svelte | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f1f6362..029069a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,7 +7,7 @@ import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte' import { applyTheme, mountSystemThemeSync } from '$lib/core/theme' import { platformService } from '$lib/platform-service' - import * as discord from '$lib/core/discord' + import { initRpc, setIdle, destroyRpc } from '$lib/core/discord' import SplashScreen from '$lib/components/chrome/SplashScreen.svelte' import AuthGate from '$lib/components/chrome/AuthGate.svelte' import Sidebar from '$lib/components/chrome/Sidebar.svelte' @@ -107,9 +107,11 @@ isTauri && settingsState.settings.autoStartServer ? 2000 : 100, ) + let discordInitialized = false if (settingsState.settings.discordRpc) { - await discord.initRpc() - await discord.setIdle() + await initRpc() + await setIdle() + discordInitialized = true } polling = true @@ -118,7 +120,7 @@ return () => { polling = false if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null } - discord.destroyRpc() + if (discordInitialized) destroyRpc() platformService.destroy() } }) From 9f6996dcdbd934601ddb66eea0ddeef1e097e2f1 Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 15:04:18 +0530 Subject: [PATCH 04/17] fix: read idleTimeoutMin from settings; clean up idle + splash canvas fixes --- src/lib/components/chrome/SplashScreen.svelte | 16 ++++++++ src/routes/+layout.svelte | 40 +++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/lib/components/chrome/SplashScreen.svelte b/src/lib/components/chrome/SplashScreen.svelte index 9a4c06c..31bd9be 100644 --- a/src/lib/components/chrome/SplashScreen.svelte +++ b/src/lib/components/chrome/SplashScreen.svelte @@ -290,6 +290,7 @@ if (logW <= 0 || logH <= 0) return if (logW === lastLogW && logH === lastLogH && scale === lastScale) return lastLogW = logW; lastLogH = logH; lastScale = scale + if (live) cleanup() // release old offscreen canvases before rebuilding at new size const built = buildCards(logW, logH) const stamps = built.cards.map(c => buildStamp(c, scale)) const vig = buildVignette(logW, logH, scale) @@ -339,10 +340,25 @@ function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame) } function onVis() { document.hidden ? pause() : resume() } + // clears all canvas contexts and nulls live state so the GC can collect the offscreen bitmaps + function cleanup() { + if (live) { + live.stamps.forEach(canvas => { + const c = canvas.getContext('2d') + if (c) c.clearRect(0, 0, canvas.width, canvas.height) + }) + const vigCtx = live.vignette.getContext('2d') + if (vigCtx) vigCtx.clearRect(0, 0, live.vignette.width, live.vignette.height) + } + ctx.clearRect(0, 0, el.width, el.height) + live = null + } + document.addEventListener('visibilitychange', onVis) raf = requestAnimationFrame(frame) return () => { cancelAnimationFrame(raf) + cleanup() extraCleanup?.() document.removeEventListener('visibilitychange', onVis) } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 029069a..d0efd2d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -24,6 +24,7 @@ const POLL_MS = 1500 let pollTimer: ReturnType | null = null + let idleTimer: ReturnType | null = null let polling = false async function pollLoop() { @@ -117,11 +118,14 @@ polling = true pollLoop() + resetIdleTimer() + return () => { polling = false if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null } - if (discordInitialized) destroyRpc() - platformService.destroy() + if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null } + if (discordInitialized) destroyRpc().catch(() => {}) + platformService.destroy().catch(() => {}) } }) @@ -146,7 +150,37 @@ if (appState.status === 'booting') _splashDismissed = false }) - function onIdleDismiss() { appState.idleSplash = false } + $effect(() => { + if (appState.status === 'ready') resetIdleTimer() + }) + + $effect(() => { + if (appState.status !== 'ready') return + // capture phase so events from any component — including modals — reset the timer + const onActivity = () => resetIdleTimer() + document.addEventListener('mousemove', onActivity, true) + document.addEventListener('keydown', onActivity, true) + document.addEventListener('touchstart', onActivity, true) + document.addEventListener('click', onActivity, true) + return () => { + document.removeEventListener('mousemove', onActivity, true) + document.removeEventListener('keydown', onActivity, true) + document.removeEventListener('touchstart', onActivity, true) + document.removeEventListener('click', onActivity, true) + } + }) + + function resetIdleTimer() { + if (idleTimer) clearTimeout(idleTimer) + appState.idleSplash = false + // read the setting live so changes take effect without a restart + const timeoutMs = (settingsState.settings.idleTimeoutMin ?? 5) * 60_000 + idleTimer = setTimeout(() => { + if (appState.status === 'ready') appState.idleSplash = true + }, timeoutMs) + } + + function onIdleDismiss() { appState.idleSplash = false; resetIdleTimer() } function onSplashRetry() { import('$lib/state/boot.svelte').then(({ retryBoot }) => { From 3926b5d06486dc04790ce2f763b17973311148ae Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 15:12:39 +0530 Subject: [PATCH 05/17] chore: clean up discord RPC hooks --- src/lib/components/reader/Reader.svelte | 3 ++- src/lib/state/library.svelte.ts | 16 +++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/lib/components/reader/Reader.svelte b/src/lib/components/reader/Reader.svelte index 19316c3..ee6c45b 100644 --- a/src/lib/components/reader/Reader.svelte +++ b/src/lib/components/reader/Reader.svelte @@ -216,6 +216,7 @@ ? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast) : () => goBack(style, adjacent, startAtLast)); + // clear Discord presence before closing function handleCloseReader() { clearReading().catch(() => {}); readerState.closeReader(); @@ -314,7 +315,7 @@ ch.id, ch.name, readerState.pageNumber, ); loadChapter(ch.id, useBlob, abortCtrl, startAtLastPageRef, markedRead, adjacent); - setReading(manga, ch).catch(() => {}); + setReading(manga, ch).catch(() => {}); // update Discord presence to show current chapter }); } }); diff --git a/src/lib/state/library.svelte.ts b/src/lib/state/library.svelte.ts index 347bf0c..b6d9038 100644 --- a/src/lib/state/library.svelte.ts +++ b/src/lib/state/library.svelte.ts @@ -204,11 +204,17 @@ class LibraryState { this.tabFilters = { ...this.tabFilters, [tab]: {} }; } - syncFromSettings(s: { hiddenLibraryTabs?: string[]; libraryPinnedTabOrder?: string[]; defaultLibraryCategoryId?: number | null; libraryShowAllInSaved?: boolean; libraryHideCompletedInSaved?: boolean }) { - if (s.hiddenLibraryTabs) this.hiddenTabs = new Set(s.hiddenLibraryTabs); - if (s.libraryPinnedTabOrder) this.pinnedTabOrder = s.libraryPinnedTabOrder; - if (s.defaultLibraryCategoryId !== undefined) this.defaultCategoryId = s.defaultLibraryCategoryId ?? null; - if (s.libraryShowAllInSaved !== undefined) this.showAllInSaved = s.libraryShowAllInSaved; + syncFromSettings(s: { + hiddenLibraryTabs?: string[]; + libraryPinnedTabOrder?: string[]; + defaultLibraryCategoryId?: number | null; + libraryShowAllInSaved?: boolean; + libraryHideCompletedInSaved?: boolean; + }) { + if (s.hiddenLibraryTabs) this.hiddenTabs = new Set(s.hiddenLibraryTabs); + if (s.libraryPinnedTabOrder) this.pinnedTabOrder = s.libraryPinnedTabOrder; + if (s.defaultLibraryCategoryId !== undefined) this.defaultCategoryId = s.defaultLibraryCategoryId ?? null; + if (s.libraryShowAllInSaved !== undefined) this.showAllInSaved = s.libraryShowAllInSaved; if (s.libraryHideCompletedInSaved !== undefined) this.hideCompletedInSaved = s.libraryHideCompletedInSaved; } From 685bd9b9da764a89f1f1d488e6e16a7806c23c35 Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 16:15:31 +0530 Subject: [PATCH 06/17] fix: revoke page blob URLs on chapter change and reader close --- src/lib/components/reader/Reader.svelte | 7 ++++++- src/lib/components/reader/lib/chapterLoader.ts | 17 ++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/lib/components/reader/Reader.svelte b/src/lib/components/reader/Reader.svelte index ee6c45b..5c6953e 100644 --- a/src/lib/components/reader/Reader.svelte +++ b/src/lib/components/reader/Reader.svelte @@ -14,6 +14,7 @@ import { historyState } from "$lib/state/history.svelte"; import { getAdapter } from "$lib/request-manager"; import { setReading, clearReading } from "$lib/core/discord"; + import { revokeBlobUrl } from "$lib/core/cache/imageCache"; import type { ReaderSettings } from "$lib/state/reader.svelte"; import ReaderControls from "$lib/components/reader/ReaderControls.svelte"; import PageView from "$lib/components/reader/PageView.svelte"; @@ -216,9 +217,13 @@ ? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast) : () => goBack(style, adjacent, startAtLast)); - // clear Discord presence before closing + // clear Discord presence and free page blob textures before closing function handleCloseReader() { clearReading().catch(() => {}); + for (const url of readerState.pageUrls) revokeBlobUrl(url); + for (const strip of readerState.stripChapters) { + for (const url of strip.urls) revokeBlobUrl(url); + } readerState.closeReader(); } diff --git a/src/lib/components/reader/lib/chapterLoader.ts b/src/lib/components/reader/lib/chapterLoader.ts index 16c6f62..9eee8ea 100644 --- a/src/lib/components/reader/lib/chapterLoader.ts +++ b/src/lib/components/reader/lib/chapterLoader.ts @@ -1,7 +1,7 @@ -import { readerState } from "$lib/state/reader.svelte"; -import { fetchPages } from "./pageLoader"; -import { cancelQueuedFetches } from "$lib/core/cache/imageCache"; -import { clearResolvedUrlCache } from "$lib/core/cache/pageCache"; +import { readerState } from "$lib/state/reader.svelte"; +import { fetchPages } from "./pageLoader"; +import { cancelQueuedFetches, revokeBlobUrl } from "$lib/core/cache/imageCache"; +import { clearResolvedUrlCache } from "$lib/core/cache/pageCache"; export function scheduleResumeDismiss() { setTimeout(() => { readerState.resumeFading = true; }, 1500); @@ -21,7 +21,14 @@ export async function loadChapter( abortCtrl.current = ctrl; cancelQueuedFetches(); - if (useBlob) clearResolvedUrlCache(); + if (useBlob) { + clearResolvedUrlCache(); + // revoke blob URLs for all loaded pages so the GPU can release their textures + for (const url of readerState.pageUrls) revokeBlobUrl(url); + for (const strip of readerState.stripChapters) { + for (const url of strip.urls) revokeBlobUrl(url); + } + } startAtLastPage.current = false; markedRead.clear(); From 0e7ff1a27cff3e068ed87ff0f2ef8e0b6f808176 Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 16:29:48 +0530 Subject: [PATCH 07/17] fix: add wheel and touchmove to idle activity listeners --- src/routes/+layout.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d0efd2d..cfa8c4e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -161,11 +161,15 @@ document.addEventListener('mousemove', onActivity, true) document.addEventListener('keydown', onActivity, true) document.addEventListener('touchstart', onActivity, true) + document.addEventListener('touchmove', onActivity, true) // sustained touch-scroll in reader + document.addEventListener('wheel', onActivity, true) // mouse-wheel / trackpad scroll in reader document.addEventListener('click', onActivity, true) return () => { document.removeEventListener('mousemove', onActivity, true) document.removeEventListener('keydown', onActivity, true) document.removeEventListener('touchstart', onActivity, true) + document.removeEventListener('touchmove', onActivity, true) + document.removeEventListener('wheel', onActivity, true) document.removeEventListener('click', onActivity, true) } }) From 6d33fb7ae1f65b45f4dc81835717f29cafd8dafd Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 19:10:52 +0530 Subject: [PATCH 08/17] fix: make weel and touch passive --- src/lib/core/discord.ts | 48 +++++++++++++++++++++++++-------------- src/routes/+layout.svelte | 35 +++++++++++++++------------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/src/lib/core/discord.ts b/src/lib/core/discord.ts index 8c98842..b7d2165 100644 --- a/src/lib/core/discord.ts +++ b/src/lib/core/discord.ts @@ -1,4 +1,5 @@ import { platformService } from '$lib/platform-service' +import { settingsState } from '$lib/state/settings.svelte' import type { Manga } from '$lib/types/manga' import type { Chapter } from '$lib/types/chapter' @@ -9,11 +10,8 @@ const APP_BUTTONS = [ const FALLBACK_IMAGE = 'moku_logo' -let sessionStart: number | null = null - -function isPublicUrl(url: string | null | undefined): boolean { - return typeof url === 'string' && url.startsWith('https://') -} +let sessionStart: number | null = null +let activeMangaId: number | null = null function trunc(s: string, max = 128): string { return s.length <= max ? s : `${s.slice(0, max - 1)}…` @@ -24,6 +22,31 @@ function formatChapter(chapter: Chapter): string { return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}` } +// Suwayomi always returns the proxy path (/api/v1/manga/{id}/thumbnail), never the raw CDN URL. +// The proxy URL is only useful to Discord when the server is publicly reachable over HTTPS. +// For localhost setups cover art falls back to the app logo until Suwayomi exposes rawThumbnailUrl. +function resolveCoverUrl(manga: Manga): string { + const serverBase = (settingsState.settings.serverUrl ?? '').replace(/\/$/, '') + if (!serverBase.startsWith('https://')) return FALLBACK_IMAGE + const path = manga.thumbnailUrl?.startsWith('/') ? manga.thumbnailUrl : `/api/v1/manga/${manga.id}/thumbnail` + return `${serverBase}${path}` +} + +function buildPresence(manga: Manga, chapter: Chapter, coverUrl: string) { + return { + details: trunc(manga.title), + state: `${formatChapter(chapter)} · Reading`, + timestamps: { start: sessionStart ?? Date.now() }, + assets: { + largeImage: coverUrl, + largeText: trunc(manga.title), + smallImage: FALLBACK_IMAGE, + smallText: 'Moku', + }, + buttons: APP_BUTTONS, + } +} + export async function initRpc(): Promise { if (!platformService.isSupported('discord-rpc')) return sessionStart = Date.now() @@ -36,18 +59,9 @@ export async function destroyRpc(): Promise { export async function setReading(manga: Manga, chapter: Chapter): Promise { if (!platformService.isSupported('discord-rpc')) return - await platformService.setDiscordPresence({ - details: trunc(manga.title), - state: `${formatChapter(chapter)} · Reading`, - timestamps: { start: sessionStart ?? Date.now() }, - assets: { - largeImage: isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE, - largeText: trunc(manga.title), - smallImage: FALLBACK_IMAGE, - smallText: 'Moku', - }, - buttons: APP_BUTTONS, - }) + + activeMangaId = manga.id + await platformService.setDiscordPresence(buildPresence(manga, chapter, resolveCoverUrl(manga))) } export async function setIdle(): Promise { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index cfa8c4e..589a6e0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -158,30 +158,33 @@ if (appState.status !== 'ready') return // capture phase so events from any component — including modals — reset the timer const onActivity = () => resetIdleTimer() - document.addEventListener('mousemove', onActivity, true) - document.addEventListener('keydown', onActivity, true) - document.addEventListener('touchstart', onActivity, true) - document.addEventListener('touchmove', onActivity, true) // sustained touch-scroll in reader - document.addEventListener('wheel', onActivity, true) // mouse-wheel / trackpad scroll in reader - document.addEventListener('click', onActivity, true) + document.addEventListener('mousemove', onActivity, true) + document.addEventListener('keydown', onActivity, true) + document.addEventListener('touchstart', onActivity, true) + // passive:true tells the browser it can render scroll frames without waiting for the handler + document.addEventListener('touchmove', onActivity, { capture: true, passive: true }) + document.addEventListener('wheel', onActivity, { capture: true, passive: true }) + document.addEventListener('click', onActivity, true) return () => { - document.removeEventListener('mousemove', onActivity, true) - document.removeEventListener('keydown', onActivity, true) - document.removeEventListener('touchstart', onActivity, true) - document.removeEventListener('touchmove', onActivity, true) - document.removeEventListener('wheel', onActivity, true) - document.removeEventListener('click', onActivity, true) + document.removeEventListener('mousemove', onActivity, true) + document.removeEventListener('keydown', onActivity, true) + document.removeEventListener('touchstart', onActivity, true) + document.removeEventListener('touchmove', onActivity, { capture: true }) + document.removeEventListener('wheel', onActivity, { capture: true }) + document.removeEventListener('click', onActivity, true) } }) function resetIdleTimer() { - if (idleTimer) clearTimeout(idleTimer) - appState.idleSplash = false + if (idleTimer) { clearTimeout(idleTimer); idleTimer = null } + if (appState.idleSplash) appState.idleSplash = false // read the setting live so changes take effect without a restart - const timeoutMs = (settingsState.settings.idleTimeoutMin ?? 5) * 60_000 + // 0 means "Never" — skip the timer entirely + const mins = settingsState.settings.idleTimeoutMin ?? 5 + if (mins === 0) return idleTimer = setTimeout(() => { if (appState.status === 'ready') appState.idleSplash = true - }, timeoutMs) + }, mins * 60_000) } function onIdleDismiss() { appState.idleSplash = false; resetIdleTimer() } From 22c4a222d8e1cd7f8698f0a111478565d5728ce2 Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 19:43:02 +0530 Subject: [PATCH 09/17] fix: idle splash exit animation works --- src/routes/+layout.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 589a6e0..dd4cf07 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -177,8 +177,6 @@ function resetIdleTimer() { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null } - if (appState.idleSplash) appState.idleSplash = false - // read the setting live so changes take effect without a restart // 0 means "Never" — skip the timer entirely const mins = settingsState.settings.idleTimeoutMin ?? 5 if (mins === 0) return From 32bdeb92ff9356660b2df4435e78c31f4dfef476 Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 20:13:41 +0530 Subject: [PATCH 10/17] fix: set rpc to idle when idle --- src/lib/components/reader/Reader.svelte | 13 +++++++++++-- src/routes/+layout.svelte | 4 ++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/lib/components/reader/Reader.svelte b/src/lib/components/reader/Reader.svelte index ea7126a..c697ea8 100644 --- a/src/lib/components/reader/Reader.svelte +++ b/src/lib/components/reader/Reader.svelte @@ -2,7 +2,7 @@ import { onMount, untrack, tick } from "svelte"; import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte"; import { settingsState, updateSettings } from "$lib/state/settings.svelte"; - import { app } from "$lib/state/app.svelte"; + import { app, appState } from "$lib/state/app.svelte"; import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds"; import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader"; import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler"; @@ -321,11 +321,20 @@ ch.id, ch.name, readerState.pageNumber, ); loadChapter(ch.id, useBlob, abortCtrl, startAtLastPageRef, markedRead, adjacent); - setReading(manga, ch).catch(() => {}); // update Discord presence to show current chapter }); } }); + // Separate from chapter load: also re-fires when idle splash dismisses so presence is restored. + $effect(() => { + const ch = readerState.activeChapter; + const manga = readerState.activeManga; + const idle = appState.idleSplash; + if (ch && manga && !idle) { + untrack(() => setReading(manga, ch).catch(() => {})); + } + }); + $effect(() => { const page = readerState.pageNumber; const chId = style === "longstrip" diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index dd4cf07..6163767 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -154,6 +154,10 @@ if (appState.status === 'ready') resetIdleTimer() }) + $effect(() => { + if (appState.idleSplash && settingsState.settings.discordRpc) setIdle().catch(() => {}) + }) + $effect(() => { if (appState.status !== 'ready') return // capture phase so events from any component — including modals — reset the timer From 0b6372bd17f8dc4c0292ee115f413cf4f584076b Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 20:58:43 +0530 Subject: [PATCH 11/17] fix: X closes reader to origin page, not previous chapter --- src/lib/state/reader.svelte.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/state/reader.svelte.ts b/src/lib/state/reader.svelte.ts index a979389..696d7b9 100644 --- a/src/lib/state/reader.svelte.ts +++ b/src/lib/state/reader.svelte.ts @@ -80,10 +80,11 @@ class ReaderState { get settings() { return settingsState.settings; } openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { + const isChapterNav = this.activeChapter !== null; this.activeChapter = chapter; this.activeChapterList = chapterList; if (manga !== undefined) this.activeManga = manga; - goto(`/reader/${this.activeManga!.id}/${chapter.id}`); + goto(`/reader/${this.activeManga!.id}/${chapter.id}`, { replaceState: isChapterNav }); } closeReader() { From 7af69fd77c05982fa6d4a7c85b04edf526b13d7b Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 20:58:56 +0530 Subject: [PATCH 12/17] fix: manga detail page shows correct cover instead of stale one --- src/lib/components/series/SeriesHeader.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/series/SeriesHeader.svelte b/src/lib/components/series/SeriesHeader.svelte index 1c9f474..ee1b8ce 100644 --- a/src/lib/components/series/SeriesHeader.svelte +++ b/src/lib/components/series/SeriesHeader.svelte @@ -93,7 +93,7 @@
From 5c09cd15ad640feaf51641c4e890827b5df487ed Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 20:59:52 +0530 Subject: [PATCH 13/17] fix: settings scroll parallax between text and row backgrounds --- src/lib/components/settings/Settings.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/components/settings/Settings.css b/src/lib/components/settings/Settings.css index b1ac984..89108b7 100644 --- a/src/lib/components/settings/Settings.css +++ b/src/lib/components/settings/Settings.css @@ -140,6 +140,7 @@ .s-content-body { flex: 1; overflow-y: auto; + transform: translateZ(0); } From 04631d93efa781814e24e26133da805b6cf8774f Mon Sep 17 00:00:00 2001 From: frozenKelp Date: Tue, 9 Jun 2026 21:45:39 +0530 Subject: [PATCH 14/17] fix: settings modal scroll layer will-change --- src/lib/components/settings/Settings.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/settings/Settings.css b/src/lib/components/settings/Settings.css index 89108b7..85adcef 100644 --- a/src/lib/components/settings/Settings.css +++ b/src/lib/components/settings/Settings.css @@ -140,7 +140,7 @@ .s-content-body { flex: 1; overflow-y: auto; - transform: translateZ(0); + will-change: transform; } From fc20835dde17af7fa50534dc1b2fe6779ce36160 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 9 Jun 2026 14:54:12 -0500 Subject: [PATCH 15/17] Fix: SplashScreen MemoryLeak + WebUI Bypass --- src/lib/components/chrome/SplashScreen.svelte | 146 ++++++++++++++++-- .../settings/sections/DevToolsSettings.svelte | 1 + src/routes/+layout.svelte | 95 ++++-------- 3 files changed, 160 insertions(+), 82 deletions(-) diff --git a/src/lib/components/chrome/SplashScreen.svelte b/src/lib/components/chrome/SplashScreen.svelte index 31bd9be..fad48ec 100644 --- a/src/lib/components/chrome/SplashScreen.svelte +++ b/src/lib/components/chrome/SplashScreen.svelte @@ -1,9 +1,46 @@ + + @@ -373,6 +464,20 @@ {/if} {/if} + {#if isDev && mode === 'idle' && devMetrics} +
+ canvas · idle splash +
+ live 1}>{devLiveCount} + total mounts {devMetrics.totalMounts} + stamps {devMetrics.stampCount} + resizes {devMetrics.resizeCount} + uptime {fmtUptime(uptimeSecs)} + last resize {fmtAgo(devMetrics.lastResizeAt)} +
+
+ {/if} + {#if mode === 'idle'}
@@ -465,4 +570,11 @@ .err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); } .err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); } .err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); } + + .dev-overlay { position:absolute; top:12px; left:12px; z-index:10; background:rgba(0,0,0,0.72); border:1px solid rgba(255,255,255,0.10); border-radius:6px; padding:8px 10px; pointer-events:none; backdrop-filter:blur(6px); } + .dev-title { display:block; font-family:var(--font-ui); font-size:9px; letter-spacing:0.14em; text-transform:uppercase; color:var(--accent); margin-bottom:6px; } + .dev-grid { display:grid; grid-template-columns:auto auto; column-gap:12px; row-gap:2px; } + .dev-k { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); white-space:nowrap; } + .dev-v { font-family:var(--font-ui); font-size:10px; color:var(--text-secondary); text-align:right; white-space:nowrap; } + .dev-warn { color:#f87171; } \ No newline at end of file diff --git a/src/lib/components/settings/sections/DevToolsSettings.svelte b/src/lib/components/settings/sections/DevToolsSettings.svelte index 260b50a..9733bbe 100644 --- a/src/lib/components/settings/sections/DevToolsSettings.svelte +++ b/src/lib/components/settings/sections/DevToolsSettings.svelte @@ -102,6 +102,7 @@ } function triggerSplash() { + if (appState.idleSplash) return splashTriggered = true setTimeout(() => splashTriggered = false, 200) appState.idleSplash = true diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6163767..3db14b5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,7 +7,7 @@ import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte' import { applyTheme, mountSystemThemeSync } from '$lib/core/theme' import { platformService } from '$lib/platform-service' - import { initRpc, setIdle, destroyRpc } from '$lib/core/discord' + import * as discord from '$lib/core/discord' import SplashScreen from '$lib/components/chrome/SplashScreen.svelte' import AuthGate from '$lib/components/chrome/AuthGate.svelte' import Sidebar from '$lib/components/chrome/Sidebar.svelte' @@ -24,7 +24,6 @@ const POLL_MS = 1500 let pollTimer: ReturnType | null = null - let idleTimer: ReturnType | null = null let polling = false async function pollLoop() { @@ -35,13 +34,12 @@ const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window - let _splashDismissed = $state(false) - let bypassed = $state(false) - let themeEditorOpen = $state(false) - let themeEditorId = $state(null) + let splashDismissed = $state(false) + let themeEditorOpen = $state(false) + let themeEditorId = $state(null) const splashVisible = $derived( - !_splashDismissed || + !splashDismissed || appState.status === 'booting' || appState.status === 'locked' || appState.status === 'error' || @@ -49,17 +47,16 @@ ) const ringFull = $derived(appState.status === 'ready') + const showApp = $derived(!splashVisible) - const showApp = $derived( - !splashVisible && ( - appState.status === 'ready' || - bypassed - ) - ) - - function onSplashReady() { _splashDismissed = true } - function onSplashUnlock() { appState.status = 'ready'; _splashDismissed = true } - function onSplashBypass() { bypassed = true; _splashDismissed = true } + function onSplashReady() { splashDismissed = true } + function onSplashUnlock() { appState.status = 'ready'; splashDismissed = true } + function onSplashBypass() { + import('$lib/state/boot.svelte').then(({ bypassBoot }) => { + bypassBoot(appState.authMode ?? 'NONE', appState.authUser ?? '', appState.authPass ?? '') + }) + splashDismissed = true + } const isReaderRoute = $derived($page.url.pathname.startsWith('/reader')) const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false) @@ -108,24 +105,19 @@ isTauri && settingsState.settings.autoStartServer ? 2000 : 100, ) - let discordInitialized = false if (settingsState.settings.discordRpc) { - await initRpc() - await setIdle() - discordInitialized = true + await discord.initRpc() + await discord.setIdle() } polling = true pollLoop() - resetIdleTimer() - return () => { polling = false if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null } - if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null } - if (discordInitialized) destroyRpc().catch(() => {}) - platformService.destroy().catch(() => {}) + discord.destroyRpc() + platformService.destroy() } }) @@ -147,49 +139,22 @@ }) $effect(() => { - if (appState.status === 'booting') _splashDismissed = false + if (appState.status === 'booting') splashDismissed = false }) - $effect(() => { - if (appState.status === 'ready') resetIdleTimer() - }) + let idleSplashLocked = false - $effect(() => { - if (appState.idleSplash && settingsState.settings.discordRpc) setIdle().catch(() => {}) - }) - - $effect(() => { - if (appState.status !== 'ready') return - // capture phase so events from any component — including modals — reset the timer - const onActivity = () => resetIdleTimer() - document.addEventListener('mousemove', onActivity, true) - document.addEventListener('keydown', onActivity, true) - document.addEventListener('touchstart', onActivity, true) - // passive:true tells the browser it can render scroll frames without waiting for the handler - document.addEventListener('touchmove', onActivity, { capture: true, passive: true }) - document.addEventListener('wheel', onActivity, { capture: true, passive: true }) - document.addEventListener('click', onActivity, true) - return () => { - document.removeEventListener('mousemove', onActivity, true) - document.removeEventListener('keydown', onActivity, true) - document.removeEventListener('touchstart', onActivity, true) - document.removeEventListener('touchmove', onActivity, { capture: true }) - document.removeEventListener('wheel', onActivity, { capture: true }) - document.removeEventListener('click', onActivity, true) - } - }) - - function resetIdleTimer() { - if (idleTimer) { clearTimeout(idleTimer); idleTimer = null } - // 0 means "Never" — skip the timer entirely - const mins = settingsState.settings.idleTimeoutMin ?? 5 - if (mins === 0) return - idleTimer = setTimeout(() => { - if (appState.status === 'ready') appState.idleSplash = true - }, mins * 60_000) + function showIdleSplash() { + if (idleSplashLocked || appState.idleSplash) return + appState.idleSplash = true } - function onIdleDismiss() { appState.idleSplash = false; resetIdleTimer() } + function onIdleDismiss() { + if (idleSplashLocked) return + idleSplashLocked = true + appState.idleSplash = false + setTimeout(() => { idleSplashLocked = false }, 400) + } function onSplashRetry() { import('$lib/state/boot.svelte').then(({ retryBoot }) => { @@ -219,7 +184,7 @@ {/if} {#if appState.idleSplash} - + {/if} {#if showApp} From abd60f261fd0e7fac298ab55693bf5bb1147b60d Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 9 Jun 2026 19:24:16 -0500 Subject: [PATCH 16/17] Fix: Splashscreen Idle & Dev --- src/lib/components/chrome/SplashScreen.svelte | 5 ++- .../settings/sections/DevToolsSettings.svelte | 4 +- src/lib/state/app.svelte.ts | 1 + src/routes/+layout.svelte | 40 ++++++++++++++----- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/lib/components/chrome/SplashScreen.svelte b/src/lib/components/chrome/SplashScreen.svelte index fad48ec..05e5415 100644 --- a/src/lib/components/chrome/SplashScreen.svelte +++ b/src/lib/components/chrome/SplashScreen.svelte @@ -49,6 +49,7 @@ notConfigured?: boolean showCards?: boolean showFps?: boolean + showDevOverlay?: boolean pinLen?: number pinCorrect?: string onReady?: () => void @@ -60,7 +61,7 @@ let { mode = 'loading', ringFull = false, failed = false, - notConfigured = false, showCards = true, showFps = false, + notConfigured = false, showCards = true, showFps = false, showDevOverlay = false, pinLen = 4, pinCorrect = '', onReady, onUnlock, onRetry, onBypass, onDismiss, }: Props = $props() @@ -464,7 +465,7 @@ {/if} {/if} - {#if isDev && mode === 'idle' && devMetrics} + {#if isDev && mode === 'idle' && devMetrics && showDevOverlay}
canvas · idle splash
diff --git a/src/lib/components/settings/sections/DevToolsSettings.svelte b/src/lib/components/settings/sections/DevToolsSettings.svelte index 9733bbe..b13b4c8 100644 --- a/src/lib/components/settings/sections/DevToolsSettings.svelte +++ b/src/lib/components/settings/sections/DevToolsSettings.svelte @@ -102,10 +102,10 @@ } function triggerSplash() { - if (appState.idleSplash) return + if (appState.devSplash) return splashTriggered = true setTimeout(() => splashTriggered = false, 200) - appState.idleSplash = true + appState.devSplash = true } async function testWindowsHello() { diff --git a/src/lib/state/app.svelte.ts b/src/lib/state/app.svelte.ts index 8e4e1f3..2f302d1 100644 --- a/src/lib/state/app.svelte.ts +++ b/src/lib/state/app.svelte.ts @@ -36,6 +36,7 @@ export const appState = $state({ toasts: [] as unknown[], appDir: '', idleSplash: false, + devSplash: false, }) export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3db14b5..d16a939 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -142,20 +142,38 @@ if (appState.status === 'booting') splashDismissed = false }) - let idleSplashLocked = false - - function showIdleSplash() { - if (idleSplashLocked || appState.idleSplash) return - appState.idleSplash = true - } + let idleTimer: ReturnType | null = null + let idleDismissLock = false function onIdleDismiss() { - if (idleSplashLocked) return - idleSplashLocked = true + if (idleDismissLock) return + idleDismissLock = true appState.idleSplash = false - setTimeout(() => { idleSplashLocked = false }, 400) + setTimeout(() => { idleDismissLock = false }, 400) } + function armIdleTimer() { + if (idleTimer !== null) clearTimeout(idleTimer) + const mins = settingsState.settings.idleTimeoutMin ?? 5 + if (mins <= 0) return + idleTimer = setTimeout(() => { + if (appState.status === 'ready' && !appState.idleSplash) appState.idleSplash = true + }, mins * 60_000) + } + + $effect(() => { + if (appState.status !== 'ready') return + + const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'touchmove', 'wheel', 'click'] as const + for (const e of events) document.addEventListener(e, armIdleTimer, { capture: true, passive: true }) + armIdleTimer() + + return () => { + if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null } + for (const e of events) document.removeEventListener(e, armIdleTimer, { capture: true }) + } + }) + function onSplashRetry() { import('$lib/state/boot.svelte').then(({ retryBoot }) => { retryBoot(appState.authMode ?? 'NONE', appState.authUser ?? '', appState.authPass ?? '') @@ -187,6 +205,10 @@ {/if} +{#if appState.devSplash} + appState.devSplash = false} /> +{/if} + {#if showApp} {#if strippedLayout} {@render children()} From 915ff66b2f39f04eb7a13e7ff4cb66b167057d54 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 9 Jun 2026 21:08:57 -0500 Subject: [PATCH 17/17] Chore: ModalBlur Component --- .../components/reader/lib/chapterLoader.ts | 18 +++++++-- src/lib/components/settings/Settings.css | 12 ++---- src/lib/components/settings/Settings.svelte | 2 + .../shared/manga/MangaPreview.svelte | 4 +- src/lib/components/shared/ui/ModalBlur.svelte | 40 +++++++++++++++++++ .../components/tracking/TrackingPanel.svelte | 8 ++-- src/lib/core/cache/imageCache.ts | 20 +++++----- src/lib/core/cache/pageCache.ts | 7 +++- 8 files changed, 82 insertions(+), 29 deletions(-) create mode 100644 src/lib/components/shared/ui/ModalBlur.svelte diff --git a/src/lib/components/reader/lib/chapterLoader.ts b/src/lib/components/reader/lib/chapterLoader.ts index 9eee8ea..5634eff 100644 --- a/src/lib/components/reader/lib/chapterLoader.ts +++ b/src/lib/components/reader/lib/chapterLoader.ts @@ -1,13 +1,15 @@ import { readerState } from "$lib/state/reader.svelte"; import { fetchPages } from "./pageLoader"; import { cancelQueuedFetches, revokeBlobUrl } from "$lib/core/cache/imageCache"; -import { clearResolvedUrlCache } from "$lib/core/cache/pageCache"; +import { clearResolvedUrlCache, clearPageCache } from "$lib/core/cache/pageCache"; export function scheduleResumeDismiss() { setTimeout(() => { readerState.resumeFading = true; }, 1500); setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500); } +let prefetchedChapterId: number | null = null; + export async function loadChapter( id: number, useBlob: boolean, @@ -23,11 +25,16 @@ export async function loadChapter( cancelQueuedFetches(); if (useBlob) { clearResolvedUrlCache(); - // revoke blob URLs for all loaded pages so the GPU can release their textures for (const url of readerState.pageUrls) revokeBlobUrl(url); for (const strip of readerState.stripChapters) { for (const url of strip.urls) revokeBlobUrl(url); } + if (prefetchedChapterId !== null && prefetchedChapterId !== id) { + const prefetchedUrls = await fetchPages(prefetchedChapterId, false).catch(() => [] as string[]); + for (const url of prefetchedUrls) revokeBlobUrl(url); + clearPageCache(prefetchedChapterId); + } + prefetchedChapterId = null; } startAtLastPage.current = false; @@ -51,10 +58,13 @@ export async function loadChapter( else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo); readerState.pageReady = true; readerState.loading = false; - if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {}); + if (adjacent.next) { + prefetchedChapterId = adjacent.next.id; + fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {}); + } } catch (e: unknown) { if (ctrl.signal.aborted) return; readerState.error = e instanceof Error ? e.message : String(e); readerState.loading = false; } -} +} \ No newline at end of file diff --git a/src/lib/components/settings/Settings.css b/src/lib/components/settings/Settings.css index 85adcef..1f6e904 100644 --- a/src/lib/components/settings/Settings.css +++ b/src/lib/components/settings/Settings.css @@ -10,9 +10,7 @@ /* ── Backdrop & Modal Shell ───────────────────────────────────────── */ .s-backdrop { position: fixed; inset: 0; - background: rgba(0,0,0,0.6); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); + z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: s-fade-in 0.14s ease both; @@ -29,10 +27,7 @@ overflow: visible; position: relative; animation: s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both; - box-shadow: - 0 0 0 1px rgba(255,255,255,0.04) inset, - 0 24px 80px rgba(0,0,0,0.7), - 0 8px 24px rgba(0,0,0,0.4); + box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); } @@ -46,7 +41,7 @@ display: flex; flex-direction: column; gap: 1px; - overflow-y: auto; + overflow-y: hidden; border-radius: var(--radius-2xl) 0 0 var(--radius-2xl); } @@ -140,7 +135,6 @@ .s-content-body { flex: 1; overflow-y: auto; - will-change: transform; } diff --git a/src/lib/components/settings/Settings.svelte b/src/lib/components/settings/Settings.svelte index 8e618a7..edc1851 100644 --- a/src/lib/components/settings/Settings.svelte +++ b/src/lib/components/settings/Settings.svelte @@ -19,6 +19,7 @@ import ContentSettings from './sections/ContentSettings.svelte' import AboutSettings from './sections/AboutSettings.svelte' import DevtoolsSettings from './sections/DevToolsSettings.svelte' + import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte' interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void } let { onclose, onOpenThemeEditor }: Props = $props() @@ -111,6 +112,7 @@ }) +