diff --git a/src/core/cache/imageCache.ts b/src/core/cache/imageCache.ts index 82a034a..a019bc5 100644 --- a/src/core/cache/imageCache.ts +++ b/src/core/cache/imageCache.ts @@ -112,7 +112,17 @@ export function deprioritizeQueue(): void { queue.sort((a, b) => b.priority - a.priority); } +export function cancelQueuedFetches(): void { + const dropped = queue.splice(0); + for (const entry of dropped) { + inflight.delete(entry.url); + entry.reject(new DOMException("Cancelled", "AbortError")); + } +} + export function clearBlobCache(): void { + cancelQueuedFetches(); cache.forEach(blob => URL.revokeObjectURL(blob)); cache.clear(); + inflight.clear(); } \ No newline at end of file diff --git a/src/core/cache/pageCache.ts b/src/core/cache/pageCache.ts index be7d9f8..4647849 100644 --- a/src/core/cache/pageCache.ts +++ b/src/core/cache/pageCache.ts @@ -1,12 +1,11 @@ import { gql, getServerUrl } from "@api/client"; -import { getBlobUrl } from "@core/cache/imageCache"; -import { dedupeRequest } from "@core/async/batchRequests"; -import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters"; +import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache"; +import { dedupeRequest } from "@core/async/batchRequests"; +import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters"; const pageCache = new Map(); const inflight = new Map>(); const resolvedUrlCache = new Map>(); -const preloadedUrls = new Set(); const aspectCache = new Map(); export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise { @@ -63,11 +62,18 @@ export function measureAspect(url: string, useBlob: boolean): Promise { } export function preloadImage(url: string, useBlob: boolean): void { - if (preloadedUrls.has(url)) return; - preloadedUrls.add(url); + if (useBlob) { + preloadBlobUrls([url], 0); + return; + } resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); } +export function clearResolvedUrlCache(): void { + resolvedUrlCache.clear(); + aspectCache.clear(); +} + export function clearPageCache(chapterId?: number): void { if (chapterId !== undefined) { pageCache.delete(chapterId); @@ -76,7 +82,6 @@ export function clearPageCache(chapterId?: number): void { pageCache.clear(); inflight.clear(); resolvedUrlCache.clear(); - preloadedUrls.clear(); aspectCache.clear(); } } \ No newline at end of file diff --git a/src/features/reader/components/PageView.svelte b/src/features/reader/components/PageView.svelte index 85443bb..e8acb0c 100644 --- a/src/features/reader/components/PageView.svelte +++ b/src/features/reader/components/PageView.svelte @@ -19,6 +19,7 @@ fadingOut: boolean; tapToToggleBar: boolean; pinchZoomEnabled: boolean; + chapterEpoch: number; onGetZoom: () => number; onSetZoom: (z: number) => void; resolveUrl: (url: string, priority?: number) => Promise; @@ -31,10 +32,152 @@ const { style, imgCls, effectiveWidth, loading, error, pageReady, pageGroups, currentGroup, stripToRender, fadingOut, - tapToToggleBar, pinchZoomEnabled, onGetZoom, onSetZoom, + tapToToggleBar, pinchZoomEnabled, chapterEpoch, onGetZoom, onSetZoom, resolveUrl, onTap, onWheel, onToggleUi, bindContainer, }: Props = $props(); + const LOAD_RADIUS = 5; + const UNLOAD_RADIUS = 10; + + type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number }; + + const flatPages = $derived.by(() => { + const out: FlatPage[] = []; + for (const chunk of stripToRender) { + for (let i = 0; i < chunk.urls.length; i++) { + out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length }); + } + } + return out; + }); + + let loadedSet = $state(new Set()); + let resolvedSrc = $state>({}); + let revokeQueue: string[] = []; + + let observer: IntersectionObserver | null = null; + const elementIndex = new Map(); + + let viewportCenter = $state(0); + + function scheduleRevoke(src: string) { + if (!src || !src.startsWith("blob:")) return; + revokeQueue.push(src); + requestAnimationFrame(() => { + const url = revokeQueue.shift(); + if (url) { + try { URL.revokeObjectURL(url); } catch { } + } + }); + } + + function loadPage(idx: number) { + if (loadedSet.has(idx)) return; + const page = flatPages[idx]; + if (!page) return; + const newSet = new Set(loadedSet); + newSet.add(idx); + loadedSet = newSet; + const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0; + resolveUrl(page.url, priority).then(src => { + if (loadedSet.has(idx)) { + resolvedSrc = { ...resolvedSrc, [idx]: src }; + } else { + scheduleRevoke(src); + } + }); + } + + function unloadPage(idx: number) { + if (!loadedSet.has(idx)) return; + const newSet = new Set(loadedSet); + newSet.delete(idx); + loadedSet = newSet; + const oldSrc = resolvedSrc[idx]; + if (oldSrc) { + const next = { ...resolvedSrc }; + delete next[idx]; + resolvedSrc = next; + scheduleRevoke(oldSrc); + } + } + + function recalcWindow() { + const center = viewportCenter; + 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); + } + } + } + + $effect(() => { + void viewportCenter; + recalcWindow(); + }); + + $effect(() => { + void flatPages.length; + recalcWindow(); + }); + + 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], + } + ); + } + + 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); + } + }; + } + + $effect(() => { + void chapterEpoch; + loadedSet = new Set(); + resolvedSrc = {}; + const resume = readerState.resumePage; + viewportCenter = resume > 1 ? resume - 1 : 0; + }); + const INSPECT_ZOOM_STEP = 0.15; const INSPECT_ZOOM_MAX = 8; @@ -194,7 +337,17 @@ 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; + } + });

{error}

{/if} - {#if style === "longstrip"} - {#each stripToRender as chunk} - {#each chunk.urls as url, i} - {#if i < 8} - {#await resolveUrl(url, 8 - i)} - {chunk.chapterName} – Page {i + 1} - {:then src} - {chunk.chapterName} – Page {i + 1} - {/await} - {:else} - {#await resolveUrl(url, 0)} - {chunk.chapterName} – Page {i + 1} - {:then src} - {chunk.chapterName} – Page {i + 1} - {/await} - {/if} - {/each} - {/each} -
- - {:else if style === "fade" && pageReady} -
- {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} - Page {store.pageNumber} - {:then src} - Page {store.pageNumber} - {/await} -
- - {:else if style === "double" && pageReady} -
- {#if pageGroups.length} -
- {#each currentGroup as pg, i} - {#await resolveUrl(store.pageUrls[pg - 1], 999)} - Page {pg} - {:then src} - Page {pg} - {/await} - {/each} + {#key chapterEpoch} + {#if style === "longstrip"} + {#each flatPages as page, gi} + {@const src = resolvedSrc[gi]} + {@const isLoaded = loadedSet.has(gi)} +
+ {#if isLoaded} + {page.chapterName} – Page {page.localIndex + 1} { + const img = e.currentTarget as HTMLImageElement; + const slot = img.closest(".strip-slot"); + if (slot && img.naturalWidth > 0) { + slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight)); + } + }} + /> + {:else} +
+ {/if}
- {:else} -
- {/if} -
+ {/each} +
- {:else if pageReady} -
- {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} - Page {store.pageNumber} - {:then src} - Page {store.pageNumber} - {/await} -
- {/if} + {:else if style === "fade" && pageReady} +
+ {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} + Page {store.pageNumber} + {:then src} + Page {store.pageNumber} + {/await} +
+ + {:else if style === "double" && pageReady} +
+ {#if pageGroups.length} +
+ {#each currentGroup as pg, i} + {#await resolveUrl(store.pageUrls[pg - 1], 999)} + Page {pg} + {:then src} + Page {pg} + {/await} + {/each} +
+ {:else} +
+ {/if} +
+ + {:else if pageReady} +
+ {#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)} + Page {store.pageNumber} + {:then src} + Page {store.pageNumber} + {/await} +
+ {/if} + {/key}
@@ -290,6 +459,20 @@ .inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; } + .strip-slot { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + } + + .strip-placeholder { + width: var(--effective-width, 100%); + max-width: var(--effective-width, 100%); + aspect-ratio: var(--aspect, 0.667); + background: transparent; + } + .img { display: block; user-select: none; image-rendering: auto; } .img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; } :global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; } diff --git a/src/features/reader/components/Reader.svelte b/src/features/reader/components/Reader.svelte index 2576a2b..945f0fd 100644 --- a/src/features/reader/components/Reader.svelte +++ b/src/features/reader/components/Reader.svelte @@ -420,23 +420,28 @@ $effect(() => { const ahead = store.settings.preloadPages ?? 3; const current = store.pageUrls[store.pageNumber - 1]; + const pageNum = store.pageNumber; + const urls = store.pageUrls; if (!current) return; - if (useBlob) { - import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => { - getBlobUrl(current, 999); - const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[]; - const behind = store.pageUrls[store.pageNumber - 2]; - preloadBlobUrls(upcoming, ahead); - if (behind) preloadBlobUrls([behind], 0); - }); - } else { - for (let i = 1; i <= ahead; i++) { - const url = store.pageUrls[store.pageNumber - 1 + i]; - if (url) preloadImage(url, useBlob); + const t = setTimeout(() => { + if (useBlob) { + import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => { + getBlobUrl(current, 999); + const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[]; + const behind = urls[pageNum - 2]; + preloadBlobUrls(upcoming, ahead); + if (behind) preloadBlobUrls([behind], 0); + }); + } else { + for (let i = 1; i <= ahead; i++) { + const url = urls[pageNum - 1 + i]; + if (url) preloadImage(url, useBlob); + } + const behind = urls[pageNum - 2]; + if (behind) preloadImage(behind, useBlob); } - const behind = store.pageUrls[store.pageNumber - 2]; - if (behind) preloadImage(behind, useBlob); - } + }, 150); + return () => clearTimeout(t); }); $effect(() => { diff --git a/src/features/reader/lib/chapterLoader.ts b/src/features/reader/lib/chapterLoader.ts index 885487e..5793803 100644 --- a/src/features/reader/lib/chapterLoader.ts +++ b/src/features/reader/lib/chapterLoader.ts @@ -1,7 +1,9 @@ -import { store, openReader } from "@store/state.svelte"; -import { readerState } from "../store/readerState.svelte"; -import { fetchPages } from "./pageLoader"; -import { trackingState } from "@features/tracking/store/trackingState.svelte"; +import { store } from "@store/state.svelte"; +import { readerState } from "../store/readerState.svelte"; +import { fetchPages } from "./pageLoader"; +import { trackingState } from "@features/tracking/store/trackingState.svelte"; +import { cancelQueuedFetches } from "@core/cache/imageCache"; +import { clearResolvedUrlCache } from "@core/cache/pageCache"; export function scheduleResumeDismiss() { setTimeout(() => { readerState.resumeFading = true; }, 1500); @@ -19,6 +21,10 @@ export async function loadChapter( abortCtrl.current?.abort(); const ctrl = new AbortController(); abortCtrl.current = ctrl; + + cancelQueuedFetches(); + if (useBlob) clearResolvedUrlCache(); + startAtLastPage.current = false; markedRead.clear(); readerState.resetForChapter(); @@ -43,7 +49,7 @@ export async function loadChapter( else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo); readerState.pageReady = true; readerState.loading = false; - if (adjacent.next) fetchPages(adjacent.next.id, useBlob).catch(() => {}); + if (adjacent.next) fetchPages(adjacent.next.id, useBlob, ctrl.signal).catch(() => {}); } catch (e: any) { if (ctrl.signal.aborted) return; readerState.error = e instanceof Error ? e.message : String(e); diff --git a/src/features/reader/lib/scrollHandler.ts b/src/features/reader/lib/scrollHandler.ts index a438cf7..0c08441 100644 --- a/src/features/reader/lib/scrollHandler.ts +++ b/src/features/reader/lib/scrollHandler.ts @@ -25,57 +25,58 @@ export function setupScrollTracking( onAppend, getStripChapters, getPageUrls, shouldAutoMark, } = callbacks; - function onScroll() { + let rafId: number | null = null; + + function tick() { + rafId = null; + const imgs = containerEl.querySelectorAll("img[data-local-page]"); if (!imgs.length) return; const containerTop = containerEl.getBoundingClientRect().top; const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT; - let activePage: number | null = null; - let activeChId: number | null = null; - - for (const img of imgs) { - if (img.getBoundingClientRect().top <= readLineY) { - activePage = Number(img.dataset.localPage); - activeChId = Number(img.dataset.chapter); - } else break; + 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; } + else hi = mid - 1; } - if (activePage === null) { - activePage = Number(imgs[0].dataset.localPage); - activeChId = Number(imgs[0].dataset.chapter); - } + const active = imgs[best]; + const activePage = Number(active.dataset.localPage); + const activeChId = Number(active.dataset.chapter); - if (activePage !== null) onPageChange(activePage); - if (activeChId) onChapterChange(activeChId); + onPageChange(activePage); + if (activeChId) onChapterChange(activeChId); - if (shouldAutoMark() && activePage !== null && activeChId) { + if (shouldAutoMark() && activeChId) { const chunks = getStripChapters(); 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; + if (atBottom) { + const last = chunks[chunks.length - 1]; + if (last) onMarkRead(last.chapterId); + } } - const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40; - if (atBottom && shouldAutoMark()) { - const chunks = getStripChapters(); - const last = chunks[chunks.length - 1]; - if (last) onMarkRead(last.chapterId); - } - } - - function onScrollAppend() { const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight; if (pct >= 0.80) onAppend(); } - containerEl.addEventListener("scroll", onScroll, { passive: true }); - containerEl.addEventListener("scroll", onScrollAppend, { passive: true }); + function onScroll() { + if (rafId !== null) return; + rafId = requestAnimationFrame(tick); + } + + containerEl.addEventListener("scroll", onScroll, { passive: true }); return () => { containerEl.removeEventListener("scroll", onScroll); - containerEl.removeEventListener("scroll", onScrollAppend); + if (rafId !== null) cancelAnimationFrame(rafId); }; } @@ -107,4 +108,4 @@ export function appendNextChapter( onDone(); }) .catch(() => onDone()); -} +} \ No newline at end of file