Compare commits

...

21 Commits

Author SHA1 Message Date
Youwes09 f10b343108 Fix: Library Mappings 2026-06-09 22:57:50 -05:00
Youwes09 a8ad9034fc Fix: Reader Longstrip Bookmark + ProgressBar 2026-06-09 22:52:11 -05:00
Shozikan f99fa60e8e Merge pull request #95 from frozenKelp/main
fix: idle splash, Discord RPC, memory leaks, reader navigation, cover art, settings scroll
2026-06-09 21:11:29 -05:00
Youwes09 915ff66b2f Chore: ModalBlur Component 2026-06-09 21:08:57 -05:00
Youwes09 abd60f261f Fix: Splashscreen Idle & Dev 2026-06-09 19:24:16 -05:00
Youwes09 fc20835dde Fix: SplashScreen MemoryLeak + WebUI Bypass 2026-06-09 14:54:12 -05:00
frozenKelp 04631d93ef fix: settings modal scroll layer will-change 2026-06-09 21:45:39 +05:30
frozenKelp 5c09cd15ad fix: settings scroll parallax between text and row backgrounds 2026-06-09 20:59:52 +05:30
frozenKelp 7af69fd77c fix: manga detail page shows correct cover instead of stale one 2026-06-09 20:58:56 +05:30
frozenKelp 0b6372bd17 fix: X closes reader to origin page, not previous chapter 2026-06-09 20:58:43 +05:30
frozenKelp 32bdeb92ff fix: set rpc to idle when idle 2026-06-09 20:13:41 +05:30
frozenKelp 22c4a222d8 fix: idle splash exit animation works 2026-06-09 19:43:02 +05:30
frozenKelp 26cb16ec0f Merge branch 'main' of https://github.com/moku-project/Moku 2026-06-09 19:16:10 +05:30
frozenKelp 6d33fb7ae1 fix: make weel and touch passive 2026-06-09 19:10:52 +05:30
frozenKelp 0e7ff1a27c fix: add wheel and touchmove to idle activity listeners 2026-06-09 16:29:48 +05:30
frozenKelp 685bd9b9da fix: revoke page blob URLs on chapter change and reader close 2026-06-09 16:15:31 +05:30
frozenKelp 3926b5d064 chore: clean up discord RPC hooks 2026-06-09 15:16:27 +05:30
frozenKelp 9f6996dcdb fix: read idleTimeoutMin from settings; clean up idle + splash canvas fixes 2026-06-09 15:04:18 +05:30
frozenKelp 294865fe9d style: use specific imports for discord functions 2026-06-09 10:55:22 +05:30
frozenKelp 13e760594d fix: sync libraryShowAllInSaved setting to library state 2026-06-09 10:54:20 +05:30
frozenKelp b44b12ba86 fix: update discord rpc to emit presence when reading chapters 2026-06-09 10:53:34 +05:30
26 changed files with 773 additions and 238 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">
import { onMount } from 'svelte'
import logoUrl from '$lib/assets/moku-icon-splash.svg'
import { platformService } from '$lib/platform-service'
const isTauri = platformService.platform === 'tauri'
const isDev = import.meta.env.DEV
interface Props {
mode?: 'loading' | 'idle' | 'locked'
@@ -12,6 +49,7 @@
notConfigured?: boolean
showCards?: boolean
showFps?: boolean
showDevOverlay?: boolean
pinLen?: number
pinCorrect?: string
onReady?: () => void
@@ -23,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()
@@ -80,7 +118,7 @@
$effect(() => {
if (mode === 'loading' && !failed && !notConfigured && !ringFull) {
if (!isTauri) return // no ring animation on web; probe outcome drives exit
if (!isTauri) return
animStart = null
animPhase = 1
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) {
const ctx = el.getContext('2d')!
let live: RenderState | null = null
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) {
const gen = ++buildGen
if (logW <= 0 || logH <= 0) return
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return
lastLogW = logW; lastLogH = logH; lastScale = scale
if (live) cleanup()
const built = buildCards(logW, logH)
const stamps = built.cards.map(c => buildStamp(c, scale))
const vig = buildVignette(logW, logH, scale)
el.width = Math.round(logW * 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
@@ -343,8 +446,13 @@
raf = requestAnimationFrame(frame)
return () => {
cancelAnimationFrame(raf)
cleanup()
extraCleanup?.()
document.removeEventListener('visibilitychange', onVis)
if (isDev && mode === 'idle') {
splashDevUnregister(el)
devLiveCount = splashDevLiveCount()
}
}
}
</script>
@@ -357,6 +465,20 @@
{/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'}
<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">
@@ -449,4 +571,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; }
</style>
+1 -2
View File
@@ -393,7 +393,7 @@
{:else}
<LibraryToolbar
tab={libraryState.tab}
tabSortMode={libraryState.tabSort[libraryState.tab]?.mode ?? 'alphabetical'}
tabSortMode={libraryState.tabSort[libraryState.tab]?.mode ?? 'az'}
tabSortDir={libraryState.tabSort[libraryState.tab]?.dir ?? 'asc'}
tabStatus={libraryState.tabStatus[libraryState.tab] ?? 'ALL'}
tabFilters={libraryState.tabFilters[libraryState.tab] ?? {}}
@@ -423,7 +423,6 @@
onFilterPanelToggle={() => filterPanelOpen = !filterPanelOpen}
onRefresh={startRefresh}
onCancelRefresh={cancelRefresh}
onRefreshCategory={refreshCategory}
onOpenDownloadsFolder={openDownloadsFolder}
onTabDragStart={onTabDragStart}
onTabDragOver={onTabDragOver}
@@ -1,6 +1,7 @@
<script lang="ts">
import { CheckSquare, Trash, Folder } from 'phosphor-svelte'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import { settingsState } from '$lib/state/settings.svelte'
import type { Manga, Category } from '$lib/types'
interface Props {
@@ -27,6 +28,9 @@
let movePanelOpen = $state(false)
const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? false)
const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true)
function onDocDown(e: MouseEvent) {
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
}
@@ -114,10 +118,11 @@
class="card"
class:card-selected={isSelected}
class:select-mode={selectMode}
class:stats-always={statsAlways}
onclick={(e) => onCardClick(e, m)}
oncontextmenu={(e) => onCardContextMenu(e, m)}
>
<div class="cover-wrap" class:completed={isCompleted}>
<div class="cover-wrap" class:completed={isCompleted} class:cover-contain={!cropCovers}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" id={m.id} />
<div class="overlay">
<div class="badges">
@@ -236,6 +241,7 @@
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; }
.cover-contain :global(.cover) { object-fit: contain; }
.overlay {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
@@ -245,6 +251,7 @@
transition: opacity 0.18s ease;
}
.card:not(.select-mode):hover .overlay { opacity: 1; }
.stats-always .overlay { opacity: 1; }
.badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
.badge {
@@ -4,21 +4,19 @@
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
} from "phosphor-svelte";
import LibraryFilters from "./LibraryFilters.svelte";
import type { Category } from "@types";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
import type { Category } from "$lib/types";
import type { LibrarySortOption, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "$lib/state/library.svelte";
interface Props {
tab: string;
tabSortMode: LibrarySortMode;
tabSortMode: LibrarySortOption;
tabSortDir: LibrarySortDir;
tabStatus: LibraryStatusFilter;
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
hasActiveFilters: boolean;
anims: boolean;
anims?: boolean;
visibleCategories: Category[];
visibleTabIds: string[];
virtualTabIds: string[];
folderTabIds: string[];
completedCatId: number | null;
counts: Record<string, number>;
search: string;
@@ -35,7 +33,7 @@
tabsEl: HTMLDivElement;
onSearchChange: (v: string) => void;
onTabChange: (f: string) => void;
onSortChange: (mode: LibrarySortMode) => void;
onSortChange: (mode: LibrarySortOption) => void;
onSortDirToggle: () => void;
onStatusChange: (s: LibraryStatusFilter) => void;
onFilterToggle: (f: LibraryContentFilter) => void;
@@ -44,7 +42,6 @@
onFilterPanelToggle: () => void;
onRefresh: () => void;
onCancelRefresh: () => void;
onRefreshCategory: (catId: number) => void;
onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, id: string) => void;
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
@@ -55,13 +52,13 @@
let {
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
anims = false, visibleCategories, visibleTabIds, completedCatId,
counts, search, refreshing, refreshProgress, refreshDone, refreshingCatId,
activeDragKind, dragInsertIdx, dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
tabsEl = $bindable(),
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
onRefresh, onCancelRefresh, onRefreshCategory, onOpenDownloadsFolder,
onRefresh, onCancelRefresh, onOpenDownloadsFolder,
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
}: Props = $props();
@@ -85,18 +82,18 @@
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
});
const SORT_LABELS: Record<LibrarySortMode, string> = {
const SORT_LABELS: Record<LibrarySortOption, string> = {
az: "AZ",
unreadCount: "Unread chapters",
totalChapters: "Total chapters",
recentlyAdded: "Recently added",
recentlyRead: "Recently read",
dateAdded: "Recently added",
lastRead: "Recently read",
latestFetched: "Latest fetched chapter",
latestUploaded: "Latest uploaded chapter",
};
const ALL_SORT_MODES: LibrarySortMode[] = [
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
const ALL_SORT_MODES: LibrarySortOption[] = [
"az", "unreadCount", "totalChapters", "dateAdded", "lastRead", "latestFetched", "latestUploaded",
];
</script>
+205 -66
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from "svelte";
import { readerState } from "$lib/state/reader.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { createPinchTracker } from "$lib/components/reader/lib/pinchZoom";
@@ -54,11 +55,51 @@
let resolvedSrc = $state<Record<number, string>>({});
let revokeQueue: string[] = [];
let observer: IntersectionObserver | null = null;
const elementIndex = new Map<Element, number>();
// Aspect ratios (w/h) keyed by flat index, written by the img onload handler.
// Retained as a fallback for scrollToFlatIndex when a slot is not yet in DOM.
const aspectMap = new Map<number, number>();
let viewportCenter = $state(0);
let currentSrc = $state<string | null>(null);
let currentGroupSrcs = $state<(string | null)[]>([]);
let centerIdx = $state(0);
// ── Non-longstrip page src resolution ────────────────────────────────────
$effect(() => {
if (style === "longstrip" || !pageReady) return;
const pageNum = readerState.pageNumber;
const urls = readerState.pageUrls;
const group = currentGroup;
currentSrc = null;
currentGroupSrcs = group.map(() => null);
let cancelled = false;
if (style === "double") {
group.forEach((pg, i) => {
const url = urls[pg - 1];
if (!url) return;
resolveUrl(url, 999).then(src => {
if (cancelled) return;
currentGroupSrcs = currentGroupSrcs.map((s, j) => j === i ? src : s);
});
});
} else {
const url = urls[pageNum - 1];
if (url) resolveUrl(url, 999).then(src => { if (!cancelled) currentSrc = src; });
}
return () => { cancelled = true; };
});
// ── Non-longstrip: scroll to top on every page change ────────────────────
// Ported from Suwayomi's useReaderScrollToStartOnPageChange.
// Prevents stale pan position carrying over when flipping pages.
$effect(() => {
void readerState.pageNumber;
if (style !== "longstrip" && containerEl) {
containerEl.scrollTo(0, 0);
}
});
// ── Blob URL revocation ───────────────────────────────────────────────────
function scheduleRevoke(src: string) {
if (!src || !src.startsWith("blob:")) return;
revokeQueue.push(src);
@@ -68,6 +109,7 @@
});
}
// ── Load window management ────────────────────────────────────────────────
function loadPage(idx: number) {
if (loadedSet.has(idx)) return;
const page = flatPages[idx];
@@ -99,62 +141,162 @@
}
}
function recalcWindow() {
const center = viewportCenter;
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
function recalcWindow(center: number) {
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
const evictLo = center - UNLOAD_RADIUS;
const evictHi = center + UNLOAD_RADIUS;
for (let i = 0; i < flatPages.length; i++) {
if (i >= lo && i <= hi) loadPage(i);
else if (i < evictLo || i > evictHi) unloadPage(i);
if (i >= lo && i <= hi) loadPage(i);
else if (i < evictLo || i > evictHi) unloadPage(i);
}
}
$effect(() => { void viewportCenter; recalcWindow(); });
$effect(() => { void flatPages.length; recalcWindow(); });
$effect(() => { recalcWindow(centerIdx); });
$effect(() => { void flatPages.length; recalcWindow(centerIdx); });
function setupObserver(containerEl: HTMLElement) {
observer?.disconnect();
elementIndex.clear();
observer = new IntersectionObserver(
(entries) => {
let best = -1;
let bestRatio = -1;
for (const entry of entries) {
const idx = elementIndex.get(entry.target);
if (idx === undefined) continue;
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
bestRatio = entry.intersectionRatio;
best = idx;
}
}
if (best >= 0 && best !== viewportCenter) viewportCenter = best;
},
{ root: containerEl, rootMargin: "0px", threshold: [0, 0.1, 0.5, 1.0] },
);
}
// ── Scroll position preservation on image resize above viewport ───────────
// Ported from Suwayomi's usePreserveOnLeadingPageRender.
//
// Problem: when a placeholder above the current scroll position loads its
// real image and changes height, the browser shifts the scroll position
// relative to the viewport (layout shift). This corrects for that by:
// 1. Tracking the first visible image and its offsetTop at last scroll.
// 2. On every ResizeObserver entry for an image above scrollTop, computing
// the delta and applying it as a scroll correction.
//
// MutationObserver watches for images being added/removed so the
// ResizeObserver stays in sync with the actual DOM without needing
// querySelectorAll on every scroll tick.
$effect(() => {
if (style !== "longstrip" || !containerEl) return;
function observePage(el: HTMLDivElement, idx: number) {
elementIndex.set(el, idx);
observer?.observe(el);
return {
update(newIdx: number) { elementIndex.set(el, newIdx); },
destroy() { observer?.unobserve(el); elementIndex.delete(el); },
let visibleImg: HTMLElement | undefined;
let visibleImgTop = 0;
let lastScrollTop = 0;
const onScroll = () => {
lastScrollTop = containerEl.scrollTop;
if (visibleImg) {
visibleImgTop = visibleImg.offsetTop;
}
};
containerEl.addEventListener("scroll", onScroll, { passive: true });
const intersectionObs = new IntersectionObserver((entries) => {
const first = entries.find(e => e.isIntersecting);
if (first?.target instanceof HTMLElement) {
visibleImg = first.target;
visibleImgTop = first.target.offsetTop;
}
});
const resizeObs = new ResizeObserver((entries) => {
if (!visibleImg) return;
const hasEntryBeforeScroll = entries.some(e => {
if (!(e.target instanceof HTMLElement)) return false;
// Skip zero-size preload placeholders (they are outside the load window)
if (!e.target.clientWidth && !e.target.clientHeight) return false;
return e.target.offsetTop < lastScrollTop;
});
if (!hasEntryBeforeScroll) return;
const newTop = lastScrollTop - visibleImgTop + visibleImg.offsetTop;
containerEl.scrollTo({ top: newTop, behavior: "instant" } as ScrollToOptions);
});
const observe = (el: Element) => { intersectionObs.observe(el); resizeObs.observe(el); };
const unobserve = (el: Element) => { intersectionObs.unobserve(el); resizeObs.unobserve(el); };
const mutationObs = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach(n => {
if (!(n instanceof HTMLElement)) return;
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
imgs.forEach(observe);
});
m.removedNodes.forEach(n => {
if (!(n instanceof HTMLElement)) return;
const imgs = n instanceof HTMLImageElement ? [n] : Array.from(n.querySelectorAll("img"));
imgs.forEach(unobserve);
});
}
});
mutationObs.observe(containerEl, { childList: true, subtree: true });
// Observe images already in the DOM at setup time
containerEl.querySelectorAll("img").forEach(observe);
return () => {
containerEl.removeEventListener("scroll", onScroll);
mutationObs.disconnect();
resizeObs.disconnect();
intersectionObs.disconnect();
};
});
// ── Cursor hide on inactivity (longstrip) ─────────────────────────────────
// Ported from Suwayomi's useReaderHideCursorOnInactivity.
// Hides the cursor after 5 s of mouse inactivity, restores on movement.
$effect(() => {
if (style !== "longstrip" || !containerEl) return;
const HIDE_AFTER_MS = 5_000;
let timer: ReturnType<typeof setTimeout> | null = null;
const show = () => {
containerEl.style.cursor = "";
if (timer) clearTimeout(timer);
timer = setTimeout(() => { containerEl.style.cursor = "none"; }, HIDE_AFTER_MS);
};
show(); // start the timer immediately
window.addEventListener("mousemove", show, { passive: true });
return () => {
containerEl.style.cursor = "";
window.removeEventListener("mousemove", show);
if (timer) clearTimeout(timer);
};
});
// ── Scroll to target flat index ───────────────────────────────────────────
export function notifyScrollCenter(idx: number) {
centerIdx = idx;
}
export async function scrollToFlatIndex(idx: number) {
if (!containerEl || !flatPages.length) return;
centerIdx = idx;
recalcWindow(idx);
// Wait for Svelte to render any newly-in-window slots.
await tick();
if (!containerEl) return;
// Use scrollIntoView — the browser knows the exact element position
// regardless of image load state or aspect ratio. This is the same approach
// used by Suwayomi's useReaderHandlePageSelection (imageRef.scrollIntoView).
const slots = containerEl.querySelectorAll<HTMLElement>(".strip-slot");
const slot = slots[idx];
if (slot) {
slot.scrollIntoView({ block: "start", behavior: "instant" });
} else {
// Slot not in DOM — proportional fallback (very unlikely after tick).
containerEl.scrollTop = (idx / flatPages.length) * containerEl.scrollHeight;
}
}
// ── Reset on chapter change ───────────────────────────────────────────────
let lastChapterId = 0;
$effect(() => {
const chapterId = readerState.activeChapter?.id ?? 0;
if (chapterId === lastChapterId) return;
lastChapterId = chapterId;
loadedSet = new Set<number>();
resolvedSrc = {};
const resume = readerState.resumePage;
viewportCenter = resume > 1 ? resume - 1 : 0;
loadedSet = new Set<number>();
resolvedSrc = {};
centerIdx = 0;
aspectMap.clear();
});
// ── Inspect / zoom helpers ────────────────────────────────────────────────
const INSPECT_ZOOM_STEP = 0.15;
const INSPECT_ZOOM_MAX = 8;
@@ -238,6 +380,11 @@
return () => cancelAnimationFrame(rafId);
});
$effect(() => {
if (style !== "longstrip") stopMidScroll();
});
// ── Pinch zoom ────────────────────────────────────────────────────────────
let pinch: PinchTracker | null = null;
$effect(() => {
@@ -255,6 +402,7 @@
}
});
// ── Pointer / mouse / wheel event handlers ────────────────────────────────
export function onInspectMouseDown(e: MouseEvent) {
if ((e.target as Element).closest(".bar")) return;
if (e.button === 1 && style === "longstrip") {
@@ -388,18 +536,7 @@
function setContainer(el: HTMLDivElement) {
containerEl = el;
bindContainer(el);
if (style === "longstrip") setupObserver(el);
}
$effect(() => {
if (style === "longstrip" && containerEl) {
setupObserver(containerEl);
} else if (style !== "longstrip") {
observer?.disconnect();
observer = null;
stopMidScroll();
}
});
</script>
<div
@@ -459,7 +596,7 @@
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
{@const src = resolvedSrc[gi]}
{@const isLoaded = loadedSet.has(gi)}
<div class="strip-slot" use:observePage={gi}>
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId}>
{#if isLoaded && src}
<img
{src}
@@ -475,7 +612,9 @@
const img = e.currentTarget as HTMLImageElement;
const slot = img.closest<HTMLElement>(".strip-slot");
if (slot && img.naturalWidth > 0) {
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
const aspect = img.naturalWidth / img.naturalHeight;
slot.style.setProperty("--aspect", String(aspect));
aspectMap.set(gi, aspect);
}
}}
/>
@@ -490,11 +629,11 @@
{:else if style === "fade" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
{#if currentSrc}
<img src={currentSrc} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
{:else}
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
{:then src}
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" style="opacity:{fadingOut ? 0 : 1};transition:opacity 0.1s ease" draggable="false" />
{/await}
{/if}
</div>
{:else if style === "double" && pageReady}
@@ -502,11 +641,11 @@
{#if pageGroups.length}
<div class="double-wrap">
{#each currentGroup as pg, i (pg)}
{#await resolveUrl(readerState.pageUrls[pg - 1], 999)}
{#if currentGroupSrcs[i]}
<img src={currentGroupSrcs[i]} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
{:else}
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">{@render skeleton()}</div>
{:then src}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
{/await}
{/if}
{/each}
</div>
{:else}
@@ -518,11 +657,11 @@
{:else if pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(readerState.pageUrls[readerState.pageNumber - 1], 999)}
{#if currentSrc}
<img src={currentSrc} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
{:else}
<div class="page-loader page-loader-single" aria-hidden="true">{@render skeleton()}</div>
{:then src}
<img {src} alt="Page {readerState.pageNumber}" class={imgCls} decoding="async" draggable="false" />
{/await}
{/if}
</div>
{/if}
</div>
+68 -23
View File
@@ -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";
@@ -12,7 +12,10 @@
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
import { historyState } from "$lib/state/history.svelte";
import { setPreviewManga } from "$lib/state/series.svelte";
import { getAdapter } from "$lib/request-manager";
import { setReading, clearReading } from "$lib/core/discord";
import { revokeBlobUrl, cancelQueuedFetches, preloadBlobUrls } 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";
@@ -209,6 +212,36 @@
const startAtLast = () => { startAtLastPageRef.current = true; };
function flatIndexForPage(page: number): number {
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
const chunks = readerState.stripChapters;
let offset = 0;
for (const chunk of chunks) {
if (chunk.chapterId === chId) return offset + Math.max(0, page - 1);
offset += chunk.urls.length;
}
return Math.max(0, page - 1);
}
function primedJump(page: number, commit = true) {
if (useBlob && commit && style !== "longstrip") {
cancelQueuedFetches();
const urls = readerState.pageUrls;
const lo = Math.max(0, page - 2);
const hi = Math.min(urls.length, page + 4);
preloadBlobUrls(urls.slice(lo, hi), 999);
}
jumpToPage(
page,
style,
lastPage,
style === "longstrip" ? (idx) => pageViewRef.scrollToFlatIndex(idx) : null,
stripToRender.reduce((s, c) => s + c.urls.length, 0),
readerState.visibleChapterId ?? readerState.activeChapter?.id ?? 0,
readerState.stripChapters,
);
}
const goNext = $derived(rtl
? () => goBack(style, adjacent, startAtLast)
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
@@ -216,17 +249,26 @@
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
: () => goBack(style, adjacent, startAtLast));
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({
goNext: () => goNext(),
goPrev: () => goPrev(),
closeReader: () => readerState.closeReader(),
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
closeReader: () => handleCloseReader(),
goToPage: (p) => primedJump(p),
lastPage: () => lastPage,
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => { app.setSettingsOpen(true); },
openSettings: () => { app.setSettingsOpen(true); },
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(settingsState.settings.autoScroll ?? false) }); },
toggleMarker: () => {
@@ -313,6 +355,15 @@
}
});
$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"
@@ -339,26 +390,18 @@
if (style === "longstrip" && readerState.pageUrls.length && readerState.activeChapter) {
const ch = readerState.activeChapter;
const urls = readerState.pageUrls;
const targetPg = untrack(() => readerState.resumePage);
const resumeTo = untrack(() => readerState.resumePage);
appending = false;
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
readerState.visibleChapterId = ch.id;
tick().then(() => {
if (!containerEl) return;
if (targetPg > 1) {
const chId = ch.id;
const scrollToResumePage = () => {
const target = containerEl!.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
containerEl!.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`).forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
const doScroll = () => { target.scrollIntoView({ block: "start" }); readerState.stripResumeReady = true; };
if (target.complete && target.naturalHeight > 0) doScroll();
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
};
scrollToResumePage();
if (resumeTo > 1) {
pageViewRef.scrollToFlatIndex(resumeTo - 1);
readerState.stripResumeReady = true;
return;
}
containerEl!.scrollTop = 0;
containerEl.scrollTop = 0;
});
}
});
@@ -408,10 +451,11 @@
untrack(() => {
cleanupScroll();
cleanupScroll = setupScrollTracking(containerEl!, {
onPageChange: (p) => { readerState.pageNumber = p; },
onChapterChange: (id) => { readerState.visibleChapterId = id; },
onMarkRead: (id) => markChapterRead(id, markedRead),
onAppend: () => {
onPageChange: (p) => { readerState.pageNumber = p; },
onChapterChange: (id) => { readerState.visibleChapterId = id; },
onCenterIdxChange: (idx) => { pageViewRef?.notifyScrollCenter(idx); },
onMarkRead: (id) => markChapterRead(id, markedRead),
onAppend: () => {
if (appending || !readerState.stripChapters.length) return;
appending = true;
appendNextChapter(
@@ -606,6 +650,7 @@
onClampZoom={clampZoom}
onApplySettings={applySettings}
onSettingsOpen={() => { app.setSettingsOpen(true); }}
onOpenPreview={() => { if (readerState.activeManga) setPreviewManga(readerState.activeManga); }}
{perMangaEnabled}
/>
@@ -666,7 +711,7 @@
{barPosition}
onGoPrev={goPrev}
onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
onJumpToPage={(p, commit) => primedJump(p, commit)}
/>
{/snippet}
@@ -680,7 +725,7 @@
{barPosition}
onGoPrev={goPrev}
onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
onJumpToPage={(p, commit) => primedJump(p, commit)}
/>
{/if}
</div>
@@ -36,6 +36,7 @@
onClampZoom: (z: number) => number;
onApplySettings: (patch: Partial<ReaderSettings>) => void;
onSettingsOpen: () => void;
onOpenPreview: () => void;
perMangaEnabled: boolean;
}
@@ -47,7 +48,7 @@
barPosition, progressBar,
onCaptureZoomAnchor, onRestoreZoomAnchor,
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
onClampZoom, onApplySettings, onSettingsOpen,
onClampZoom, onApplySettings, onSettingsOpen, onOpenPreview,
perMangaEnabled,
}: Props = $props();
@@ -155,12 +156,12 @@
<span class="ch-info">&#xE2CE;</span>
{:else}
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
<span class="ch-marquee-content">
<span class="ch-title">{readerState.activeManga?.title}</span>
<span class="ch-sep">/</span>
<span class="ch-name">{displayChapter?.name}</span>
<button class="ch-marquee-content ch-preview-btn" onclick={onOpenPreview}>
<span class="ch-title">{readerState.activeManga?.title}</span>
<span class="ch-sep">/</span>
<span class="ch-name">{displayChapter?.name}</span>
</button>
</span>
</span>
{/if}
</div>
{#if !isVertical}
@@ -494,6 +495,8 @@
.ch-marquee-track { overflow-x: auto; min-width: 0; flex: 1; scrollbar-width: none; }
.ch-marquee-track::-webkit-scrollbar { display: none; }
.ch-marquee-content { display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
.ch-preview-btn { background: none; border: none; cursor: pointer; padding: 0; font-size: inherit; font-family: inherit; border-radius: var(--radius-sm); transition: opacity var(--t-fast); }
.ch-preview-btn:hover { opacity: 0.7; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
.ch-name { color: var(--text-muted); }
+18 -2
View File
@@ -21,11 +21,27 @@
readerState.dlOpen = false;
}
let bannerMounted = $state(false);
let bannerFading = $state(false);
$effect(() => {
if (showResumeBanner) {
bannerMounted = true;
bannerFading = false;
} else if (bannerMounted) {
bannerFading = true;
}
});
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
function onBannerAnimationEnd() {
if (bannerFading) { bannerMounted = false; bannerFading = false; }
}
</script>
{#if showResumeBanner}
<button class="resume-banner" class:fading={resumeFading} onclick={onDismissResume}>
{#if bannerMounted}
<button class="resume-banner" class:fading={bannerFading} onclick={onDismissResume} onanimationend={onBannerAnimationEnd}>
Bookmark at page {resumePage}
</button>
{/if}
@@ -20,7 +20,7 @@
barPosition: "top" | "left" | "right";
onGoPrev: () => void;
onGoNext: () => void;
onJumpToPage: (page: number) => void;
onJumpToPage: (page: number, commit?: boolean) => void;
}
const {
@@ -32,12 +32,22 @@
const isVertical = $derived(barPosition === "left" || barPosition === "right");
const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage);
const hPct = $derived(`--pct:${sliderPct}%`);
const hPct = $derived(`--pct:${sliderPct}%`);
function sliderValToPage(raw: number): number {
return rtl ? sliderMax - raw + 1 : raw;
}
function pageToSliderVal(page: number): number {
return rtl ? sliderMax - page + 1 : page;
}
function handleH(e: Event) {
const raw = Number((e.target as HTMLInputElement).value);
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), false);
}
function handleHCommit(e: Event) {
onJumpToPage(sliderValToPage(Number((e.target as HTMLInputElement).value)), true);
}
function markerPct(pageNumber: number, forRtl = false): number {
@@ -46,9 +56,9 @@
return ((ord - 1) / (sliderMax - 1)) * 100;
}
// Custom vertical slider
let trackEl = $state<HTMLDivElement | null>(null);
let dragging = $state(false);
let pendingPage = 0;
function pctFromPointer(clientY: number): number {
if (!trackEl) return 0;
@@ -64,22 +74,24 @@
if (e.button !== 0) return;
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragging = true;
dragging = true;
readerState.sliderDragging = true;
const pct = pctFromPointer(e.clientY);
onJumpToPage(pageFromPct(pct));
pendingPage = pageFromPct(pctFromPointer(e.clientY));
onJumpToPage(pendingPage, false);
}
function handleTrackPointerMove(e: PointerEvent) {
if (!dragging) return;
const pct = pctFromPointer(e.clientY);
onJumpToPage(pageFromPct(pct));
pendingPage = pageFromPct(pctFromPointer(e.clientY));
onJumpToPage(pendingPage, false);
}
function handleTrackPointerUp(e: PointerEvent) {
if (!dragging) return;
dragging = false;
readerState.sliderDragging = false;
readerState.sliderHover = false;
onJumpToPage(pendingPage, true);
}
</script>
@@ -102,8 +114,9 @@
style={hPct}
min={1}
max={sliderMax}
value={hValue}
value={pageToSliderVal(sliderPage)}
oninput={handleH}
onchange={handleHCommit}
onmousedown={() => readerState.sliderDragging = true}
onmouseup={() => readerState.sliderDragging = false}
/>
+30 -8
View File
@@ -1,13 +1,15 @@
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, preloadBlobUrls } from "$lib/core/cache/imageCache";
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,
@@ -21,7 +23,19 @@ export async function loadChapter(
abortCtrl.current = ctrl;
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;
markedRead.clear();
@@ -32,19 +46,27 @@ export async function loadChapter(
const resumeTo = bookmark ? bookmark.pageNumber : 0;
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
readerState.resumeDismissed = false;
readerState.resumeVisible = resumeTo > 1;
if (resumeTo > 1) scheduleResumeDismiss();
readerState.resumeVisible = false;
readerState.pageNumber = 1;
try {
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
if (ctrl.signal.aborted) return;
readerState.pageUrls = urls;
if (useBlob && resumeTo > 1) {
const lo = Math.max(0, resumeTo - 2);
const hi = Math.min(urls.length, resumeTo + 4);
preloadBlobUrls(urls.slice(lo, hi), 900);
}
if (startAtLastPage.current) readerState.pageNumber = urls.length;
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 (resumeTo > 1) readerState.resumeVisible = true;
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);
+20 -4
View File
@@ -64,14 +64,30 @@ export function goBack(style: string, adjacent: Adjacent, startAtLastPage: () =>
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, readerState.activeChapterList); }
}
export function jumpToPage(page: number, style: string, lastPage: number, containerEl: HTMLElement | null) {
export function jumpToPage(
page: number,
style: string,
lastPage: number,
scrollToFlatIndex: ((idx: number) => void) | null,
flatPageCount: number,
activeChapterId: number,
stripChapters: { chapterId: number; urls: string[] }[],
) {
if (style === "longstrip") {
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" });
if (!scrollToFlatIndex || flatPageCount === 0) return;
let offset = 0;
for (const chunk of stripChapters) {
if (chunk.chapterId === activeChapterId) {
scrollToFlatIndex(offset + Math.max(0, page - 1));
return;
}
offset += chunk.urls.length;
}
scrollToFlatIndex(Math.max(0, page - 1));
return;
}
if (style === "double" && readerState.pageGroups.length) {
const group = readerState.pageGroups[page - 1];
const group = readerState.pageGroups.find(g => g.includes(page)) ?? readerState.pageGroups.findLast(g => g[0] <= page);
if (group) readerState.pageNumber = group[0];
} else {
readerState.pageNumber = Math.max(1, Math.min(lastPage, page));
+57 -16
View File
@@ -7,34 +7,65 @@ export interface StripChapter {
}
export interface ScrollHandlerCallbacks {
onPageChange: (page: number) => void;
onChapterChange: (chapterId: number) => void;
onMarkRead: (chapterId: number) => void;
onAppend: () => void;
getStripChapters: () => StripChapter[];
getPageUrls: () => string[];
shouldAutoMark: () => boolean;
onPageChange: (page: number) => void;
onChapterChange: (chapterId: number) => void;
onCenterIdxChange: (flatIdx: number) => void;
onMarkRead: (chapterId: number) => void;
onAppend: () => void;
getStripChapters: () => StripChapter[];
getPageUrls: () => string[];
shouldAutoMark: () => boolean;
}
/**
* Returns true if the element is considered "at" the read-line.
*
* Ported from Suwayomi's ReaderPager.utils `isPageInViewport`:
* - If the element's top is above the line AND its bottom is below it fully covers the line
* (handles a single page that is taller than the viewport).
* - If the element's top is at or below the line AND its bottom is also below it leading edge
* has crossed the line (normal scroll-past case).
*
* Using Math.trunc to avoid floating-point jitter from getBoundingClientRect.
*/
function isPageAtReadLine(el: HTMLElement, readLineY: number): boolean {
const rect = el.getBoundingClientRect();
const top = Math.trunc(rect.top);
const bottom = Math.trunc(rect.bottom);
const line = Math.trunc(readLineY);
// Element completely spans the read line (taller than viewport or very tall image)
if (top <= line && bottom >= line) return true;
// Element's top edge is at or above the line
if (top <= line) return true;
return false;
}
export function setupScrollTracking(
containerEl: HTMLElement,
callbacks: ScrollHandlerCallbacks,
): () => void {
const { onPageChange, onChapterChange, onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark } = callbacks;
const {
onPageChange, onChapterChange, onCenterIdxChange,
onMarkRead, onAppend, getStripChapters, getPageUrls, shouldAutoMark,
} = callbacks;
let rafId: number | null = null;
function tick() {
rafId = null;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
if (!imgs.length) return;
const containerTop = containerEl.getBoundingClientRect().top;
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
const containerRect = containerEl.getBoundingClientRect();
const readLineY = containerRect.top + containerEl.clientHeight * READ_LINE_PCT;
// Find the last image whose top is at or above the read line.
// Binary search is still valid here since images are ordered top-to-bottom.
let lo = 0, hi = imgs.length - 1, best = 0;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
if (isPageAtReadLine(imgs[mid], readLineY)) { best = mid; lo = mid + 1; }
else hi = mid - 1;
}
@@ -45,10 +76,19 @@ export function setupScrollTracking(
onPageChange(activePage);
if (activeChId) onChapterChange(activeChId);
const chunks = getStripChapters();
let flatOffset = 0;
for (const chunk of chunks) {
if (chunk.chapterId === activeChId) {
onCenterIdxChange(flatOffset + activePage - 1);
break;
}
flatOffset += chunk.urls.length;
}
if (shouldAutoMark() && activeChId) {
const chunks = getStripChapters();
const chunk = chunks.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : getPageUrls().length;
const chunk = chunks.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : getPageUrls().length;
if (total > 0 && activePage >= total) onMarkRead(activeChId);
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
@@ -58,8 +98,9 @@ export function setupScrollTracking(
}
}
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.80) onAppend();
if ((containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight >= 0.80) {
onAppend();
}
}
function onScroll() {
+12 -5
View File
@@ -9,7 +9,8 @@
import { get } from 'svelte/store'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import { resolvedCover } from '$lib/core/cover/coverResolver'
import type { MangaPrefs } from '$lib/types/settings'
import type { Manga, Chapter, Category } from '$lib/types'
import { seriesState } from '$lib/state/series.svelte'
import { setPreviewManga } from '$lib/state/series.svelte'
@@ -59,6 +60,7 @@
let manageOpen: boolean = $state(false)
let genresExpanded: boolean = $state(false)
let descExpanded: boolean = $state(false)
let altOpen: boolean = $state(false)
const statusLabel = $derived(
@@ -93,7 +95,7 @@
<div class="cover-wrap">
<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>
</div>
@@ -104,7 +106,7 @@
</div>
{:else}
<div class="meta">
<p class="title">{manga?.title}</p>
<button class="title" onclick={() => manga && setPreviewManga(manga)} disabled={!manga}>{manga?.title}</button>
{#if manga?.author || manga?.artist}
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' · ')}</p>
@@ -148,8 +150,8 @@
{#if manga?.description}
<div class="desc-wrap">
<p class="desc">{manga.description}</p>
<button class="expand-toggle" onclick={() => genresExpanded = !genresExpanded}>Read more</button>
<p class="desc" class:desc-open={descExpanded}>{manga.description}</p>
<button class="expand-toggle" onclick={() => descExpanded = !descExpanded}>{descExpanded ? 'Show less' : 'Read more'}</button>
</div>
{/if}
</div>
@@ -277,7 +279,11 @@
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-primary); line-height: var(--leading-snug);
letter-spacing: var(--tracking-tight);
background: none; border: none; padding: 0; text-align: left; cursor: pointer;
transition: color var(--t-base);
}
.title:hover:not(:disabled) { color: var(--accent-fg); }
.title:disabled { cursor: default; }
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
@@ -328,6 +334,7 @@
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
.expand-toggle {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); align-self: flex-start; transition: color var(--t-base);
+3 -8
View File
@@ -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);
}
@@ -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 @@
})
</script>
<ModalBlur />
<div class="s-backdrop" role="presentation" tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) close() }}
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
@@ -102,9 +102,10 @@
}
function triggerSplash() {
if (appState.devSplash) return
splashTriggered = true
setTimeout(() => splashTriggered = false, 200)
appState.idleSplash = true
appState.devSplash = true
}
async function testWindowsHello() {
@@ -20,6 +20,7 @@
} from "$lib/state/series.svelte";
import { app } from "$lib/state/app.svelte";
import type { Manga, Chapter, Category } from "$lib/types";
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
let manga: Manga | null = $state(null);
@@ -258,7 +259,7 @@
function openSeriesDetail() {
if (!displayManga) return;
setActiveManga(displayManga);
setNavPage(originNavPage);
app.setNavPage(originNavPage);
close();
}
@@ -353,6 +354,7 @@
</script>
{#if seriesState.previewManga}
<ModalBlur blur={4} dim={0.72} />
<div
class="backdrop"
role="button"
@@ -679,10 +681,8 @@
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.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 type { Tracker, TrackRecord, TrackSearch } from "$lib/types";
import type { Chapter } from "$lib/types";
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
let { mangaId, mangaTitle, onClose }: {
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="modal" role="dialog" aria-label="Tracking">
@@ -497,6 +499,7 @@
{#if confirmUnbindId !== null}
{@const rec = records.find(r => r.id === confirmUnbindId)}
{@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"
onclick={() => confirmUnbindId = null}
onkeydown={(e) => { if (e.key === "Escape") confirmUnbindId = null; }}>
@@ -515,10 +518,9 @@
<style>
.backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.68);
position: fixed; inset: 0;
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.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-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-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); }
+11 -9
View File
@@ -5,9 +5,9 @@ import { getUIAccessToken } from "$lib/core/auth";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6;
let active = 0;
let active = 0;
let drainScheduled = false;
let clearing = false;
let generation = 0;
interface QueueEntry {
url: string;
@@ -32,10 +32,11 @@ async function getAuthHeaders(): Promise<Record<string, string>> {
return {};
}
async function doFetch(url: string): Promise<string> {
async function doFetch(url: string, gen: number): Promise<string> {
const headers = await getAuthHeaders();
const blob = await platformService.fetchImage(url, headers);
if (clearing) throw new DOMException("Cancelled", "AbortError");
if (gen !== generation) 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);
cache.set(url, blobUrl);
return blobUrl;
@@ -55,8 +56,9 @@ function drain() {
drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!;
const gen = generation;
active++;
doFetch(entry.url)
doFetch(entry.url, gen)
.then(entry.resolve, entry.reject)
.finally(() => { active--; drain(); });
}
@@ -107,6 +109,7 @@ export function preloadBlobUrls(urls: string[], basePriority = 0): void {
export function revokeBlobUrl(url: string): void {
const blob = cache.get(url);
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
inflight.delete(url);
}
export function deprioritizeQueue(): void {
@@ -123,10 +126,9 @@ export function cancelQueuedFetches(): void {
}
export function clearBlobCache(): void {
clearing = true;
generation++;
cancelQueuedFetches();
inflight.clear();
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
inflight.clear();
clearing = false;
}
+5 -2
View File
@@ -1,5 +1,5 @@
import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
import { settingsState } from "$lib/state/settings.svelte";
import { getBlobUrl, preloadBlobUrls, revokeBlobUrl } from "$lib/core/cache/imageCache";
import { settingsState } from "$lib/state/settings.svelte";
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
@@ -90,6 +90,9 @@ export function preloadImage(url: string, useBlob: boolean): void {
}
export function clearResolvedUrlCache(): void {
for (const promise of resolvedUrlCache.values()) {
promise.then(blobUrl => { if (blobUrl) revokeBlobUrl(blobUrl); }).catch(() => {});
}
resolvedUrlCache.clear();
aspectCache.clear();
}
+31 -17
View File
@@ -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<void> {
if (!platformService.isSupported('discord-rpc')) return
sessionStart = Date.now()
@@ -36,18 +59,9 @@ export async function destroyRpc(): Promise<void> {
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
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<void> {
+1
View File
@@ -36,6 +36,7 @@ export const appState = $state({
toasts: [] as unknown[],
appDir: '',
idleSplash: false,
devSplash: false,
})
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
+17 -9
View File
@@ -4,8 +4,8 @@ import type { Category } from "$lib/types";
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
export type LibrarySortOption =
| "alphabetical"
| "unread"
| "az"
| "unreadCount"
| "lastRead"
| "dateAdded"
| "totalChapters"
@@ -153,11 +153,11 @@ class LibraryState {
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
const { mode, dir } = this.tabSort[tab] ?? { mode: "alphabetical" as LibrarySortOption, dir: "asc" as LibrarySortDir };
const { mode, dir } = this.tabSort[tab] ?? { mode: "az" as LibrarySortOption, dir: "asc" as LibrarySortDir };
const sorted = [...items].sort((a, b) => {
switch (mode) {
case "unread": return (b.unreadCount ?? 0) - (a.unreadCount ?? 0);
case "unreadCount": return (b.unreadCount ?? 0) - (a.unreadCount ?? 0);
case "lastRead": return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0);
case "dateAdded": return (b.addedAt ?? 0) - (a.addedAt ?? 0);
case "totalChapters": return (b.chapters?.totalCount ?? 0) - (a.chapters?.totalCount ?? 0);
@@ -185,7 +185,7 @@ class LibraryState {
toggleTabSortDir(tab: string) {
const prev = this.tabSort[tab];
const mode = prev?.mode ?? "alphabetical";
const mode = prev?.mode ?? "az";
const dir = prev?.dir === "asc" ? "desc" : "asc";
this.setTabSort(tab, mode, dir);
}
@@ -204,10 +204,18 @@ class LibraryState {
this.tabFilters = { ...this.tabFilters, [tab]: {} };
}
syncFromSettings(s: { hiddenLibraryTabs?: string[]; libraryPinnedTabOrder?: string[]; defaultLibraryCategoryId?: number | null }) {
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;
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[]) {
+2 -1
View File
@@ -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() {
+50 -18
View File
@@ -34,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<string | null>(null)
let splashDismissed = $state(false)
let themeEditorOpen = $state(false)
let themeEditorId = $state<string | null>(null)
const splashVisible = $derived(
!_splashDismissed ||
!splashDismissed ||
appState.status === 'booting' ||
appState.status === 'locked' ||
appState.status === 'error' ||
@@ -48,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)
@@ -141,10 +139,40 @@
})
$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() {
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
@@ -174,7 +202,11 @@
{/if}
{#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 showApp}