Fix: Improved Loading (WIP)

This commit is contained in:
Youwes09
2026-03-31 11:28:00 -05:00
parent 4959722759
commit a69d5eacc5
2 changed files with 33 additions and 170 deletions
+2 -23
View File
@@ -77,33 +77,19 @@
let devSplash = $state(false);
let platformScale = $state(1.0);
// Track last applied zoom so we only touch the DOM when the value actually changes.
let _appliedZoom = -1;
let _vhRafId: number | null = null;
function applyZoom() {
const uiZoom = store.settings.uiZoom ?? 1.0;
// Only touch the DOM when the zoom value has genuinely changed.
if (uiZoom === _appliedZoom) return;
_appliedZoom = uiZoom;
const pct = uiZoom * 100;
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
document.documentElement.style.zoom = `${pct}%`;
// Only scale the non-reader shell. The reader mounts as a fixed overlay
// and manages its own zoom — applying document-level zoom to it would
// double-scale manga images.
const shell = document.getElementById("app-shell");
if (shell) {
(shell as HTMLElement).style.zoom = `${pct}%`;
} else {
document.documentElement.style.zoom = `${pct}%`;
}
// Defer --visual-vh until after the browser has re-laid-out at the new
// zoom level so we read a stable innerHeight, not a mid-transition value.
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
_vhRafId = requestAnimationFrame(() => {
_vhRafId = null;
@@ -154,9 +140,6 @@
});
$effect(() => {
// Re-run only when uiZoom actually changes. platformScale is handled
// directly inside onScaleChanged so it doesn't trigger spurious re-runs
// of this effect on unrelated reactive flushes.
void store.settings.uiZoom;
applyZoom();
});
@@ -245,9 +228,6 @@
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true;
// We read the scale factor so onScaleChanged can re-trigger applyZoom when
// the window moves to a different-DPI monitor, but we do NOT fold it into
// the zoom math — Tauri's WebView already accounts for DPI scaling.
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1.0);
applyZoom();
@@ -305,7 +285,6 @@
}
});
// When the reader closes, show idle presence.
$effect(() => {
if (!store.activeChapter) {
if (store.settings.discordRpc) setIdle();
@@ -379,4 +358,4 @@
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.content { flex: 1; overflow: hidden; }
</style>
</style>
+31 -147
View File
@@ -1,3 +1,4 @@
<!-- Reader.svelte -->
<script lang="ts">
import { onMount, untrack, tick } from "svelte";
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus, Bookmark } from "phosphor-svelte";
@@ -9,40 +10,23 @@
import type { FitMode } from "../../store/state.svelte";
const AVG_MIN_PER_PAGE = 0.33;
const MAX_CACHED = 10;
const READ_LINE_PCT = 0.20;
const ZOOM_STEP = 0.05;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 4.0;
const ZOOM_MAX = 1.0;
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
const cacheOrder: number[] = [];
function cacheTouch(id: number) {
const i = cacheOrder.indexOf(id);
if (i !== -1) cacheOrder.splice(i, 1);
cacheOrder.push(id);
}
function cacheClearExcept(keepId: number) {
for (const id of pageCache.keys()) {
if (id !== keepId) pageCache.delete(id);
}
cacheOrder.length = 0;
if (pageCache.has(keepId)) cacheOrder.push(keepId);
}
function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]> {
const cached = pageCache.get(chapterId);
if (cached) { cacheTouch(chapterId); return Promise.resolve(cached); }
if (cached) return Promise.resolve(cached);
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) {
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => {
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.set(chapterId, urls);
cacheTouch(chapterId);
return urls;
})
.finally(() => inflight.delete(chapterId));
@@ -59,15 +43,6 @@
const aspectCache = new Map<string, number>();
function preloadImage(url: string) { new Image().src = url; }
function decodeImage(url: string): Promise<void> {
return new Promise(resolve => {
const img = new Image();
img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
img.onerror = () => resolve();
img.src = url;
});
}
function measureAspect(url: string): Promise<number> {
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
return new Promise(res => {
@@ -85,9 +60,7 @@
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
let containerEl: HTMLDivElement;
let containerWidth = $state(0);
let zoomAnchorEl: HTMLElement | null = null;
let zoomAnchorOffset: number = 0;
@@ -128,13 +101,13 @@
let nextN = $state(5);
let dlBusy = $state(false);
let hideTimer: ReturnType<typeof setTimeout> | null = null;
let markedRead = new Set<number>();
let appending = false;
let abortCtrl: AbortController | null = null;
let loadingId: number | null = null;
let navToken = 0;
let hasNavigated = false;
let resumePage = $state(0);
let resumeDismissed = $state(false);
let stripResumeReady = $state(false);
const rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
@@ -144,30 +117,11 @@
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
const overlayBars = $derived(store.settings.overlayBars ?? false);
const lastPage = $derived(store.pageUrls.length);
const effectiveWidth = $derived(
containerWidth > 0 ? Math.round(containerWidth * zoom) : undefined
);
const effectiveWidth = $derived(containerWidth > 0 ? Math.round(containerWidth * zoom) : undefined);
const zoomPct = $derived(Math.round(zoom * 100));
let resumePage = $state(0);
let resumeDismissed = $state(false);
let stripResumeReady = $state(false);
const showResumeBanner = $derived(
resumePage > 1 && !resumeDismissed &&
(style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage)
);
const displayChapter = $derived(
style === "longstrip" && visibleChapterId
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
: store.activeChapter
);
const currentBookmark = $derived(
displayChapter ? store.bookmarks.find(b => b.chapterId === displayChapter!.id) : undefined
);
const showResumeBanner = $derived(resumePage > 1 && !resumeDismissed && (style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage));
const displayChapter = $derived(style === "longstrip" && visibleChapterId ? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter) : store.activeChapter);
const currentBookmark = $derived(displayChapter ? store.bookmarks.find(b => b.chapterId === displayChapter!.id) : undefined);
const isBookmarked = $derived(!!currentBookmark);
$effect(() => {
@@ -206,20 +160,8 @@
].filter(Boolean).join(" "));
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
const stripToRender = $derived(
style === "longstrip"
? (stripChapters.length > 0
? stripChapters
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
: []
);
const currentGroup = $derived(
style === "double" && pageGroups.length
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
: [store.pageNumber]
);
const stripToRender = $derived(style === "longstrip" ? (stripChapters.length > 0 ? stripChapters : [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }]) : []);
const currentGroup = $derived(style === "double" && pageGroups.length ? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber]) : [store.pageNumber]);
$effect(() => {
const ch = store.activeChapter;
@@ -230,11 +172,9 @@
abortCtrl?.abort();
const ctrl = new AbortController();
abortCtrl = ctrl;
loadingId = id;
navToken++;
hasNavigated = false;
appending = false;
markedRead = new Set();
hasNavigated = false;
loading = true;
error = null;
pageGroups = [];
@@ -243,8 +183,6 @@
visibleChapterId = null;
store.pageUrls = [];
cacheClearExcept(id);
const bookmark = store.bookmarks.find(b => b.chapterId === id);
const resumeTo = bookmark ? bookmark.pageNumber : 0;
resumePage = resumeTo > 1 ? resumeTo : 0;
@@ -279,14 +217,9 @@
if (targetPg > 1) {
const chId = ch.id;
const scrollToResumePage = () => {
const target = containerEl.querySelector<HTMLImageElement>(
`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`
);
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"; });
containerEl.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`).forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
const doScroll = () => {
target.scrollIntoView({ block: "start" });
stripResumeReady = true;
@@ -304,19 +237,16 @@
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
// When scrolling into an appended chapter in longstrip, check if it has a bookmark
// and show the resume banner so the user can jump to their saved page.
$effect(() => {
const chId = visibleChapterId;
if (!chId || style !== "longstrip") return;
// Only fire for chapters that weren't the initial load (activeChapter handles its own resume).
if (chId === store.activeChapter?.id) return;
const bookmark = store.bookmarks.find(b => b.chapterId === chId);
if (bookmark && bookmark.pageNumber > 1) {
untrack(() => {
resumePage = bookmark.pageNumber;
resumeDismissed = false;
stripResumeReady = true; // banner shows immediately on chapter entry; no scroll needed yet
stripResumeReady = true;
});
} else {
untrack(() => { resumePage = 0; resumeDismissed = false; stripResumeReady = false; });
@@ -457,7 +387,7 @@
$effect(() => {
const ahead = store.settings.preloadPages ?? 3;
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) decodeImage(url); }
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) preloadImage(url); }
const behind = store.pageUrls[store.pageNumber - 2];
if (behind) preloadImage(behind);
});
@@ -533,11 +463,7 @@
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
if (!store.pageUrls.length) return;
if (store.pageNumber < lastPage) {
const target = store.pageNumber + 1;
const token = ++navToken;
decodeImage(store.pageUrls[target - 1]).then(() => {
if (navToken === token && store.pageNumber === target - 1) store.pageNumber = target;
});
store.pageNumber = store.pageNumber + 1;
} else if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
else closeReader();
}
@@ -548,11 +474,7 @@
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
if (!store.pageUrls.length) return;
if (store.pageNumber > 1) {
const target = store.pageNumber - 1;
const token = ++navToken;
decodeImage(store.pageUrls[target - 1]).then(() => {
if (navToken === token && store.pageNumber === target + 1) store.pageNumber = target;
});
store.pageNumber = store.pageNumber - 1;
} else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
}
@@ -638,13 +560,15 @@
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); store.pageNumber = lastPage; }
else if (matchesKeybind(e, kb.chapterRight)) {
e.preventDefault();
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
const list = store.activeChapterList;
const idx = list.findIndex(c => c.id === store.activeChapter?.id);
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
}
else if (matchesKeybind(e, kb.chapterLeft)) {
e.preventDefault();
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
const list = store.activeChapterList;
const idx = list.findIndex(c => c.id === store.activeChapter?.id);
const prev = idx > 0 ? list[idx - 1] : null;
if (prev) openReader(prev, list);
}
@@ -730,15 +654,9 @@
{#if zoomOpen}
<div class="zoom-popover">
<div class="zoom-slider-row">
<input type="range" class="zoom-slider" min={10} max={400} step={5} value={zoomPct}
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
oninput={(e) => { captureZoomAnchor(); updateSettings({ readerZoom: clampZoom(Number(e.currentTarget.value) / 100) }); restoreZoomAnchor(); }} />
</div>
<div class="zoom-presets">
{#each [50, 75, 100, 125, 150, 200] as pct}
<button class="zoom-preset" class:active={zoomPct === pct}
onclick={() => { captureZoomAnchor(); updateSettings({ readerZoom: pct / 100 }); restoreZoomAnchor(); }}>{pct}%</button>
{/each}
</div>
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
</div>
{/if}
@@ -795,9 +713,7 @@
const chId = visibleChapterId!;
const targetPg = resumePage;
const scrollToPage = () => {
const target = containerEl.querySelector<HTMLImageElement>(
`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`
);
const target = containerEl.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
if (!target) { requestAnimationFrame(scrollToPage); return; }
target.scrollIntoView({ block: "start", behavior: "smooth" });
};
@@ -825,7 +741,7 @@
data-chapter={chunk.chapterId}
data-total={chunk.urls.length}
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
loading={i < 3 ? "eager" : "lazy"}
loading={i < 5 ? "eager" : "lazy"}
decoding="async"
/>
{/each}
@@ -886,9 +802,7 @@
</div>
<style>
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform;
zoom: calc(1 / var(--ui-zoom, 1));
}
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
.overlay-bars { position: fixed; }
.overlay-bars .topbar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
.overlay-bars .bottombar { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
@@ -908,7 +822,6 @@
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
.mode-label { text-transform: capitalize; }
.zoom-wrap { position: relative; flex-shrink: 0; }
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
@@ -920,25 +833,18 @@
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
.zoom-presets { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; }
.zoom-preset { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); padding: 3px 6px; border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
.zoom-preset:hover { color: var(--text-primary); background: var(--bg-overlay); }
.zoom-preset.active { color: var(--accent-fg); background: var(--accent-muted); }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; }
.img { display: block; user-select: none; image-rendering: auto; }
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }
.fit-width { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
.fit-height { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
.fit-screen { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
.fit-original { max-width: none; width: auto; height: auto; }
.strip-gap { margin-bottom: 8px; }
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
.page-half { flex: 1; min-width: 0; object-fit: contain; }
@@ -963,32 +869,10 @@
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
.resume-banner {
position: absolute; top: var(--sp-3); left: 50%; translate: -50% 0;
display: flex; align-items: center; gap: var(--sp-2);
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-lg); padding: 6px var(--sp-3);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-secondary); z-index: 20;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
animation: scaleIn 0.15s ease both;
white-space: nowrap;
}
.resume-dismiss {
display: flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 50%;
font-size: 9px; color: var(--text-faint);
transition: color var(--t-fast), background var(--t-fast);
}
.resume-banner { position: absolute; top: var(--sp-3); left: 50%; translate: -50% 0; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: 6px var(--sp-3); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); z-index: 20; box-shadow: 0 4px 16px rgba(0,0,0,0.4); animation: scaleIn 0.15s ease both; white-space: nowrap; }
.resume-dismiss { display: flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; font-size: 9px; color: var(--text-faint); transition: color var(--t-fast), background var(--t-fast); }
.resume-dismiss:hover { color: var(--text-primary); background: var(--bg-overlay); }
.resume-jump {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--accent-fg); background: var(--accent-muted);
border: 1px solid var(--accent-dim); border-radius: var(--radius-sm);
padding: 2px 8px; cursor: pointer;
transition: filter var(--t-fast);
}
.resume-jump { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 2px 8px; cursor: pointer; transition: filter var(--t-fast); }
.resume-jump:hover { filter: brightness(1.15); }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
</style>