mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Major Bug Fixes & Loading Screen (WIP)
This commit is contained in:
+123
-46
@@ -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<DownloadQueueItem[]>([]);
|
||||
// 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<DownloadQueueItem[]>([]);
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | 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<DlPayload>("download-progress", (e) => {
|
||||
setActiveDownloads(e.payload);
|
||||
});
|
||||
return () => { unsub.then((fn) => fn()); };
|
||||
type P = { chapterId:number; mangaId:number; progress:number }[];
|
||||
const unsub = listen<P>("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 (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showFps
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => setDevSplash(false), SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading splash — shown until ring fills + transition completes
|
||||
if (!appReady) {
|
||||
return (
|
||||
<SplashScreen
|
||||
mode="loading"
|
||||
ringFull={serverProbeOk}
|
||||
failed={failed}
|
||||
showCards={settings.splashCards ?? true}
|
||||
onReady={() => setAppReady(true)}
|
||||
onRetry={() => {
|
||||
setFailed(false);
|
||||
setServerProbeOk(false);
|
||||
setRetryKey(k => k+1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
{!activeChapter && <TitleBar />}
|
||||
{idle && !activeChapter && (
|
||||
<SplashScreen
|
||||
mode="idle"
|
||||
showCards={settings.splashCards ?? true}
|
||||
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
|
||||
/>
|
||||
)}
|
||||
{!activeChapter && <TitleBar/>}
|
||||
<div className={s.content}>
|
||||
{activeChapter ? <Reader /> : <Layout />}
|
||||
{activeChapter ? <Reader/> : <Layout/>}
|
||||
</div>
|
||||
{settingsOpen && <Settings />}
|
||||
<MangaPreview />
|
||||
<Toaster />
|
||||
{settingsOpen && <Settings/>}
|
||||
<MangaPreview/>
|
||||
<Toaster/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,6 +157,8 @@ function ExploreFeed() {
|
||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fetchedGenresRef = useRef<string>("");
|
||||
|
||||
@@ -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) && (
|
||||
<div className={s.empty}>
|
||||
<span>Nothing to explore yet</span>
|
||||
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
||||
{loadError ? (
|
||||
<>
|
||||
<span>Could not reach Suwayomi</span>
|
||||
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
|
||||
<button
|
||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Nothing to explore yet</span>
|
||||
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<svg width={size} height={size} style={{
|
||||
position: "absolute", pointerEvents: "none",
|
||||
top: -((size - 80) / 2), left: -((size - 80) / 2),
|
||||
}}>
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={sw} />
|
||||
<circle cx={c} cy={c} r={r} fill="none" stroke="#4ade80" strokeWidth={sw}
|
||||
strokeLinecap="round" strokeDasharray={`${arc} ${circ}`}
|
||||
transform={`rotate(-90 ${c} ${c})`}
|
||||
style={{ transition: "stroke-dasharray 0.55s cubic-bezier(0.4,0,0.2,1)" }} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FPS counter — only mounted when showFps=true (devSplash only) ─────────────
|
||||
function FpsCounter() {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const times = useRef<number[]>([]);
|
||||
|
||||
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 (
|
||||
<div ref={divRef} style={{
|
||||
position: "fixed", top: 10, right: 14, zIndex: 10001,
|
||||
fontFamily: "var(--font-mono, 'Courier New', monospace)",
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: "0.08em",
|
||||
color: "#4ade80",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
borderRadius: 4, padding: "2px 7px",
|
||||
userSelect: "none", pointerEvents: "none",
|
||||
}}>-- fps</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CardCanvas — owns the single rAF loop ─────────────────────────────────────
|
||||
function CardCanvas({ showFps }: { showFps: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(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 (
|
||||
<>
|
||||
<canvas ref={ref} style={{
|
||||
position: "absolute", inset: 0, pointerEvents: "none",
|
||||
width: "100%", height: "100%",
|
||||
}} />
|
||||
{showFps && <FpsCounter />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, zIndex: 9999,
|
||||
background: "var(--bg-base)", overflow: "hidden",
|
||||
display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
cursor: isIdle ? "pointer" : "default",
|
||||
animation: exiting
|
||||
? `spOut ${EXIT_MS}ms cubic-bezier(0.4,0,1,1) both`
|
||||
: "spIn 0.35s cubic-bezier(0,0,0.2,1) both",
|
||||
}}>
|
||||
<style>{STATIC_CSS}</style>
|
||||
|
||||
{showCards && <CardCanvas showFps={showFps} />}
|
||||
|
||||
{isIdle ? (
|
||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<div style={{ position: "relative", width: 128, height: 128, marginBottom: 32 }}>
|
||||
<div style={{
|
||||
position: "absolute", inset: -20, borderRadius: "50%",
|
||||
background: "radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%)",
|
||||
animation: "logoBreathe 4s ease-in-out infinite",
|
||||
}} />
|
||||
<img src={logoUrl} alt="Moku" style={{
|
||||
width: 128, height: 128, borderRadius: 28,
|
||||
display: "block", position: "relative",
|
||||
animation: "logoBreathe 4s ease-in-out infinite",
|
||||
}} />
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)",
|
||||
letterSpacing: "0.22em", textTransform: "uppercase",
|
||||
margin: 0, userSelect: "none",
|
||||
animation: "hintFade 3.5s ease-in-out infinite",
|
||||
}}>press any key to continue</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ position: "relative", width: 80, height: 80, marginBottom: 20, zIndex: 1 }}>
|
||||
{!failed && <Ring progress={ringProg} />}
|
||||
<img src={logoUrl} alt="Moku"
|
||||
style={{ width: 80, height: 80, borderRadius: 18, display: "block" }} />
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: "var(--font-ui)", fontSize: 11, fontWeight: 500,
|
||||
letterSpacing: "0.26em", textTransform: "uppercase",
|
||||
color: "var(--text-secondary)", margin: "0 0 8px",
|
||||
zIndex: 1, userSelect: "none",
|
||||
}}>moku</p>
|
||||
<div style={{ zIndex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
||||
{failed ? (
|
||||
<>
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 11, color: "var(--color-error)", letterSpacing: "0.1em", margin: 0 }}>
|
||||
Could not reach Suwayomi
|
||||
</p>
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.05em", margin: 0, textAlign: "center", maxWidth: 240, lineHeight: "1.6" }}>
|
||||
Make sure tachidesk-server is on your PATH
|
||||
</p>
|
||||
<button onClick={onRetry} style={{
|
||||
marginTop: 4, padding: "5px 16px", borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--border-dim)", background: "var(--bg-raised)",
|
||||
color: "var(--text-muted)", cursor: "pointer",
|
||||
fontFamily: "var(--font-ui)", fontSize: 11, letterSpacing: "0.08em",
|
||||
}}>Retry</button>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontFamily: "var(--font-ui)", fontSize: 10, color: "var(--text-faint)", letterSpacing: "0.12em", margin: 0, minWidth: 160, textAlign: "center" }}>
|
||||
{ringFull ? "Ready" : `Initializing server${dots}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,13 +58,14 @@ function fetchLibrary() {
|
||||
}
|
||||
|
||||
export default function Library() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<HTMLDivElement>(null);
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<HTMLDivElement>(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 (
|
||||
<div className={s.center}>
|
||||
<p className={s.errorMsg}>Could not reach Suwayomi</p>
|
||||
<p className={s.errorDetail}>{error}</p>
|
||||
<p className={s.errorDetail}>Make sure the server is running, then retry.</p>
|
||||
<button
|
||||
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
||||
onClick={() => setRetryCount((c) => c + 1)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -256,9 +256,14 @@ export default function Reader() {
|
||||
* currently reading (for topbar display) without triggering a full reload.
|
||||
*/
|
||||
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(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<number | null>(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(() => {
|
||||
|
||||
@@ -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: <Gear size={14} weight="light" /> },
|
||||
@@ -20,6 +20,7 @@ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
||||
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
|
||||
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
||||
{ id: "devtools", label: "Dev Tools", icon: <Wrench size={14} weight="light" /> },
|
||||
];
|
||||
|
||||
// ── Primitives ────────────────────────────────────────────────────────────────
|
||||
@@ -174,6 +175,24 @@ function GeneralTab({ settings, update }: { settings: Settings; update: (p: Part
|
||||
checked={settings.autoStartServer}
|
||||
onChange={(v) => update({ autoStartServer: v })} />
|
||||
</div>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Inactivity</p>
|
||||
<SelectRow
|
||||
label="Idle screen timeout"
|
||||
description="Show the Moku idle splash after this much inactivity. Set to Never to disable."
|
||||
value={String(settings.idleTimeoutMin ?? 5)}
|
||||
options={[
|
||||
{ value: "0", label: "Never" },
|
||||
{ value: "1", label: "1 minute" },
|
||||
{ value: "2", label: "2 minutes" },
|
||||
{ value: "5", label: "5 minutes" },
|
||||
{ value: "10", label: "10 minutes" },
|
||||
{ value: "15", label: "15 minutes" },
|
||||
{ value: "30", label: "30 minutes" },
|
||||
]}
|
||||
onChange={(v) => update({ idleTimeoutMin: Number(v) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -340,6 +359,13 @@ function PerformanceTab({ settings, update }: { settings: Settings; update: (p:
|
||||
checked={settings.gpuAcceleration}
|
||||
onChange={(v) => update({ gpuAcceleration: v })} />
|
||||
</div>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Idle / Splash Screen</p>
|
||||
<Toggle label="Animated card background"
|
||||
description="Show floating manga cards on the splash and idle screens. Disable if the animation feels slow on your machine."
|
||||
checked={settings.splashCards ?? true}
|
||||
onChange={(v) => update({ splashCards: v })} />
|
||||
</div>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Interface</p>
|
||||
<Toggle label="Compact sidebar"
|
||||
@@ -702,6 +728,51 @@ function FoldersTab() {
|
||||
);
|
||||
}
|
||||
|
||||
function DevToolsTab() {
|
||||
const [splashTriggered, setSplashTriggered] = useState(false);
|
||||
|
||||
function triggerSplash() {
|
||||
setSplashTriggered(true);
|
||||
setTimeout(() => setSplashTriggered(false), 200);
|
||||
(window as any).__mokuShowSplash?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Splash Screen</p>
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>Preview idle screen</span>
|
||||
<span className={s.toggleDesc}>Show the idle splash — dismiss with any click or key</span>
|
||||
</div>
|
||||
<button
|
||||
className={s.dangerBtn}
|
||||
style={{ background: splashTriggered ? "var(--accent-fg)" : undefined,
|
||||
color: splashTriggered ? "var(--bg-base)" : undefined,
|
||||
borderColor: splashTriggered ? "var(--accent-fg)" : undefined,
|
||||
transition: "all 0.15s ease" }}
|
||||
onClick={triggerSplash}
|
||||
>
|
||||
Show idle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Build Info</p>
|
||||
<div className={s.aboutBlock}>
|
||||
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)" }}>
|
||||
Mode: {import.meta.env.MODE}
|
||||
</p>
|
||||
<p className={s.aboutLine} style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 11, color: "var(--text-faint)", marginTop: "var(--sp-1)" }}>
|
||||
Dev: {String(import.meta.env.DEV)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutTab() {
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
@@ -776,6 +847,7 @@ export default function SettingsModal() {
|
||||
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
|
||||
{tab === "folders" && <FoldersTab />}
|
||||
{tab === "about" && <AboutTab />}
|
||||
{tab === "devtools" && <DevToolsTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user