Fix: Reader Rewrite

This commit is contained in:
Youwes09
2026-03-24 23:52:39 -05:00
parent fc535f3f74
commit 2163f4a8a6
2 changed files with 276 additions and 213 deletions
+1 -1
View File
@@ -209,7 +209,7 @@
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []); const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]); const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []); const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []);
const recentHistory = $derived(store.history.slice(0, 8)); const recentHistory = $derived(store.history.slice(0, 6));
const stats = $derived(store.readingStats); const stats = $derived(store.readingStats);
function handleRowWheel(e: WheelEvent) { function handleRowWheel(e: WheelEvent) {
+275 -212
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte"; import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
@@ -7,13 +7,17 @@
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds"; import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
import type { FitMode } from "../../store/state.svelte"; import type { FitMode } from "../../store/state.svelte";
/** Average reading time per page in minutes — used for read-time estimates. */ // ─── Constants ────────────────────────────────────────────────────────────────
const AVG_MIN_PER_PAGE = 0.33; // ~20 seconds/page → 5 min per 15-page chapter
const AVG_MIN_PER_PAGE = 0.33;
const MAX_CACHED = 10;
const READ_LINE_PCT = 0.20;
// ─── Page cache ───────────────────────────────────────────────────────────────
const pageCache = new Map<number, string[]>(); const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>(); const inflight = new Map<number, Promise<string[]>>();
const cacheOrder: number[] = []; const cacheOrder: number[] = [];
const MAX_CACHED = 10;
function cacheTouch(id: number) { function cacheTouch(id: number) {
const i = cacheOrder.indexOf(id); const i = cacheOrder.indexOf(id);
@@ -36,7 +40,12 @@
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) { if (!inflight.has(chapterId)) {
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { 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; }) .then(d => {
const urls = d.fetchChapterPages.pages.map(thumbUrl);
pageCache.set(chapterId, urls);
cacheTouch(chapterId);
return urls;
})
.finally(() => inflight.delete(chapterId)); .finally(() => inflight.delete(chapterId));
inflight.set(chapterId, p); inflight.set(chapterId, p);
} }
@@ -48,6 +57,8 @@
}); });
} }
// ─── Image helpers ────────────────────────────────────────────────────────────
const aspectCache = new Map<string, number>(); const aspectCache = new Map<string, number>();
function preloadImage(url: string) { new Image().src = url; } function preloadImage(url: string) { new Image().src = url; }
@@ -64,16 +75,25 @@
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
return new Promise(res => { return new Promise(res => {
const img = new Image(); const img = new Image();
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); }; img.onload = () => {
const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
aspectCache.set(url, r);
res(r);
};
img.onerror = () => res(0.67); img.onerror = () => res(0.67);
img.src = url; img.src = url;
}); });
} }
// ─── Types ────────────────────────────────────────────────────────────────────
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; } interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
// ─── DOM refs ─────────────────────────────────────────────────────────────────
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
let hideTimer: ReturnType<typeof setTimeout> | null = null;
// ─── UI state ─────────────────────────────────────────────────────────────────
let loading = $state(true); let loading = $state(true);
let error: string | null = $state(null); let error: string | null = $state(null);
@@ -86,11 +106,17 @@
let visibleChapterId: number | null = $state(null); let visibleChapterId: number | null = $state(null);
let nextN = $state(5); let nextN = $state(5);
let dlBusy = $state(false); let dlBusy = $state(false);
let markedRead = new Set<number>(); let hideTimer: ReturnType<typeof setTimeout> | null = null;
let appended = new Set<number>();
let appending = false; // ─── Non-reactive bookkeeping ─────────────────────────────────────────────────
let abortCtrl: AbortController | null = null;
let loadingId: number | null = null; let markedRead = new Set<number>();
let appending = false;
let abortCtrl: AbortController | null = null;
let loadingId: number | null = null;
let navToken = 0;
// ─── Derived ──────────────────────────────────────────────────────────────────
const rtl = $derived(store.settings.readingDirection === "rtl"); const rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode); const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
@@ -106,7 +132,7 @@
: store.activeChapter : store.activeChapter
); );
const adjacent = $derived((() => { const adjacent = $derived.by(() => {
const ref = displayChapter ?? store.activeChapter; const ref = displayChapter ?? store.activeChapter;
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] }; if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
const idx = store.activeChapterList.findIndex(c => c.id === ref.id); const idx = store.activeChapterList.findIndex(c => c.id === ref.id);
@@ -115,14 +141,14 @@
next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null, next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null,
remaining: store.activeChapterList.slice(idx + 1), remaining: store.activeChapterList.slice(idx + 1),
}; };
})()); });
const visibleChunkLastPage = $derived((() => { const visibleChunkLastPage = $derived.by(() => {
if (style !== "longstrip" || !autoNext) return lastPage; if (style !== "longstrip" || !autoNext) return lastPage;
const chId = visibleChapterId ?? store.activeChapter?.id; const chId = visibleChapterId ?? store.activeChapter?.id;
const chunk = stripChapters.find(c => c.chapterId === chId); const chunk = stripChapters.find(c => c.chapterId === chId);
return chunk?.urls.length ?? lastPage; return chunk?.urls.length ?? lastPage;
})()); });
const imgCls = $derived([ const imgCls = $derived([
"img", "img",
@@ -135,52 +161,21 @@
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]); const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
function markChapterRead(id: number) { const stripToRender = $derived(
if (markedRead.has(id)) return; style === "longstrip"
markedRead.add(id); ? (autoNext && stripChapters.length > 0
? stripChapters
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
: []
);
// Find the chapter to get its page count for a time estimate. const currentGroup = $derived(
const chapter = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter; style === "double" && pageGroups.length
const pages = chapter?.pageCount ?? store.pageUrls.length ?? 15; ? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE)); : [store.pageNumber]
);
// Record the completion in the read log with an accurate time estimate. // ─── Chapter loading ──────────────────────────────────────────────────────────
if (store.activeManga && chapter) {
addHistory(
{
mangaId: store.activeManga.id,
mangaTitle: store.activeManga.title,
thumbnailUrl: store.activeManga.thumbnailUrl,
chapterId: id,
chapterName: chapter.name,
pageNumber: pages,
readAt: Date.now(),
},
/* completed */ true,
minutes,
);
}
gql(MARK_CHAPTER_READ, { id, isRead: true })
.then(() => {
if (store.activeManga) {
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
checkAndMarkCompleted(store.activeManga.id, updated);
}
})
.catch(e => { markedRead.delete(id); console.error(e); });
}
function maybeMarkCurrentRead() {
const ch = store.activeChapter;
if (ch && markOnNext) markChapterRead(ch.id);
}
function showUi() {
uiVisible = true;
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => uiVisible = false, 3000);
}
$effect(() => { $effect(() => {
const ch = store.activeChapter; const ch = store.activeChapter;
@@ -192,7 +187,7 @@
const ctrl = new AbortController(); const ctrl = new AbortController();
abortCtrl = ctrl; abortCtrl = ctrl;
loadingId = id; loadingId = id;
appended = new Set([id]); navToken++;
appending = false; appending = false;
markedRead = new Set(); markedRead = new Set();
loading = true; loading = true;
@@ -208,11 +203,7 @@
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
store.pageUrls = urls; store.pageUrls = urls;
pageReady = true; pageReady = true;
if (style === "longstrip" && autoNext) { loading = false;
stripChapters = [{ chapterId: id, chapterName: store.activeChapter?.name ?? "", urls }];
visibleChapterId = id;
}
loading = false;
} catch (e: any) { } catch (e: any) {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
error = e instanceof Error ? e.message : String(e); error = e instanceof Error ? e.message : String(e);
@@ -220,6 +211,33 @@
} }
} }
// ─── Strip initialisation ─────────────────────────────────────────────────────
// Runs when a chapter finishes loading in longstrip mode.
// Starts the strip with just the current chapter; appendNextChapter adds more
// as the user scrolls. Nothing is ever removed from the DOM mid-read.
$effect(() => {
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
const ch = store.activeChapter;
const urls = store.pageUrls;
appending = false;
if (autoNext) {
stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
visibleChapterId = ch.id;
} else {
stripChapters = [];
visibleChapterId = null;
}
if (containerEl) containerEl.scrollTop = 0;
}
});
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
// ─── Forward append only ──────────────────────────────────────────────────────
// Appends the next chapter to the bottom when the user scrolls past 80%.
// No eviction, no prepend, no sliding window — chapters accumulate forward.
function appendNextChapter() { function appendNextChapter() {
if (appending || !stripChapters.length) return; if (appending || !stripChapters.length) return;
const lastChunk = stripChapters[stripChapters.length - 1]; const lastChunk = stripChapters[stripChapters.length - 1];
@@ -227,147 +245,124 @@
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId); const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
if (lastIdx < 0 || lastIdx >= list.length - 1) return; if (lastIdx < 0 || lastIdx >= list.length - 1) return;
const next = list[lastIdx + 1]; const next = list[lastIdx + 1];
if (!next || appended.has(next.id)) return; if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
appended.add(next.id);
appending = true; appending = true;
fetchPages(next.id) fetchPages(next.id)
.then(urls => { .then(urls => {
urls.forEach(url => measureAspect(url).catch(() => {})); urls.forEach(url => measureAspect(url).catch(() => {}));
urls.slice(0, 6).forEach(preloadImage); urls.slice(0, 6).forEach(preloadImage);
return urls; return urls;
}) })
.then(async urls => { .then(urls => {
if (stripChapters.some(c => c.chapterId === next.id)) return; if (stripChapters.some(c => c.chapterId === next.id)) { appending = false; return; }
const MAX_STRIP = 8; stripChapters = [...stripChapters, { chapterId: next.id, chapterName: next.name, urls }];
if (stripChapters.length >= MAX_STRIP && containerEl) {
const anchorTop = containerEl.scrollTop;
const anchorHeight = containerEl.scrollHeight;
stripChapters = [...stripChapters.slice(1), { chapterId: next.id, chapterName: next.name, urls }];
await tick();
if (containerEl) containerEl.scrollTop = Math.max(0, anchorTop + (containerEl.scrollHeight - anchorHeight));
} else {
stripChapters = [...stripChapters, { chapterId: next.id, chapterName: next.name, urls }];
}
appending = false; appending = false;
}) })
.catch(() => { appending = false; }); .catch(() => { appending = false; });
} }
function setupScrollTracking() { // ─── Scroll tracking ──────────────────────────────────────────────────────────
let stripChaptersRef: StripChapter[] = [];
$effect(() => { stripChaptersRef = stripChapters; });
let autoNextRef = false;
$effect(() => { autoNextRef = autoNext; });
function setupScrollTracking(): () => void {
if (!containerEl || style !== "longstrip") return () => {}; if (!containerEl || style !== "longstrip") return () => {};
const READ_LINE_PCT = 0.20;
function onScroll() { function onScroll() {
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
if (!imgs.length) return;
const containerTop = containerEl.getBoundingClientRect().top; const containerTop = containerEl.getBoundingClientRect().top;
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT; const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
let activePage: number | null = null; let activePage: number | null = null;
let activeChId: number | null = null; let activeChId: number | null = null;
for (const img of imgs) { for (const img of imgs) {
if (img.getBoundingClientRect().top <= readLineY) { if (img.getBoundingClientRect().top <= readLineY) {
activePage = Number(img.dataset.localPage); activePage = Number(img.dataset.localPage);
activeChId = Number(img.dataset.chapter); activeChId = Number(img.dataset.chapter);
} else break; } else break;
} }
if (activePage === null && imgs.length > 0) {
activePage = Number((imgs[0] as HTMLElement).dataset.localPage); if (activePage === null) {
activeChId = Number((imgs[0] as HTMLElement).dataset.chapter); activePage = Number(imgs[0].dataset.localPage);
activeChId = Number(imgs[0].dataset.chapter);
} }
if (activePage !== null) store.pageNumber = activePage; if (activePage !== null) store.pageNumber = activePage;
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId; if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
if (store.settings.autoMarkRead && activePage !== null && activeChId) { if (store.settings.autoMarkRead && activePage !== null && activeChId) {
const chunk = stripChapters.find(c => c.chapterId === activeChId); const chunk = stripChaptersRef.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : store.pageUrls.length; const total = chunk ? chunk.urls.length : store.pageUrls.length;
if (total > 0 && activePage >= total - 1) markChapterRead(activeChId); if (total > 0 && activePage >= total) markChapterRead(activeChId);
} }
if (containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40) { if (containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40) {
const last = stripChapters[stripChapters.length - 1]; const last = stripChaptersRef[stripChaptersRef.length - 1];
if (last && store.settings.autoMarkRead) markChapterRead(last.chapterId); if (last && store.settings.autoMarkRead) markChapterRead(last.chapterId);
} }
} }
function onScroll80() { function onScrollAppend() {
if (!autoNextRef) return;
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight; const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.8) appendNextChapter(); if (pct >= 0.80) appendNextChapter();
} }
containerEl.addEventListener("scroll", onScroll, { passive: true }); containerEl.addEventListener("scroll", onScroll, { passive: true });
if (autoNext) containerEl.addEventListener("scroll", onScroll80, { passive: true }); containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
onScroll();
return () => { return () => {
containerEl.removeEventListener("scroll", onScroll); containerEl.removeEventListener("scroll", onScroll);
containerEl.removeEventListener("scroll", onScroll80); containerEl.removeEventListener("scroll", onScrollAppend);
}; };
} }
function advanceGroup(forward: boolean) { // ─── Observer lifecycle ───────────────────────────────────────────────────────
if (!pageGroups.length) return;
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
if (forward) {
if (gi < pageGroups.length - 1) store.pageNumber = pageGroups[gi + 1][0];
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
else closeReader();
} else {
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0];
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
}
}
function goForward() { let cleanupScroll: () => void = () => {};
if (loading) return;
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } return; }
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
if (!store.pageUrls.length) return;
if (store.pageNumber < lastPage) { const target = store.pageNumber + 1; decodeImage(store.pageUrls[target - 1]).then(() => { if (store.pageNumber === target - 1) store.pageNumber = target; }); }
else if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
else closeReader();
}
function goBack() {
if (loading) return;
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); return; }
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
if (!store.pageUrls.length) return;
if (store.pageNumber > 1) { const target = store.pageNumber - 1; decodeImage(store.pageUrls[target - 1]).then(() => { if (store.pageNumber === target + 1) store.pageNumber = target; }); }
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
}
const goNext = $derived(rtl ? goBack : goForward);
const goPrev = $derived(rtl ? goForward : goBack);
function cycleStyle() {
const opts = ["single", "longstrip"] as const;
const cur = style === "double" ? "single" : style;
updateSettings({ pageStyle: opts[(opts.indexOf(cur as typeof opts[number]) + 1) % opts.length] });
}
function cycleFit() {
const opts: FitMode[] = ["width", "height", "screen", "original"];
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
}
$effect(() => { $effect(() => {
if (store.activeChapter && lastPage && store.activeManga) { void style;
const chapterId = store.activeChapter.id; if (!containerEl) return;
const chapterName = store.activeChapter.name; untrack(() => {
const mangaId = store.activeManga.id; cleanupScroll();
const mangaTitle = store.activeManga.title; cleanupScroll = setupScrollTracking();
const thumb = store.activeManga.thumbnailUrl; });
const pageNum = store.pageNumber; });
const atLast = store.pageNumber === lastPage;
untrack(() => { // ─── Prefetch + cache eviction ────────────────────────────────────────────────
// Progress save — updates "continue reading" position in history
// but does NOT count as a completion (completed=false is the default). $effect(() => {
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() }); if (store.activeChapter && store.activeChapterList.length) {
// For paged (non-longstrip) mode, reaching the last page marks it read. const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
// markChapterRead will fire addHistory again with completed:true. if (idx >= 0) {
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId); const toPin: number[] = [store.activeChapter.id];
}); for (let i = 1; i <= 3; i++) {
const entry = store.activeChapterList[idx + i];
if (!entry) break;
toPin.push(entry.id);
fetchPages(entry.id)
.then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); })
.catch(() => {});
}
if (idx > 0) {
toPin.push(store.activeChapterList[idx - 1].id);
fetchPages(store.activeChapterList[idx - 1].id).catch(() => {});
}
cacheEvict(new Set(toPin));
}
} }
}); });
// ─── Double-page spread computation ──────────────────────────────────────────
$effect(() => { $effect(() => {
if (style === "double" && store.pageUrls.length) { if (style === "double" && store.pageUrls.length) {
let cancelled = false; let cancelled = false;
@@ -389,6 +384,8 @@
} else { pageGroups = []; } } else { pageGroups = []; }
}); });
// ─── Preload around current page ─────────────────────────────────────────────
$effect(() => { $effect(() => {
const ahead = store.settings.preloadPages ?? 3; 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) decodeImage(url); }
@@ -396,39 +393,120 @@
if (behind) preloadImage(behind); if (behind) preloadImage(behind);
}); });
// ─── Progress / history tracking ─────────────────────────────────────────────
$effect(() => { $effect(() => {
if (store.activeChapter && store.activeChapterList.length) { if (store.activeChapter && lastPage && store.activeManga) {
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id); const chapterId = store.activeChapter.id;
if (idx >= 0) { const chapterName = store.activeChapter.name;
const toPin: number[] = [store.activeChapter.id]; const mangaId = store.activeManga.id;
for (let i = 1; i <= 3; i++) { const mangaTitle = store.activeManga.title;
const entry = store.activeChapterList[idx + i]; const thumb = store.activeManga.thumbnailUrl;
if (!entry) break; const pageNum = store.pageNumber;
toPin.push(entry.id); const atLast = store.pageNumber === lastPage;
fetchPages(entry.id).then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); }).catch(() => {}); untrack(() => {
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
});
}
});
// ─── Mark read ────────────────────────────────────────────────────────────────
function markChapterRead(id: number) {
if (markedRead.has(id)) return;
markedRead.add(id);
const chapter = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
const pages = chapter?.pageCount ?? store.pageUrls.length ?? 15;
const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE));
if (store.activeManga && chapter) {
addHistory(
{ mangaId: store.activeManga.id, mangaTitle: store.activeManga.title, thumbnailUrl: store.activeManga.thumbnailUrl, chapterId: id, chapterName: chapter.name, pageNumber: pages, readAt: Date.now() },
true, minutes,
);
}
gql(MARK_CHAPTER_READ, { id, isRead: true })
.then(() => {
if (store.activeManga) {
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
checkAndMarkCompleted(store.activeManga.id, updated);
} }
if (idx > 0) { const prev = store.activeChapterList[idx - 1]; toPin.push(prev.id); fetchPages(prev.id).catch(() => {}); } })
cacheEvict(new Set(toPin)); .catch(e => { markedRead.delete(id); console.error(e); });
} }
}
});
$effect(() => { function maybeMarkCurrentRead() {
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) { const ch = store.activeChapter;
appended = new Set([store.activeChapter.id]); if (ch && markOnNext) markChapterRead(ch.id);
appending = false; }
if (autoNext) {
stripChapters = [{ chapterId: store.activeChapter.id, chapterName: store.activeChapter.name, urls: store.pageUrls }];
visibleChapterId = store.activeChapter.id;
} else {
stripChapters = [];
visibleChapterId = null;
}
if (containerEl) containerEl.scrollTop = 0;
}
});
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; }); // ─── Navigation ───────────────────────────────────────────────────────────────
function advanceGroup(forward: boolean) {
if (!pageGroups.length) return;
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
if (forward) {
if (gi < pageGroups.length - 1) store.pageNumber = pageGroups[gi + 1][0];
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
else closeReader();
} else {
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0];
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
}
}
function goForward() {
if (loading) return;
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } return; }
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;
});
} else if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
else closeReader();
}
function goBack() {
if (loading) return;
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); return; }
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;
});
} else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
}
const goNext = $derived(rtl ? goBack : goForward);
const goPrev = $derived(rtl ? goForward : goBack);
// ─── Settings toggles ─────────────────────────────────────────────────────────
function cycleStyle() {
const opts = ["single", "longstrip"] as const;
const cur = style === "double" ? "single" : style;
updateSettings({ pageStyle: opts[(opts.indexOf(cur as typeof opts[number]) + 1) % opts.length] });
}
function cycleFit() {
const opts: FitMode[] = ["width", "height", "screen", "original"];
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
}
// ─── UI helpers ───────────────────────────────────────────────────────────────
function showUi() {
uiVisible = true;
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => uiVisible = false, 3000);
}
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
if (!e.ctrlKey) return; if (!e.ctrlKey) return;
@@ -486,45 +564,21 @@
dlBusy = false; dlOpen = false; dlBusy = false; dlOpen = false;
} }
let scrollCleanup: () => void = () => {}; // ─── Mount / unmount ──────────────────────────────────────────────────────────
onMount(() => { onMount(() => {
showUi(); showUi();
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
window.addEventListener("wheel", onWheel, { passive: false }); window.addEventListener("wheel", onWheel, { passive: false });
containerEl?.focus({ preventScroll: true }); containerEl?.focus({ preventScroll: true });
scrollCleanup = setupScrollTracking();
return () => { return () => {
abortCtrl?.abort(); abortCtrl?.abort();
if (hideTimer) clearTimeout(hideTimer); if (hideTimer) clearTimeout(hideTimer);
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
window.removeEventListener("wheel", onWheel); window.removeEventListener("wheel", onWheel);
scrollCleanup(); cleanupScroll();
}; };
}); });
$effect(() => {
if (!containerEl) return;
void style; void store.pageUrls.length; void autoNext;
untrack(() => {
scrollCleanup();
scrollCleanup = setupScrollTracking();
});
});
const stripToRender = $derived(
style === "longstrip"
? (autoNext && 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]
);
</script> </script>
<div class="root" role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}> <div class="root" role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
@@ -609,7 +663,16 @@
{#if style === "longstrip"} {#if style === "longstrip"}
{#each stripToRender as chunk} {#each stripToRender as chunk}
{#each chunk.urls as url, i} {#each chunk.urls as url, i}
<img src={url} alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 3 ? "eager" : "lazy"} decoding="async" height="1000" /> <img
src={url}
alt="{chunk.chapterName} Page {i + 1}"
data-local-page={i + 1}
data-chapter={chunk.chapterId}
data-total={chunk.urls.length}
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
loading={i < 3 ? "eager" : "lazy"}
decoding="async"
/>
{/each} {/each}
{/each} {/each}
<div style="height:1px;flex-shrink:0"></div> <div style="height:1px;flex-shrink:0"></div>
@@ -692,7 +755,7 @@
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); } .zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 2px var(--sp-2); border-radius: var(--radius-sm); transition: color var(--t-base), background var(--t-base); }
.zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); } .zoom-reset:hover { color: var(--text-primary); background: var(--bg-overlay); }
.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 { 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; overflow-anchor: none; } .viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; } .viewer:focus { outline: none; }
.img { display: block; user-select: none; image-rendering: auto; } .img { display: block; user-select: none; image-rendering: auto; }
.img.optimize-contrast { image-rendering: -webkit-optimize-contrast; } .img.optimize-contrast { image-rendering: -webkit-optimize-contrast; }