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 ? ( press any key to continue ) : ( <> {!failed && } moku {failed ? ( <> Could not reach Suwayomi Make sure tachidesk-server is on your PATH Retry > ) : ( {ringFull ? "Ready" : `Initializing server${dots}`} )} > )} ); }
press any key to continue
moku
Could not reach Suwayomi
Make sure tachidesk-server is on your PATH
{ringFull ? "Ready" : `Initializing server${dots}`}