aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-05-10 22:03:45 -0700
committerPinapelz <yukais@pinapelz.com>2026-05-10 22:03:45 -0700
commite41672da741b6ef9b37601bc84f97428d4b1f544 (patch)
treebb0f396ca8fd31984e7e8084ff2fbc97320342ef
parent8ff36e53b658d3b00b44128d1b8331297bd2e19f (diff)
type: add volume controls
-rw-r--r--src/app/game/page.styles.ts35
-rw-r--r--src/app/game/page.tsx118
2 files changed, 143 insertions, 10 deletions
diff --git a/src/app/game/page.styles.ts b/src/app/game/page.styles.ts
index a1c6cc8..d410339 100644
--- a/src/app/game/page.styles.ts
+++ b/src/app/game/page.styles.ts
@@ -427,6 +427,41 @@ 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;
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={{
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage