mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Fix: Reader Backlog-Glitch & History/Stats Rewrite
This commit is contained in:
@@ -1,104 +1,41 @@
|
|||||||
Todo:
|
Major Revisions:
|
||||||
3. Explore Manga Upscaler & Other Image Processing
|
- Moku + Crossplatform Support (MacOS Remaining)
|
||||||
4. Font Weird on Flatpak, Investigate and Fix
|
- Contemplate Anime Support, Add Novel Support (Consumet API)
|
||||||
5. Investigate "egl:failed to create dri2 screen" & more GPU Issues
|
- Enable Cloudflare Bypass (Suwayomi Config)
|
||||||
|
- Moku-Share to Easily Migrate/Share Manga (Investigate Usecase/Feasibility)
|
||||||
|
- Adjustment in Settings for Theme Editor:
|
||||||
Bugs:
|
- Allow User to Edit/Create Themes
|
||||||
|
- Allow For Command-Line IPC for Temporary (Apply Once) & Permanent Themes OR External Methodology for Matugen Colors
|
||||||
- Add Back after Search & Clear on Search
|
|
||||||
- Fix Tag-Based Search to Allow for Finding New Manga Rather than PURE-DB
|
Minor Revisions:
|
||||||
- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks
|
- Improve Deduplication Algorithm with Advanced Option Settings (Implement Chapter & Author-based Comparisons, Resource Intensive)
|
||||||
|
- Integrate Download Directory Changes (Settings)
|
||||||
|
- Investigate feasibility of Multi-Page Screenshot (Reader)
|
||||||
- Fix Infinite Scroll Hitting Button Non-reactive to Chapter State, hence Resulting in Error. (Doesn't work on single or double digit, but works on select chapters?) (doesnt work 1 - 2 qnd 51 - 52?) Cause unknow
|
- Add Hover Info on Library (Make sure doesn't conflict with additional clicks)
|
||||||
- - Reader appears to be adding integers? Marks chapters incorrectly, need to stablize and patch. User is able to
|
|
||||||
skip chapters, etc
|
Priority Bugs:
|
||||||
- Mark as Read no longer working on select chapters, choose more robust methodology.
|
- Resume on Home-Page leads to Reader, not Cached 5 chapters
|
||||||
- Reset to top when user clicks next chapter in reader.
|
- Fix History (Counting Chapters, Etc)
|
||||||
|
- Cache ALL Cover Pictures & Details for Manga in Library
|
||||||
|
|
||||||
- Fix Downloaded in Library (Tags Broken) & All
|
|
||||||
- Using Delete All Crashes App (But Works)
|
|
||||||
- Fix Folder Display in Library
|
|
||||||
- Add Version Tags (To Find Version)
|
|
||||||
- Sidebar Icon Highlighted
|
|
||||||
- Introduce Deduplication into Library & Search
|
|
||||||
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Add PDF Textbook Support
|
|
||||||
- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm)
|
|
||||||
- Migration Features
|
|
||||||
- Multi-Page Long Screenshot
|
|
||||||
- Add Consumet Api (Anime & Light Novel Support)
|
|
||||||
|
|
||||||
|
|
||||||
Big Revisions:
|
|
||||||
0. Expand into fully-fledged reader, with modular manga support
|
|
||||||
1. Anime & Novel Support
|
|
||||||
2. Tracker Support
|
|
||||||
3. Cloudflare Bypass Enable Support
|
|
||||||
4. macOS Support (feasible)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Testing:
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
|
|
||||||
Completed:
|
|
||||||
8. Fix Polling on Download Manager (Instantanous Response)
|
|
||||||
19. Debounce Time on Reader to improve lag (Toggle Setting)
|
|
||||||
10. Download Manager Pause and Cancel All Not Working + Download Lag on Series Detail Side
|
|
||||||
17. Change Library Text change to "No manga saved to library, browse sources to add some."
|
|
||||||
9. Fix CSS issue on Sidebar (Weird Green Overlay on Button)
|
|
||||||
7. Fix Scaling (100 = 125% and so forth)
|
|
||||||
2. Expand Criteria on Series Detail (Tags, Summaries) Make more Compact
|
|
||||||
14. Right-Click should have (Remove Library & Delete All) + Make New Folder (Context Menu)
|
|
||||||
15. Explorer Right-Click New Context Menu with Add to Library, Add to Folder, etc
|
|
||||||
11. Reader & UI needs download and other Notifications
|
|
||||||
- Fix Mark all Above as Read to Mark all Below as Read (Should be visual based) also add Unread Option, Sidebar Category for mark all above as read and mark all below as unread. (Series Detail)
|
|
||||||
- Add Refresh Details on Series Details.
|
|
||||||
- Patch GenreDrill & Integrate into Explore Folder
|
|
||||||
18. Disable NSFW Extensions option in settings
|
|
||||||
- Filtering by Genre (Accessed by Clicking tags on Manga)
|
|
||||||
- Remove Series Detail Mark Read & Unread
|
|
||||||
20. Expand History (Total Time Read, etc)
|
|
||||||
12. Delete all Downloads should also cancel all download queues
|
|
||||||
13. Cancel Download along with Queue & Download Timeout Feature
|
|
||||||
- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh)
|
|
||||||
- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue)
|
|
||||||
- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug)
|
|
||||||
- Extensions Page no Longer Loading efficiently
|
|
||||||
- Map out MangaPreview tags to GenreDrill
|
|
||||||
- GenreDrill & GenreFilter pages do not populate completely.
|
|
||||||
16. Contrast Adjustment Option in Settings for Users (UI FOCUSED)
|
|
||||||
- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break)
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
General/Misc Bugs:
|
||||||
|
- Fix Highlightable Elements
|
||||||
|
- Investigate "egl:failed to create dri2 screen"
|
||||||
|
- Check Fonts/Design on Flatpak
|
||||||
|
- Fix Delete-All Crash (Deletes All but Cripples App)
|
||||||
|
- Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?)
|
||||||
|
|
||||||
|
In-Progress:`
|
||||||
|
|
||||||
|
|
||||||
Important Commands:
|
Important Commands:
|
||||||
cd ~/Projects/Manga/Moku
|
cd ~/Projects/Manga/Moku
|
||||||
pnpm build
|
pnpm build
|
||||||
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
tar -czf packaging/frontend-dist.tar.gz -C dist .
|
||||||
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}'
|
||||||
|
|
||||||
1. nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
|
||||||
2. nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
nix-shell -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json"
|
||||||
3. flatpak build-bundle repo moku.flatpak dev.moku.app
|
nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command flatpak-builder --repo=repo --force-clean build-dir dev.moku.app.yml
|
||||||
|
flatpak build-bundle repo moku.flatpak dev.moku.app
|
||||||
+1
-1
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 739d3d907892d7903dc52bc6a1ecc2af350f21a47f5b199bf47d0c70a9f9ff27
|
sha256: 9f3e4a6b059f4236bf63fcbaa27cca6041257d39940172e5c1bb520c7cdc51e8
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
function formatReadTime(mins: number): string {
|
function formatReadTime(mins: number): string {
|
||||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
||||||
if (mins < 60) return `${Math.round(mins)}m`;
|
if (mins < 60) return `${Math.round(mins)}m`;
|
||||||
const h = Math.floor(mins / 60), r = mins % 60;
|
const h = Math.floor(mins / 60), r = Math.round(mins % 60);
|
||||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
const d = Math.floor(h / 24), rh = h % 24;
|
const d = Math.floor(h / 24), rh = h % 24;
|
||||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
||||||
@@ -35,11 +35,28 @@
|
|||||||
let loadingLibrary: boolean = $state(true);
|
let loadingLibrary: boolean = $state(true);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
loadLibrary();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadLibrary() {
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
|
||||||
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
|
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => loadingLibrary = false);
|
.finally(() => loadingLibrary = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch library and reset hero chapters whenever the reader closes,
|
||||||
|
// so the hero reflects the latest-read chapter immediately.
|
||||||
|
$effect(() => {
|
||||||
|
const sessionId = store.readerSessionId;
|
||||||
|
if (sessionId === 0) return; // skip initial mount — onMount handles that
|
||||||
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
|
loadingLibrary = true;
|
||||||
|
heroChapters = [];
|
||||||
|
heroAllChapters = [];
|
||||||
|
heroChaptersFor = null;
|
||||||
|
loadLibrary();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchExtraCompleted(library: Manga[]) {
|
async function fetchExtraCompleted(library: Manga[]) {
|
||||||
@@ -92,9 +109,9 @@
|
|||||||
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
|
||||||
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; }
|
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; }
|
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
|
||||||
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; } }
|
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
|
||||||
@@ -108,6 +125,7 @@
|
|||||||
|
|
||||||
let heroStageH = $state(300);
|
let heroStageH = $state(300);
|
||||||
let heroChapters: Chapter[] = $state([]);
|
let heroChapters: Chapter[] = $state([]);
|
||||||
|
let heroAllChapters: Chapter[] = $state([]);
|
||||||
let loadingHeroChapters = $state(false);
|
let loadingHeroChapters = $state(false);
|
||||||
let heroChaptersFor: number | null = null;
|
let heroChaptersFor: number | null = null;
|
||||||
|
|
||||||
@@ -120,14 +138,16 @@
|
|||||||
heroChaptersFor = mangaId;
|
heroChaptersFor = mangaId;
|
||||||
loadingHeroChapters = true;
|
loadingHeroChapters = true;
|
||||||
heroChapters = [];
|
heroChapters = [];
|
||||||
|
heroAllChapters = [];
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||||
if (heroChaptersFor !== mangaId) return;
|
if (heroChaptersFor !== mangaId) return;
|
||||||
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
heroAllChapters = all;
|
||||||
const lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead);
|
const lastReadIdx = heroEntry ? all.findIndex(c => c.id === heroEntry!.chapterId) : all.findLastIndex(c => c.isRead);
|
||||||
const startIdx = Math.max(0, lastReadIdx);
|
const startIdx = Math.max(0, lastReadIdx);
|
||||||
heroChapters = all.slice(startIdx, startIdx + 5);
|
heroChapters = all.slice(startIdx, startIdx + 5);
|
||||||
} catch { heroChapters = []; }
|
} catch { heroChapters = []; heroAllChapters = []; }
|
||||||
finally { loadingHeroChapters = false; }
|
finally { loadingHeroChapters = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +157,7 @@
|
|||||||
if (!heroMangaId) return;
|
if (!heroMangaId) return;
|
||||||
resuming = true;
|
resuming = true;
|
||||||
try {
|
try {
|
||||||
let all = heroChapters;
|
let all = heroAllChapters;
|
||||||
if (!all.length) {
|
if (!all.length) {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
|
||||||
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
@@ -150,8 +170,8 @@
|
|||||||
async function resumeActive() {
|
async function resumeActive() {
|
||||||
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
|
||||||
if (!heroEntry) return;
|
if (!heroEntry) return;
|
||||||
const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0];
|
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
|
||||||
if (target && heroChapters.length) { await openChapter(target); return; }
|
if (target && heroAllChapters.length) { await openChapter(target); return; }
|
||||||
resuming = true;
|
resuming = true;
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
|
||||||
|
|||||||
@@ -248,8 +248,8 @@
|
|||||||
return [
|
return [
|
||||||
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
|
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "Mark above as read", icon: CheckCircle, onClick: () => markAboveRead(idx), disabled: idx === 0 || above.filter(c => !c.isRead).length === 0 },
|
{ label: "Mark above as read", icon: CheckCircle, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
|
||||||
{ label: "Mark above as unread", icon: Circle, onClick: () => markAboveUnread(idx), disabled: idx === 0 || above.filter(c => c.isRead).length === 0 },
|
{ label: "Mark above as unread", icon: Circle, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: "Mark below as read", icon: CheckCircle, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
|
{ label: "Mark below as read", icon: CheckCircle, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
|
||||||
{ label: "Mark below as unread", icon: Circle, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
{ label: "Mark below as unread", icon: Circle, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
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. */
|
||||||
|
const AVG_MIN_PER_PAGE = 0.33; // ~20 seconds/page → 5 min per 15-page chapter
|
||||||
|
|
||||||
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[] = [];
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
function cacheEvict(keep: Set<number>) {
|
function cacheEvict(keep: Set<number>) {
|
||||||
while (pageCache.size > MAX_CACHED) {
|
while (pageCache.size > MAX_CACHED) {
|
||||||
const victim = cacheOrder.find(id => !keep.has(id));
|
const victim = cacheOrder.find(id => !keep.has(id));
|
||||||
if (!victim) break;
|
if (victim === undefined) break;
|
||||||
cacheOrder.splice(cacheOrder.indexOf(victim), 1);
|
cacheOrder.splice(cacheOrder.indexOf(victim), 1);
|
||||||
pageCache.delete(victim);
|
pageCache.delete(victim);
|
||||||
}
|
}
|
||||||
@@ -67,10 +70,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; startGlobalIdx: number; }
|
interface StripChapter { chapterId: number; chapterName: string; urls: string[]; }
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
let containerEl: HTMLDivElement;
|
||||||
let sentinelEl: HTMLDivElement = $state() as HTMLDivElement;
|
|
||||||
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -89,8 +91,6 @@
|
|||||||
let appending = false;
|
let appending = false;
|
||||||
let abortCtrl: AbortController | null = null;
|
let abortCtrl: AbortController | null = null;
|
||||||
let loadingId: number | null = null;
|
let loadingId: number | null = null;
|
||||||
let scrollAnchor: { scrollTop: number; scrollHeight: number } | null = null;
|
|
||||||
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -100,9 +100,11 @@
|
|||||||
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
||||||
const lastPage = $derived(store.pageUrls.length);
|
const lastPage = $derived(store.pageUrls.length);
|
||||||
|
|
||||||
const displayChapter = $derived((style === "longstrip" && autoNext && visibleChapterId)
|
const displayChapter = $derived(
|
||||||
|
style === "longstrip" && autoNext && visibleChapterId
|
||||||
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
|
||||||
: store.activeChapter);
|
: store.activeChapter
|
||||||
|
);
|
||||||
|
|
||||||
const adjacent = $derived((() => {
|
const adjacent = $derived((() => {
|
||||||
const ref = displayChapter ?? store.activeChapter;
|
const ref = displayChapter ?? store.activeChapter;
|
||||||
@@ -132,20 +134,46 @@
|
|||||||
].filter(Boolean).join(" "));
|
].filter(Boolean).join(" "));
|
||||||
|
|
||||||
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]);
|
||||||
const styleLabel = $derived(style);
|
|
||||||
|
|
||||||
function maybeMarkCurrentRead() {
|
function markChapterRead(id: number) {
|
||||||
const ch = store.activeChapter;
|
if (markedRead.has(id)) return;
|
||||||
if (!ch || !markOnNext || markedRead.has(ch.id)) return;
|
markedRead.add(id);
|
||||||
markedRead.add(ch.id);
|
|
||||||
gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true })
|
// Find the chapter to get its page count for a time estimate.
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Record the completion in the read log with an accurate time estimate.
|
||||||
|
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(() => {
|
.then(() => {
|
||||||
if (store.activeManga) {
|
if (store.activeManga) {
|
||||||
const updated = store.activeChapterList.map(c => c.id === ch.id ? { ...c, isRead: true } : c);
|
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
||||||
checkAndMarkCompleted(store.activeManga.id, updated);
|
checkAndMarkCompleted(store.activeManga.id, updated);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(e => { markedRead.delete(ch.id); console.error(e); });
|
.catch(e => { markedRead.delete(id); console.error(e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeMarkCurrentRead() {
|
||||||
|
const ch = store.activeChapter;
|
||||||
|
if (ch && markOnNext) markChapterRead(ch.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUi() {
|
function showUi() {
|
||||||
@@ -167,7 +195,6 @@
|
|||||||
appended = new Set([id]);
|
appended = new Set([id]);
|
||||||
appending = false;
|
appending = false;
|
||||||
markedRead = new Set();
|
markedRead = new Set();
|
||||||
aspectCache.clear();
|
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
pageGroups = [];
|
pageGroups = [];
|
||||||
@@ -182,7 +209,7 @@
|
|||||||
store.pageUrls = urls;
|
store.pageUrls = urls;
|
||||||
pageReady = true;
|
pageReady = true;
|
||||||
if (style === "longstrip" && autoNext) {
|
if (style === "longstrip" && autoNext) {
|
||||||
stripChapters = [{ chapterId: id, chapterName: store.activeChapter?.name ?? "", urls, startGlobalIdx: 0 }];
|
stripChapters = [{ chapterId: id, chapterName: store.activeChapter?.name ?? "", urls }];
|
||||||
visibleChapterId = id;
|
visibleChapterId = id;
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -194,9 +221,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendNextChapter() {
|
function appendNextChapter() {
|
||||||
if (appending) return;
|
if (appending || !stripChapters.length) return;
|
||||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||||
if (!lastChunk) return;
|
|
||||||
const list = store.activeChapterList;
|
const list = store.activeChapterList;
|
||||||
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;
|
||||||
@@ -205,23 +231,22 @@
|
|||||||
appended.add(next.id);
|
appended.add(next.id);
|
||||||
appending = true;
|
appending = true;
|
||||||
fetchPages(next.id)
|
fetchPages(next.id)
|
||||||
.then(urls => { urls.forEach(url => measureAspect(url).catch(() => {})); urls.slice(0, 6).forEach(preloadImage); return urls; })
|
|
||||||
.then(urls => {
|
.then(urls => {
|
||||||
|
urls.forEach(url => measureAspect(url).catch(() => {}));
|
||||||
|
urls.slice(0, 6).forEach(preloadImage);
|
||||||
|
return urls;
|
||||||
|
})
|
||||||
|
.then(async urls => {
|
||||||
if (stripChapters.some(c => c.chapterId === next.id)) return;
|
if (stripChapters.some(c => c.chapterId === next.id)) return;
|
||||||
const last = stripChapters[stripChapters.length - 1];
|
|
||||||
const start = last ? last.startGlobalIdx + last.urls.length : 0;
|
|
||||||
const MAX_STRIP = 8;
|
const MAX_STRIP = 8;
|
||||||
if (stripChapters.length >= MAX_STRIP && containerEl) {
|
if (stripChapters.length >= MAX_STRIP && containerEl) {
|
||||||
scrollAnchor = { scrollTop: containerEl.scrollTop, scrollHeight: containerEl.scrollHeight };
|
const anchorTop = containerEl.scrollTop;
|
||||||
stripChapters = [...stripChapters.slice(1), { chapterId: next.id, chapterName: next.name, urls, startGlobalIdx: start }];
|
const anchorHeight = containerEl.scrollHeight;
|
||||||
tick().then(() => {
|
stripChapters = [...stripChapters.slice(1), { chapterId: next.id, chapterName: next.name, urls }];
|
||||||
if (!scrollAnchor || !containerEl) return;
|
await tick();
|
||||||
const gained = containerEl.scrollHeight - scrollAnchor.scrollHeight;
|
if (containerEl) containerEl.scrollTop = Math.max(0, anchorTop + (containerEl.scrollHeight - anchorHeight));
|
||||||
if (gained < 0) containerEl.scrollTop = Math.max(0, scrollAnchor.scrollTop + gained);
|
|
||||||
scrollAnchor = null;
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
stripChapters = [...stripChapters, { chapterId: next.id, chapterName: next.name, urls, startGlobalIdx: start }];
|
stripChapters = [...stripChapters, { chapterId: next.id, chapterName: next.name, urls }];
|
||||||
}
|
}
|
||||||
appending = false;
|
appending = false;
|
||||||
})
|
})
|
||||||
@@ -229,51 +254,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupScrollTracking() {
|
function setupScrollTracking() {
|
||||||
if (!containerEl || style !== "longstrip") return;
|
if (!containerEl || style !== "longstrip") return () => {};
|
||||||
const READ_LINE_PCT = 0.20;
|
const READ_LINE_PCT = 0.20;
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
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]");
|
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||||
let activeLocalPage: 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) {
|
||||||
const rect = img.getBoundingClientRect();
|
if (img.getBoundingClientRect().top <= readLineY) {
|
||||||
if (rect.top <= readLineY) { activeLocalPage = Number(img.dataset.localPage); activeChId = Number(img.dataset.chapter); }
|
activePage = Number(img.dataset.localPage);
|
||||||
else break;
|
activeChId = Number(img.dataset.chapter);
|
||||||
|
} else break;
|
||||||
}
|
}
|
||||||
if (activeLocalPage === null && imgs.length > 0) { activeLocalPage = Number(imgs[0].dataset.localPage); activeChId = Number(imgs[0].dataset.chapter); }
|
if (activePage === null && imgs.length > 0) {
|
||||||
if (activeLocalPage !== null) store.pageNumber = activeLocalPage;
|
activePage = Number((imgs[0] as HTMLElement).dataset.localPage);
|
||||||
|
activeChId = Number((imgs[0] as HTMLElement).dataset.chapter);
|
||||||
|
}
|
||||||
|
if (activePage !== null) store.pageNumber = activePage;
|
||||||
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
|
||||||
if (store.settings.autoMarkRead && activeLocalPage !== null && activeChId) {
|
|
||||||
|
if (store.settings.autoMarkRead && activePage !== null && activeChId) {
|
||||||
const chunk = stripChapters.find(c => c.chapterId === activeChId);
|
const chunk = stripChapters.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 && activeLocalPage >= total - 1 && !markedRead.has(activeChId)) {
|
if (total > 0 && activePage >= total - 1) markChapterRead(activeChId);
|
||||||
markedRead.add(activeChId);
|
|
||||||
const chIdSnap = activeChId;
|
|
||||||
gql(MARK_CHAPTER_READ, { id: chIdSnap, isRead: true })
|
|
||||||
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
|
||||||
.catch(e => { markedRead.delete(chIdSnap); console.error(e); });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (containerEl.scrollTop + containerEl.clientHeight < containerEl.scrollHeight - 40) return;
|
if (containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40) {
|
||||||
const last = stripChapters[stripChapters.length - 1];
|
const last = stripChapters[stripChapters.length - 1];
|
||||||
if (last && store.settings.autoMarkRead && !markedRead.has(last.chapterId)) {
|
if (last && store.settings.autoMarkRead) markChapterRead(last.chapterId);
|
||||||
markedRead.add(last.chapterId);
|
|
||||||
const lastIdSnap = last.chapterId;
|
|
||||||
gql(MARK_CHAPTER_READ, { id: lastIdSnap, isRead: true })
|
|
||||||
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === lastIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onScroll80() {
|
function onScroll80() {
|
||||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||||
if (pct >= 0.8) appendNextChapter();
|
if (pct >= 0.8) appendNextChapter();
|
||||||
}
|
}
|
||||||
|
|
||||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||||
if (autoNext) containerEl.addEventListener("scroll", onScroll80, { passive: true });
|
if (autoNext) containerEl.addEventListener("scroll", onScroll80, { passive: true });
|
||||||
onScroll();
|
onScroll();
|
||||||
return () => { containerEl.removeEventListener("scroll", onScroll); containerEl.removeEventListener("scroll", onScroll80); };
|
return () => {
|
||||||
|
containerEl.removeEventListener("scroll", onScroll);
|
||||||
|
containerEl.removeEventListener("scroll", onScroll80);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function advanceGroup(forward: boolean) {
|
function advanceGroup(forward: boolean) {
|
||||||
@@ -294,7 +320,7 @@
|
|||||||
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } return; }
|
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } return; }
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||||
if (!store.pageUrls.length) return;
|
if (!store.pageUrls.length) return;
|
||||||
if (store.pageNumber < lastPage) { decodeImage(store.pageUrls[store.pageNumber]).then(() => store.pageNumber++); }
|
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 if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
||||||
else closeReader();
|
else closeReader();
|
||||||
}
|
}
|
||||||
@@ -304,7 +330,7 @@
|
|||||||
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); return; }
|
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); return; }
|
||||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||||
if (!store.pageUrls.length) return;
|
if (!store.pageUrls.length) return;
|
||||||
if (store.pageNumber > 1) { decodeImage(store.pageUrls[store.pageNumber - 2]).then(() => store.pageNumber--); }
|
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);
|
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,19 +354,16 @@
|
|||||||
const chapterName = store.activeChapter.name;
|
const chapterName = store.activeChapter.name;
|
||||||
const mangaId = store.activeManga.id;
|
const mangaId = store.activeManga.id;
|
||||||
const mangaTitle = store.activeManga.title;
|
const mangaTitle = store.activeManga.title;
|
||||||
const thumbUrl = store.activeManga.thumbnailUrl;
|
const thumb = store.activeManga.thumbnailUrl;
|
||||||
const pageNum = store.pageNumber;
|
const pageNum = store.pageNumber;
|
||||||
const atLast = store.pageNumber === lastPage;
|
const atLast = store.pageNumber === lastPage;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumbUrl, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
|
// Progress save — updates "continue reading" position in history
|
||||||
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) {
|
// but does NOT count as a completion (completed=false is the default).
|
||||||
if (!markedRead.has(chapterId)) {
|
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
|
||||||
markedRead.add(chapterId);
|
// For paged (non-longstrip) mode, reaching the last page marks it read.
|
||||||
gql(MARK_CHAPTER_READ, { id: chapterId, isRead: true })
|
// markChapterRead will fire addHistory again with completed:true.
|
||||||
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chapterId ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
|
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) markChapterRead(chapterId);
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -395,7 +418,7 @@
|
|||||||
appended = new Set([store.activeChapter.id]);
|
appended = new Set([store.activeChapter.id]);
|
||||||
appending = false;
|
appending = false;
|
||||||
if (autoNext) {
|
if (autoNext) {
|
||||||
stripChapters = [{ chapterId: store.activeChapter.id, chapterName: store.activeChapter.name, urls: store.pageUrls, startGlobalIdx: 0 }];
|
stripChapters = [{ chapterId: store.activeChapter.id, chapterName: store.activeChapter.name, urls: store.pageUrls }];
|
||||||
visibleChapterId = store.activeChapter.id;
|
visibleChapterId = store.activeChapter.id;
|
||||||
} else {
|
} else {
|
||||||
stripChapters = [];
|
stripChapters = [];
|
||||||
@@ -405,7 +428,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => { if (store.activeChapter?.id && containerEl) containerEl.scrollTop = 0; });
|
|
||||||
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
|
||||||
|
|
||||||
function onWheel(e: WheelEvent) {
|
function onWheel(e: WheelEvent) {
|
||||||
@@ -464,7 +486,7 @@
|
|||||||
dlBusy = false; dlOpen = false;
|
dlBusy = false; dlOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let scrollCleanup: (() => void) | undefined;
|
let scrollCleanup: () => void = () => {};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
showUi();
|
showUi();
|
||||||
@@ -477,31 +499,32 @@
|
|||||||
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?.();
|
scrollCleanup();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!containerEl) return;
|
if (!containerEl) return;
|
||||||
const _style = style;
|
void style; void store.pageUrls.length; void autoNext;
|
||||||
const _len = store.pageUrls.length;
|
|
||||||
const _auto = autoNext;
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
scrollCleanup?.();
|
scrollCleanup();
|
||||||
scrollCleanup = setupScrollTracking();
|
scrollCleanup = setupScrollTracking();
|
||||||
});
|
});
|
||||||
return () => { scrollCleanup?.(); };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const stripToRender = $derived(style === "longstrip"
|
const stripToRender = $derived(
|
||||||
|
style === "longstrip"
|
||||||
? (autoNext && stripChapters.length > 0
|
? (autoNext && stripChapters.length > 0
|
||||||
? stripChapters
|
? stripChapters
|
||||||
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls, startGlobalIdx: 0 }])
|
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
|
||||||
: []);
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
const currentGroup = $derived(style === "double" && pageGroups.length
|
const currentGroup = $derived(
|
||||||
|
style === "double" && pageGroups.length
|
||||||
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
|
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [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(); }}>
|
||||||
@@ -543,7 +566,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="mode-btn" onclick={cycleStyle}>
|
<button class="mode-btn" onclick={cycleStyle}>
|
||||||
{#if style === "single"}<Square size={14} weight="light" />{:else}<Rows size={14} weight="light" />{/if}
|
{#if style === "single"}<Square size={14} weight="light" />{:else}<Rows size={14} weight="light" />{/if}
|
||||||
<span class="mode-label">{styleLabel}</span>
|
<span class="mode-label">{style}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if style !== "single"}
|
{#if style !== "single"}
|
||||||
<button class="mode-btn" class:active={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
<button class="mode-btn" class:active={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
||||||
@@ -589,7 +612,7 @@
|
|||||||
<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" height="1000" />
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
<div bind:this={sentinelEl} style="height:1px;flex-shrink:0;overflow-anchor:none"></div>
|
<div style="height:1px;flex-shrink:0"></div>
|
||||||
{:else if pageReady}
|
{:else if pageReady}
|
||||||
{#if style === "double" && pageGroups.length}
|
{#if style === "double" && pageGroups.length}
|
||||||
<div class="double-wrap">
|
<div class="double-wrap">
|
||||||
@@ -663,13 +686,13 @@
|
|||||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||||||
.zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); }
|
.zoom-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px var(--sp-2); border-radius: var(--radius-sm); min-width: 36px; text-align: center; transition: color var(--t-base), background var(--t-base); }
|
||||||
.zoom-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
.zoom-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||||||
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 160px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
||||||
.zoom-slider { width: 140px; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
.zoom-slider { width: 140px; 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-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
||||||
.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: auto; }
|
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; overflow-anchor: none; }
|
||||||
.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; }
|
||||||
|
|||||||
+83
-12
@@ -21,6 +21,20 @@ export interface HistoryEntry {
|
|||||||
readAt: number;
|
readAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReadLogEntry — append-only record of every chapter-completion event.
|
||||||
|
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
|
||||||
|
* this log never overwrites existing entries. It is the source of truth
|
||||||
|
* for all reading stats.
|
||||||
|
*/
|
||||||
|
export interface ReadLogEntry {
|
||||||
|
mangaId: number;
|
||||||
|
chapterId: number;
|
||||||
|
readAt: number;
|
||||||
|
/** Minutes spent on this chapter (estimated from page count or default). */
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReadingStats {
|
export interface ReadingStats {
|
||||||
totalChaptersRead: number;
|
totalChaptersRead: number;
|
||||||
totalMangaRead: number;
|
totalMangaRead: number;
|
||||||
@@ -32,7 +46,7 @@ export interface ReadingStats {
|
|||||||
lastStreakDate: string;
|
lastStreakDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AVG_MIN_PER_CHAPTER = 5;
|
const AVG_MIN_PER_CHAPTER = 5; // fallback when no page count is available
|
||||||
|
|
||||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||||
totalChaptersRead: 0,
|
totalChaptersRead: 0,
|
||||||
@@ -230,9 +244,21 @@ class Store {
|
|||||||
navPage: NavPage = $state(saved?.navPage ?? "home");
|
navPage: NavPage = $state(saved?.navPage ?? "home");
|
||||||
libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
|
libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
|
||||||
history: HistoryEntry[] = $state(saved?.history ?? []);
|
history: HistoryEntry[] = $state(saved?.history ?? []);
|
||||||
|
/**
|
||||||
|
* readLog — append-only, never deduped. Every chapter completion/progress
|
||||||
|
* event lands here. This is the authoritative source for all reading stats.
|
||||||
|
* Capped at 5 000 entries; oldest are trimmed first.
|
||||||
|
*/
|
||||||
|
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||||
readingStats: ReadingStats = $state(mergeStats(saved));
|
readingStats: ReadingStats = $state(mergeStats(saved));
|
||||||
settings: Settings = $state(mergeSettings(saved));
|
settings: Settings = $state(mergeSettings(saved));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped each time the reader closes. Home.svelte watches this to know
|
||||||
|
* when to re-fetch library data and refresh the hero section.
|
||||||
|
*/
|
||||||
|
readerSessionId: number = $state(0);
|
||||||
|
|
||||||
genreFilter: string = $state("");
|
genreFilter: string = $state("");
|
||||||
searchPrefill: string = $state("");
|
searchPrefill: string = $state("");
|
||||||
activeManga: Manga | null = $state(null);
|
activeManga: Manga | null = $state(null);
|
||||||
@@ -260,6 +286,7 @@ class Store {
|
|||||||
$effect(() => { persist({ navPage: this.navPage }); });
|
$effect(() => { persist({ navPage: this.navPage }); });
|
||||||
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
|
||||||
$effect(() => { persist({ history: this.history }); });
|
$effect(() => { persist({ history: this.history }); });
|
||||||
|
$effect(() => { persist({ readLog: this.readLog }); });
|
||||||
$effect(() => { persist({ readingStats: this.readingStats }); });
|
$effect(() => { persist({ readingStats: this.readingStats }); });
|
||||||
$effect(() => { persist({ settings: this.settings }); });
|
$effect(() => { persist({ settings: this.settings }); });
|
||||||
});
|
});
|
||||||
@@ -277,19 +304,49 @@ class Store {
|
|||||||
this.activeChapterList = [];
|
this.activeChapterList = [];
|
||||||
this.pageUrls = [];
|
this.pageUrls = [];
|
||||||
this.pageNumber = 1;
|
this.pageNumber = 1;
|
||||||
|
this.readerSessionId += 1; // signals Home to refresh
|
||||||
}
|
}
|
||||||
|
|
||||||
addHistory(entry: HistoryEntry) {
|
/**
|
||||||
const isNewChapter = !this.history.some(x => x.chapterId === entry.chapterId);
|
* Record a reading event.
|
||||||
|
*
|
||||||
|
* @param entry - The history entry for the "continue reading" UI.
|
||||||
|
* @param completed - True when the chapter was fully read (triggers stat
|
||||||
|
* accrual). False for mid-chapter progress updates.
|
||||||
|
* @param minutes - Actual minutes to credit; defaults to AVG_MIN_PER_CHAPTER.
|
||||||
|
*/
|
||||||
|
addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
|
||||||
|
// ── 1. Update the deduped "continue reading" history ──────────────────
|
||||||
|
// Always keep the latest position for each chapter at the top.
|
||||||
if (this.history[0]?.chapterId === entry.chapterId) {
|
if (this.history[0]?.chapterId === entry.chapterId) {
|
||||||
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
|
||||||
} else {
|
} else {
|
||||||
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueChapters = new Set(this.history.map(e => e.chapterId));
|
// ── 2. Append to the read log (only on completion) ────────────────────
|
||||||
const uniqueManga = new Set(this.history.map(e => e.mangaId));
|
// This is append-only — every completed chapter read lands here,
|
||||||
|
// including re-reads. We cap at 5 000 to keep storage bounded.
|
||||||
|
if (completed) {
|
||||||
|
const logEntry: ReadLogEntry = {
|
||||||
|
mangaId: entry.mangaId,
|
||||||
|
chapterId: entry.chapterId,
|
||||||
|
readAt: entry.readAt,
|
||||||
|
minutes,
|
||||||
|
};
|
||||||
|
this.readLog = [...this.readLog, logEntry].slice(-5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Recompute stats from the read log ──────────────────────────────
|
||||||
|
// Use the log as ground truth so stats are always accurate even after
|
||||||
|
// history is cleared or entries are back-filled.
|
||||||
|
const log = completed
|
||||||
|
? [...this.readLog] // already updated above
|
||||||
|
: this.readLog;
|
||||||
|
|
||||||
|
const uniqueChapters = new Set(log.map(e => e.chapterId));
|
||||||
|
const uniqueManga = new Set(log.map(e => e.mangaId));
|
||||||
|
const totalMinutes = log.reduce((sum, e) => sum + e.minutes, 0);
|
||||||
|
|
||||||
const today = todayStr();
|
const today = todayStr();
|
||||||
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
|
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
|
||||||
@@ -303,9 +360,9 @@ class Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.readingStats = {
|
this.readingStats = {
|
||||||
totalChaptersRead: Math.max(this.readingStats.totalChaptersRead, uniqueChapters.size),
|
totalChaptersRead: uniqueChapters.size,
|
||||||
totalMangaRead: Math.max(this.readingStats.totalMangaRead, uniqueManga.size),
|
totalMangaRead: uniqueManga.size,
|
||||||
totalMinutesRead: this.readingStats.totalMinutesRead + (isNewChapter ? AVG_MIN_PER_CHAPTER : 0),
|
totalMinutesRead: totalMinutes,
|
||||||
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt,
|
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt,
|
||||||
lastReadAt: entry.readAt,
|
lastReadAt: entry.readAt,
|
||||||
currentStreakDays,
|
currentStreakDays,
|
||||||
@@ -314,11 +371,25 @@ class Store {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHistory() { this.history = []; }
|
clearHistory() { this.history = []; this.readLog = []; }
|
||||||
clearHistoryForManga(mangaId: number) { this.history = this.history.filter(x => x.mangaId !== mangaId); }
|
clearHistoryForManga(mangaId: number) {
|
||||||
|
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||||
|
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
|
||||||
|
// Recompute stats after removal
|
||||||
|
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
|
||||||
|
const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
|
||||||
|
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
|
||||||
|
this.readingStats = {
|
||||||
|
...this.readingStats,
|
||||||
|
totalChaptersRead: uniqueChapters.size,
|
||||||
|
totalMangaRead: uniqueManga.size,
|
||||||
|
totalMinutesRead: totalMinutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
wipeAllData() {
|
wipeAllData() {
|
||||||
this.history = [];
|
this.history = [];
|
||||||
|
this.readLog = [];
|
||||||
this.readingStats = { ...DEFAULT_READING_STATS };
|
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||||
this.settings = { ...this.settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
|
this.settings = { ...this.settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||||
}
|
}
|
||||||
@@ -452,7 +523,7 @@ export const store = new Store();
|
|||||||
|
|
||||||
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
|
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
|
||||||
export function closeReader() { store.closeReader(); }
|
export function closeReader() { store.closeReader(); }
|
||||||
export function addHistory(entry: HistoryEntry) { store.addHistory(entry); }
|
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
|
||||||
export function clearHistory() { store.clearHistory(); }
|
export function clearHistory() { store.clearHistory(); }
|
||||||
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
|
||||||
export function wipeAllData() { store.wipeAllData(); }
|
export function wipeAllData() { store.wipeAllData(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user