[V1] Reader Simplification & Fixes

This commit is contained in:
Youwes09
2026-02-26 23:31:01 -06:00
parent 272d7673ce
commit 8c38330143
2 changed files with 229 additions and 197 deletions
+16 -13
View File
@@ -3,6 +3,7 @@ Todo:
4. Font Weird on Flatpak, Investigate and Fix 4. Font Weird on Flatpak, Investigate and Fix
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues 5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
Bugs: Bugs:
- Add Back after Search & Clear on Search - Add Back after Search & Clear on Search
@@ -10,15 +11,14 @@ Bugs:
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks - Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
- Fix Storage Glitch (Currently uses Full Space Instead of Free Space) - Fix Storage Glitch (Currently uses Full Space Instead of Free Space)
- Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
- Fix the Mark as Read (Glitched)
Features: Features:
- Add PDF Textbook Support - Add PDF Textbook Support
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm) - Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
- Migration Features - Migration Features
- Multi-Page Long Screenshot - Multi-Page Long Screenshot
- - Add Consumet Api (Anime & Light Novel Support)
Big Revisions: Big Revisions:
@@ -31,16 +31,9 @@ Big Revisions:
Testing: Testing:
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
5. Lock reader on valid chapters to avoid bugs, etc. - Fix the Infinite Append/Scroll on Downloaded Manga, (Unable to Transfer Between Downloaded and Internet Based Manga Providing, hence resulting in feature breaking till toggled and retoggled)
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load - Fix the Mark as Read (Glitched)
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
- Properly Kill Tachidesk-Server
- Fix Reader Marking As Read.
- Fix scaling on splash screen
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
Completed: Completed:
@@ -72,6 +65,16 @@ Completed:
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED) 16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break) - Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
- Clean up Migrate Model to be more initutive - Clean up Migrate Model to be more initutive
6. Fix laggy renderer on single page (same cache as longscroll) & set default longstrip
5. Lock reader on valid chapters to avoid bugs, etc.
1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load
- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail)
- Properly Kill Tachidesk-Server
- Fix scaling on splash screen
- Idle Screen Test Uses Animations, but Reality still uses old system with Mouse Movement = Dismiss + No Fade Out
- Idle Screen is Super laggy, needs minimum of 60 fps hence needs more optimization
- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug)
+199 -170
View File
@@ -40,8 +40,6 @@ function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]>
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
// The inflight promise is shared — never pass a caller's signal into it,
// because one caller aborting would kill the fetch for everyone else waiting.
if (!inflight.has(chapterId)) { if (!inflight.has(chapterId)) {
const p = gql<{ fetchChapterPages: { pages: string[] } }>( const p = gql<{ fetchChapterPages: { pages: string[] } }>(
FETCH_CHAPTER_PAGES, { chapterId }, FETCH_CHAPTER_PAGES, { chapterId },
@@ -56,10 +54,8 @@ function fetchPages(chapterId: number, signal?: AbortSignal): Promise<string[]>
const base = inflight.get(chapterId)!; const base = inflight.get(chapterId)!;
// No abort signal — return the shared promise directly
if (!signal) return base; if (!signal) return base;
// Wrap so this caller can abort their own wait without cancelling the network request
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
base.then(resolve, reject); base.then(resolve, reject);
@@ -186,13 +182,16 @@ export default function Reader() {
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Live-value refs — updated every render, readable inside effects without stale closures // Live-value refs — always current, safe to read inside effects/callbacks
const settingsRef = useRef<typeof settings | null>(null); const settingsRef = useRef<typeof settings | null>(null);
const chapterListRef = useRef<typeof activeChapterList>([]); const chapterListRef = useRef<typeof activeChapterList>([]);
const loadingIdRef = useRef<number | null>(null); const loadingIdRef = useRef<number | null>(null);
const markedReadRef = useRef<Set<number>>(new Set()); const markedReadRef = useRef<Set<number>>(new Set());
const appendedRef = useRef<Set<number>>(new Set()); const appendedRef = useRef<Set<number>>(new Set());
const appendingRef = useRef(false); // prevents concurrent appends
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const visibleChapterRef = useRef<number | null>(null); // single truth for visible chapter
const stripChaptersRef = useRef<StripChapter[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -203,7 +202,8 @@ export default function Reader() {
const [pageGroups, setPageGroups] = useState<number[][]>([]); const [pageGroups, setPageGroups] = useState<number[][]>([]);
const [stripChapters, setStripChapters] = useState<StripChapter[]>([]); const [stripChapters, setStripChapters] = useState<StripChapter[]>([]);
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null); const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
const stripChaptersRef = useRef<StripChapter[]>([]);
// Keep refs in sync every render
stripChaptersRef.current = stripChapters; stripChaptersRef.current = stripChapters;
const { const {
@@ -219,7 +219,6 @@ export default function Reader() {
const maxW = settings.maxPageWidth ?? 900; const maxW = settings.maxPageWidth ?? 900;
const autoNext = settings.autoNextChapter ?? false; const autoNext = settings.autoNextChapter ?? false;
// Sync live refs every render
settingsRef.current = settings; settingsRef.current = settings;
chapterListRef.current = activeChapterList; chapterListRef.current = activeChapterList;
@@ -242,9 +241,11 @@ export default function Reader() {
if (!activeChapter) { if (!activeChapter) {
abortRef.current?.abort(); abortRef.current?.abort();
appendedRef.current = new Set(); appendedRef.current = new Set();
appendingRef.current = false;
markedReadRef.current = new Set(); markedReadRef.current = new Set();
setStripChapters([]); setStripChapters([]);
setVisibleChapterId(null); setVisibleChapterId(null);
visibleChapterRef.current = null;
return; return;
} }
@@ -254,7 +255,8 @@ export default function Reader() {
const targetId = activeChapter.id; const targetId = activeChapter.id;
loadingIdRef.current = targetId; loadingIdRef.current = targetId;
appendedRef.current = new Set(); appendedRef.current = new Set([targetId]); // seed so we never double-append ch1
appendingRef.current = false;
markedReadRef.current = new Set(); markedReadRef.current = new Set();
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -262,6 +264,7 @@ export default function Reader() {
setPageReady(false); setPageReady(false);
setStripChapters([]); setStripChapters([]);
setVisibleChapterId(null); setVisibleChapterId(null);
visibleChapterRef.current = null;
fetchPages(targetId, ctrl.signal) fetchPages(targetId, ctrl.signal)
.then(async (urls) => { .then(async (urls) => {
@@ -271,46 +274,15 @@ export default function Reader() {
setPageUrls(urls); setPageUrls(urls);
setPageReady(true); setPageReady(true);
if (style === "longstrip" && autoNext) { if (style === "longstrip" && autoNext) {
setStripChapters([{ chapterId: targetId, chapterName: activeChapter.name, urls, startGlobalIdx: 0 }]); const firstChunk: StripChapter = {
chapterId: targetId,
chapterName: activeChapter.name,
urls,
startGlobalIdx: 0,
};
setStripChapters([firstChunk]);
setVisibleChapterId(targetId); setVisibleChapterId(targetId);
// Manual kick: when stripChapters goes 0→1 the dep-array change visibleChapterRef.current = targetId;
// re-creates the observer. If the sentinel is already in the viewport
// at that moment the new observer fires immediately and handles the
// append itself. But if getBoundingClientRect shows it's already
// visible before the observer has a chance to fire, kick directly.
const _sentinel = sentinelRef.current;
const _el = containerRef.current;
if (_sentinel && _el) {
const sr = _sentinel.getBoundingClientRect();
const er = _el.getBoundingClientRect();
if (sr.top < er.bottom + 500) {
const list = chapterListRef.current;
const idx = list.findIndex((c) => c.id === targetId);
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
if (next && !appendedRef.current.has(next.id)) {
appendedRef.current.add(next.id);
fetchPages(next.id)
.then((u) => Promise.all(u.map(measureAspect)).then(() => u))
.then((u) => setStripChapters((cur) => {
if (cur.some((c) => c.chapterId === next.id)) return cur;
const last = cur[cur.length - 1];
const newStart = last ? last.startGlobalIdx + last.urls.length : 0;
const updated = [...cur, { chapterId: next.id, chapterName: next.name, urls: u, startGlobalIdx: newStart }];
if (updated.length > 3) {
const container = containerRef.current;
if (container) {
const firstKeptImg = container.querySelector(
`img[data-chapter="${updated[1].chapterId}"]`
) as HTMLElement | null;
if (firstKeptImg) container.scrollTop -= firstKeptImg.offsetTop;
}
return updated.slice(-3);
}
return updated;
})).catch((err) => { console.error(err); appendedRef.current.delete(next.id); });
}
}
}
} }
setLoading(false); setLoading(false);
}) })
@@ -321,68 +293,12 @@ export default function Reader() {
}); });
}, [activeChapter?.id]); }, [activeChapter?.id]);
// ── Longstrip: IntersectionObserver for page number + visible chapter ───────── // ── Append next chapter to the strip ────────────────────────────────────────
// Watches every img[data-page]. Fires on scroll, keyboard jump, or any navigation. // Extracted so both the sentinel observer and the "already in viewport" check
useEffect(() => { // use exactly the same code path.
const el = containerRef.current; const appendNextChapter = useCallback(() => {
if (!el || style !== "longstrip") return; if (appendingRef.current) return;
const obs = new IntersectionObserver((entries) => {
let topPage = Infinity;
let topChId: number | null = null;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const p = Number((entry.target as HTMLElement).dataset.page);
const ch = Number((entry.target as HTMLElement).dataset.chapter);
if (p < topPage) { topPage = p; topChId = ch; }
}
if (topPage !== Infinity) setPageNumber(topPage);
if (topChId && topChId !== visibleChapterId) {
// Mark the chapter we just left as read
if (settingsRef.current?.autoMarkRead && visibleChapterId && !markedReadRef.current.has(visibleChapterId)) {
markedReadRef.current.add(visibleChapterId);
gql(MARK_CHAPTER_READ, { id: visibleChapterId, isRead: true }).catch(console.error);
}
setVisibleChapterId(topChId);
}
}, { root: el, threshold: 0.1 });
el.querySelectorAll("img[data-page]").forEach((img) => obs.observe(img));
return () => obs.disconnect();
}, [style, stripChapters, pageUrls, visibleChapterId]);
// ── Longstrip: sentinel triggers append (or plain chapter advance) ────────────
// The sentinel div sits at the very bottom of the strip. When it enters the
// viewport — via scroll, keyboard jump, or any other means — we fetch the
// next chapter. rootMargin pre-fires 500px before it's actually visible.
useEffect(() => {
const sentinel = sentinelRef.current;
const el = containerRef.current;
// Gatekeeper: don't observe until chapter 1 is actually in the DOM.
// stripChapters is in the dep array, so this effect re-runs the moment
// content exists — and the new observer fires immediately for an already-
// visible sentinel.
if (!sentinel || !el || style !== "longstrip") return;
// Gatekeeper for autoNext: don't create the observer until the strip has
// content. On mount the sentinel is at the top of an empty container —
// it fires immediately, sees no strip, and goes permanently idle.
// stripChapters is a dep so this effect re-runs the moment ch1 is seeded,
// creating a fresh observer that accurately checks the sentinel position.
// Non-autoNext uses loadingIdRef not stripChapters — no gate needed there.
if (autoNext && stripChapters.length === 0) return;
const obs = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
if (!autoNext) {
const list = chapterListRef.current;
const idx = list.findIndex((c) => c.id === loadingIdRef.current);
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
if (next) openReader(next, list);
return;
}
// Read from ref — always current, no stale closure, no updater needed
const strip = stripChaptersRef.current; const strip = stripChaptersRef.current;
const lastChunk = strip[strip.length - 1]; const lastChunk = strip[strip.length - 1];
if (!lastChunk) return; if (!lastChunk) return;
@@ -394,74 +310,187 @@ export default function Reader() {
const nextEntry = list[lastIdx + 1]; const nextEntry = list[lastIdx + 1];
if (!nextEntry || appendedRef.current.has(nextEntry.id)) return; if (!nextEntry || appendedRef.current.has(nextEntry.id)) return;
// Lock immediately and synchronously — before any async work
appendedRef.current.add(nextEntry.id); appendedRef.current.add(nextEntry.id);
appendingRef.current = true;
fetchPages(nextEntry.id) fetchPages(nextEntry.id)
.then((urls) => Promise.all(urls.map(measureAspect)).then(() => urls)) .then((urls) => Promise.all(urls.map(measureAspect)).then(() => urls))
.then((urls) => { .then((urls) => {
setStripChapters((cur) => { setStripChapters((cur) => {
if (cur.some((c) => c.chapterId === nextEntry.id)) return cur; if (cur.some((c) => c.chapterId === nextEntry.id)) return cur;
const last = cur[cur.length - 1]; const last = cur[cur.length - 1];
const newStart = last ? last.startGlobalIdx + last.urls.length : 0; const newStart = last ? last.startGlobalIdx + last.urls.length : 0;
const next = [...cur, { chapterId: nextEntry.id, chapterName: nextEntry.name, urls, startGlobalIdx: newStart }]; const updated = [...cur, {
if (next.length > 3) { chapterId: nextEntry.id,
// Compensate scroll so the viewport doesn't jump when the chapterName: nextEntry.name,
// first chunk is trimmed off the top of the DOM. urls,
startGlobalIdx: newStart,
}];
// Trim to 3 chunks max. Fix scroll compensation: subtract the height
// of the removed chunk (sum of its images), not the kept element's offsetTop.
if (updated.length > 3) {
const container = containerRef.current; const container = containerRef.current;
if (container) { if (container) {
const firstKeptImg = container.querySelector( const removedChunk = updated[0];
`img[data-chapter="${next[1].chapterId}"]` let removedHeight = 0;
) as HTMLElement | null; container.querySelectorAll<HTMLElement>(
if (firstKeptImg) { `img[data-chapter="${removedChunk.chapterId}"]`
container.scrollTop -= firstKeptImg.offsetTop; ).forEach((img) => {
} removedHeight += img.offsetHeight + (settingsRef.current?.pageGap ? 8 : 0);
}
return next.slice(-3);
}
return next;
}); });
}).catch((err) => { if (removedHeight > 0) {
console.error(err); // Use requestAnimationFrame so the DOM has updated before we adjust
appendedRef.current.delete(nextEntry.id); // allow retry on failure requestAnimationFrame(() => {
if (containerRef.current) {
containerRef.current.scrollTop -= removedHeight;
}
}); });
}
}
return updated.slice(-3);
}
return updated;
});
appendingRef.current = false;
})
.catch((err) => {
console.error("appendNextChapter failed:", err);
appendedRef.current.delete(nextEntry.id); // allow retry
appendingRef.current = false;
});
}, []); // no deps — reads everything from refs
// ── Longstrip: IntersectionObserver for page number + visible chapter ────────
// Uses a MutationObserver to auto-observe newly added images so we never need
// to recreate this observer when chapters are appended.
useEffect(() => {
const el = containerRef.current;
if (!el || style !== "longstrip") return;
const pageObs = new IntersectionObserver((entries) => {
let topPage = Infinity;
let topChId: number | null = null;
let topY = Infinity;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const target = entry.target as HTMLElement;
const p = Number(target.dataset.page);
const ch = Number(target.dataset.chapter);
const y = entry.boundingClientRect.top;
// Track the topmost visible image by its position on screen
if (y < topY) { topY = y; topPage = p; topChId = ch; }
}
if (topPage !== Infinity) setPageNumber(topPage);
if (topChId && topChId !== visibleChapterRef.current) {
const prev = visibleChapterRef.current;
// Mark the chapter we just finished scrolling past
if (settingsRef.current?.autoMarkRead && prev && !markedReadRef.current.has(prev)) {
markedReadRef.current.add(prev);
gql(MARK_CHAPTER_READ, { id: prev, isRead: true }).catch(console.error);
}
visibleChapterRef.current = topChId;
setVisibleChapterId(topChId);
}
}, { root: el, threshold: 0.1 });
// Observe all existing images
el.querySelectorAll<HTMLElement>("img[data-page]").forEach((img) => pageObs.observe(img));
// Auto-observe any images added later (new strip chunks)
const mutObs = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach((node) => {
if (node instanceof HTMLElement) {
if (node.matches("img[data-page]")) {
pageObs.observe(node);
} else {
node.querySelectorAll<HTMLElement>("img[data-page]").forEach((img) => pageObs.observe(img));
}
}
});
}
});
mutObs.observe(el, { childList: true, subtree: true });
return () => {
pageObs.disconnect();
mutObs.disconnect();
};
// Only recreate when style changes — MutationObserver handles new images
}, [style]);
// ── Longstrip: sentinel triggers append ──────────────────────────────────────
useEffect(() => {
const sentinel = sentinelRef.current;
const el = containerRef.current;
if (!sentinel || !el || style !== "longstrip" || !autoNext) return;
// Don't install until we have content — empty strip means sentinel is at
// the top and would fire immediately.
if (stripChapters.length === 0) return;
const obs = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
appendNextChapter();
}, { root: el, rootMargin: "0px 0px 500px 0px", threshold: 0 }); }, { root: el, rootMargin: "0px 0px 500px 0px", threshold: 0 });
obs.observe(sentinel); obs.observe(sentinel);
return () => obs.disconnect();
// stripChapters.length as dep: observer re-mounts exactly once when ch1
// arrives (0→1), not on every subsequent append.
}, [style, autoNext, stripChapters.length, openReader]);
// Mark last chunk read when reaching the very bottom of the strip // If sentinel is already visible right now, kick immediately
const sr = sentinel.getBoundingClientRect();
const er = el.getBoundingClientRect();
if (sr.top < er.bottom + 500) {
appendNextChapter();
}
return () => obs.disconnect();
// Re-run only when mode changes or first chunk arrives (0→N).
// appendNextChapter is stable (no deps), so it won't cause extra re-runs.
}, [style, autoNext, stripChapters.length > 0, appendNextChapter]);
// ── Mark last chapter read when reaching the very bottom ─────────────────────
useEffect(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el || style !== "longstrip" || !autoNext) return; if (!el || style !== "longstrip") return;
const onScroll = () => { const onScroll = () => {
if (el.scrollTop + el.clientHeight < el.scrollHeight - 40) return; if (el.scrollTop + el.clientHeight < el.scrollHeight - 40) return;
setStripChapters((cur) => { const last = stripChaptersRef.current[stripChaptersRef.current.length - 1];
const last = cur[cur.length - 1]; if (!last) return;
if (last && settingsRef.current?.autoMarkRead && !markedReadRef.current.has(last.chapterId)) { if (settingsRef.current?.autoMarkRead && !markedReadRef.current.has(last.chapterId)) {
markedReadRef.current.add(last.chapterId); markedReadRef.current.add(last.chapterId);
gql(MARK_CHAPTER_READ, { id: last.chapterId, isRead: true }).catch(console.error); gql(MARK_CHAPTER_READ, { id: last.chapterId, isRead: true }).catch(console.error);
} }
return cur;
});
}; };
el.addEventListener("scroll", onScroll, { passive: true }); el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll); return () => el.removeEventListener("scroll", onScroll);
}, [style, autoNext, stripChapters]); // Only depends on style — reads strip from ref, so no stale closure issues
}, [style]);
// Rebuild strip when autoNext is toggled while longstrip is active // Rebuild strip when autoNext is toggled while longstrip is active
useEffect(() => { useEffect(() => {
if (style !== "longstrip" || !pageUrls.length || !activeChapter) return; if (style !== "longstrip" || !pageUrls.length || !activeChapter) return;
appendedRef.current = new Set(); appendedRef.current = new Set([activeChapter.id]);
appendingRef.current = false;
if (autoNext) { if (autoNext) {
setStripChapters([{ chapterId: activeChapter.id, chapterName: activeChapter.name, urls: pageUrls, startGlobalIdx: 0 }]); setStripChapters([{
chapterId: activeChapter.id,
chapterName: activeChapter.name,
urls: pageUrls,
startGlobalIdx: 0,
}]);
setVisibleChapterId(activeChapter.id); setVisibleChapterId(activeChapter.id);
visibleChapterRef.current = activeChapter.id;
} else { } else {
setStripChapters([]); setStripChapters([]);
setVisibleChapterId(null); setVisibleChapterId(null);
visibleChapterRef.current = null;
} }
if (containerRef.current) containerRef.current.scrollTop = 0; if (containerRef.current) containerRef.current.scrollTop = 0;
}, [autoNext, style]); }, [autoNext, style]);
@@ -471,32 +500,6 @@ export default function Reader() {
if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0; if (style !== "longstrip" && containerRef.current) containerRef.current.scrollTop = 0;
}, [pageNumber, style]); }, [pageNumber, style]);
// ── Double-page grouping ─────────────────────────────────────────────────────
useEffect(() => {
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
let cancelled = false;
const snap = pageUrls;
Promise.all(snap.map(measureAspect)).then((aspects) => {
if (cancelled || snap !== pageUrls) return;
const offset = settings.offsetDoubleSpreads;
const groups: number[][] = [[1]];
if (offset) groups.push([2]);
let i = offset ? 3 : 2;
while (i <= snap.length) {
const a = aspects[i - 1];
const nextA = aspects[i] ?? 0;
if (a > 1.2 || i === snap.length || nextA > 1.2) {
groups.push([i++]);
} else {
groups.push(rtl ? [i + 1, i] : [i, i + 1]);
i += 2;
}
}
setPageGroups(groups);
});
return () => { cancelled = true; };
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
// ── Preload adjacent pages ─────────────────────────────────────────────────── // ── Preload adjacent pages ───────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const ahead = settings.preloadPages ?? 3; const ahead = settings.preloadPages ?? 3;
@@ -547,7 +550,7 @@ export default function Reader() {
return chunk ? Math.max(1, pageNumber - chunk.startGlobalIdx) : pageNumber; return chunk ? Math.max(1, pageNumber - chunk.startGlobalIdx) : pageNumber;
}, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]); }, [style, autoNext, stripChapters, visibleChapterId, activeChapter?.id, pageNumber]);
// ── Auto-mark read + history ───────────────────────────────────────────────── // ── Auto-mark read + history (non-longstrip) ─────────────────────────────────
useEffect(() => { useEffect(() => {
if (!activeChapter || !lastPage) return; if (!activeChapter || !lastPage) return;
if (activeManga) { if (activeManga) {
@@ -557,14 +560,40 @@ export default function Reader() {
chapterName: activeChapter.name, pageNumber, readAt: Date.now(), chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
}); });
} }
if (style === "longstrip" && autoNext) return; // handled by IntersectionObserver if (style === "longstrip") return; // handled by scroll/intersection observers
if (settings.autoMarkRead && pageNumber === lastPage) { if (settings.autoMarkRead && pageNumber === lastPage) {
if (!markedReadRef.current.has(activeChapter.id)) { if (!markedReadRef.current.has(activeChapter.id)) {
markedReadRef.current.add(activeChapter.id); markedReadRef.current.add(activeChapter.id);
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error); gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
} }
} }
}, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead, style, autoNext]); }, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead, style]);
// ── Double-page grouping ─────────────────────────────────────────────────────
useEffect(() => {
if (style !== "double" || !pageUrls.length) { setPageGroups([]); return; }
let cancelled = false;
const snap = pageUrls;
Promise.all(snap.map(measureAspect)).then((aspects) => {
if (cancelled || snap !== pageUrls) return;
const offset = settings.offsetDoubleSpreads;
const groups: number[][] = [[1]];
if (offset) groups.push([2]);
let i = offset ? 3 : 2;
while (i <= snap.length) {
const a = aspects[i - 1];
const nextA = aspects[i] ?? 0;
if (a > 1.2 || i === snap.length || nextA > 1.2) {
groups.push([i++]);
} else {
groups.push(rtl ? [i + 1, i] : [i, i + 1]);
i += 2;
}
}
setPageGroups(groups);
});
return () => { cancelled = true; };
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
// ── Navigation ─────────────────────────────────────────────────────────────── // ── Navigation ───────────────────────────────────────────────────────────────
const advanceGroup = useCallback((forward: boolean) => { const advanceGroup = useCallback((forward: boolean) => {
@@ -815,7 +844,7 @@ export default function Reader() {
); );
}) })
)} )}
{/* Entering viewport (or within 500px of it) triggers next-chapter fetch */} {/* Sentinel: entering viewport triggers next-chapter fetch */}
<div ref={sentinelRef} style={{ height: 1, flexShrink: 0, overflowAnchor: "none" }} /> <div ref={sentinelRef} style={{ height: 1, flexShrink: 0, overflowAnchor: "none" }} />
</> </>
) : (pageReady && ( ) : (pageReady && (