From e41672da741b6ef9b37601bc84f97428d4b1f544 Mon Sep 17 00:00:00 2001
From: Pinapelz <yukais@pinapelz.com>
Date: Sun, 10 May 2026 22:03:45 -0700
Subject: type: add volume controls

---
 src/app/game/page.tsx | 118 +++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 108 insertions(+), 10 deletions(-)

(limited to 'src/app/game/page.tsx')

diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx
index 5b5a782..7938d8f 100644
--- a/src/app/game/page.tsx
+++ b/src/app/game/page.tsx
@@ -45,6 +45,9 @@ import {
   OpacityLabel,
   OpacitySlider,
   OpacityValue,
+  PreviewWrap,
+  PreviewBtn,
+  PreviewHint,
   CompletedLineFade,
   GameFooter,
   ControlBtn,
@@ -86,6 +89,7 @@ const isVideoUrl = (url: string) => {
   return !!ext && VIDEO_EXTENSIONS.has(ext);
 };
 const BACKGROUND_OPACITY_KEY = "lrcType.backgroundOpacity";
+const AUDIO_VOLUME_KEY = "lrcType.audioVolume";
 
 function GameInner() {
   const searchParams = useSearchParams();
@@ -130,6 +134,15 @@ function GameInner() {
     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]);
 
@@ -139,6 +152,39 @@ function GameInner() {
     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[]>([]);
@@ -337,8 +383,12 @@ function GameInner() {
       }
     };
     const onEnded = () => {
-      setPhase("finished");
-      setGameDurationMs(Date.now() - gameStartTimeRef.current);
+      if (phaseRef.current === "playing") {
+        setPhase("finished");
+        setGameDurationMs(Date.now() - gameStartTimeRef.current);
+        return;
+      }
+      setIsPreviewPlaying(false);
     };
     media.addEventListener("timeupdate", onTimeUpdate);
     media.addEventListener("loadedmetadata", onLoadedMetadata);
@@ -395,6 +445,23 @@ function GameInner() {
     } 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;
@@ -406,6 +473,7 @@ function GameInner() {
     lastHandledIdxRef.current = -1;
     media.pause();
     media.currentTime = 0;
+    setIsPreviewPlaying(false);
     setPhase("countdown");
     setCountdown(5);
     setGameDurationMs(0);
@@ -440,6 +508,7 @@ function GameInner() {
       media.pause();
       media.currentTime = 0;
     }
+    setIsPreviewPlaying(false);
     if (countdownIntervalRef.current !== null) {
       clearInterval(countdownIntervalRef.current);
       countdownIntervalRef.current = null;
@@ -595,6 +664,43 @@ function GameInner() {
                 </>
               )}
 
+              <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>
@@ -612,14 +718,6 @@ function GameInner() {
                   />
                 </OpacityControl>
               )}
-
-              <StartBtn
-                onClick={handleStart}
-                disabled={!isReady}
-                suppressHydrationWarning
-              >
-                {loadingLrc ? "Loading song..." : "▶  Start Game"}
-              </StartBtn>
               <CodeSection>
                 <div
                   style={{
-- 
cgit v1.2.3

