"use client"; import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, Suspense, } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import Link from "next/link"; import { FaRedo } from "react-icons/fa"; import { MdLibraryMusic } from "react-icons/md"; import { toast, ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { GameRoot, GameGlobalStyle, GameNavbar, GameContent, HUD, HudStat, HudValue, HudLabel, ComboValue, GameArea, UpcomingWrap, UpcomingLabel, UpcomingText, CurrentWrap, LineTimingMeta, LineTimingValue, LineTimingRow, LineTimingBar, LineTimingFill, CharRow, WordWrap, CharBox, ClearToast, GetReadyText, BackgroundVideo, OpacityControl, OpacityLabel, OpacitySlider, OpacityValue, CompletedLineFade, GameFooter, ControlBtn, ProgressWrap, ProgressFill, TimeText, StartOverlay, StartCard, CountdownNumber, SongTitleText, SongArtistText, StartBtn, CodeSection, CodeInputRow, CodeInputField, CodeLoadBtn, ResultsOverlay, ResultsCard, ResultsTitle, BigScore, StatsGrid, StatBlock, StatValue, StatLabel, ActionRow, PlayAgainBtn, HomeBtn, } from "./page.styles"; import { gReducer, initialGState } from "./game.stat"; import { formatTime, parseLrcLines, calculateCPSNeeded } from "./game.utils"; type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished"; const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]); const isVideoUrl = (url: string) => { if (!url) return false; const cleaned = url.split("?")[0].split("#")[0]; const ext = cleaned.split(".").pop()?.toLowerCase(); return !!ext && VIDEO_EXTENSIONS.has(ext); }; const BACKGROUND_OPACITY_KEY = "lrcType.backgroundOpacity"; function GameInner() { const searchParams = useSearchParams(); const router = useRouter(); useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); }, []); const audioRef = useRef(null); const videoRef = useRef(null); const gameStartTimeRef = useRef(0); const lastHandledIdxRef = useRef(-1); const lastLineAdvanceAtRef = useRef(0); const [phase, setPhase] = useState("idle"); const [currentMs, setCurrentMs] = useState(0); const [lineTimingPct, setLineTimingPct] = useState(0); const [lineRemainingMs, setLineRemainingMs] = useState(0); const [currentLineTime, setCurrentLineTime] = useState(0); const [duration, setDuration] = useState(0); const [progressPct, setProgressPct] = useState(0); const [gameDurationMs, setGameDurationMs] = useState(0); const [lrcContent, setLrcContent] = useState(""); const [audioUrl, setAudioUrl] = useState(""); const [songTitle, setSongTitle] = useState("Unknown Title"); const [songArtist, setSongArtist] = useState("Unknown Artist"); const [offset, setOffset] = useState(0); const [loadingLrc, setLoadingLrc] = useState(false); const [codeInput, setCodeInput] = useState(""); const [wrongChar, setWrongChar] = useState(false); const [clearShowing, setClearShowing] = useState(false); const [comboAnimKey, setComboAnimKey] = useState(0); const [countdown, setCountdown] = useState(0); const [backgroundOpacity, setBackgroundOpacity] = useState(() => { if (typeof window === "undefined") return 0; const stored = localStorage.getItem(BACKGROUND_OPACITY_KEY); if (stored === null) return 0; const parsed = Number(stored); if (!Number.isFinite(parsed)) return 0; return Math.min(100, Math.max(0, parsed)); }); const [skipBacking, setSkipBacking] = useState(false); const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]); useEffect(() => { localStorage.setItem(BACKGROUND_OPACITY_KEY, String(backgroundOpacity)); }, [backgroundOpacity]); const charRowRef = useRef(null); const charRefs = useRef<(HTMLSpanElement | null)[]>([]); const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState([]); const countdownIntervalRef = useRef(null); const [g, dispatch] = useReducer(gReducer, initialGState); const gameLines = useMemo( () => parseLrcLines(lrcContent, { skipBacking }), [lrcContent, skipBacking] ); const isReady = !loadingLrc && !!lrcContent && !!audioUrl; const accuracy = g.totalCorrect + g.totalMiss > 0 ? Math.round((g.totalCorrect / (g.totalCorrect + g.totalMiss)) * 100) : 100; const elapsedMs = phase === "playing" ? Math.max(1, Date.now() - gameStartTimeRef.current) : gameDurationMs; const wpm = elapsedMs > 0 ? Math.round(g.totalCorrect / 5 / (elapsedMs / 60000)) : 0; const gRef = useRef(g); const currentLineContent = g.displayedLineIdx >= 0 ? gameLines[g.displayedLineIdx]?.content ?? "" : ""; useEffect(() => { charRefs.current = []; }, [currentLineContent]); useLayoutEffect(() => { if (!charRowRef.current) return; let frame = 0; const text = currentLineContent.toLowerCase(); const recompute = () => { const nodes = charRefs.current; const indicators = new Array(text.length).fill(false); for (let i = 0; i < text.length - 1; i += 1) { if (text[i] !== " ") continue; const curr = nodes[i]; const next = nodes[i + 1]; if (!curr || !next) continue; const currRect = curr.getBoundingClientRect(); const nextRect = next.getBoundingClientRect(); if (nextRect.top - currRect.top > 1) { indicators[i] = true; } } setWrapSpaceIndicators(indicators); }; const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { frame = requestAnimationFrame(recompute); }); }; schedule(); if (document.fonts?.ready) { document.fonts.ready.then(schedule); } const observer = new ResizeObserver(schedule); observer.observe(charRowRef.current); window.addEventListener("resize", schedule); return () => { observer.disconnect(); window.removeEventListener("resize", schedule); cancelAnimationFrame(frame); }; }, [currentLineContent]); useEffect(() => { gRef.current = g; }, [g]); const phaseRef = useRef("idle"); useEffect(() => { phaseRef.current = phase; }, [phase]); const offsetRef = useRef(0); useEffect(() => { offsetRef.current = offset; }, [offset]); useEffect(() => { if (!("mediaSession" in navigator)) return; const mediaSession = navigator.mediaSession; mediaSession.setActionHandler("pause", () => {}); return () => { mediaSession.setActionHandler("pause", null); }; }, []); useEffect(() => { return () => { if (countdownIntervalRef.current !== null) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } }; }, []); const lineAnimRef = useRef({ startMs: 0, endMs: 0, startPerf: 0 }); const timeBasedLineIdx = useMemo(() => { if (!gameLines.length) return -1; let idx = -1; for (let i = 0; i < gameLines.length; i++) { if (gameLines[i].millisecond <= currentMs) idx = i; else break; } return idx; }, [currentMs, gameLines]); const intermissionData = useMemo(() => { const firstMs = gameLines[0]?.millisecond ?? 0; if (firstMs <= 0) { return { pct: 100, remainingMs: 0 }; } const clampedCurrent = Math.max(0, currentMs); const remainingMs = Math.max(0, firstMs - clampedCurrent); const pct = Math.min(100, Math.max(0, (clampedCurrent / firstMs) * 100)); return { pct, remainingMs }; }, [gameLines, currentMs]); useEffect(() => { const idx = g.displayedLineIdx; if (idx < 0 || !gameLines[idx]) { lineAnimRef.current = { startMs: 0, endMs: 0, startPerf: 0 }; setLineTimingPct(0); setLineRemainingMs(0); setCurrentLineTime(-1); return; } const start = gameLines[idx].millisecond; const end = gameLines[idx + 1]?.millisecond ?? start + 5000; lineAnimRef.current = { startMs: start, endMs: end, startPerf: performance.now(), }; setLineTimingPct(0); const currentLineTime = end - start; setLineRemainingMs(Math.max(0, currentLineTime)); setCurrentLineTime(Math.max(currentLineTime, currentLineTime)); }, [g.displayedLineIdx, gameLines]); useEffect(() => { if (phase !== "playing") return; let rafId = 0; const tick = () => { const { startMs, endMs, startPerf } = lineAnimRef.current; if (endMs <= startMs) { setLineTimingPct(100); setLineRemainingMs(0); } else { const elapsed = performance.now() - startPerf; const duration = endMs - startMs; const pct = Math.min(100, Math.max(0, (elapsed / duration) * 100)); const remaining = Math.max(0, duration - elapsed); setLineTimingPct(pct); setLineRemainingMs(remaining); } rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); }, [phase]); useEffect(() => { const media = isVideo ? videoRef.current : audioRef.current; if (!media) return; const onTimeUpdate = () => { setCurrentMs(media.currentTime * 1000 + offsetRef.current); if (media.duration && !isNaN(media.duration)) { setDuration(media.duration * 1000); setProgressPct((media.currentTime / media.duration) * 100); } }; const onLoadedMetadata = () => { if (!isNaN(media.duration)) { setDuration(media.duration * 1000); setGameDurationMs(media.duration * 1000); } }; const onEnded = () => { setPhase("finished"); setGameDurationMs(Date.now() - gameStartTimeRef.current); }; media.addEventListener("timeupdate", onTimeUpdate); media.addEventListener("loadedmetadata", onLoadedMetadata); media.addEventListener("ended", onEnded); return () => { media.removeEventListener("timeupdate", onTimeUpdate); media.removeEventListener("loadedmetadata", onLoadedMetadata); media.removeEventListener("ended", onEnded); }; }, [isVideo, audioUrl]); useEffect(() => { if (phaseRef.current !== "playing") return; if (timeBasedLineIdx < 0) return; if (timeBasedLineIdx <= lastHandledIdxRef.current) return; lastHandledIdxRef.current = timeBasedLineIdx; lastLineAdvanceAtRef.current = performance.now(); dispatch({ type: "ADVANCE", newIdx: timeBasedLineIdx, prevCompleted: gRef.current.lineCompleted, }); }, [timeBasedLineIdx]); const loadData = useCallback((data: Record) => { if (typeof data.lrc === "string" && data.lrc) { setLoadingLrc(true); fetch(data.lrc) .then((r) => r.text()) .then((t) => { setLrcContent(t); setLoadingLrc(false); }); } if (typeof data.file1 === "string") setAudioUrl(data.file1); if (typeof data.offset === "number") setOffset(data.offset); if (typeof data.offset === "string" && data.offset.trim() !== "") setOffset(Number(data.offset)); if (typeof data.title === "string") setSongTitle(data.title); if (typeof data.artist === "string") setSongArtist(data.artist); if (typeof data.skip_backing === "boolean") setSkipBacking(data.skip_backing); if (typeof data.skip_backing === "string") setSkipBacking(data.skip_backing === "true"); }, []); useEffect(() => { const code = searchParams.get("code"); if (!code) return; try { const json = atob(code); const data = JSON.parse(json) as Record; loadData(data); } catch {} }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleStart = useCallback(() => { const media = isVideo ? videoRef.current : audioRef.current; if (!media || !lrcContent || !audioUrl) return; if (countdownIntervalRef.current !== null) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } dispatch({ type: "RESET" }); lastHandledIdxRef.current = -1; media.pause(); media.currentTime = 0; setPhase("countdown"); setCountdown(5); setGameDurationMs(0); setProgressPct(0); setCurrentMs(0); const beginPlayback = () => { media.currentTime = 0; media.play(); setPhase("playing"); gameStartTimeRef.current = Date.now(); }; countdownIntervalRef.current = window.setInterval(() => { setCountdown((c) => { if (c <= 1) { if (countdownIntervalRef.current !== null) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } beginPlayback(); return 0; } return c - 1; }); }, 1000); }, [lrcContent, audioUrl, gameLines, isVideo]); const handleRestart = useCallback(() => { const media = isVideo ? videoRef.current : audioRef.current; if (media) { media.pause(); media.currentTime = 0; } if (countdownIntervalRef.current !== null) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } setCountdown(0); dispatch({ type: "RESET" }); lastHandledIdxRef.current = -1; setPhase("idle"); setCurrentMs(0); setProgressPct(0); }, [isVideo]); const handleLoadCode = useCallback(() => { if (!codeInput.trim()) return; try { const json = atob(codeInput.trim()); const data = JSON.parse(json) as Record; loadData(data); handleRestart(); toast.success("Song loaded!", { theme: "dark" }); } catch { toast.error("Invalid code. Please check and try again.", { theme: "dark", }); } }, [codeInput, loadData, handleRestart]); const handleKeyPress = useCallback( (char: string) => { if (phaseRef.current !== "playing") return; const line = gameLines[gRef.current.displayedLineIdx]; if (!line || gRef.current.lineCompleted) return; const expected = line.content[gRef.current.typedCount]; if (expected === undefined) return; if (char.toLowerCase() === expected.toLowerCase()) { const willComplete = gRef.current.typedCount + 1 >= line.content.length; dispatch({ type: "CORRECT", willComplete }); if (willComplete) { setClearShowing(true); setTimeout(() => setClearShowing(false), 700); setComboAnimKey((k) => k + 1); } } else { if (performance.now() - lastLineAdvanceAtRef.current < 100) return; dispatch({ type: "WRONG" }); setWrongChar(true); setTimeout(() => setWrongChar(false), 320); } }, [gameLines], ); useEffect(() => { if (phase !== "playing") return; const handler = (e: KeyboardEvent) => { if (e.key.length === 1) { e.preventDefault(); handleKeyPress(e.key); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [phase, handleKeyPress]); return ( {!isVideo && ( ); } export default function GamePage() { return ( <>
Loading...
} >
); }