aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-06-01 21:19:05 -0700
committerPinapelz <yukais@pinapelz.com>2026-06-01 21:19:51 -0700
commit0d35e75edbc75f186e4a1ed52fbc3549ee9f5cd6 (patch)
tree90abc1a6d556fc54e4277907dc340736791a5677 /src
init commit
Diffstat (limited to 'src')
-rw-r--r--src/app/create/page.styles.ts168
-rw-r--r--src/app/create/page.tsx207
-rw-r--r--src/app/favicon.icobin0 -> 15406 bytes
-rw-r--r--src/app/game/game.stat.ts82
-rw-r--r--src/app/game/game.utils.ts55
-rw-r--r--src/app/game/page.styles.ts663
-rw-r--r--src/app/game/page.tsx1011
-rw-r--r--src/app/globals.css6
-rw-r--r--src/app/layout.tsx22
-rw-r--r--src/app/page.styles.ts251
-rw-r--r--src/app/page.tsx5
-rw-r--r--src/app/registry.tsx26
-rw-r--r--src/app/styles/shared.ts78
-rw-r--r--src/app/typing/page.styles.ts168
-rw-r--r--src/app/typing/page.tsx161
-rw-r--r--src/app/use_timer.ts33
16 files changed, 2936 insertions, 0 deletions
diff --git a/src/app/create/page.styles.ts b/src/app/create/page.styles.ts
new file mode 100644
index 0000000..b54e095
--- /dev/null
+++ b/src/app/create/page.styles.ts
@@ -0,0 +1,168 @@
+import styled from "styled-components";
+
+export const Content = styled.div`
+ max-width: 600px;
+ margin: 40px auto;
+ padding: 0 24px 60px;
+`;
+
+export const Heading = styled.h1`
+ font-size: 22px;
+ font-weight: 800;
+ margin: 0 0 4px;
+`;
+
+export const Subheading = styled.p`
+ font-size: 13px;
+ color: #909090;
+ margin: 0 0 32px;
+`;
+
+export const Form = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+`;
+
+export const FieldGroup = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+`;
+
+export const Label = styled.label`
+ font-size: 12px;
+ font-weight: 600;
+ color: #606060;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+`;
+
+export const Input = styled.input`
+ height: 40px;
+ padding: 0 12px;
+ border: 1px solid #d4d4d4;
+ border-radius: 8px;
+ font-size: 14px;
+ color: #1a1a1a;
+ background-color: #fff;
+ transition: border-color 0.15s;
+ &:focus {
+ outline: none;
+ border-color: #1a1a1a;
+ }
+ &::placeholder {
+ color: #b0b0b0;
+ }
+`;
+
+export const Divider = styled.div`
+ height: 1px;
+ background-color: #e5e5e5;
+ margin: 6px 0;
+`;
+
+export const Row = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+`;
+
+export const GenerateButton = styled.button`
+ height: 42px;
+ padding: 0 24px;
+ border-radius: 10px;
+ border: none;
+ background-color: #1a1a1a;
+ color: #fff;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background-color 0.15s;
+ margin-top: 6px;
+ &:hover {
+ background-color: #333;
+ }
+`;
+
+export const ModeButton = styled.button<{ $active: boolean }>`
+ height: 42px;
+ padding: 0 24px;
+ border-radius: 10px;
+ border: none;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 600;
+ background-color: ${(p) => (p.$active ? "#1a1a1a" : "#e5e5e5")};
+ color: ${(p) => (p.$active ? "#fff" : "#1a1a1a")};
+ transition: background-color 0.15s;
+`;
+
+export const OutputSection = styled.div`
+ margin-top: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+`;
+
+export const OutputLabel = styled.div`
+ font-size: 12px;
+ font-weight: 600;
+ color: #606060;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 5px;
+`;
+
+export const CodeBox = styled.div`
+ position: relative;
+ background-color: #f0f0f0;
+ border: 1px solid #d4d4d4;
+ border-radius: 10px;
+ padding: 14px 48px 14px 14px;
+ font-family: "Courier New", monospace;
+ font-size: 13px;
+ color: #1a1a1a;
+ word-break: break-all;
+ line-height: 1.5;
+`;
+
+export const CopyButton = styled.button<{ $copied: boolean }>`
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ width: 30px;
+ height: 30px;
+ border-radius: 6px;
+ border: none;
+ background-color: ${(p) => (p.$copied ? "#22c55e" : "#d4d4d4")};
+ color: ${(p) => (p.$copied ? "#fff" : "#606060")};
+ font-size: 13px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.15s, color 0.15s;
+ &:hover {
+ background-color: ${(p) => (p.$copied ? "#16a34a" : "#c0c0c0")};
+ color: #1a1a1a;
+ }
+`;
+
+export const OpenLink = styled.a`
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ color: #1a1a1a;
+ text-decoration: none;
+ border: 1px solid #d4d4d4;
+ border-radius: 8px;
+ padding: 8px 14px;
+ background-color: #fff;
+ transition: background-color 0.15s;
+ &:hover {
+ background-color: #f0f0f0;
+ }
+`;
diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx
new file mode 100644
index 0000000..744ab95
--- /dev/null
+++ b/src/app/create/page.tsx
@@ -0,0 +1,207 @@
+"use client";
+import { useState } from "react";
+import { MdLibraryMusic } from "react-icons/md";
+import { FaCopy, FaCheck, FaExternalLinkAlt } from "react-icons/fa";
+import { Root, Navbar, Logo, LogoIcon, NavLink } from "../styles/shared";
+import {
+ Content,
+ Heading,
+ Subheading,
+ Form,
+ FieldGroup,
+ Label,
+ Input,
+ Divider,
+ Row,
+ GenerateButton,
+ OutputSection,
+ OutputLabel,
+ CodeBox,
+ CopyButton,
+ OpenLink,
+} from "./page.styles";
+
+interface TypingPayload {
+ file1?: string;
+ lrc?: string;
+ offset?: number;
+ title?: string;
+ artist?: string;
+ skip_backing?: boolean;
+}
+
+export default function CreatePage() {
+ const [lrc, setLrc] = useState("");
+ const [file1, setFile1] = useState("");
+ const [offset, setOffset] = useState("");
+
+ const [typingTitle, setTypingTitle] = useState("");
+ const [typingArtist, setTypingArtist] = useState("");
+ const [skipBacking, setSkipBacking] = useState(true);
+
+ const [code, setCode] = useState<string | null>(null);
+ const [copiedCode, setCopiedCode] = useState(false);
+ const [copiedUrl, setCopiedUrl] = useState(false);
+
+ const resetCopyStates = () => {
+ setCopiedCode(false);
+ setCopiedUrl(false);
+ };
+
+ const generate = () => {
+ const payload: TypingPayload = {};
+ if (file1.trim()) payload.file1 = file1.trim();
+ if (lrc.trim()) payload.lrc = lrc.trim();
+ if (offset.trim() !== "") payload.offset = Number(offset);
+ if (typingTitle.trim()) payload.title = typingTitle.trim();
+ if (typingArtist.trim()) payload.artist = typingArtist.trim();
+ payload.skip_backing = skipBacking;
+
+ setCode(btoa(JSON.stringify(payload)));
+ resetCopyStates();
+ };
+
+ const copy = (text: string, which: "code" | "url") => {
+ navigator.clipboard.writeText(text);
+ if (which === "code") {
+ setCopiedCode(true);
+ setTimeout(() => setCopiedCode(false), 2000);
+ } else {
+ setCopiedUrl(true);
+ setTimeout(() => setCopiedUrl(false), 2000);
+ }
+ };
+
+ const shareUrl = code ? `${window.location.origin}/game?code=${code}` : "";
+
+ return (
+ <Root>
+ <Navbar>
+ <Logo href="/typing">
+ <LogoIcon>
+ <MdLibraryMusic />
+ </LogoIcon>
+ LRC-Type
+ </Logo>
+ <NavLink href="/typing">← Back</NavLink>
+ </Navbar>
+
+ <Content>
+ <Heading>Create a Code</Heading>
+ <Subheading>
+ Generate a shareable code for your typing game session.
+ </Subheading>
+
+ <Form>
+ <FieldGroup>
+ <Label>Primary Media</Label>
+ <Input
+ type="url"
+ placeholder="https://example.com/song.mp4"
+ value={file1}
+ onChange={(e) => setFile1(e.target.value)}
+ />
+ </FieldGroup>
+
+ <FieldGroup>
+ <Label>LRC Lyrics</Label>
+ <Input
+ type="url"
+ placeholder="https://example.com/song.lrc"
+ value={lrc}
+ onChange={(e) => setLrc(e.target.value)}
+ />
+ </FieldGroup>
+
+ <FieldGroup>
+ <Label title="Offset in milliseconds. Increase this value if the main audio is ahead of the lyrics.">
+ LRC Offset (ms)
+ </Label>
+ <Input
+ type="number"
+ placeholder="0"
+ value={offset}
+ onChange={(e) => setOffset(e.target.value)}
+ step="25"
+ />
+ </FieldGroup>
+
+ <Divider />
+
+ <Row>
+ <FieldGroup>
+ <Label>Title</Label>
+ <Input
+ type="text"
+ placeholder="Song Title"
+ value={typingTitle}
+ onChange={(e) => setTypingTitle(e.target.value)}
+ />
+ </FieldGroup>
+ <FieldGroup>
+ <Label>Artist</Label>
+ <Input
+ type="text"
+ placeholder="Artist Name"
+ value={typingArtist}
+ onChange={(e) => setTypingArtist(e.target.value)}
+ />
+ </FieldGroup>
+ </Row>
+
+ <Row>
+ <FieldGroup>
+ <Label title="When enabled, lyrics inside parentheses are treated as backing lyrics and skipped.">
+ Skip Backing
+ </Label>
+ <Input
+ type="checkbox"
+ checked={skipBacking}
+ onChange={(e) => setSkipBacking(e.target.checked)}
+ style={{ width: "18px", height: "18px", marginTop: "10px" }}
+ />
+ </FieldGroup>
+ </Row>
+
+ <GenerateButton onClick={generate}>Generate Code</GenerateButton>
+ </Form>
+
+ {code && (
+ <OutputSection>
+ <div>
+ <OutputLabel>Code</OutputLabel>
+ <CodeBox>
+ {code}
+ <CopyButton
+ $copied={copiedCode}
+ onClick={() => copy(code, "code")}
+ aria-label="Copy code"
+ >
+ {copiedCode ? <FaCheck /> : <FaCopy />}
+ </CopyButton>
+ </CodeBox>
+ </div>
+
+ <div>
+ <OutputLabel>Share URL</OutputLabel>
+ <CodeBox>
+ {shareUrl}
+ <CopyButton
+ $copied={copiedUrl}
+ onClick={() => copy(shareUrl, "url")}
+ aria-label="Copy URL"
+ >
+ {copiedUrl ? <FaCheck /> : <FaCopy />}
+ </CopyButton>
+ </CodeBox>
+ </div>
+
+ <OpenLink href={shareUrl} target="_blank" rel="noopener noreferrer">
+ <FaExternalLinkAlt /> Open in Typing Game
+ </OpenLink>
+ </OutputSection>
+ )}
+ </Content>
+ </Root>
+ );
+}
diff --git a/src/app/favicon.ico b/src/app/favicon.ico
new file mode 100644
index 0000000..28f11e9
--- /dev/null
+++ b/src/app/favicon.ico
Binary files differ
diff --git a/src/app/game/game.stat.ts b/src/app/game/game.stat.ts
new file mode 100644
index 0000000..43136e6
--- /dev/null
+++ b/src/app/game/game.stat.ts
@@ -0,0 +1,82 @@
+export interface GState {
+ displayedLineIdx: number;
+ typedCount: number;
+ lineCompleted: boolean;
+ combo: number;
+ maxCombo: number;
+ score: number;
+ totalCorrect: number;
+ totalMiss: number;
+ linesCleared: number;
+ wpm: number;
+}
+
+export type GAction =
+ | { type: "ADVANCE"; newIdx: number; prevCompleted: boolean }
+ | { type: "CORRECT"; willComplete: boolean }
+ | { type: "WRONG" }
+ | { type: "RESET" };
+
+export const initialGState: GState = {
+ displayedLineIdx: -1,
+ typedCount: 0,
+ lineCompleted: false,
+ combo: 0,
+ maxCombo: 0,
+ score: 0,
+ totalCorrect: 0,
+ totalMiss: 0,
+ linesCleared: 0,
+ wpm: 0,
+};
+
+export function gReducer(state: GState, action: GAction): GState {
+ switch (action.type) {
+ case "ADVANCE": {
+ const prevIdx = state.displayedLineIdx;
+ const comboReset = !action.prevCompleted && prevIdx >= 0;
+ return {
+ ...state,
+ displayedLineIdx: action.newIdx,
+ typedCount: 0,
+ lineCompleted: false,
+ combo: comboReset ? 0 : state.combo,
+ };
+ }
+
+ case "CORRECT": {
+ const newTypedCount = state.typedCount + 1;
+ const newCombo = state.combo + 1;
+ const newMaxCombo = Math.max(state.maxCombo, newCombo);
+ const comboBonus = Math.min(50, Math.floor(newCombo / 10) * 5);
+ const newScore = state.score + 10 + comboBonus;
+ const newTotalCorrect = state.totalCorrect + 1;
+ if (action.willComplete) {
+ return {
+ ...state,
+ typedCount: newTypedCount,
+ lineCompleted: true,
+ combo: newCombo,
+ maxCombo: newMaxCombo,
+ score: newScore,
+ totalCorrect: newTotalCorrect,
+ linesCleared: state.linesCleared + 1,
+ };
+ }
+ return {
+ ...state,
+ typedCount: newTypedCount,
+ combo: newCombo,
+ maxCombo: newMaxCombo,
+ score: newScore,
+ totalCorrect: newTotalCorrect,
+ };
+ }
+ case "WRONG":
+ return { ...state, totalMiss: state.totalMiss + 1, combo: 0 };
+ case "RESET":
+ return { ...initialGState };
+ default:
+ return state;
+ }
+} \ No newline at end of file
diff --git a/src/app/game/game.utils.ts b/src/app/game/game.utils.ts
new file mode 100644
index 0000000..b2037e5
--- /dev/null
+++ b/src/app/game/game.utils.ts
@@ -0,0 +1,55 @@
+export interface GameLine {
+ millisecond: number;
+ content: string;
+}
+
+export function parseLrcLines(
+ lrcText: string,
+ options?: { skipBacking?: boolean }
+): GameLine[] {
+ const result: GameLine[] = [];
+ const lineRegex = /\[(\d{2,3}):(\d{2})\.(\d{2,3})\]/g;
+ const { skipBacking = false } = options ?? {};
+
+ for (const rawLine of lrcText.split("\n")) {
+ const timestamps: number[] = [];
+ let match: RegExpExecArray | null;
+ let lastIndex = 0;
+
+ lineRegex.lastIndex = 0;
+ while ((match = lineRegex.exec(rawLine)) !== null) {
+ const minutes = parseInt(match[1], 10);
+ const seconds = parseInt(match[2], 10);
+ const msField = match[3];
+ const ms =
+ msField.length === 2
+ ? parseInt(msField, 10) * 10
+ : parseInt(msField, 10);
+ timestamps.push(minutes * 60_000 + seconds * 1_000 + ms);
+ lastIndex = match.index + match[0].length;
+ }
+
+ if (timestamps.length === 0) continue;
+
+ const content = (skipBacking
+ ? rawLine.slice(lastIndex).replace(/\([^)]*\)/g, "")
+ : rawLine.slice(lastIndex)
+ ).trim();
+
+ for (const ms of timestamps) {
+ result.push({ millisecond: ms, content });
+ }
+ }
+
+ result.sort((a, b) => a.millisecond - b.millisecond);
+ return result;
+}
+
+export function calculateCPSNeeded(text: string, seconds: number): number {
+ return text.length / seconds;
+}
+
+export function formatTime(ms: number): string {
+ const s = Math.max(0, Math.floor(ms / 1000));
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+}
diff --git a/src/app/game/page.styles.ts b/src/app/game/page.styles.ts
new file mode 100644
index 0000000..d410339
--- /dev/null
+++ b/src/app/game/page.styles.ts
@@ -0,0 +1,663 @@
+import styled, { keyframes, css, createGlobalStyle } from "styled-components";
+
+/* ----- ANIMATIONS ----- */
+
+export const pulseAnim = keyframes`
+ 0% { opacity: 1; }
+ 50% { opacity: 0.4; }
+ 100% { opacity: 1; }
+`;
+
+export const wrongShakeAnim = keyframes`
+ 0% { transform: translateX(0); }
+ 20% { transform: translateX(-4px); }
+ 40% { transform: translateX(4px); }
+ 60% { transform: translateX(-4px); }
+ 80% { transform: translateX(4px); }
+ 100% { transform: translateX(0); }
+`;
+
+export const clearPopAnim = keyframes`
+ 0% { transform: scale(0.8); opacity: 0; }
+ 40% { transform: scale(1.2); opacity: 1; }
+ 100% { transform: scale(1.0); opacity: 0; }
+`;
+
+export const fadeInUpAnim = keyframes`
+ 0% { transform: translateY(10px); opacity: 0; }
+ 100% { transform: translateY(0); opacity: 1; }
+`;
+
+export const comboScaleAnim = keyframes`
+ 0% { transform: scale(1); }
+ 50% { transform: scale(1.4); }
+ 100% { transform: scale(1); }
+`;
+
+export const glowAnim = keyframes`
+ 0% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); }
+ 50% { box-shadow: 0 0 16px 4px rgba(124, 58, 237, 0.8); }
+ 100% { box-shadow: 0 0 4px 0px rgba(124, 58, 237, 0.4); }
+`;
+
+export const GameGlobalStyle = createGlobalStyle`
+ html,
+ body {
+ height: 100%;
+ overflow: hidden;
+ }
+`;
+
+/* ----- LAYOUT ----- */
+
+export const GameRoot = styled.div`
+ position: fixed;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ background: #0d0d14;
+ color: #ffffff;
+ font-family: "Roboto", "Segoe UI", Arial, sans-serif;
+ overflow: hidden;
+ z-index: 0;
+`;
+
+export const BackgroundVideo = styled.video`
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ pointer-events: none;
+ z-index: 0;
+`;
+
+export const GameNavbar = styled.nav`
+ position: sticky;
+ top: 0;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ height: 52px;
+ padding: 0 20px;
+ background: rgba(13, 13, 20, 0.75);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ z-index: 20;
+`;
+
+export const GameContent = styled.div`
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+ z-index: 1;
+`;
+
+/* ----- HUD ----- */
+
+export const HUD = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 10px 24px;
+ background: rgba(13, 13, 20, 0.75);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ position: relative;
+ z-index: 2;
+`;
+
+export const HudStat = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+`;
+
+export const HudValue = styled.span`
+ font-size: 22px;
+ font-weight: 700;
+ color: #ffffff;
+`;
+
+export const HudLabel = styled.span`
+ font-size: 10px;
+ letter-spacing: 1.5px;
+ text-transform: uppercase;
+ color: rgba(255, 255, 255, 0.45);
+`;
+
+export const ComboValue = styled(HudValue)<{ $animate: boolean }>`
+ ${({ $animate }) =>
+ $animate &&
+ css`
+ animation: ${comboScaleAnim} 0.25s ease;
+ `}
+`;
+
+/* ----- MAIN GAME AREA ----- */
+
+export const GameArea = styled.div`
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px 32px;
+ gap: 24px;
+ overflow: hidden;
+
+ & > * {
+ position: relative;
+ z-index: 1;
+ }
+`;
+
+export const UpcomingWrap = styled.div`
+ width: 100%;
+ max-width: 800px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+`;
+
+export const UpcomingLabel = styled.span`
+ font-size: 10px;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ color: rgba(255, 255, 255, 0.30);
+ margin-bottom: 2px;
+`;
+
+export const UpcomingText = styled.p`
+ font-size: 20px;
+ color: rgba(255, 255, 255, 0.30);
+ font-weight: 400;
+ font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
+ min-height: 28px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin: 0;
+`;
+
+export const CurrentWrap = styled.div`
+ width: 100%;
+ max-width: 800px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+`;
+
+export const LineTimingRow = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+`;
+
+export const LineTimingMeta = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ font-size: 13px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ color: rgba(255, 255, 255, 0.45);
+`;
+
+export const LineTimingValue = styled.span`
+ font-variant-numeric: tabular-nums;
+`;
+
+export const LineTimingBar = styled.div`
+ width: 100%;
+ height: 3px;
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 2px;
+ overflow: hidden;
+`;
+
+export const LineTimingFill = styled.div.attrs<{ $pct: number }>((props) => ({
+ style: {
+ transform: `scaleX(${props.$pct / 100})`,
+ },
+}))<{ $pct: number }>`
+ height: 100%;
+ width: 100%;
+ border-radius: 2px;
+ background: #7c3aed;
+ transform-origin: left;
+ will-change: transform;
+`;
+
+export const CharRow = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ align-items: flex-end;
+ min-height: 64px;
+`;
+
+export const WordWrap = styled.span`
+ display: inline-flex;
+ gap: 2px;
+ white-space: nowrap;
+`;
+
+export const CharBox = styled.span<{
+ $state: "typed" | "active" | "pending" | "wrong";
+}>`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 36px;
+ font-weight: 700;
+ font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
+ padding: 0 3px;
+ border-radius: 4px;
+ transition: all 0.08s ease;
+
+ ${({ $state }) => {
+ switch ($state) {
+ case "typed":
+ return css`
+ color: #22c55e;
+ opacity: 0.7;
+ `;
+ case "active":
+ return css`
+ color: #fbbf24;
+ border-bottom: 3px solid #fbbf24;
+ animation: ${pulseAnim} 1s ease infinite;
+ `;
+ case "pending":
+ return css`
+ color: rgba(255, 255, 255, 0.25);
+ `;
+ case "wrong":
+ return css`
+ color: #ef4444;
+ animation: ${wrongShakeAnim} 0.3s ease;
+ `;
+ }
+ }}
+`;
+
+export const ClearToast = styled.div`
+ position: absolute;
+ font-size: 28px;
+ font-weight: 800;
+ color: #22c55e;
+ animation: ${clearPopAnim} 0.7s ease forwards;
+ pointer-events: none;
+`;
+
+export const GetReadyText = styled.p`
+ font-size: 28px;
+ color: rgba(255, 255, 255, 0.50);
+ font-weight: 500;
+ text-align: center;
+ animation: ${pulseAnim} 1.5s ease infinite;
+ margin: 0;
+`;
+
+export const CompletedLineFade = styled.div`
+ font-size: 18px;
+ color: rgba(255, 255, 255, 0.20);
+ margin-top: 4px;
+ min-height: 26px;
+ transition: opacity 0.3s;
+`;
+
+/* ----- FOOTER ----- */
+
+export const GameFooter = styled.footer`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 24px;
+ background: rgba(255, 255, 255, 0.04);
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ position: relative;
+ z-index: 2;
+`;
+
+export const ControlBtn = styled.button`
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ border: 1px solid rgba(255, 255, 255, 0.20);
+ background: rgba(255, 255, 255, 0.08);
+ color: #ffffff;
+ font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: background 0.15s;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.15);
+ }
+`;
+
+export const ProgressWrap = styled.div`
+ flex: 1;
+ height: 6px;
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 3px;
+ overflow: hidden;
+ cursor: pointer;
+`;
+
+export const ProgressFill = styled.div.attrs<{ $pct: number }>((props) => ({
+ style: {
+ width: `${props.$pct}%`,
+ },
+}))<{ $pct: number }>`
+ height: 100%;
+ background: #7c3aed;
+ border-radius: 3px;
+ transition: width 0.3s linear;
+`;
+
+export const TimeText = styled.span`
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.50);
+ font-family: monospace;
+ white-space: nowrap;
+`;
+
+/* ----- START SCREEN ----- */
+
+export const StartOverlay = styled.div`
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(13, 13, 20, 0.96);
+ z-index: 10;
+ animation: ${fadeInUpAnim} 0.4s ease;
+`;
+
+export const StartCard = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 20px;
+ padding: 40px;
+ max-width: 520px;
+ width: 100%;
+ text-align: center;
+`;
+
+export const OpacityControl = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: stretch;
+`;
+
+export const OpacityLabel = styled.div`
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+export const OpacityValue = styled.span`
+ font-variant-numeric: tabular-nums;
+`;
+
+export const OpacitySlider = styled.input`
+ width: 100%;
+`;
+
+export const PreviewWrap = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+export const PreviewBtn = styled.button`
+ width: 100%;
+ padding: 10px 16px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ background: rgba(255, 255, 255, 0.08);
+ color: #ffffff;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ &:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.16);
+ }
+
+ &:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+`;
+
+export const PreviewHint = styled.div`
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ text-align: center;
+`;
+
+export const CountdownNumber = styled.div`
+ font-size: 72px;
+ font-weight: 900;
+ color: #ffffff;
+ line-height: 1;
+ letter-spacing: 2px;
+`;
+
+export const SongTitleText = styled.h1`
+ font-size: 32px;
+ font-weight: 800;
+ color: #ffffff;
+ line-height: 1.2;
+ margin: 0;
+`;
+
+export const SongArtistText = styled.p`
+ font-size: 16px;
+ color: rgba(255, 255, 255, 0.50);
+ margin: 0;
+`;
+
+export const StartBtn = styled.button`
+ padding: 14px 40px;
+ border-radius: 12px;
+ background: #7c3aed;
+ color: #ffffff;
+ font-size: 18px;
+ font-weight: 700;
+ border: none;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ &:hover:not(:disabled) {
+ background: #6d28d9;
+ transform: translateY(-2px);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+`;
+
+export const CodeSection = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+export const CodeInputRow = styled.div`
+ display: flex;
+ gap: 8px;
+`;
+
+export const CodeInputField = styled.input`
+ flex: 1;
+ padding: 8px 12px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(255, 255, 255, 0.06);
+ color: #ffffff;
+ font-size: 13px;
+ outline: none;
+ transition: border-color 0.15s;
+
+ &:focus {
+ border-color: rgba(255, 255, 255, 0.35);
+ }
+
+ &::placeholder {
+ color: rgba(255, 255, 255, 0.30);
+ }
+`;
+
+export const CodeLoadBtn = styled.button`
+ padding: 8px 16px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(255, 255, 255, 0.08);
+ color: #ffffff;
+ font-size: 13px;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background 0.15s;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.15);
+ }
+`;
+
+/* ----- RESULTS SCREEN ----- */
+
+export const ResultsOverlay = styled.div`
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(13, 13, 20, 0.96);
+ z-index: 10;
+ animation: ${fadeInUpAnim} 0.4s ease;
+`;
+
+export const ResultsCard = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24px;
+ padding: 40px;
+ max-width: 540px;
+ width: 100%;
+ text-align: center;
+`;
+
+export const ResultsTitle = styled.p`
+ font-size: 14px;
+ letter-spacing: 3px;
+ text-transform: uppercase;
+ color: rgba(255, 255, 255, 0.40);
+ margin: 0;
+`;
+
+export const BigScore = styled.h2`
+ font-size: 64px;
+ font-weight: 900;
+ color: #ffffff;
+ line-height: 1;
+ margin: 0;
+`;
+
+export const StatsGrid = styled.div`
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 16px;
+ width: 100%;
+`;
+
+export const StatBlock = styled.div`
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+`;
+
+export const StatValue = styled.span`
+ font-size: 28px;
+ font-weight: 700;
+ color: #ffffff;
+`;
+
+export const StatLabel = styled.span`
+ font-size: 11px;
+ letter-spacing: 1.5px;
+ text-transform: uppercase;
+ color: rgba(255, 255, 255, 0.40);
+`;
+
+export const ActionRow = styled.div`
+ display: flex;
+ gap: 12px;
+`;
+
+export const PlayAgainBtn = styled.button`
+ padding: 10px 28px;
+ border-radius: 12px;
+ background: #7c3aed;
+ color: #ffffff;
+ font-size: 15px;
+ font-weight: 700;
+ border: none;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ &:hover {
+ background: #6d28d9;
+ transform: translateY(-2px);
+ }
+`;
+
+export const HomeBtn = styled.button`
+ padding: 10px 28px;
+ border-radius: 12px;
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.20);
+ color: #ffffff;
+ font-size: 15px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.08);
+ }
+`;
diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx
new file mode 100644
index 0000000..bce01b3
--- /dev/null
+++ b/src/app/game/page.tsx
@@ -0,0 +1,1011 @@
+"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<HTMLAudioElement>(null);
+ const videoRef = useRef<HTMLVideoElement>(null);
+ const gameStartTimeRef = useRef<number>(0);
+ const lastHandledIdxRef = useRef(-1);
+ const lastLineAdvanceAtRef = useRef(0);
+
+ const [phase, setPhase] = useState<GamePhase>("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<HTMLDivElement | null>(null);
+ const charRefs = useRef<(HTMLSpanElement | null)[]>([]);
+ const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState<boolean[]>([]);
+ const countdownIntervalRef = useRef<number | null>(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<GamePhase>("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<string, unknown>) => {
+ 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<string, unknown>;
+ 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<string, unknown>;
+ 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 (
+ <GameRoot>
+ <ToastContainer theme="dark" />
+ {!isVideo && (
+ <audio ref={audioRef} src={audioUrl || undefined} preload="auto" />
+ )}
+ {isVideo && (
+ <BackgroundVideo
+ ref={videoRef}
+ src={audioUrl || undefined}
+ preload="auto"
+ playsInline
+ style={{ opacity: backgroundOpacity / 100 }}
+ />
+ )}
+ <GameNavbar style={{ justifyContent: "space-between" }}>
+ <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
+ <Link
+ href="/"
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ textDecoration: "none",
+ color: "#ffffff",
+ fontWeight: 700,
+ fontSize: 15,
+ }}
+ >
+ <MdLibraryMusic style={{ fontSize: 20, color: "#a78bfa" }} />
+ LRC-Type
+ </Link>
+
+ <Link
+ href="/create"
+ style={{
+ fontSize: 13,
+ color: "rgba(255,255,255,0.6)",
+ textDecoration: "none",
+ }}
+ >
+ Create
+ </Link>
+ </div>
+ </GameNavbar>
+
+ <GameContent style={{ position: "relative" }}>
+ {phase === "idle" && (
+ <StartOverlay>
+ <StartCard>
+ {!isReady ? (
+ <>
+ <SongTitleText>LRC-Type</SongTitleText>
+ <SongArtistText>Enter a game code to begin!</SongArtistText>
+ </>
+ ) : (
+ <>
+ <SongTitleText>
+ {loadingLrc ? "Loading..." : songTitle}
+ </SongTitleText>
+ <SongArtistText>{songArtist}</SongArtistText>
+ </>
+ )}
+
+ <StartBtn
+ onClick={handleStart}
+ disabled={!isReady}
+ suppressHydrationWarning
+ >
+ {loadingLrc ? "Loading song..." : "▶ Start Game"}
+ </StartBtn>
+
+ <OpacityControl>
+ <OpacityLabel>
+ Volume
+ <OpacityValue>{audioVolume}%</OpacityValue>
+ </OpacityLabel>
+ <OpacitySlider
+ type="range"
+ min="0"
+ max="100"
+ value={audioVolume}
+ onChange={(e) => setAudioVolume(Number(e.target.value))}
+ />
+ </OpacityControl>
+
+ <PreviewWrap>
+ <PreviewBtn
+ onClick={handlePreviewToggle}
+ disabled={!audioUrl}
+ suppressHydrationWarning
+ >
+ {isPreviewPlaying ? "⏸ Pause Preview" : "▶ Preview Audio"}
+ </PreviewBtn>
+ <PreviewHint>
+ {audioUrl
+ ? "Use preview to test your volume before starting."
+ : "Load a chart to enable audio preview."}
+ </PreviewHint>
+ </PreviewWrap>
+
+ {isVideo && (
+ <OpacityControl>
+ <OpacityLabel>
+ Background opacity
+ <OpacityValue>{backgroundOpacity}%</OpacityValue>
+ </OpacityLabel>
+ <OpacitySlider
+ type="range"
+ min="0"
+ max="100"
+ value={backgroundOpacity}
+ onChange={(e) =>
+ setBackgroundOpacity(Number(e.target.value))
+ }
+ />
+ </OpacityControl>
+ )}
+ <CodeSection>
+ <div
+ style={{
+ fontSize: 11,
+ color: "rgba(255,255,255,0.3)",
+ letterSpacing: 1,
+ textTransform: "uppercase",
+ }}
+ >
+ Load a chart
+ </div>
+ <CodeInputRow>
+ <CodeInputField
+ placeholder="Enter a LRC-Type code..."
+ value={codeInput}
+ onChange={(e) => setCodeInput(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleLoadCode()}
+ />
+ <CodeLoadBtn onClick={handleLoadCode}>Load</CodeLoadBtn>
+ </CodeInputRow>
+ </CodeSection>
+ </StartCard>
+ </StartOverlay>
+ )}
+
+ {phase === "countdown" && (
+ <StartOverlay>
+ <StartCard>
+ <SongTitleText>Get Ready</SongTitleText>
+ <CountdownNumber>{countdown}</CountdownNumber>
+ </StartCard>
+ </StartOverlay>
+ )}
+
+ {phase === "finished" && (
+ <ResultsOverlay>
+ <ResultsCard>
+ <ResultsTitle>Results {songTitle}</ResultsTitle>
+ <BigScore>{g.score.toLocaleString()}</BigScore>
+ <StatsGrid>
+ <StatBlock>
+ <StatValue>{accuracy}%</StatValue>
+ <StatLabel>Accuracy</StatLabel>
+ </StatBlock>
+ <StatBlock>
+ <StatValue>x{g.maxCombo}</StatValue>
+ <StatLabel>Max Combo</StatLabel>
+ </StatBlock>
+ <StatBlock>
+ <StatValue>{wpm}</StatValue>
+ <StatLabel>WPM</StatLabel>
+ </StatBlock>
+ <StatBlock>
+ <StatValue>{g.totalMiss}</StatValue>
+ <StatLabel>Missed Chars</StatLabel>
+ </StatBlock>
+ </StatsGrid>
+ <ActionRow>
+ <PlayAgainBtn onClick={handleRestart}>Play Again</PlayAgainBtn>
+ <HomeBtn onClick={() => router.push("/typing")}>Home</HomeBtn>
+ </ActionRow>
+ </ResultsCard>
+ </ResultsOverlay>
+ )}
+
+ <HUD>
+ <HudStat>
+ <HudValue>{g.score.toLocaleString()}</HudValue>
+ <HudLabel>Score</HudLabel>
+ </HudStat>
+ <HudStat>
+ <ComboValue
+ $animate={comboAnimKey > 0}
+ key={`combo-${comboAnimKey}`}
+ >
+ x{g.combo}
+ </ComboValue>
+ <HudLabel>Combo</HudLabel>
+ </HudStat>
+ <HudStat>
+ <HudValue>{accuracy}%</HudValue>
+ <HudLabel>Accuracy</HudLabel>
+ </HudStat>
+ <HudStat>
+ <HudValue>{wpm}</HudValue>
+ <HudLabel>WPM</HudLabel>
+ </HudStat>
+ <HudStat>
+ <HudValue>{g.totalMiss}</HudValue>
+ <HudLabel>Misses</HudLabel>
+ </HudStat>
+ </HUD>
+
+ <GameArea>
+ {phase === "playing" &&
+ g.displayedLineIdx < 0 &&
+ gameLines.length > 0 && (
+ <>
+ <UpcomingWrap>
+ <UpcomingLabel>Next</UpcomingLabel>
+ <UpcomingText>
+ {gameLines[0] && gameLines[0].content.trim() === ""
+ ? "[INTERMISSION]"
+ : gameLines[0]?.content ?? ""}
+ </UpcomingText>
+ </UpcomingWrap>
+ <CurrentWrap style={{ position: "relative" }}>
+ <LineTimingRow>
+ <LineTimingMeta>
+ Time to first line:{" "}
+ <LineTimingValue>
+ {Math.max(0, intermissionData.remainingMs / 1000).toFixed(1)}s
+ </LineTimingValue>
+ </LineTimingMeta>
+ </LineTimingRow>
+ <div
+ style={{
+ fontSize: 12,
+ color: "rgba(255,255,255,0.6)",
+ marginTop: 8,
+ textAlign: "center",
+ }}
+ >
+ {intermissionData.remainingMs > 5000 && "Press Space to skip long intermissions"}
+ </div>
+ <LineTimingBar>
+ <LineTimingFill $pct={intermissionData.pct} />
+ </LineTimingBar>
+ <CharRow ref={charRowRef} />
+ <CompletedLineFade>[INTERMISSION]</CompletedLineFade>
+ </CurrentWrap>
+ </>
+ )}
+ {g.displayedLineIdx >= 0 && gameLines[g.displayedLineIdx] && (
+ <>
+ <UpcomingWrap>
+ <UpcomingLabel>Next</UpcomingLabel>
+ <UpcomingText>
+ {gameLines[g.displayedLineIdx + 1] &&
+ gameLines[g.displayedLineIdx + 1].content.trim() === ""
+ ? "[INTERMISSION]"
+ : gameLines[g.displayedLineIdx + 1]?.content ?? ""}
+ </UpcomingText>
+ </UpcomingWrap>
+ <CurrentWrap style={{ position: "relative" }}>
+ <LineTimingRow>
+ <LineTimingMeta>
+ Time left:{" "}
+ <LineTimingValue>
+ {Math.max(0, lineRemainingMs / 1000).toFixed(1)}s
+ </LineTimingValue>
+ </LineTimingMeta>
+ {gameLines[g.displayedLineIdx].content.trim() !== "" && (
+ <LineTimingMeta>
+ Estimated CPS:{" "}
+ <LineTimingValue>
+ {calculateCPSNeeded(
+ gameLines[g.displayedLineIdx].content,
+ currentLineTime / 1000
+ ).toFixed(1)}
+ </LineTimingValue>
+ </LineTimingMeta>
+ )}
+ </LineTimingRow>
+ <LineTimingBar>
+ <LineTimingFill $pct={lineTimingPct} />
+ </LineTimingBar>
+ <CharRow ref={charRowRef}>
+ {gameLines[g.displayedLineIdx].content.trim() !== "" &&
+ (() => {
+ const rawText = gameLines[g.displayedLineIdx].content;
+ const text = rawText.toLowerCase();
+ const tokens = text.split(/(\s+)/).filter(Boolean);
+ let renderIndex = 0;
+ return tokens.flatMap((token, tokenIdx) => {
+ if (/^\s+$/.test(token)) {
+ return token.split("").map((ch, spaceIdx) => {
+ let state: "typed" | "active" | "pending" | "wrong";
+ if (renderIndex < g.typedCount) state = "typed";
+ else if (renderIndex === g.typedCount)
+ state = wrongChar ? "wrong" : "active";
+ else state = "pending";
+ const charIndex = renderIndex;
+ const showIndicator =
+ ch === " " &&
+ wrapSpaceIndicators[charIndex] &&
+ state !== "typed";
+ const displayChar =
+ ch === " "
+ ? showIndicator
+ ? "␣"
+ : "\u00A0"
+ : ch;
+ const element = (
+ <CharBox
+ key={`space-${tokenIdx}-${spaceIdx}`}
+ $state={state}
+ ref={(el) => {
+ charRefs.current[charIndex] = el;
+ }}
+ >
+ {displayChar}
+ </CharBox>
+ );
+ renderIndex += 1;
+ return element;
+ });
+ }
+
+ const wordChars = token.split("").map((ch, charIdx) => {
+ let state: "typed" | "active" | "pending" | "wrong";
+ if (renderIndex < g.typedCount) state = "typed";
+ else if (renderIndex === g.typedCount)
+ state = wrongChar ? "wrong" : "active";
+ else state = "pending";
+ const charIndex = renderIndex;
+ const element = (
+ <CharBox
+ key={`char-${tokenIdx}-${charIdx}`}
+ $state={state}
+ ref={(el) => {
+ charRefs.current[charIndex] = el;
+ }}
+ >
+ {ch}
+ </CharBox>
+ );
+ renderIndex += 1;
+ return element;
+ });
+
+ return (
+ <WordWrap key={`word-${tokenIdx}`}>
+ {wordChars}
+ </WordWrap>
+ );
+ });
+ })()}
+ </CharRow>
+ {clearShowing && <ClearToast>CLEAR!</ClearToast>}
+ <CompletedLineFade>
+ {gameLines[g.displayedLineIdx].content.trim() === ""
+ ? "[INTERMISSION]"
+ : g.lineCompleted
+ ? "Cleared - waiting for next line..."
+ : gameLines[g.displayedLineIdx].content}
+ </CompletedLineFade>
+ </CurrentWrap>
+ </>
+ )}
+ {phase === "idle" && (
+ <GetReadyText style={{ opacity: 0.3 }}>
+ Start the game to begin typing
+ </GetReadyText>
+ )}
+ </GameArea>
+
+ <GameFooter>
+ <ControlBtn onClick={handleRestart} title="Restart">
+ <FaRedo />
+ </ControlBtn>
+ <ProgressWrap>
+ <ProgressFill $pct={progressPct} />
+ </ProgressWrap>
+ <TimeText>
+ {formatTime(Math.max(0, currentMs))} / {formatTime(duration)}
+ </TimeText>
+ </GameFooter>
+ </GameContent>
+ </GameRoot>
+ );
+}
+
+export default function GamePage() {
+ return (
+ <>
+ <GameGlobalStyle />
+ <Suspense
+ fallback={
+ <GameRoot>
+ <div
+ style={{
+ flex: 1,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: 18,
+ color: "rgba(255,255,255,0.5)",
+ }}
+ >
+ Loading...
+ </div>
+ </GameRoot>
+ }
+ >
+ <GameInner />
+ </Suspense>
+ </>
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..6cfbe02
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,6 @@
+html,
+body {
+ margin: 0;
+ padding: 0;
+ background-color: #f9f9f9;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..67d8da9
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from "next";
+import StyledComponentsRegistry from "./registry";
+
+export const metadata: Metadata = {
+ title: "LRC-Type",
+ description:
+ "A typing game powered by LRC lyrics. Type along to your favourite songs!",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <html lang="en">
+ <body>
+ <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
+ </body>
+ </html>
+ );
+} \ No newline at end of file
diff --git a/src/app/page.styles.ts b/src/app/page.styles.ts
new file mode 100644
index 0000000..50d28d7
--- /dev/null
+++ b/src/app/page.styles.ts
@@ -0,0 +1,251 @@
+import styled from "styled-components";
+import Link from "next/link";
+
+export const NavLeft = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 14px;
+`;
+
+export const NavCenter = styled.div`
+ display: flex;
+ align-items: center;
+ flex: 0 1 560px;
+`;
+
+export const SearchBox = styled.div`
+ display: flex;
+ align-items: center;
+ flex: 1;
+ height: 38px;
+ border: 1px solid #d4d4d4;
+ border-radius: 10px;
+ overflow: hidden;
+ background-color: #f0f0f0;
+ transition: border-color 0.2s;
+ &:focus-within {
+ border-color: #1a1a1a;
+ }
+`;
+
+export const SearchInput = styled.input`
+ flex: 1;
+ height: 100%;
+ padding: 0 14px;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: #1a1a1a;
+ font-size: 14px;
+ &::placeholder {
+ color: #909090;
+ }
+`;
+
+export const SearchButton = styled.button`
+ width: 52px;
+ height: 100%;
+ background-color: #e8e8e8;
+ border: none;
+ border-left: 1px solid #d4d4d4;
+ color: #606060;
+ font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ &:hover {
+ background-color: #d4d4d4;
+ color: #1a1a1a;
+ }
+`;
+
+export const NavRight = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+`;
+
+
+
+export const ChipsBar = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 24px;
+ overflow-x: auto;
+ background-color: #f9f9f9;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+`;
+
+export const Chip = styled.button<{ $active?: boolean }>`
+ white-space: nowrap;
+ padding: 7px 16px;
+ border-radius: 10px;
+ border: 1px solid ${(p) => (p.$active ? "transparent" : "#d4d4d4")};
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s;
+ background-color: ${(p) => (p.$active ? "#1a1a1a" : "transparent")};
+ color: ${(p) => (p.$active ? "#fff" : "#606060")};
+ &:hover {
+ background-color: ${(p) => (p.$active ? "#333" : "#f0f0f0")};
+ color: ${(p) => (p.$active ? "#fff" : "#1a1a1a")};
+ }
+`;
+
+export const GridContainer = styled.div`
+ padding: 8px 24px 24px;
+`;
+
+export const CardGrid = styled.div`
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 20px;
+`;
+
+export const Card = styled(Link)`
+ cursor: pointer;
+ border-radius: 14px;
+ text-decoration: none;
+ color: inherit;
+ display: block;
+ transition: transform 0.15s, box-shadow 0.15s;
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+ }
+`;
+
+export const ThumbnailWrapper = styled.div`
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background-color: #e4e4e4;
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #c0c0c0;
+ font-size: 36px;
+ overflow: hidden;
+ position: relative;
+`;
+
+export const Thumbnail = styled.img`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+`;
+
+export const PlayOverlay = styled.div`
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0);
+ border-radius: 12px;
+ transition: background 0.2s;
+ ${Card}:hover & {
+ background: rgba(0, 0, 0, 0.25);
+ }
+`;
+
+export const PlayCircle = styled.div`
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ opacity: 0;
+ transform: scale(0.8);
+ transition: opacity 0.2s, transform 0.2s;
+ ${Card}:hover & {
+ opacity: 1;
+ transform: scale(1);
+ }
+`;
+
+export const CardMeta = styled.div`
+ display: flex;
+ gap: 12px;
+ margin-top: 12px;
+ padding: 0 4px 12px;
+`;
+
+export const CardInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+`;
+
+export const CardTitle = styled.span`
+ font-size: 14px;
+ font-weight: 600;
+ color: #1a1a1a;
+ line-height: 1.35;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+`;
+
+export const CardSub = styled.span`
+ font-size: 12px;
+ color: #909090;
+ line-height: 1.3;
+`;
+
+export const EmptyState = styled.div`
+ grid-column: 1 / -1;
+ padding: 48px 0;
+ text-align: center;
+ font-size: 14px;
+ color: #909090;
+`;
+
+export const CtaSection = styled.div`
+ padding: 32px 24px;
+ border-top: 1px solid #e5e5e5;
+ margin-top: 8px;
+`;
+
+export const SectionHeading = styled.h2`
+ font-size: 17px;
+ font-weight: 700;
+ color: #1a1a1a;
+ margin: 0 0 14px;
+`;
+
+export const OpenPlayerLink = styled(Link)`
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 22px;
+ border-radius: 10px;
+ background-color: #1a1a1a;
+ color: #fff;
+ font-size: 14px;
+ font-weight: 600;
+ text-decoration: none;
+ transition: background-color 0.15s;
+ &:hover {
+ background-color: #333;
+ }
+`;
+
+export const PlayerDescription = styled.p`
+ font-size: 13px;
+ color: #909090;
+ margin: 14px 0 0;
+ line-height: 1.6;
+ max-width: 480px;
+`;
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..f8e1c1b
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function HomePage() {
+ redirect("/typing");
+}
diff --git a/src/app/registry.tsx b/src/app/registry.tsx
new file mode 100644
index 0000000..4a5a4e1
--- /dev/null
+++ b/src/app/registry.tsx
@@ -0,0 +1,26 @@
+"use client";
+import React, { useState } from "react";
+import { useServerInsertedHTML } from "next/navigation";
+import { ServerStyleSheet, StyleSheetManager } from "styled-components";
+
+export default function StyledComponentsRegistry({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
+
+ useServerInsertedHTML(() => {
+ const styles = styledComponentsStyleSheet.getStyleElement();
+ styledComponentsStyleSheet.instance.clearTag();
+ return <>{styles}</>;
+ });
+
+ if (typeof window !== "undefined") return <>{children}</>;
+
+ return (
+ <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
+ {children as React.ReactElement}
+ </StyleSheetManager>
+ );
+}
diff --git a/src/app/styles/shared.ts b/src/app/styles/shared.ts
new file mode 100644
index 0000000..d1b0232
--- /dev/null
+++ b/src/app/styles/shared.ts
@@ -0,0 +1,78 @@
+import styled from "styled-components";
+import Link from "next/link";
+
+export const Root = styled.div`
+ min-height: 100vh;
+ background-color: #f9f9f9;
+ color: #1a1a1a;
+ font-family: "Roboto", "Segoe UI", Arial, sans-serif;
+`;
+
+export const Navbar = styled.nav`
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 56px;
+ padding: 0 20px;
+ background-color: #ffffffee;
+ backdrop-filter: blur(12px);
+ border-bottom: 1px solid #e5e5e5;
+`;
+
+export const Logo = styled(Link)`
+ font-size: 17px;
+ font-weight: 800;
+ letter-spacing: 0.3px;
+ color: #1a1a1a;
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ user-select: none;
+`;
+
+export const LogoIcon = styled.span`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background-color: #1a1a1a;
+ color: #fff;
+ border-radius: 6px;
+ width: 30px;
+ height: 22px;
+ font-size: 10px;
+`;
+
+export const NavLink = styled(Link)`
+ font-size: 13px;
+ font-weight: 500;
+ color: #606060;
+ text-decoration: none;
+ padding: 6px 10px;
+ border-radius: 8px;
+ transition: background-color 0.15s, color 0.15s;
+ &:hover {
+ background-color: #f0f0f0;
+ color: #1a1a1a;
+ }
+`;
+
+export const NavCtaLink = styled(Link)`
+ font-size: 13px;
+ font-weight: 600;
+ color: #1a1a1a;
+ text-decoration: none;
+ padding: 6px 12px;
+ border-radius: 999px;
+ background-color: #f5f5f5;
+ border: 1px solid #e5e5e5;
+ transition: background-color 0.15s, border-color 0.15s, color 0.15s;
+ &:hover {
+ background-color: #ededed;
+ border-color: #d4d4d4;
+ color: #1a1a1a;
+ }
+`;
diff --git a/src/app/typing/page.styles.ts b/src/app/typing/page.styles.ts
new file mode 100644
index 0000000..34f4ebc
--- /dev/null
+++ b/src/app/typing/page.styles.ts
@@ -0,0 +1,168 @@
+import styled, { createGlobalStyle } from "styled-components";
+import {
+ Root as BaseRoot,
+ Navbar as BaseNavbar,
+ Logo as BaseLogo,
+ LogoIcon as BaseLogoIcon,
+ NavLink as BaseNavLink,
+ NavCtaLink as BaseNavCtaLink,
+} from "../styles/shared";
+import {
+ NavLeft,
+ NavCenter,
+ SearchBox as BaseSearchBox,
+ SearchInput as BaseSearchInput,
+ SearchButton as BaseSearchButton,
+ NavRight,
+ ChipsBar as BaseChipsBar,
+ Chip as BaseChip,
+ GridContainer,
+ CardGrid,
+ Card as BaseCard,
+ ThumbnailWrapper as BaseThumbnailWrapper,
+ Thumbnail,
+ PlayOverlay,
+ PlayCircle,
+ CardMeta,
+ CardInfo,
+ CardTitle as BaseCardTitle,
+ CardSub as BaseCardSub,
+ EmptyState as BaseEmptyState,
+ CtaSection as BaseCtaSection,
+ SectionHeading as BaseSectionHeading,
+ OpenPlayerLink as BaseOpenPlayerLink,
+ PlayerDescription as BasePlayerDescription,
+} from "../page.styles";
+
+export { NavLeft, NavCenter, NavRight, GridContainer, CardGrid, Thumbnail, PlayOverlay, PlayCircle, CardMeta, CardInfo };
+
+export const TypingGlobalStyle = createGlobalStyle`
+ html,
+ body {
+ background-color: #0b0b10;
+ }
+`;
+
+export const Root = styled(BaseRoot)`
+ background-color: #0b0b10;
+ color: #f5f5f5;
+`;
+
+export const Navbar = styled(BaseNavbar)`
+ background-color: rgba(11, 11, 16, 0.9);
+ border-bottom: 1px solid #1f1f2a;
+`;
+
+export const Logo = styled(BaseLogo)`
+ color: #f5f5f5;
+`;
+
+export const LogoIcon = styled(BaseLogoIcon)`
+ background-color: #f5f5f5;
+ color: #0b0b10;
+`;
+
+export const NavLink = styled(BaseNavLink)`
+ color: #b0b3bd;
+ &:hover {
+ background-color: #1a1d29;
+ color: #fff;
+ }
+`;
+
+export const NavCtaLink = styled(BaseNavCtaLink)`
+ background-color: #1a1d29;
+ border-color: #2a2f3d;
+ color: #fff;
+ &:hover {
+ background-color: #222838;
+ border-color: #3a4154;
+ }
+`;
+
+export const SearchBox = styled(BaseSearchBox)`
+ border-color: #2a2f3d;
+ background-color: #141824;
+ &:focus-within {
+ border-color: #4b5563;
+ }
+`;
+
+export const SearchInput = styled(BaseSearchInput)`
+ color: #f5f5f5;
+ &::placeholder {
+ color: #8b90a0;
+ }
+`;
+
+export const SearchButton = styled(BaseSearchButton)`
+ background-color: #1a1d29;
+ border-left-color: #2a2f3d;
+ color: #c0c4d0;
+ &:hover {
+ background-color: #222838;
+ color: #fff;
+ }
+`;
+
+
+
+export const ChipsBar = styled(BaseChipsBar)`
+ background-color: #0f111a;
+`;
+
+export const Chip = styled(BaseChip)`
+ border-color: #2a2f3d;
+ color: #b8bcc7;
+ background-color: transparent;
+ &:hover {
+ background-color: #1a1d29;
+ color: #fff;
+ }
+`;
+
+export const Card = styled(BaseCard)`
+ border: 1px solid #1f1f2a;
+ background-color: #0f111a;
+ &:hover {
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
+ border-color: #2a2f3d;
+ }
+`;
+
+export const ThumbnailWrapper = styled(BaseThumbnailWrapper)`
+ background-color: #1a1d29;
+ color: #4b5563;
+`;
+
+export const CardTitle = styled(BaseCardTitle)`
+ color: #f5f5f5;
+`;
+
+export const CardSub = styled(BaseCardSub)`
+ color: #9aa0ad;
+`;
+
+export const EmptyState = styled(BaseEmptyState)`
+ color: #9aa0ad;
+`;
+
+export const CtaSection = styled(BaseCtaSection)`
+ border-top-color: #1f1f2a;
+`;
+
+export const SectionHeading = styled(BaseSectionHeading)`
+ color: #f5f5f5;
+`;
+
+export const OpenPlayerLink = styled(BaseOpenPlayerLink)`
+ background-color: #f5f5f5;
+ color: #0b0b10;
+ &:hover {
+ background-color: #e5e7eb;
+ }
+`;
+
+export const PlayerDescription = styled(BasePlayerDescription)`
+ color: #9aa0ad;
+`; \ No newline at end of file
diff --git a/src/app/typing/page.tsx b/src/app/typing/page.tsx
new file mode 100644
index 0000000..27ad173
--- /dev/null
+++ b/src/app/typing/page.tsx
@@ -0,0 +1,161 @@
+"use client";
+import { useEffect, useState } from "react";
+import { FaPlay, FaMusic, FaSearch } from "react-icons/fa";
+import { MdLibraryMusic } from "react-icons/md";
+import {
+ Root,
+ Navbar,
+ Logo,
+ LogoIcon,
+ NavCtaLink,
+ NavLeft,
+ NavCenter,
+ SearchBox,
+ SearchInput,
+ SearchButton,
+ NavRight,
+
+ ChipsBar,
+ Chip,
+ GridContainer,
+ CardGrid,
+ Card,
+ ThumbnailWrapper,
+ Thumbnail,
+ PlayOverlay,
+ PlayCircle,
+ CardMeta,
+ CardInfo,
+ CardTitle,
+ CardSub,
+ EmptyState,
+ TypingGlobalStyle,
+} from "./page.styles";
+
+interface TypingEntry {
+ title: string;
+ artist: string;
+ thumbnail: string;
+ code: string;
+}
+
+type TypingData = Record<string, TypingEntry[]>;
+
+function capitalize(s: string) {
+ return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+export default function TypingPage() {
+ const [data, setData] = useState<TypingData>({});
+ const [activeChip, setActiveChip] = useState("all");
+ const [search, setSearch] = useState("");
+
+ useEffect(() => {
+ fetch("/typing.json")
+ .then((r) => r.json())
+ .then((json: TypingData) => setData(json))
+ .catch(() => {});
+ }, []);
+
+ const categories = Object.keys(data);
+ const chips = [
+ { key: "all", label: "All" },
+ ...categories.map((category) => ({
+ key: category,
+ label: capitalize(category),
+ })),
+ ];
+
+ const visibleItems: TypingEntry[] =
+ activeChip === "all" ? Object.values(data).flat() : data[activeChip] ?? [];
+
+ const normalizedSearch = search.trim().toLowerCase();
+ const searchableItems = normalizedSearch ? Object.values(data).flat() : visibleItems;
+
+ const filtered = normalizedSearch
+ ? searchableItems.filter(
+ (item) =>
+ item.title.toLowerCase().includes(normalizedSearch) ||
+ item.artist.toLowerCase().includes(normalizedSearch),
+ )
+ : searchableItems;
+
+ return (
+ <>
+ <TypingGlobalStyle />
+ <Root>
+ <Navbar>
+ <NavLeft>
+ <Logo href="/">
+ <LogoIcon>
+ <MdLibraryMusic />
+ </LogoIcon>
+ LRC-Type
+ </Logo>
+ </NavLeft>
+
+ <NavCenter>
+ <SearchBox>
+ <SearchInput
+ placeholder="Search typing charts..."
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ <SearchButton aria-label="Search">
+ <FaSearch />
+ </SearchButton>
+ </SearchBox>
+ </NavCenter>
+
+ <NavRight>
+ <NavCtaLink href="/create">Create</NavCtaLink>
+
+ </NavRight>
+ </Navbar>
+
+ <ChipsBar>
+ {chips.map((chip) => (
+ <Chip
+ key={chip.key}
+ $active={chip.key === activeChip}
+ onClick={() => setActiveChip(chip.key)}
+ >
+ {chip.label}
+ </Chip>
+ ))}
+ </ChipsBar>
+
+ <GridContainer>
+ <CardGrid>
+ {filtered.length === 0 ? (
+ <EmptyState>No results found.</EmptyState>
+ ) : (
+ filtered.map((item) => (
+ <Card key={item.code} href={`/game?code=${item.code}`} target="_blank" rel="noopener noreferrer">
+ <ThumbnailWrapper>
+ {item.thumbnail ? (
+ <Thumbnail src={item.thumbnail} alt={item.title} />
+ ) : (
+ <FaMusic />
+ )}
+ <PlayOverlay>
+ <PlayCircle>
+ <FaPlay />
+ </PlayCircle>
+ </PlayOverlay>
+ </ThumbnailWrapper>
+ <CardMeta>
+ <CardInfo>
+ <CardTitle>{item.title}</CardTitle>
+ <CardSub>{item.artist}</CardSub>
+ </CardInfo>
+ </CardMeta>
+ </Card>
+ ))
+ )}
+ </CardGrid>
+ </GridContainer>
+ </Root>
+ </>
+ );
+}
diff --git a/src/app/use_timer.ts b/src/app/use_timer.ts
new file mode 100644
index 0000000..487591d
--- /dev/null
+++ b/src/app/use_timer.ts
@@ -0,0 +1,33 @@
+import { useEffect, useState, useCallback } from "react";
+
+function useTimer(speed = 1) {
+ const [paused, setPaused] = useState(true);
+ const play = useCallback(() => setPaused(false), []);
+ const pause = useCallback(() => setPaused(true), []);
+
+ const [currentMillisecond, setCurrentMillisecond] = useState(0);
+ const reset = useCallback(() => setCurrentMillisecond(0), []);
+
+ useEffect(() => {
+ if (!paused) {
+ let last = Date.now();
+ const timer = window.setInterval(() => {
+ const now = Date.now();
+ setCurrentMillisecond((cm) => cm + (now - last) * speed);
+ last = now;
+ }, 97);
+ return () => window.clearInterval(timer);
+ }
+ }, [paused, speed]);
+
+ return {
+ currentMillisecond,
+ setCurrentMillisecond,
+ reset,
+ paused,
+ play,
+ pause
+ };
+}
+
+export default useTimer; \ No newline at end of file
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage