From f866d4d0e991ac1772bb834863579418922c0a06 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 24 Feb 2026 16:14:46 -0600 Subject: [PATCH] [V1] Major Bug Fixes & Loading Screen (WIP) --- Todo | 91 ++++- src-tauri/src/lib.rs | 92 +++-- src/App.tsx | 169 +++++--- src/components/explore/Explore.tsx | 46 ++- src/components/explore/GenreDrillPage.tsx | 26 +- src/components/layout/SplashScreen.tsx | 458 ++++++++++++++++++++++ src/components/pages/Library.tsx | 41 +- src/components/pages/Reader.tsx | 48 ++- src/components/settings/Settings.tsx | 76 +++- src/store/index.ts | 4 + 10 files changed, 929 insertions(+), 122 deletions(-) create mode 100644 src/components/layout/SplashScreen.tsx diff --git a/Todo b/Todo index aa7d937..7314ea6 100644 --- a/Todo +++ b/Todo @@ -1,22 +1,89 @@ Todo: -1. Check all Keybind Toggles -2. Update ReadME with Comprehensive Feature List -3. Explore Manga Upscaler -4. Add Zoom-Slider for Zoom in Manga Reader - +3. Explore Manga Upscaler & Other Image Processing +4. Font Weird on Flatpak, Investigate and Fix +5. Investigate "egl:failed to create dri2 screen" Bugs: -2. Fix Auto-scroll between chapters & state when moving chapters (Implemented but not Fluidic) -3. Patch Chapters to Grid View -5. Fix Keybind Toggles + +- +- Opening Explore first time loads nothing, going to different page then going back loads everything relatively instantly? (Explore Page Bug) +- Add Scroll Wheel Compatibility to Explore, etc (Explore Page Bug) +- Add Back after Search & Clear on Search +- Add as Package in Nix Flake & Check Later +- GenreDrill & GenreFilter pages do not populate completely. +- Fix Initial Async Loading (Shows Suwayomi Not Loaded Till Refresh) +- Fix Explore Polling into 115 Sources (It currently includes languages) also Super Laggy +- Allow to move back from a window and return to previous state, not completely reset (Whole UI Issue) +16. Contrast Adjustment Option in Settings for Users (UI FOCUSED) + + +- Fix Mangafire Main Dispatcher Issue + + +- Fix Zoom in Reader changing Pages (Zoom Out Threshold in Reader causes Break) +- Add Download Location Change, Moku-Migration, Flatpak & Normal Installation Directory Checks + +- Clean up Migrate Model to be more initutive Features: -1. Frecency based Manga Suggestions -2. Proper Explore Tab +- Add PDF Textbook Support +- Major revision to disable entire manga-subsection and use as +solely as a reader/document launcher. +- Multiple Tag Filters + Mor Tags, Types, Etc +- Consider what to do empty space in sidebar (Maybe Daily Reading Time Brainstorm) +- Properly Kill Tachidesk-Server +- Migration Features +- Multi-Page Long Screenshot +- 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) -Test: -1. URL & Extension Additions \ No newline at end of file + + +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. +1. Cache issue when opening manga series detail first time (Loading screen addition) & Async Load +- Fix Download Cards (Series Detail Download UI) & Fix Download Range Expand +- Fix Grid View for Large Amounts of Chapter (Check Initial D) (Series Detail) +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 + + +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 + + + + +Important Commands: +cd ~/Projects/Manga/Moku +pnpm build +tar -czf packaging/frontend-dist.tar.gz -C dist . +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 +3. flatpak build-bundle repo moku.flatpak dev.moku.app \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 29070f2..2dbe752 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use std::sync::Mutex; use nix::sys::statvfs::statvfs; use serde::Serialize; -use tauri::Manager; +use tauri::{Manager, WindowEvent}; use tauri_plugin_shell::{ShellExt, process::CommandChild}; use walkdir::WalkDir; @@ -51,12 +51,9 @@ fn get_storage_info(downloads_path: String) -> Result { }; let vfs = statvfs(&stat_path).map_err(|e| e.to_string())?; - // f_frsize is the fundamental block size used for block counts. - // f_bsize (block_size()) is just the preferred I/O size and must not be - // used with blocks()/blocks_free() — that gives wildly wrong numbers. let frsize = vfs.fragment_size() as u64; - let total_bytes = vfs.blocks() * frsize; - let free_bytes = vfs.blocks_available() * frsize; + let total_bytes = vfs.blocks() * frsize; + let free_bytes = vfs.blocks_available() * frsize; Ok(StorageInfo { manga_bytes, @@ -66,31 +63,76 @@ fn get_storage_info(downloads_path: String) -> Result { }) } +fn kill_tachidesk(app: &tauri::AppHandle) { + // Kill the tracked child handle + let state = app.state::(); + let mut guard = state.0.lock().unwrap(); + if let Some(child) = guard.take() { + let _ = child.kill(); + println!("Killed tracked server child."); + } + // Belt-and-suspenders: the JVM registers its process name as "tachidesk", + // not "tachidesk-server", so pkill must target the short name. + let _ = std::process::Command::new("pkill") + .arg("-f") + .arg("tachidesk") + .status(); +} + +/// Spawn the server. Guards against double-spawn — if a child is already +/// tracked, this is a no-op (handles React StrictMode double-invoke in dev). +#[tauri::command] +fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), String> { + let state = app.state::(); + { + let guard = state.0.lock().unwrap(); + if guard.is_some() { + println!("Server already running, skipping spawn."); + return Ok(()); + } + } + + let shell = app.shell(); + match shell.command(&binary).spawn() { + Ok((_rx, child)) => { + println!("Spawned server: {}", binary); + let mut guard = state.0.lock().unwrap(); + *guard = Some(child); + Ok(()) + } + Err(e) => { + eprintln!("Failed to spawn {}: {}", binary, e); + Err(e.to_string()) + } + } +} + +/// Explicit kill — called from App.tsx cleanup. The window-close handler +/// below is the authoritative kill path; this is a secondary safety net. +#[tauri::command] +fn kill_server(app: tauri::AppHandle) -> Result<(), String> { + kill_tachidesk(&app); + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(ServerState(Mutex::new(None))) - .invoke_handler(tauri::generate_handler![get_storage_info]) - .setup(|app| { - let shell = app.shell(); - let app_handle = app.handle().clone(); - - let status = shell.command("tachidesk-server").spawn(); - - match status { - Ok((_rx, child)) => { - println!("Tachidesk server process spawned successfully."); - let state = app_handle.state::(); - let mut guard = state.0.lock().unwrap(); - *guard = Some(child); - } - Err(e) => { - eprintln!("Failed to spawn Tachidesk server: {}", e); - } + .invoke_handler(tauri::generate_handler![ + get_storage_info, + spawn_server, + kill_server, + ]) + .setup(|_app| Ok(())) + .on_window_event(|window, event| { + // Kill the server when the main window is actually destroyed. + // This fires reliably on close regardless of whether the JS + // cleanup callback ran. + if let WindowEvent::Destroyed = event { + kill_tachidesk(window.app_handle()); } - - Ok(()) }) .run(tauri::generate_context!()) .expect("error while running moku"); diff --git a/src/App.tsx b/src/App.tsx index 87d770f..e489e39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { gql } from "./lib/client"; @@ -11,9 +11,12 @@ import Settings from "./components/settings/Settings"; import MangaPreview from "./components/explore/MangaPreview"; import TitleBar from "./components/layout/TitleBar"; import Toaster from "./components/layout/Toaster"; +import SplashScreen, { EXIT_MS as SPLASH_EXIT_MS } from "./components/layout/SplashScreen"; import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; import s from "./App.module.css"; +const MAX_ATTEMPTS = 30; + export default function App() { const activeChapter = useStore((s) => s.activeChapter); const settingsOpen = useStore((s) => s.settingsOpen); @@ -21,24 +24,51 @@ export default function App() { const setActiveDownloads = useStore((s) => s.setActiveDownloads); const addToast = useStore((s) => s.addToast); - // Ref-based snapshot of the last known queue so we can diff across polls/events - const prevQueueRef = useRef([]); + // serverProbeOk = server responded, but we wait for ring to finish before showing UI + const [serverProbeOk, setServerProbeOk] = useState(!settings.autoStartServer); + // appReady = ring filled + transition done, show main UI + const [appReady, setAppReady] = useState(!settings.autoStartServer); + const [failed, setFailed] = useState(false); + const [retryKey, setRetryKey] = useState(0); + const [idle, setIdle] = useState(false); + // dev tools: force show splash + const [devSplash, setDevSplash] = useState(false); + + const prevQueueRef = useRef([]); + const idleTimerRef = useRef | null>(null); + + // expose devSplash trigger via window for settings + useEffect(() => { + (window as any).__mokuShowSplash = () => setDevSplash(true); + return () => { delete (window as any).__mokuShowSplash; }; + }, []); + + useEffect(() => { + if (!appReady) return; + function resetIdle() { + setIdle(false); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000; + if (idleTimeoutMs === 0) return; + idleTimerRef.current = setTimeout(() => setIdle(true), idleTimeoutMs); + } + const events = ["mousemove","mousedown","keydown","touchstart","wheel"]; + events.forEach(e => window.addEventListener(e, resetIdle, { passive:true })); + resetIdle(); + return () => { + events.forEach(e => window.removeEventListener(e, resetIdle)); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + }; + }, [appReady, settings.idleTimeoutMin]); - /** Compare old queue → new queue and toast for anything that finished. */ function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) { for (const item of prev) { if (item.state !== "DOWNLOADING") continue; - const stillPresent = next.some((q) => q.chapter.id === item.chapter.id); - if (!stillPresent) { + if (!next.some(q => q.chapter.id === item.chapter.id)) { const manga = item.chapter.manga; - addToast({ - kind: "success", - title: "Chapter downloaded", - body: manga - ? `${manga.title} — ${item.chapter.name}` - : item.chapter.name, - duration: 4000, - }); + addToast({ kind:"success", title:"Chapter downloaded", + body: manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name, + duration: 4000 }); } } } @@ -46,13 +76,9 @@ export default function App() { function applyQueue(next: DownloadQueueItem[]) { detectCompletions(prevQueueRef.current, next); prevQueueRef.current = next; - setActiveDownloads( - next.map((item) => ({ - chapterId: item.chapter.id, - mangaId: item.chapter.mangaId, - progress: item.progress, - })) - ); + setActiveDownloads(next.map(item => ({ + chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress, + }))); } useEffect(() => { @@ -60,52 +86,103 @@ export default function App() { }, [settings.uiScale]); useEffect(() => { - const prevent = (e: MouseEvent) => e.preventDefault(); - document.addEventListener("contextmenu", prevent); - return () => document.removeEventListener("contextmenu", prevent); + const p = (e: MouseEvent) => e.preventDefault(); + document.addEventListener("contextmenu", p); + return () => document.removeEventListener("contextmenu", p); }, []); useEffect(() => { if (!settings.autoStartServer) return; - invoke("spawn_server", { binary: settings.serverBinary }).catch((err) => - console.warn("Could not start server:", err) - ); + invoke("spawn_server", { binary: settings.serverBinary }).catch(err => + console.warn("Could not start server:", err)); return () => { invoke("kill_server").catch(() => {}); }; }, [settings.autoStartServer, settings.serverBinary]); - // Global download status poller — always running, regardless of which page is open. - // This is the single source of truth for completion toasts. + // Poll until server responds useEffect(() => { + if (serverProbeOk) return; + let cancelled = false, tries = 0; + async function probe() { + if (cancelled) return; + tries++; + try { + const res = await fetch(`${settings.serverUrl}/api/graphql`, { + method:"POST", headers:{"Content-Type":"application/json"}, + body: JSON.stringify({ query:"{ __typename }" }), + signal: AbortSignal.timeout(2000), + }); + if (res.ok && !cancelled) { setServerProbeOk(true); return; } + } catch {} + if (tries >= MAX_ATTEMPTS && !cancelled) { setFailed(true); return; } + if (!cancelled) setTimeout(probe, 800); + } + const t = setTimeout(probe, 800); + return () => { cancelled = true; clearTimeout(t); }; + }, [serverProbeOk, settings.serverUrl, retryKey]); + + useEffect(() => { + if (!appReady) return; function poll() { gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) - .then((d) => applyQueue(d.downloadStatus.queue)) - .catch(console.error); + .then(d => applyQueue(d.downloadStatus.queue)).catch(console.error); } - poll(); // immediate first fetch + poll(); const id = setInterval(poll, 2000); return () => clearInterval(id); - }, []); + }, [appReady]); - // Tauri real-time event — supplements the poller for instant UI badge updates. - // The payload is a lighter shape (no chapter name/manga), so we only use it - // for active download progress, not for completion detection. useEffect(() => { - type DlPayload = { chapterId: number; mangaId: number; progress: number }[]; - const unsub = listen("download-progress", (e) => { - setActiveDownloads(e.payload); - }); - return () => { unsub.then((fn) => fn()); }; + type P = { chapterId:number; mangaId:number; progress:number }[]; + const unsub = listen

("download-progress", e => setActiveDownloads(e.payload)); + return () => { unsub.then(fn => fn()); }; }, [setActiveDownloads]); + // Dev splash overlay — shows idle mode so you can dismiss with any interaction + if (devSplash) { + return ( + { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }} + /> + ); + } + + // Loading splash — shown until ring fills + transition completes + if (!appReady) { + return ( + setAppReady(true)} + onRetry={() => { + setFailed(false); + setServerProbeOk(false); + setRetryKey(k => k+1); + }} + /> + ); + } + return (

- {!activeChapter && } + {idle && !activeChapter && ( + { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }} + /> + )} + {!activeChapter && }
- {activeChapter ? : } + {activeChapter ? : }
- {settingsOpen && } - - + {settingsOpen && } + +
); } \ No newline at end of file diff --git a/src/components/explore/Explore.tsx b/src/components/explore/Explore.tsx index 7496342..551b43a 100644 --- a/src/components/explore/Explore.tsx +++ b/src/components/explore/Explore.tsx @@ -157,6 +157,8 @@ function ExploreFeed() { const [genreResults, setGenreResults] = useState>(new Map()); const [loadingGenres, setLoadingGenres] = useState(false); const [sources, setSources] = useState([]); + const [loadError, setLoadError] = useState(false); + const [retryCount, setRetryCount] = useState(0); const abortRef = useRef(null); const fetchedGenresRef = useRef(""); @@ -208,10 +210,25 @@ function ExploreFeed() { ]; } - // ── Library + sources load (runs once) ──────────────────────────────────── + // ── Library + sources load (retries when suwayomi wasn't ready) ───────────── useEffect(() => { + // If we already have data, no need to re-fetch (cache hit path) + const alreadyLoaded = allManga.length > 0 && sources.length > 0; + if (alreadyLoaded) return; + + setLoadingLib(true); + setLoadingPopular(true); + setLoadError(false); + const preferredLang = settings.preferredExtensionLang || "en"; + // Clear stale failed cache entries so we actually retry + if (retryCount > 0) { + cache.clear(CACHE_KEYS.LIBRARY); + cache.clear(CACHE_KEYS.SOURCES); + fetchedGenresRef.current = ""; + } + // Library — fire immediately, independent of sources cache.get(CACHE_KEYS.LIBRARY, () => Promise.all([ @@ -222,7 +239,7 @@ function ExploreFeed() { return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m); }) ).then(setAllManga) - .catch(console.error) + .catch((e) => { console.error(e); setLoadError(true); }) .finally(() => setLoadingLib(false)); // Sources — then kick off popular AND genres simultaneously @@ -230,7 +247,7 @@ function ExploreFeed() { gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) .then((d) => dedupeSources(d.sources.nodes, preferredLang)) ).then((allSources) => { - if (allSources.length === 0) { setLoadingPopular(false); return; } + if (allSources.length === 0) { setLoadingPopular(false); setLoadError(true); return; } // Cap to 2 sources for the explore feed — halves the network calls const topSources = getTopSources(allSources).slice(0, 2); @@ -292,9 +309,9 @@ function ExploreFeed() { .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) .finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); }); }) - .catch(console.error); + .catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [retryCount]); // ── Frecency genres (derived from history + library) ────────────────────── const frecencyGenres = useMemo(() => { @@ -459,8 +476,23 @@ function ExploreFeed() { continueReading.length === 0 && recommended.length === 0 && popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
- Nothing to explore yet - Add manga to your library or install sources to get started. + {loadError ? ( + <> + Could not reach Suwayomi + Make sure the server is running, then try again. + + + ) : ( + <> + Nothing to explore yet + Add manga to your library or install sources to get started. + + )}
)} diff --git a/src/components/explore/GenreDrillPage.tsx b/src/components/explore/GenreDrillPage.tsx index 6bff9a1..7f3a41c 100644 --- a/src/components/explore/GenreDrillPage.tsx +++ b/src/components/explore/GenreDrillPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useRef, memo } from "react"; import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; -import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache"; +import { cache, CACHE_KEYS } from "../../lib/cache"; import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/sourceUtils"; import { useStore } from "../../store"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; @@ -67,13 +67,18 @@ export default function GenreDrillPage() { gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) .then((d) => dedupeSources(d.sources.nodes, preferredLang)) ).then((allSources) => { - const topSources = getTopSources(allSources); + // Use ALL deduped sources for drill pages (not just frecency top 4) + // Cap at 8 to avoid hammering the server too hard + const sourcesToQuery = allSources.slice(0, 8); return cache.get(CACHE_KEYS.GENRE(genre), () => Promise.allSettled( - topSources.map((src) => - gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { - source: src.id, type: "SEARCH", page: 1, query: genre, - }, ctrl.signal).then((d) => d.fetchSourceManga.mangas) + // Fetch page 1 and page 2 from each source for a fuller result set + sourcesToQuery.flatMap((src) => + [1, 2].map((page) => + gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { + source: src.id, type: "SEARCH", page, query: genre, + }, ctrl.signal).then((d) => d.fetchSourceManga.mangas) + ) ) ).then((results) => { const merged: Manga[] = []; @@ -91,9 +96,14 @@ export default function GenreDrillPage() { }, [genre]); const filtered = useMemo(() => { + // Library manga: only include if genre matches (we have full metadata) const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre)); - const srcMatches = sourceManga.filter((m) => !m.genre?.length || m.genre.includes(genre)); - return dedupeMangaById([...libMatches, ...srcMatches]); + // Source manga: include ALL results — they came from a genre search, + // but the API often returns no genre tags in the brief response payload. + // De-duplicate against library matches by id. + const libIds = new Set(libMatches.map((m) => m.id)); + const srcAll = sourceManga.filter((m) => !libIds.has(m.id)); + return dedupeMangaById([...libMatches, ...srcAll]); }, [libraryManga, sourceManga, genre]); function openCtx(e: React.MouseEvent, m: Manga) { diff --git a/src/components/layout/SplashScreen.tsx b/src/components/layout/SplashScreen.tsx new file mode 100644 index 0000000..1c11901 --- /dev/null +++ b/src/components/layout/SplashScreen.tsx @@ -0,0 +1,458 @@ +import { useEffect, useRef, useState } from "react"; +import logoUrl from "../../assets/moku-icon.svg"; + +export type SplashMode = "loading" | "idle"; +export const EXIT_MS = 320; + +interface Props { + mode: SplashMode; + ringFull?: boolean; + failed?: boolean; + showCards?: boolean; + showFps?: boolean; // only passed from devSplash + onReady?: () => void; + onRetry?: () => void; + onDismiss?: () => void; +} + +// ── Hash ────────────────────────────────────────────────────────────────────── +function hash(n: number): number { + let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b); + x = Math.imul(x ^ (x >>> 16), 0x45d9f3b); + return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff; +} + +// ── Dimensions ──────────────────────────────────────────────────────────────── +// Use window dimensions for card/stamp generation (reasonable at load time), +// but the canvas itself will resize dynamically — see CardCanvas below. +const VW = typeof window !== "undefined" ? window.innerWidth : 1280; +const VH = typeof window !== "undefined" ? window.innerHeight : 800; +const BUF = 80; +const COLS = 14; + +// ── Card definition — lines stored here so stamps use the exact same value ─── +interface CardDef { + layer: 0 | 1 | 2; + cx: number; + w: number; + h: number; + lines: number; // 1‒3, stored once, used by both stamp builder & (future) draw + alpha: number; + speed: number; + cycleSec: number; + phase: number; + travel: number; + yStart: number; + angleStart: number; + tilt: number; +} + +const LAYER_CFG = [ + { wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 }, + { wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 }, + { wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 }, +] as const; + +const CARDS: CardDef[] = (() => { + const out: CardDef[] = []; + const laneW = VW / COLS; + for (let layer = 0; layer < 3; layer++) { + const cfg = LAYER_CFG[layer]; + for (let col = 0; col < COLS; col++) { + const seed = col * 31 + layer * 97 + 7; + const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin); + const h = w * 1.44; + const maxNudge = (laneW - w) / 2 - 2; + const cx = (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, maxNudge); + const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin); + const travel = VH + h + BUF; + out.push({ + layer: layer as 0 | 1 | 2, + cx, w, h, + lines: 1 + Math.floor(hash(seed + 7) * 3), // same seed+7 always + alpha: cfg.alpha, + speed, + cycleSec: travel / speed, + phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1, + travel, + yStart: VH + h / 2 + BUF / 2, + angleStart:hash(seed + 3) * 50 - 25, + tilt: (hash(seed + 4) * 2 - 1) * 18, + }); + } + } + return out; +})(); + +// ── Pre-computed per-card trig deltas ──────────────────────────────────────── +// angleStart and tilt are fixed; only p (0→1) scales the tilt. +// We can't fully precompute because p changes per frame, but we CAN precompute +// the per-radian cos/sin values and use small-angle linearisation... actually +// the simplest win is to note angles are small (±43° max) and just avoid +// recomputing Math.cos/sin of angleStart every frame — cache them, then +// use rotation composition for the tilt delta which is tiny per frame. +// +// Simpler and sufficient: cache base angle cos/sin for each card at module init, +// then compose with the tilt delta using the rotation formula: +// cos(a+d) = cos(a)*cos(d) - sin(a)*sin(d) +// sin(a+d) = sin(a)*cos(d) + cos(a)*sin(d) +// Since the tilt delta is at most 18° total over the whole travel, per-frame +// delta is tiny — Math.cos of a tiny number ≈ 1, Math.sin ≈ angle. +// But the cleanest approach: just cache angleStart's cos/sin, and per frame +// only compute cos/sin of the TILT FRACTION (small value). +interface CardTrig { cosA: number; sinA: number; tiltRad: number; } +const CARD_TRIG: CardTrig[] = CARDS.map(c => ({ + cosA: Math.cos(c.angleStart * (Math.PI / 180)), + sinA: Math.sin(c.angleStart * (Math.PI / 180)), + tiltRad: c.tilt * (Math.PI / 180), +})); + +// ── Rounded rect path helper ────────────────────────────────────────────────── +function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); + ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); + ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); + ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); +} + +// ── Stamp builder — runs ONCE at module init ────────────────────────────────── +// Each card is pre-rendered at full opacity to a tiny offscreen canvas. +// Hot path does zero path ops — just globalAlpha + drawImage per card. +const STAMP_PAD = 6; + +const STAMPS: HTMLCanvasElement[] = (() => { + if (typeof document === "undefined") return []; + return CARDS.map(c => { + const oc = document.createElement("canvas"); + oc.width = Math.ceil(c.w + STAMP_PAD * 2); + oc.height = Math.ceil(c.h + STAMP_PAD * 2); + const ctx = oc.getContext("2d")!; + const x0 = STAMP_PAD; + const y0 = STAMP_PAD; + const coverH = (c.w * 0.72) * 1.05; + // Text lines start just below the cover rect + const lineY0 = y0 + 3 + coverH + 5; + + // Shadow + ctx.fillStyle = "rgba(0,0,0,0.5)"; + rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill(); + + // Body + ctx.fillStyle = "rgba(255,255,255,0.07)"; + rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill(); + + // Border + ctx.strokeStyle = "rgba(255,255,255,0.75)"; + ctx.lineWidth = 1.2; + rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke(); + + // Cover area + ctx.fillStyle = "rgba(255,255,255,0.15)"; + rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill(); + + // Cover tint band + ctx.fillStyle = "rgba(255,255,255,0.08)"; + rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill(); + + // Text lines — use c.lines (same value as buildCards computed) + for (let li = 0; li < c.lines; li++) { + ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)"; + ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2); + } + + return oc; + }); +})(); + +// ── Pre-baked vignette canvas ───────────────────────────────────────────────── +const VIGNETTE: HTMLCanvasElement | null = (() => { + if (typeof document === "undefined") return null; + const oc = document.createElement("canvas"); + oc.width = VW; oc.height = VH; + const ctx = oc.getContext("2d")!; + const g = ctx.createRadialGradient(VW / 2, VH / 2, 0, VW / 2, VH / 2, Math.max(VW, VH) * 0.65); + g.addColorStop(0.15, "rgba(0,0,0,0)"); + g.addColorStop(1, "rgba(0,0,0,0.82)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, VW, VH); + return oc; +})(); + +// ── Draw frame — hot path ───────────────────────────────────────────────────── +// Uses setTransform() instead of manual translate/rotate undo. +// setTransform sets the full matrix in one call — no floating-point drift, +// no stack push/pop, one fewer operation than save+restore. +function drawFrame(ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number) { + ctx.clearRect(0, 0, cw, ch); + + for (let i = 0; i < CARDS.length; i++) { + const c = CARDS[i]; + const p = ((t / c.cycleSec) + c.phase) % 1; + + const alpha = p < 0.07 + ? (p / 0.07) * c.alpha + : p > 0.86 + ? ((1 - p) / 0.14) * c.alpha + : c.alpha; + + if (alpha < 0.005) continue; + + const cy = c.yStart - p * c.travel; + + // Compose base rotation with tilt delta using trig identity — + // avoids two Math.cos/sin calls; only one pair for the small delta. + const tg = CARD_TRIG[i]; + const delta = tg.tiltRad * p; // small value (≤ 18° * 1) + const cosDelta = Math.cos(delta); + const sinDelta = Math.sin(delta); + const cos = tg.cosA * cosDelta - tg.sinA * sinDelta; + const sin = tg.sinA * cosDelta + tg.cosA * sinDelta; + + ctx.globalAlpha = alpha; + // setTransform(a,b,c,d,e,f) = [cos,sin,-sin,cos,tx,ty] + ctx.setTransform(cos, sin, -sin, cos, c.cx, cy); + ctx.drawImage(STAMPS[i], -c.w / 2 - STAMP_PAD, -c.h / 2 - STAMP_PAD); + } + + // Reset to identity + full opacity in one call + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalAlpha = 1; + if (VIGNETTE) ctx.drawImage(VIGNETTE, 0, 0, cw, ch); +} + +// ── Ring ────────────────────────────────────────────────────────────────────── +function Ring({ progress }: { progress: number }) { + const r = 44, sw = 2, pad = 8; + const size = (r + pad) * 2, c = r + pad; + const circ = 2 * Math.PI * r; + const arc = circ * Math.min(Math.max(progress, 0.025), 0.999); + return ( + + + + + ); +} + +// ── FPS counter — only mounted when showFps=true (devSplash only) ───────────── +function FpsCounter() { + const divRef = useRef(null); + const times = useRef([]); + + useEffect(() => { + let raf = 0; + function tick(now: number) { + const arr = times.current; + arr.push(now); + if (arr.length > 60) arr.shift(); + if (arr.length > 1 && divRef.current) { + const fps = Math.round((arr.length - 1) / ((arr[arr.length - 1] - arr[0]) / 1000)); + divRef.current.textContent = `${fps} fps`; + divRef.current.style.color = fps >= 55 ? "#4ade80" : fps >= 30 ? "#facc15" : "#f87171"; + } + raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + + return ( +
-- fps
+ ); +} + +// ── CardCanvas — owns the single rAF loop ───────────────────────────────────── +function CardCanvas({ showFps }: { showFps: boolean }) { + const ref = useRef(null); + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + const ctx = canvas.getContext("2d", { alpha: true, willReadFrequently: false }); + if (!ctx) return; + + // Keep canvas resolution in sync with its CSS size + function syncSize() { + if (!canvas) return; + canvas.width = canvas.offsetWidth || window.innerWidth; + canvas.height = canvas.offsetHeight || window.innerHeight; + } + syncSize(); + const ro = new ResizeObserver(syncSize); + ro.observe(canvas); + + let raf = 0, t0 = -1; + function frame(now: number) { + if (t0 < 0) t0 = now; + drawFrame(ctx!, (now - t0) / 1000, canvas!.width, canvas!.height); + raf = requestAnimationFrame(frame); + } + raf = requestAnimationFrame(frame); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, []); + + return ( + <> + + {showFps && } + + ); +} + +// ── Static CSS ──────────────────────────────────────────────────────────────── +const STATIC_CSS = ` +@keyframes spIn { from{opacity:0;transform:scale(1.015)} to{opacity:1;transform:scale(1)} } +@keyframes spOut { from{opacity:1;transform:scale(1)} to{opacity:0;transform:scale(0.96)} } +@keyframes logoBreathe { + 0%,100%{transform:scale(1);filter:drop-shadow(0 0 0px rgba(255,255,255,0))} + 50% {transform:scale(1.04);filter:drop-shadow(0 0 18px rgba(255,255,255,0.12))} +} +@keyframes hintFade { 0%,100%{opacity:0.35} 50%{opacity:0.7} } +`; + +// ── Main ────────────────────────────────────────────────────────────────────── +export default function SplashScreen({ + mode, ringFull = false, failed = false, + showCards = true, showFps = false, + onReady, onRetry, onDismiss, +}: Props) { + const [dots, setDots] = useState(""); + const [ringProg, setRingProg] = useState(0.025); + const [exiting, setExiting] = useState(false); + const exitLock = useRef(false); + + function triggerExit(cb?: () => void) { + if (exitLock.current) return; + exitLock.current = true; + setExiting(true); + setTimeout(() => cb?.(), EXIT_MS); + } + + useEffect(() => { + if (!ringFull) return; + setRingProg(1); + const t = setTimeout(() => triggerExit(onReady), 650); + return () => clearTimeout(t); + }, [ringFull]); + + useEffect(() => { + const id = setInterval(() => setDots(d => d.length >= 3 ? "" : d + "."), 420); + return () => clearInterval(id); + }, []); + + // Idle dismiss: keydown / mousedown / touchstart only — NO mousemove + useEffect(() => { + if (mode !== "idle" || !onDismiss) return; + function handler() { triggerExit(onDismiss); } + window.addEventListener("keydown", handler, { once: true }); + window.addEventListener("mousedown", handler, { once: true }); + window.addEventListener("touchstart", handler, { once: true }); + return () => { + window.removeEventListener("keydown", handler); + window.removeEventListener("mousedown", handler); + window.removeEventListener("touchstart", handler); + }; + }, [mode, onDismiss]); + + const isIdle = mode === "idle"; + + return ( +
+ + + {showCards && } + + {isIdle ? ( +
+
+
+ Moku +
+

press any key to continue

+
+ ) : ( + <> +
+ {!failed && } + Moku +
+

moku

+
+ {failed ? ( + <> +

+ Could not reach Suwayomi +

+

+ Make sure tachidesk-server is on your PATH +

+ + + ) : ( +

+ {ringFull ? "Ready" : `Initializing server${dots}`} +

+ )} +
+ + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/pages/Library.tsx b/src/components/pages/Library.tsx index 0b3c15c..ae26ddc 100644 --- a/src/components/pages/Library.tsx +++ b/src/components/pages/Library.tsx @@ -58,13 +58,14 @@ function fetchLibrary() { } export default function Library() { - const [allManga, setAllManga] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [search, setSearch] = useState(""); - const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); - const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null); - const scrollRef = useRef(null); + const [allManga, setAllManga] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [retryCount, setRetryCount] = useState(0); + const [search, setSearch] = useState(""); + const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); + const [emptyCtx, setEmptyCtx] = useState<{ x: number; y: number } | null>(null); + const scrollRef = useRef(null); const setActiveManga = useStore((state) => state.setActiveManga); const libraryFilter = useStore((state) => state.libraryFilter); @@ -80,18 +81,30 @@ export default function Library() { const loadData = useCallback((showLoading = false) => { if (showLoading) setLoading(true); + // Clear a previously failed cache entry so we actually retry the network call + if (!cache.has(CACHE_KEYS.LIBRARY)) { + // cache miss — fresh fetch, nothing to clear + } fetchLibrary() .then((nodes) => { setAllManga(nodes); setError(null); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }, []); + // Initial load — delayed on first mount so the server has time to start. + // retryCount bumps force a re-run; manual retries clear the cache first. useEffect(() => { - loadData(true); - // Re-fetch when library cache is invalidated (e.g. by Explore or GenreDrillPage) + setLoading(true); + setError(null); + + if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); + loadData(false); + + // Re-fetch when library cache is invalidated by other pages const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false)); return unsub; - }, [loadData]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [retryCount]); useEffect(() => { scrollRef.current?.scrollTo({ top: 0 }); @@ -271,7 +284,13 @@ export default function Library() { if (error) return (

Could not reach Suwayomi

-

{error}

+

Make sure the server is running, then retry.

+
); diff --git a/src/components/pages/Reader.tsx b/src/components/pages/Reader.tsx index a12a4bd..22f0254 100644 --- a/src/components/pages/Reader.tsx +++ b/src/components/pages/Reader.tsx @@ -256,9 +256,14 @@ export default function Reader() { * currently reading (for topbar display) without triggering a full reload. */ const [visibleChapterId, setVisibleChapterId] = useState(null); + // Ref mirror so the scroll handler always reads the latest value without + // closing over a stale state snapshot from a previous effect render. + const visibleChapterIdRef = useRef(null); // Keep the ref mirror in sync so the scroll handler always sees current strip state useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]); + // Keep visibleChapterId ref in sync + useEffect(() => { visibleChapterIdRef.current = visibleChapterId; }, [visibleChapterId]); // Restore scroll position synchronously after a head-trim, before the browser paints useLayoutEffect(() => { @@ -681,33 +686,54 @@ export default function Reader() { // ── Infinite append ────────────────────────────────────────────────── if (!autoNext) { - const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80; + // Only navigate when the strip genuinely overflows the viewport. + // If pages are short/zoomed-out, scrollHeight === clientHeight and + // atBottom would always be true, causing unwanted chapter switches. + const isScrollable = el.scrollHeight > el.clientHeight + 4; + const atBottom = isScrollable && el.scrollTop + el.clientHeight >= el.scrollHeight - 80; if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList); return; } const strip = stripChaptersRef.current; - // Silently update visibleChapterId as we scroll into each chunk + // Silently update visibleChapterId as we scroll into each chunk. + // Use the ref so we always compare against the current value, not a + // stale closure snapshot from when the effect was last set up. for (const chunk of strip) { const chunkEnd = chunk.startGlobalIdx + chunk.urls.length; if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) { - if (chunk.chapterId !== visibleChapterId) { - setVisibleChapterId(chunk.chapterId); + if (chunk.chapterId !== visibleChapterIdRef.current) { + // Mark the chapter we just *left* as read before updating the ref. if (settings.autoMarkRead) { - const prevChunk = strip[strip.indexOf(chunk) - 1]; - if (prevChunk) { - if (!markedReadRef.current.has(prevChunk.chapterId)) { - markedReadRef.current.add(prevChunk.chapterId); - gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error); - } + const chunkIdx = strip.indexOf(chunk); + const prevChunk = chunkIdx > 0 ? strip[chunkIdx - 1] : null; + if (prevChunk && !markedReadRef.current.has(prevChunk.chapterId)) { + markedReadRef.current.add(prevChunk.chapterId); + gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error); } } + visibleChapterIdRef.current = chunk.chapterId; + setVisibleChapterId(chunk.chapterId); } break; } } + // When the user reaches the very bottom of the full strip, mark the + // last chapter as read (it never triggers the "crossed into next chunk" path). + if (settings.autoMarkRead) { + const isScrollable = el.scrollHeight > el.clientHeight + 4; + const atVeryBottom = isScrollable && el.scrollTop + el.clientHeight >= el.scrollHeight - 40; + if (atVeryBottom) { + const lastChunk = strip[strip.length - 1]; + if (lastChunk && !markedReadRef.current.has(lastChunk.chapterId)) { + markedReadRef.current.add(lastChunk.chapterId); + gql(MARK_CHAPTER_READ, { id: lastChunk.chapterId, isRead: true }).catch(console.error); + } + } + } + // Append next chapter when within 300px of the bottom const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300; if (!nearBottom) return; @@ -751,7 +777,7 @@ export default function Reader() { el.removeEventListener("scroll", onScroll); cancelAnimationFrame(rafRef.current); }; - }, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]); + }, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages]); // Reset scroll position when switching chapters in non-longstrip modes useEffect(() => { diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 75746c9..d6d5cc1 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, useCallback } from "react"; -import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash } from "@phosphor-icons/react"; +import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench } from "@phosphor-icons/react"; import { invoke } from "@tauri-apps/api/core"; import { gql } from "../../lib/client"; import { GET_DOWNLOADS_PATH } from "../../lib/queries"; @@ -9,7 +9,7 @@ import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from import type { Settings, FitMode } from "../../store"; import s from "./Settings.module.css"; -type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about"; +type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools"; const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: "general", label: "General", icon: }, @@ -20,6 +20,7 @@ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: "storage", label: "Storage", icon: }, { id: "folders", label: "Folders", icon: }, { id: "about", label: "About", icon: }, + { id: "devtools", label: "Dev Tools", icon: }, ]; // ── Primitives ──────────────────────────────────────────────────────────────── @@ -174,6 +175,24 @@ function GeneralTab({ settings, update }: { settings: Settings; update: (p: Part checked={settings.autoStartServer} onChange={(v) => update({ autoStartServer: v })} />
+
+

Inactivity

+ update({ idleTimeoutMin: Number(v) })} + /> +
); } @@ -340,6 +359,13 @@ function PerformanceTab({ settings, update }: { settings: Settings; update: (p: checked={settings.gpuAcceleration} onChange={(v) => update({ gpuAcceleration: v })} /> +
+

Idle / Splash Screen

+ update({ splashCards: v })} /> +

Interface

setSplashTriggered(false), 200); + (window as any).__mokuShowSplash?.(); + } + + return ( +
+
+

Splash Screen

+
+
+ Preview idle screen + Show the idle splash — dismiss with any click or key +
+ +
+
+
+

Build Info

+
+

+ Mode: {import.meta.env.MODE} +

+

+ Dev: {String(import.meta.env.DEV)} +

+
+
+
+ ); +} + function AboutTab() { return (
@@ -776,6 +847,7 @@ export default function SettingsModal() { {tab === "storage" && } {tab === "folders" && } {tab === "about" && } + {tab === "devtools" && }
diff --git a/src/store/index.ts b/src/store/index.ts index 21c3bee..817b433 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -65,6 +65,8 @@ export interface Settings { autoStartServer: boolean; preferredExtensionLang: string; keybinds: Keybinds; + idleTimeoutMin?: number; + splashCards?: boolean; storageLimitGb: number | null; folders: Folder[]; /** Debounce delay (ms) applied to the reader's scroll/page-change handler. 0 = off. */ @@ -95,6 +97,8 @@ export const DEFAULT_SETTINGS: Settings = { autoStartServer: true, preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS, + idleTimeoutMin: 5, + splashCards: true, storageLimitGb: null, folders: [], readerDebounceMs: 120,