Merge pull request #95 from frozenKelp/main

fix: idle splash, Discord RPC, memory leaks, reader navigation, cover art, settings scroll
This commit is contained in:
Shozikan
2026-06-09 21:11:29 -05:00
committed by GitHub
17 changed files with 347 additions and 78 deletions
+132 -3
View File
@@ -1,9 +1,46 @@
<script module lang="ts">
function getLiveSet(): Set<HTMLCanvasElement> {
const g = window as any
if (!g.__splashCanvasSet) g.__splashCanvasSet = new Set<HTMLCanvasElement>()
return g.__splashCanvasSet as Set<HTMLCanvasElement>
}
function pruneSet(set: Set<HTMLCanvasElement>) {
for (const el of set) if (!el.isConnected) set.delete(el)
}
export function splashDevRegister(el: HTMLCanvasElement) {
const set = getLiveSet()
pruneSet(set)
set.add(el)
}
export function splashDevUnregister(el: HTMLCanvasElement) {
const set = getLiveSet()
set.delete(el)
pruneSet(set)
}
export function splashDevLiveCount(): number {
const set = getLiveSet()
pruneSet(set)
return set.size
}
export function splashDevNextMount(): number {
const g = window as any
g.__splashTotalMounts = (g.__splashTotalMounts ?? 0) + 1
return g.__splashTotalMounts as number
}
</script>
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import logoUrl from '$lib/assets/moku-icon-splash.svg' import logoUrl from '$lib/assets/moku-icon-splash.svg'
import { platformService } from '$lib/platform-service' import { platformService } from '$lib/platform-service'
const isTauri = platformService.platform === 'tauri' const isTauri = platformService.platform === 'tauri'
const isDev = import.meta.env.DEV
interface Props { interface Props {
mode?: 'loading' | 'idle' | 'locked' mode?: 'loading' | 'idle' | 'locked'
@@ -12,6 +49,7 @@
notConfigured?: boolean notConfigured?: boolean
showCards?: boolean showCards?: boolean
showFps?: boolean showFps?: boolean
showDevOverlay?: boolean
pinLen?: number pinLen?: number
pinCorrect?: string pinCorrect?: string
onReady?: () => void onReady?: () => void
@@ -23,7 +61,7 @@
let { let {
mode = 'loading', ringFull = false, failed = false, mode = 'loading', ringFull = false, failed = false,
notConfigured = false, showCards = true, showFps = false, notConfigured = false, showCards = true, showFps = false, showDevOverlay = false,
pinLen = 4, pinCorrect = '', pinLen = 4, pinCorrect = '',
onReady, onUnlock, onRetry, onBypass, onDismiss, onReady, onUnlock, onRetry, onBypass, onDismiss,
}: Props = $props() }: Props = $props()
@@ -80,7 +118,7 @@
$effect(() => { $effect(() => {
if (mode === 'loading' && !failed && !notConfigured && !ringFull) { if (mode === 'loading' && !failed && !notConfigured && !ringFull) {
if (!isTauri) return // no ring animation on web; probe outcome drives exit if (!isTauri) return
animStart = null animStart = null
animPhase = 1 animPhase = 1
animFrame = requestAnimationFrame(animateRing) animFrame = requestAnimationFrame(animateRing)
@@ -280,22 +318,87 @@
} }
} }
interface DevMetrics {
totalMounts: number
resizeCount: number
stampCount: number
mountedAt: number
lastResizeAt: number | null
}
let devMetrics = $state<DevMetrics | null>(null)
let uptimeSecs = $state(0)
let devLiveCount = $state(0)
$effect(() => {
if (!isDev || mode !== 'idle' || !devMetrics) return
const start = Date.now()
const iv = setInterval(() => { uptimeSecs = Math.floor((Date.now() - start) / 1000) }, 1000)
return () => clearInterval(iv)
})
function fmtUptime(s: number): string {
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m ${s % 60}s`
}
function fmtAgo(ts: number | null): string {
if (ts === null) return '—'
const s = Math.floor((Date.now() - ts) / 1000)
if (s < 60) return `${s}s ago`
return `${Math.floor(s / 60)}m ago`
}
function mountCanvas(el: HTMLCanvasElement) { function mountCanvas(el: HTMLCanvasElement) {
const ctx = el.getContext('2d')! const ctx = el.getContext('2d')!
let live: RenderState | null = null let live: RenderState | null = null
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0 let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0
if (isDev && mode === 'idle') {
splashDevRegister(el)
devLiveCount = splashDevLiveCount()
uptimeSecs = 0
devMetrics = {
totalMounts: splashDevNextMount(),
resizeCount: 0,
stampCount: 0,
mountedAt: Date.now(),
lastResizeAt: null,
}
}
function cleanup() {
if (live) {
live.stamps.forEach(c => { c.width = 0; c.height = 0 })
live.vignette.width = 0
live.vignette.height = 0
live = null
}
ctx.clearRect(0, 0, el.width, el.height)
}
function applySize(logW: number, logH: number, scale: number) { function applySize(logW: number, logH: number, scale: number) {
const gen = ++buildGen const gen = ++buildGen
if (logW <= 0 || logH <= 0) return if (logW <= 0 || logH <= 0) return
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return if (logW === lastLogW && logH === lastLogH && scale === lastScale) return
lastLogW = logW; lastLogH = logH; lastScale = scale lastLogW = logW; lastLogH = logH; lastScale = scale
if (live) cleanup()
const built = buildCards(logW, logH) const built = buildCards(logW, logH)
const stamps = built.cards.map(c => buildStamp(c, scale)) const stamps = built.cards.map(c => buildStamp(c, scale))
const vig = buildVignette(logW, logH, scale) const vig = buildVignette(logW, logH, scale)
el.width = Math.round(logW * scale) el.width = Math.round(logW * scale)
el.height = Math.round(logH * scale) el.height = Math.round(logH * scale)
if (gen === buildGen) live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: el.width, CH: el.height, scale } if (gen === buildGen) {
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: el.width, CH: el.height, scale }
if (isDev && mode === 'idle' && devMetrics) {
devMetrics = {
...devMetrics,
resizeCount: devMetrics.resizeCount + 1,
stampCount: stamps.length,
lastResizeAt: Date.now(),
}
}
}
} }
let extraCleanup: (() => void) | undefined let extraCleanup: (() => void) | undefined
@@ -343,8 +446,13 @@
raf = requestAnimationFrame(frame) raf = requestAnimationFrame(frame)
return () => { return () => {
cancelAnimationFrame(raf) cancelAnimationFrame(raf)
cleanup()
extraCleanup?.() extraCleanup?.()
document.removeEventListener('visibilitychange', onVis) document.removeEventListener('visibilitychange', onVis)
if (isDev && mode === 'idle') {
splashDevUnregister(el)
devLiveCount = splashDevLiveCount()
}
} }
} }
</script> </script>
@@ -357,6 +465,20 @@
{/if} {/if}
{/if} {/if}
{#if isDev && mode === 'idle' && devMetrics && showDevOverlay}
<div class="dev-overlay">
<span class="dev-title">canvas · idle splash</span>
<div class="dev-grid">
<span class="dev-k">live</span> <span class="dev-v" class:dev-warn={devLiveCount > 1}>{devLiveCount}</span>
<span class="dev-k">total mounts</span> <span class="dev-v">{devMetrics.totalMounts}</span>
<span class="dev-k">stamps</span> <span class="dev-v">{devMetrics.stampCount}</span>
<span class="dev-k">resizes</span> <span class="dev-v">{devMetrics.resizeCount}</span>
<span class="dev-k">uptime</span> <span class="dev-v">{fmtUptime(uptimeSecs)}</span>
<span class="dev-k">last resize</span> <span class="dev-v">{fmtAgo(devMetrics.lastResizeAt)}</span>
</div>
</div>
{/if}
{#if mode === 'idle'} {#if mode === 'idle'}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center"> <div style="z-index:1;display:flex;flex-direction:column;align-items:center">
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px"> <div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
@@ -449,4 +571,11 @@
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); } .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 { 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); } .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; }
</style> </style>
+24 -2
View File
@@ -2,7 +2,7 @@
import { onMount, untrack, tick } from "svelte"; import { onMount, untrack, tick } from "svelte";
import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte"; import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte";
import { settingsState, updateSettings } from "$lib/state/settings.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 { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader"; import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler"; import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler";
@@ -13,6 +13,8 @@
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader"; import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
import { historyState } from "$lib/state/history.svelte"; import { historyState } from "$lib/state/history.svelte";
import { getAdapter } from "$lib/request-manager"; 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 type { ReaderSettings } from "$lib/state/reader.svelte";
import ReaderControls from "$lib/components/reader/ReaderControls.svelte"; import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
import PageView from "$lib/components/reader/PageView.svelte"; import PageView from "$lib/components/reader/PageView.svelte";
@@ -216,10 +218,20 @@
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast) ? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
: () => goBack(style, adjacent, startAtLast)); : () => goBack(style, adjacent, startAtLast));
// 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();
}
const onKey = createReaderKeyHandler({ const onKey = createReaderKeyHandler({
goNext: () => goNext(), goNext: () => goNext(),
goPrev: () => goPrev(), goPrev: () => goPrev(),
closeReader: () => readerState.closeReader(), closeReader: () => handleCloseReader(),
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl), goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
lastPage: () => lastPage, lastPage: () => lastPage,
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); }, adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
@@ -313,6 +325,16 @@
} }
}); });
// 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(() => { $effect(() => {
const page = readerState.pageNumber; const page = readerState.pageNumber;
const chId = style === "longstrip" const chId = style === "longstrip"
+23 -6
View File
@@ -1,13 +1,15 @@
import { readerState } from "$lib/state/reader.svelte"; import { readerState } from "$lib/state/reader.svelte";
import { fetchPages } from "./pageLoader"; import { fetchPages } from "./pageLoader";
import { cancelQueuedFetches } from "$lib/core/cache/imageCache"; 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() { export function scheduleResumeDismiss() {
setTimeout(() => { readerState.resumeFading = true; }, 1500); setTimeout(() => { readerState.resumeFading = true; }, 1500);
setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500); setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500);
} }
let prefetchedChapterId: number | null = null;
export async function loadChapter( export async function loadChapter(
id: number, id: number,
useBlob: boolean, useBlob: boolean,
@@ -21,7 +23,19 @@ export async function loadChapter(
abortCtrl.current = ctrl; abortCtrl.current = ctrl;
cancelQueuedFetches(); cancelQueuedFetches();
if (useBlob) clearResolvedUrlCache(); if (useBlob) {
clearResolvedUrlCache();
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; startAtLastPage.current = false;
markedRead.clear(); markedRead.clear();
@@ -44,7 +58,10 @@ export async function loadChapter(
else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo); else if (resumeTo > 1) readerState.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
readerState.pageReady = true; readerState.pageReady = true;
readerState.loading = false; 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) { } catch (e: unknown) {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
readerState.error = e instanceof Error ? e.message : String(e); readerState.error = e instanceof Error ? e.message : String(e);
@@ -93,7 +93,7 @@
<div class="cover-wrap"> <div class="cover-wrap">
<button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}> <button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}>
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" id={seriesState.activeManga?.id ?? manga?.id} /> <Thumbnail src={resolvedCover(manga?.id ?? seriesState.activeManga?.id ?? 0, manga?.thumbnailUrl ?? seriesState.activeManga?.thumbnailUrl ?? "")} alt={manga?.title ?? seriesState.activeManga?.title ?? ""} class="cover" id={manga?.id ?? seriesState.activeManga?.id} />
</button> </button>
</div> </div>
+3 -8
View File
@@ -10,9 +10,7 @@
/* ── Backdrop & Modal Shell ───────────────────────────────────────── */ /* ── Backdrop & Modal Shell ───────────────────────────────────────── */
.s-backdrop { .s-backdrop {
position: fixed; inset: 0; 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); z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
animation: s-fade-in 0.14s ease both; animation: s-fade-in 0.14s ease both;
@@ -29,10 +27,7 @@
overflow: visible; overflow: visible;
position: relative; position: relative;
animation: s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both; animation: s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both;
box-shadow: box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
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);
} }
@@ -46,7 +41,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 1px;
overflow-y: auto; overflow-y: hidden;
border-radius: var(--radius-2xl) 0 0 var(--radius-2xl); border-radius: var(--radius-2xl) 0 0 var(--radius-2xl);
} }
@@ -19,6 +19,7 @@
import ContentSettings from './sections/ContentSettings.svelte' import ContentSettings from './sections/ContentSettings.svelte'
import AboutSettings from './sections/AboutSettings.svelte' import AboutSettings from './sections/AboutSettings.svelte'
import DevtoolsSettings from './sections/DevToolsSettings.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 } interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void }
let { onclose, onOpenThemeEditor }: Props = $props() let { onclose, onOpenThemeEditor }: Props = $props()
@@ -111,6 +112,7 @@
}) })
</script> </script>
<ModalBlur />
<div class="s-backdrop" role="presentation" tabindex="-1" <div class="s-backdrop" role="presentation" tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) close() }} onclick={(e) => { if (e.target === e.currentTarget) close() }}
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}> onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
@@ -102,9 +102,10 @@
} }
function triggerSplash() { function triggerSplash() {
if (appState.devSplash) return
splashTriggered = true splashTriggered = true
setTimeout(() => splashTriggered = false, 200) setTimeout(() => splashTriggered = false, 200)
appState.idleSplash = true appState.devSplash = true
} }
async function testWindowsHello() { async function testWindowsHello() {
@@ -20,6 +20,7 @@
} from "$lib/state/series.svelte"; } from "$lib/state/series.svelte";
import { app } from "$lib/state/app.svelte"; import { app } from "$lib/state/app.svelte";
import type { Manga, Chapter, Category } from "$lib/types"; import type { Manga, Chapter, Category } from "$lib/types";
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
let manga: Manga | null = $state(null); let manga: Manga | null = $state(null);
@@ -353,6 +354,7 @@
</script> </script>
{#if seriesState.previewManga} {#if seriesState.previewManga}
<ModalBlur blur={4} dim={0.72} />
<div <div
class="backdrop" class="backdrop"
role="button" role="button"
@@ -679,10 +681,8 @@
<style> <style>
.backdrop { .backdrop {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings); z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both; animation: fadeIn 0.12s ease both;
} }
.modal { .modal {
@@ -0,0 +1,40 @@
<script lang="ts">
let {
blur = 8,
dim = 0.6,
zIndex = 'var(--z-settings)',
animate = true,
}: {
blur?: number
dim?: number
zIndex?: string | number
animate?: boolean
} = $props()
</script>
<div
class="modal-blur"
class:animate
style="--blur:{blur}px; --dim:{dim}; --z:{zIndex}"
></div>
<style>
.modal-blur {
position: fixed;
inset: 0;
backdrop-filter: blur(var(--blur));
-webkit-backdrop-filter: blur(var(--blur));
background: rgba(0, 0, 0, var(--dim));
pointer-events: none;
z-index: var(--z);
}
.modal-blur.animate {
animation: blur-in 0.14s ease both;
}
@keyframes blur-in {
from { opacity: 0 }
to { opacity: 1 }
}
</style>
@@ -10,6 +10,7 @@
import { markManyRead } from "$lib/request-manager/chapters"; import { markManyRead } from "$lib/request-manager/chapters";
import type { Tracker, TrackRecord, TrackSearch } from "$lib/types"; import type { Tracker, TrackRecord, TrackSearch } from "$lib/types";
import type { Chapter } from "$lib/types"; import type { Chapter } from "$lib/types";
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
let { mangaId, mangaTitle, onClose }: { let { mangaId, mangaTitle, onClose }: {
mangaId: number; mangaId: number;
@@ -250,6 +251,7 @@
} }
}} /> }} />
<ModalBlur blur={4} dim={0.68} />
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal" role="dialog" aria-label="Tracking"> <div class="modal" role="dialog" aria-label="Tracking">
@@ -497,6 +499,7 @@
{#if confirmUnbindId !== null} {#if confirmUnbindId !== null}
{@const rec = records.find(r => r.id === confirmUnbindId)} {@const rec = records.find(r => r.id === confirmUnbindId)}
{@const trk = rec ? trackerFor(rec.trackerId) : null} {@const trk = rec ? trackerFor(rec.trackerId) : null}
<ModalBlur blur={2} dim={0.45} zIndex="calc(var(--z-settings) + 1)" />
<div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel" <div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel"
onclick={() => confirmUnbindId = null} onclick={() => confirmUnbindId = null}
onkeydown={(e) => { if (e.key === "Escape") confirmUnbindId = null; }}> onkeydown={(e) => { if (e.key === "Escape") confirmUnbindId = null; }}>
@@ -515,10 +518,9 @@
<style> <style>
.backdrop { .backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.68); position: fixed; inset: 0;
z-index: var(--z-settings); z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both; animation: fadeIn 0.12s ease both;
} }
.modal { .modal {
@@ -646,7 +648,7 @@
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; } .result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
.confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); background: rgba(0,0,0,0.45); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; } .confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; }
.confirm-modal { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-xl); padding: var(--sp-5); width: 260px; display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 16px 48px rgba(0,0,0,0.5); animation: scaleIn 0.15s ease both; } .confirm-modal { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-xl); padding: var(--sp-5); width: 260px; display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 16px 48px rgba(0,0,0,0.5); animation: scaleIn 0.15s ease both; }
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); margin: 0; } .confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); margin: 0; }
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; margin: 0; letter-spacing: var(--tracking-wide); } .confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; margin: 0; letter-spacing: var(--tracking-wide); }
+11 -9
View File
@@ -5,9 +5,9 @@ import { getUIAccessToken } from "$lib/core/auth";
const cache = new Map<string, string>(); const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>(); const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6; const MAX_CONCURRENT = 6;
let active = 0; let active = 0;
let drainScheduled = false; let drainScheduled = false;
let clearing = false; let generation = 0;
interface QueueEntry { interface QueueEntry {
url: string; url: string;
@@ -32,10 +32,11 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
return {}; return {};
} }
async function doFetch(url: string): Promise<string> { async function doFetch(url: string, gen: number): Promise<string> {
const headers = await getAuthHeaders(); const headers = await getAuthHeaders();
const blob = await platformService.fetchImage(url, headers); if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
if (clearing) throw new DOMException("Cancelled", "AbortError"); const blob = await platformService.fetchImage(url, headers);
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl); cache.set(url, blobUrl);
return blobUrl; return blobUrl;
@@ -55,8 +56,9 @@ function drain() {
drainScheduled = false; drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) { while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!; const entry = queue.shift()!;
const gen = generation;
active++; active++;
doFetch(entry.url) doFetch(entry.url, gen)
.then(entry.resolve, entry.reject) .then(entry.resolve, entry.reject)
.finally(() => { active--; drain(); }); .finally(() => { active--; drain(); });
} }
@@ -107,6 +109,7 @@ export function preloadBlobUrls(urls: string[], basePriority = 0): void {
export function revokeBlobUrl(url: string): void { export function revokeBlobUrl(url: string): void {
const blob = cache.get(url); const blob = cache.get(url);
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); } if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
inflight.delete(url);
} }
export function deprioritizeQueue(): void { export function deprioritizeQueue(): void {
@@ -123,10 +126,9 @@ export function cancelQueuedFetches(): void {
} }
export function clearBlobCache(): void { export function clearBlobCache(): void {
clearing = true; generation++;
cancelQueuedFetches(); cancelQueuedFetches();
inflight.clear();
cache.forEach(blob => URL.revokeObjectURL(blob)); cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear(); cache.clear();
inflight.clear();
clearing = false;
} }
+5 -2
View File
@@ -1,5 +1,5 @@
import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache"; import { getBlobUrl, preloadBlobUrls, revokeBlobUrl } from "$lib/core/cache/imageCache";
import { settingsState } from "$lib/state/settings.svelte"; import { settingsState } from "$lib/state/settings.svelte";
const pageCache = new Map<number, string[]>(); const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>(); const inflight = new Map<number, Promise<string[]>>();
@@ -90,6 +90,9 @@ export function preloadImage(url: string, useBlob: boolean): void {
} }
export function clearResolvedUrlCache(): void { export function clearResolvedUrlCache(): void {
for (const promise of resolvedUrlCache.values()) {
promise.then(blobUrl => { if (blobUrl) revokeBlobUrl(blobUrl); }).catch(() => {});
}
resolvedUrlCache.clear(); resolvedUrlCache.clear();
aspectCache.clear(); aspectCache.clear();
} }
+31 -17
View File
@@ -1,4 +1,5 @@
import { platformService } from '$lib/platform-service' import { platformService } from '$lib/platform-service'
import { settingsState } from '$lib/state/settings.svelte'
import type { Manga } from '$lib/types/manga' import type { Manga } from '$lib/types/manga'
import type { Chapter } from '$lib/types/chapter' import type { Chapter } from '$lib/types/chapter'
@@ -9,11 +10,8 @@ const APP_BUTTONS = [
const FALLBACK_IMAGE = 'moku_logo' const FALLBACK_IMAGE = 'moku_logo'
let sessionStart: number | null = null let sessionStart: number | null = null
let activeMangaId: number | null = null
function isPublicUrl(url: string | null | undefined): boolean {
return typeof url === 'string' && url.startsWith('https://')
}
function trunc(s: string, max = 128): string { function trunc(s: string, max = 128): string {
return s.length <= max ? s : `${s.slice(0, max - 1)}` 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)}` 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<void> { export async function initRpc(): Promise<void> {
if (!platformService.isSupported('discord-rpc')) return if (!platformService.isSupported('discord-rpc')) return
sessionStart = Date.now() sessionStart = Date.now()
@@ -36,18 +59,9 @@ export async function destroyRpc(): Promise<void> {
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> { export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
if (!platformService.isSupported('discord-rpc')) return if (!platformService.isSupported('discord-rpc')) return
await platformService.setDiscordPresence({
details: trunc(manga.title), activeMangaId = manga.id
state: `${formatChapter(chapter)} · Reading`, await platformService.setDiscordPresence(buildPresence(manga, chapter, resolveCoverUrl(manga)))
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,
})
} }
export async function setIdle(): Promise<void> { export async function setIdle(): Promise<void> {
+1
View File
@@ -36,6 +36,7 @@ export const appState = $state({
toasts: [] as unknown[], toasts: [] as unknown[],
appDir: '', appDir: '',
idleSplash: false, idleSplash: false,
devSplash: false,
}) })
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) } export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
+12 -4
View File
@@ -204,10 +204,18 @@ class LibraryState {
this.tabFilters = { ...this.tabFilters, [tab]: {} }; this.tabFilters = { ...this.tabFilters, [tab]: {} };
} }
syncFromSettings(s: { hiddenLibraryTabs?: string[]; libraryPinnedTabOrder?: string[]; defaultLibraryCategoryId?: number | null }) { syncFromSettings(s: {
if (s.hiddenLibraryTabs) this.hiddenTabs = new Set(s.hiddenLibraryTabs); hiddenLibraryTabs?: string[];
if (s.libraryPinnedTabOrder) this.pinnedTabOrder = s.libraryPinnedTabOrder; libraryPinnedTabOrder?: string[];
if (s.defaultLibraryCategoryId !== undefined) this.defaultCategoryId = s.defaultLibraryCategoryId ?? null; 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[]) { setCategories(cats: Category[]) {
+2 -1
View File
@@ -80,10 +80,11 @@ class ReaderState {
get settings() { return settingsState.settings; } get settings() { return settingsState.settings; }
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
const isChapterNav = this.activeChapter !== null;
this.activeChapter = chapter; this.activeChapter = chapter;
this.activeChapterList = chapterList; this.activeChapterList = chapterList;
if (manga !== undefined) this.activeManga = manga; if (manga !== undefined) this.activeManga = manga;
goto(`/reader/${this.activeManga!.id}/${chapter.id}`); goto(`/reader/${this.activeManga!.id}/${chapter.id}`, { replaceState: isChapterNav });
} }
closeReader() { closeReader() {
+50 -18
View File
@@ -34,13 +34,12 @@
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
let _splashDismissed = $state(false) let splashDismissed = $state(false)
let bypassed = $state(false) let themeEditorOpen = $state(false)
let themeEditorOpen = $state(false) let themeEditorId = $state<string | null>(null)
let themeEditorId = $state<string | null>(null)
const splashVisible = $derived( const splashVisible = $derived(
!_splashDismissed || !splashDismissed ||
appState.status === 'booting' || appState.status === 'booting' ||
appState.status === 'locked' || appState.status === 'locked' ||
appState.status === 'error' || appState.status === 'error' ||
@@ -48,17 +47,16 @@
) )
const ringFull = $derived(appState.status === 'ready') const ringFull = $derived(appState.status === 'ready')
const showApp = $derived(!splashVisible)
const showApp = $derived( function onSplashReady() { splashDismissed = true }
!splashVisible && ( function onSplashUnlock() { appState.status = 'ready'; splashDismissed = true }
appState.status === 'ready' || function onSplashBypass() {
bypassed import('$lib/state/boot.svelte').then(({ bypassBoot }) => {
) bypassBoot(appState.authMode ?? 'NONE', appState.authUser ?? '', appState.authPass ?? '')
) })
splashDismissed = true
function onSplashReady() { _splashDismissed = true } }
function onSplashUnlock() { appState.status = 'ready'; _splashDismissed = true }
function onSplashBypass() { bypassed = true; _splashDismissed = true }
const isReaderRoute = $derived($page.url.pathname.startsWith('/reader')) const isReaderRoute = $derived($page.url.pathname.startsWith('/reader'))
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false) const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
@@ -141,10 +139,40 @@
}) })
$effect(() => { $effect(() => {
if (appState.status === 'booting') _splashDismissed = false if (appState.status === 'booting') splashDismissed = false
}) })
function onIdleDismiss() { appState.idleSplash = false } let idleTimer: ReturnType<typeof setTimeout> | null = null
let idleDismissLock = false
function onIdleDismiss() {
if (idleDismissLock) return
idleDismissLock = true
appState.idleSplash = false
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() { function onSplashRetry() {
import('$lib/state/boot.svelte').then(({ retryBoot }) => { import('$lib/state/boot.svelte').then(({ retryBoot }) => {
@@ -174,7 +202,11 @@
{/if} {/if}
{#if appState.idleSplash} {#if appState.idleSplash}
<SplashScreen mode="idle" onDismiss={onIdleDismiss} /> <SplashScreen mode="idle" showCards={settingsState.settings.splashCards ?? true} onDismiss={onIdleDismiss} />
{/if}
{#if appState.devSplash}
<SplashScreen mode="idle" showDevOverlay onDismiss={() => appState.devSplash = false} />
{/if} {/if}
{#if showApp} {#if showApp}