"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, PreviewWrap, PreviewBtn, PreviewHint, 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"; const AUDIO_VOLUME_KEY = "lrcType.audioVolume"; 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 [audioVolume, setAudioVolume] = useState(() => { if (typeof window === "undefined") return 100; const stored = localStorage.getItem(AUDIO_VOLUME_KEY); if (stored === null) return 100; const parsed = Number(stored); if (!Number.isFinite(parsed)) return 100; return Math.min(100, Math.max(0, parsed)); }); const [isPreviewPlaying, setIsPreviewPlaying] = useState(false); const [skipBacking, setSkipBacking] = useState(false); const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]); useEffect(() => { localStorage.setItem(BACKGROUND_OPACITY_KEY, String(backgroundOpacity)); }, [backgroundOpacity]); useEffect(() => { localStorage.setItem(AUDIO_VOLUME_KEY, String(audioVolume)); }, [audioVolume]); useEffect(() => { const media = isVideo ? videoRef.current : audioRef.current; if (!media) return; media.volume = audioVolume / 100; }, [audioVolume, isVideo, audioUrl]); useEffect(() => { const media = isVideo ? videoRef.current : audioRef.current; if (!media) { setIsPreviewPlaying(false); return; } const handlePlay = () => setIsPreviewPlaying(true); const handlePause = () => setIsPreviewPlaying(false); const handleEnded = () => setIsPreviewPlaying(false); media.addEventListener("play", handlePlay); media.addEventListener("pause", handlePause); media.addEventListener("ended", handleEnded); return () => { media.removeEventListener("play", handlePlay); media.removeEventListener("pause", handlePause); media.removeEventListener("ended", handleEnded); }; }, [isVideo, audioUrl]); useEffect(() => { setIsPreviewPlaying(false); }, [audioUrl]); 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; const firstMediaMs = firstMs - offsetRef.current; const remainingMs = Math.max(0, firstMs - currentMs); if (!gameLines.length || firstMediaMs <= 0) { return { pct: remainingMs === 0 ? 100 : 0, remainingMs }; } const mediaCurrentMs = currentMs - offsetRef.current; const pct = Math.min(100, Math.max(0, (mediaCurrentMs / firstMediaMs) * 100)); return { pct, remainingMs }; }, [gameLines, currentMs, offset]); 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 = () => { if (phaseRef.current === "playing") { setPhase("finished"); setGameDurationMs(Date.now() - gameStartTimeRef.current); return; } setIsPreviewPlaying(false); }; 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 handlePreviewToggle = useCallback(() => { if (phase !== "idle") return; const media = isVideo ? videoRef.current : audioRef.current; if (!media || !audioUrl) return; if (media.paused) { void media.play().catch(() => { toast.error("Unable to start preview. Try interacting with the page again.", { theme: "dark", }); }); return; } media.pause(); }, [phase, isVideo, audioUrl]); 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; setIsPreviewPlaying(false); 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; } setIsPreviewPlaying(false); 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 === " ") { const idx = gRef.current.displayedLineIdx; if (idx < 0 && gameLines.length > 0) { const firstMs = gameLines[0]?.millisecond ?? 0; const media = isVideo ? videoRef.current : audioRef.current; if (media) { const currentMsLocal = media.currentTime * 1000 + offsetRef.current; const intermissionRemaining = Math.max(0, firstMs - currentMsLocal); if (intermissionRemaining > 5000) { e.preventDefault(); const targetMs = firstMs - 3000; media.currentTime = Math.max(0, (targetMs - offsetRef.current) / 1000); setCurrentMs(media.currentTime * 1000 + offsetRef.current); return; } } } } if (e.key.length === 1) { e.preventDefault(); handleKeyPress(e.key); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [phase, handleKeyPress, gameLines, isVideo]); return ( {!isVideo && ( ); } export default function GamePage() { return ( <>
Loading...
} >
); }