diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-04-16 18:19:19 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-04-16 18:19:19 -0700 |
| commit | b7ab308f5d92172ff6e10d078cb12c1501cd161a (patch) | |
| tree | c472e5200444cbd2af8aea549573b2bec8689092 | |
| parent | c67744e3e5a7079a994cbd0ff196a7269a57ee5a (diff) | |
feat: if media is video file play as BGA
| -rw-r--r-- | src/app/game/page.styles.ts | 43 | ||||
| -rw-r--r-- | src/app/game/page.tsx | 118 |
2 files changed, 134 insertions, 27 deletions
diff --git a/src/app/game/page.styles.ts b/src/app/game/page.styles.ts index 833901d..fce88be 100644 --- a/src/app/game/page.styles.ts +++ b/src/app/game/page.styles.ts @@ -51,6 +51,17 @@ export const GameRoot = styled.div` 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` @@ -73,6 +84,8 @@ export const GameContent = styled.div` display: flex; flex-direction: column; overflow: hidden; + position: relative; + z-index: 1; `; /* ----- HUD ----- */ @@ -86,6 +99,8 @@ export const HUD = styled.div` padding: 10px 24px; background: rgba(255, 255, 255, 0.04); border-bottom: 1px solid rgba(255, 255, 255, 0.06); + position: relative; + z-index: 2; `; export const HudStat = styled.div` @@ -293,6 +308,8 @@ export const GameFooter = styled.footer` 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` @@ -366,6 +383,32 @@ export const StartCard = styled.div` 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 CountdownNumber = styled.div` font-size: 72px; font-weight: 900; diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx index 95e590a..3ad7009 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/page.tsx @@ -39,6 +39,11 @@ import { CharBox, ClearToast, GetReadyText, + BackgroundVideo, + OpacityControl, + OpacityLabel, + OpacitySlider, + OpacityValue, CompletedLineFade, GameFooter, ControlBtn, @@ -72,11 +77,21 @@ 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"; + function GameInner() { const searchParams = useSearchParams(); const router = useRouter(); const audioRef = useRef<HTMLAudioElement>(null); + const videoRef = useRef<HTMLVideoElement>(null); const gameStartTimeRef = useRef<number>(0); const lastHandledIdxRef = useRef(-1); @@ -101,7 +116,24 @@ function GameInner() { const [clearShowing, setClearShowing] = useState(false); const [comboAnimKey, setComboAnimKey] = useState(0); const [countdown, setCountdown] = useState(0); + const [backgroundOpacity, setBackgroundOpacity] = useState(0); const [skipBacking, setSkipBacking] = useState(false); + const isVideo = useMemo(() => isVideoUrl(audioUrl), [audioUrl]); + + useEffect(() => { + const stored = localStorage.getItem(BACKGROUND_OPACITY_KEY); + if (stored === null) return; + const parsed = Number(stored); + if (Number.isFinite(parsed)) { + const clamped = Math.min(100, Math.max(0, parsed)); + setBackgroundOpacity(clamped); + } + }, []); + + useEffect(() => { + localStorage.setItem(BACKGROUND_OPACITY_KEY, String(backgroundOpacity)); + }, [backgroundOpacity]); + const charRowRef = useRef<HTMLDivElement | null>(null); const charRefs = useRef<(HTMLSpanElement | null)[]>([]); const [wrapSpaceIndicators, setWrapSpaceIndicators] = useState<boolean[]>([]); @@ -270,31 +302,34 @@ function GameInner() { }, [phase]); useEffect(() => { - const audio = audioRef.current; - if (!audio) return; + const media = isVideo ? videoRef.current : audioRef.current; + if (!media) return; const onTimeUpdate = () => { - setCurrentMs(audio.currentTime * 1000 + offsetRef.current); - if (audio.duration && !isNaN(audio.duration)) { - setDuration(audio.duration * 1000); - setProgressPct((audio.currentTime / audio.duration) * 100); + 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(audio.duration)) setDuration(audio.duration * 1000); + if (!isNaN(media.duration)) { + setDuration(media.duration * 1000); + setGameDurationMs(media.duration * 1000); + } }; const onEnded = () => { setPhase("finished"); setGameDurationMs(Date.now() - gameStartTimeRef.current); }; - audio.addEventListener("timeupdate", onTimeUpdate); - audio.addEventListener("loadedmetadata", onLoadedMetadata); - audio.addEventListener("ended", onEnded); + media.addEventListener("timeupdate", onTimeUpdate); + media.addEventListener("loadedmetadata", onLoadedMetadata); + media.addEventListener("ended", onEnded); return () => { - audio.removeEventListener("timeupdate", onTimeUpdate); - audio.removeEventListener("loadedmetadata", onLoadedMetadata); - audio.removeEventListener("ended", onEnded); + media.removeEventListener("timeupdate", onTimeUpdate); + media.removeEventListener("loadedmetadata", onLoadedMetadata); + media.removeEventListener("ended", onEnded); }; - }, []); // intentionally empty deps + }, [isVideo, audioUrl]); useEffect(() => { if (phaseRef.current !== "playing") return; @@ -341,16 +376,16 @@ function GameInner() { }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleStart = useCallback(() => { - const audio = audioRef.current; - if (!audio || !lrcContent || !audioUrl) return; + 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; - audio.pause(); - audio.currentTime = 0; + media.pause(); + media.currentTime = 0; setPhase("countdown"); setCountdown(5); setGameDurationMs(0); @@ -358,8 +393,8 @@ function GameInner() { setCurrentMs(0); const beginPlayback = () => { - audio.currentTime = 0; - audio.play(); + media.currentTime = 0; + media.play(); setPhase("playing"); gameStartTimeRef.current = Date.now(); if (gameLines[0]) { @@ -385,13 +420,13 @@ function GameInner() { return c - 1; }); }, 1000); - }, [lrcContent, audioUrl, gameLines]); + }, [lrcContent, audioUrl, gameLines, isVideo]); const handleRestart = useCallback(() => { - const audio = audioRef.current; - if (audio) { - audio.pause(); - audio.currentTime = 0; + const media = isVideo ? videoRef.current : audioRef.current; + if (media) { + media.pause(); + media.currentTime = 0; } if (countdownIntervalRef.current !== null) { clearInterval(countdownIntervalRef.current); @@ -403,7 +438,7 @@ function GameInner() { setPhase("idle"); setCurrentMs(0); setProgressPct(0); - }, []); + }, [isVideo]); const handleLoadCode = useCallback(() => { if (!codeInput.trim()) return; @@ -459,7 +494,18 @@ function GameInner() { return ( <GameRoot> <ToastContainer theme="dark" /> - <audio ref={audioRef} src={audioUrl || undefined} preload="auto" /> + {isVideo && ( + <BackgroundVideo + ref={videoRef} + src={audioUrl || undefined} + preload="auto" + playsInline + style={{ opacity: backgroundOpacity / 100 }} + /> + )} + {!isVideo && ( + <audio ref={audioRef} src={audioUrl || undefined} preload="auto" /> + )} <GameNavbar style={{ justifyContent: "space-between" }}> <div style={{ display: "flex", alignItems: "center", gap: 16 }}> <Link @@ -518,6 +564,24 @@ function GameInner() { </> )} + {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> + )} + <StartBtn onClick={handleStart} disabled={!isReady} |
