"use client"; import { useCallback, useEffect, 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, GameNavbar, GameContent, HUD, HudStat, HudValue, HudLabel, ComboValue, GameArea, UpcomingWrap, UpcomingLabel, UpcomingText, CurrentWrap, LineTimingMeta, LineTimingValue, LineTimingBar, LineTimingFill, CharRow, WordWrap, CharBox, ClearToast, GetReadyText, 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, type GameLine } from "./game.utils"; type GamePhase = "idle" | "countdown" | "playing" | "paused" | "finished"; function GameInner() { const searchParams = useSearchParams(); const router = useRouter(); const audioRef = useRef(null); const gameStartTimeRef = useRef(0); const lastHandledIdxRef = useRef(-1); const [phase, setPhase] = useState("idle"); const [currentMs, setCurrentMs] = useState(0); const [lineTimingPct, setLineTimingPct] = useState(0); const [lineRemainingMs, setLineRemainingMs] = 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 countdownIntervalRef = useRef(null); const [g, dispatch] = useReducer(gReducer, initialGState); const gameLines = useMemo(() => parseLrcLines(lrcContent), [lrcContent]); 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); 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]); useEffect(() => { const idx = g.displayedLineIdx; if (idx < 0 || !gameLines[idx]) { lineAnimRef.current = { startMs: 0, endMs: 0, startPerf: 0 }; setLineTimingPct(0); setLineRemainingMs(0); 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); setLineRemainingMs(Math.max(0, end - start)); }, [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 audio = audioRef.current; if (!audio) return; const onTimeUpdate = () => { setCurrentMs(audio.currentTime * 1000 + offsetRef.current); if (audio.duration && !isNaN(audio.duration)) { setDuration(audio.duration * 1000); setProgressPct((audio.currentTime / audio.duration) * 100); } }; const onLoadedMetadata = () => { if (!isNaN(audio.duration)) setDuration(audio.duration * 1000); }; const onEnded = () => { setPhase("finished"); setGameDurationMs(Date.now() - gameStartTimeRef.current); }; audio.addEventListener("timeupdate", onTimeUpdate); audio.addEventListener("loadedmetadata", onLoadedMetadata); audio.addEventListener("ended", onEnded); return () => { audio.removeEventListener("timeupdate", onTimeUpdate); audio.removeEventListener("loadedmetadata", onLoadedMetadata); audio.removeEventListener("ended", onEnded); }; }, []); // intentionally empty deps useEffect(() => { if (phaseRef.current !== "playing") return; if (timeBasedLineIdx < 0) return; if (timeBasedLineIdx <= lastHandledIdxRef.current) return; lastHandledIdxRef.current = timeBasedLineIdx; dispatch({ type: "ADVANCE", newIdx: timeBasedLineIdx, prevCompleted: gRef.current.lineCompleted, }); }, [timeBasedLineIdx]); const loadData = useCallback((data: Record) => { if (data.lrc) { setLoadingLrc(true); fetch(data.lrc) .then((r) => r.text()) .then((t) => { setLrcContent(t); setLoadingLrc(false); }); } if (data.file1) setAudioUrl(data.file1); if (data.offset) setOffset(Number(data.offset)); if (data.title) setSongTitle(data.title); if (data.artist) setSongArtist(data.artist); }, []); 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 audio = audioRef.current; if (!audio || !lrcContent || !audioUrl) return; if (countdownIntervalRef.current !== null) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } dispatch({ type: "RESET" }); lastHandledIdxRef.current = -1; audio.pause(); audio.currentTime = 0; setPhase("countdown"); setCountdown(5); setGameDurationMs(0); setProgressPct(0); setCurrentMs(0); const beginPlayback = () => { audio.currentTime = 0; audio.play(); setPhase("playing"); gameStartTimeRef.current = Date.now(); if (gameLines[0]) { dispatch({ type: "ADVANCE", newIdx: 0, prevCompleted: true, }); lastHandledIdxRef.current = 0; } }; 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]); const handleRestart = useCallback(() => { const audio = audioRef.current; if (audio) { audio.pause(); audio.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); }, []); 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 { 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 ( ); } export default function GamePage() { return (
Loading...
} >
); }