Spaces:
Running
Running
Download app/src/ui/DanceControls.jsx from FormaLau/microduck-simulator: direct link, hf CLI and curl.
- Browser
- Download file 21 kB
-
https://huggingface.co/spaces/FormaLau/microduck-simulator/resolve/main/app/src/ui/DanceControls.jsx
- Command line
-
hf download hf://spaces/FormaLau/microduck-simulator/app/src/ui/DanceControls.jsx
-
curl -L -o DanceControls.jsx https://huggingface.co/spaces/FormaLau/microduck-simulator/resolve/main/app/src/ui/DanceControls.jsx
21 kB
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { Box, Button, Stack } from "@mui/material"; | |
| import { useGame } from "../store.js"; | |
| import { performanceMode } from "../game/dances/performance_mode.js"; | |
| const MUSIC_URL = | |
| `${import.meta.env.BASE_URL}audio/another-one-bites-the-dust.mp3`; | |
| // First clearly detected beat in the locally tested MP3. | |
| const MUSIC_START_SECONDS = 4.644; | |
| const DANCES = { | |
| neck: { | |
| key: "KeyN", | |
| label: "N · Neck", | |
| title: "Neck isolation (N)", | |
| method: "neckIsolation", | |
| }, | |
| circle: { | |
| key: "KeyH", | |
| label: "H · Circle", | |
| title: "Circular neck isolation (H)", | |
| method: "headCircleIsolation", | |
| }, | |
| }; | |
| function isTypingTarget(target) { | |
| if (!(target instanceof Element)) return false; | |
| return Boolean( | |
| target.closest( | |
| 'input, textarea, select, [contenteditable="true"], [role="textbox"]', | |
| ), | |
| ); | |
| } | |
| function findRollersButton() { | |
| return [...document.querySelectorAll("button")].find( | |
| (button) => button.textContent?.trim().toLowerCase() === "rollers", | |
| ); | |
| } | |
| export default function DanceControls() { | |
| const entered = useGame((state) => state.entered); | |
| const menuOpen = useGame((state) => state.menuOpen); | |
| const rootRef = useRef(null); | |
| const audioRef = useRef(null); | |
| const audioAvailableRef = useRef(true); | |
| const activeController = useRef(null); | |
| const launchToken = useRef(0); | |
| const musicEnabledRef = useRef(true); | |
| const musicStartedRef = useRef(false); | |
| const recordingStreamRef = useRef(null); | |
| const mediaRecorderRef = useRef(null); | |
| const recordingChunksRef = useRef([]); | |
| const captureEndedHandlerRef = useRef(null); | |
| const [activeDance, setActiveDance] = useState(null); | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [error, setError] = useState(""); | |
| const [position, setPosition] = useState(null); | |
| const stopMusic = useCallback((reset = true) => { | |
| const audio = audioRef.current; | |
| if (!audio || !audioAvailableRef.current) { | |
| musicStartedRef.current = false; | |
| return; | |
| } | |
| audio.pause(); | |
| if (reset) { | |
| try { | |
| audio.currentTime = MUSIC_START_SECONDS; | |
| } catch { | |
| // Metadata may not be available yet. | |
| } | |
| } | |
| musicStartedRef.current = false; | |
| }, []); | |
| /** | |
| * Start the soundtrack at the first detected beat. | |
| * | |
| * Returns the play() promise so Performance mode can begin only once | |
| * playback has actually started. | |
| */ | |
| const startMusic = useCallback((restart = true) => { | |
| const audio = audioRef.current; | |
| // Music is optional. Missing audio never blocks the dances. | |
| if (!audio || !audioAvailableRef.current) { | |
| return Promise.resolve(false); | |
| } | |
| try { | |
| if (restart) { | |
| audio.pause(); | |
| audio.currentTime = MUSIC_START_SECONDS; | |
| } | |
| audio.muted = !musicEnabledRef.current; | |
| const playPromise = audio.play(); | |
| if (!playPromise?.then) { | |
| musicStartedRef.current = true; | |
| return Promise.resolve(true); | |
| } | |
| return playPromise | |
| .then(() => { | |
| musicStartedRef.current = true; | |
| return true; | |
| }) | |
| .catch((err) => { | |
| console.warn("Dance music could not be played:", err); | |
| return false; | |
| }); | |
| } catch (err) { | |
| console.warn("Dance music could not be played:", err); | |
| return Promise.resolve(false); | |
| } | |
| }, []); | |
| const toggleMusic = useCallback(() => { | |
| musicEnabledRef.current = !musicEnabledRef.current; | |
| const audio = audioRef.current; | |
| if (audio && audioAvailableRef.current) { | |
| // Mute instead of pause so the song remains synchronized. | |
| audio.muted = !musicEnabledRef.current; | |
| } | |
| console.info( | |
| `Microduck dance music: ${ | |
| musicEnabledRef.current ? "ON" : "OFF" | |
| }`, | |
| ); | |
| }, []); | |
| function downloadRecording(blob) { | |
| const stamp = new Date() | |
| .toISOString() | |
| .replace(/[:.]/g, "-"); | |
| const url = URL.createObjectURL(blob); | |
| const anchor = document.createElement("a"); | |
| anchor.href = url; | |
| anchor.download = `microduck-performance-${stamp}.webm`; | |
| anchor.style.display = "none"; | |
| document.body.appendChild(anchor); | |
| anchor.click(); | |
| anchor.remove(); | |
| window.setTimeout(() => { | |
| URL.revokeObjectURL(url); | |
| }, 1000); | |
| } | |
| const stopRecording = useCallback(() => { | |
| const recorder = mediaRecorderRef.current; | |
| const stream = recordingStreamRef.current; | |
| if (recorder && recorder.state !== "inactive") { | |
| recorder.stop(); | |
| } else { | |
| setIsRecording(false); | |
| } | |
| stream?.getTracks?.().forEach((track) => { | |
| track.stop(); | |
| }); | |
| recordingStreamRef.current = null; | |
| }, []); | |
| const startRecording = useCallback(async () => { | |
| if ( | |
| !navigator.mediaDevices?.getDisplayMedia || | |
| typeof MediaRecorder === "undefined" | |
| ) { | |
| throw new Error( | |
| "Screen recording is not supported by this browser.", | |
| ); | |
| } | |
| // The browser still asks the user what to capture. | |
| // preferCurrentTab makes this simulator tab the obvious choice in Chrome. | |
| const stream = await navigator.mediaDevices.getDisplayMedia({ | |
| video: { | |
| displaySurface: "browser", | |
| frameRate: { | |
| ideal: 60, | |
| max: 60, | |
| }, | |
| }, | |
| audio: true, | |
| preferCurrentTab: true, | |
| selfBrowserSurface: "include", | |
| systemAudio: "include", | |
| surfaceSwitching: "exclude", | |
| }); | |
| // If the optional soundtrack exists, require captured tab audio so the | |
| // exported recording keeps the music. If the MP3 is absent from the | |
| // public repo, video-only recording is perfectly valid and must not block | |
| // Performance mode. | |
| if ( | |
| audioAvailableRef.current && | |
| stream.getAudioTracks().length === 0 | |
| ) { | |
| stream.getTracks().forEach((track) => track.stop()); | |
| throw new Error( | |
| 'No audio track was captured. Choose "This Tab" and enable "Share tab audio", then click Performance again.', | |
| ); | |
| } | |
| const mimeCandidates = [ | |
| "video/webm;codecs=vp9,opus", | |
| "video/webm;codecs=vp8,opus", | |
| "video/webm", | |
| ]; | |
| const mimeType = mimeCandidates.find((candidate) => | |
| MediaRecorder.isTypeSupported(candidate), | |
| ); | |
| const options = { | |
| videoBitsPerSecond: 10_000_000, | |
| audioBitsPerSecond: 192_000, | |
| }; | |
| if (mimeType) { | |
| options.mimeType = mimeType; | |
| } | |
| const recorder = new MediaRecorder(stream, options); | |
| recordingChunksRef.current = []; | |
| recordingStreamRef.current = stream; | |
| mediaRecorderRef.current = recorder; | |
| recorder.addEventListener("dataavailable", (event) => { | |
| if (event.data?.size > 0) { | |
| recordingChunksRef.current.push(event.data); | |
| } | |
| }); | |
| recorder.addEventListener("stop", () => { | |
| const chunks = recordingChunksRef.current; | |
| recordingChunksRef.current = []; | |
| if (chunks.length > 0) { | |
| const blob = new Blob(chunks, { | |
| type: recorder.mimeType || "video/webm", | |
| }); | |
| downloadRecording(blob); | |
| } | |
| recordingStreamRef.current | |
| ?.getTracks?.() | |
| .forEach((track) => track.stop()); | |
| recordingStreamRef.current = null; | |
| mediaRecorderRef.current = null; | |
| setIsRecording(false); | |
| }); | |
| // Clicking Chrome's "Stop sharing" also stops the performance. | |
| stream.getVideoTracks()[0]?.addEventListener( | |
| "ended", | |
| () => { | |
| captureEndedHandlerRef.current?.(); | |
| }, | |
| { once: true }, | |
| ); | |
| recorder.start(1000); | |
| setIsRecording(true); | |
| return true; | |
| }, []); | |
| /** | |
| * Stop only the current movement/camera controller. | |
| * Music deliberately keeps playing. | |
| */ | |
| const cancelCurrentDance = useCallback(() => { | |
| launchToken.current += 1; | |
| activeController.current?.cancel?.(); | |
| activeController.current = null; | |
| window.rl?.headTarget?.fill?.(0); | |
| setActiveDance(null); | |
| }, []); | |
| /** | |
| * Stop movement, performance camera and soundtrack. | |
| */ | |
| const stopEverything = useCallback(() => { | |
| cancelCurrentDance(); | |
| stopMusic(true); | |
| stopRecording(); | |
| }, [cancelCurrentDance, stopMusic, stopRecording]); | |
| useEffect(() => { | |
| captureEndedHandlerRef.current = stopEverything; | |
| return () => { | |
| captureEndedHandlerRef.current = null; | |
| }; | |
| }, [stopEverything]); | |
| /** | |
| * Manual N/H movement. | |
| * | |
| * Switching N <-> H leaves the soundtrack untouched. | |
| */ | |
| const startDance = useCallback( | |
| (danceName) => { | |
| const dance = DANCES[danceName]; | |
| if (!dance) return; | |
| const rl = window.rl; | |
| if (!rl) { | |
| setError("The simulator is not ready yet."); | |
| return; | |
| } | |
| const fn = rl[dance.method]; | |
| if (typeof fn !== "function") { | |
| setError( | |
| `window.rl.${dance.method}() is unavailable. Check the dance integration in game.js.`, | |
| ); | |
| return; | |
| } | |
| const hadController = Boolean(activeController.current); | |
| cancelCurrentDance(); | |
| setError(""); | |
| setActiveDance(danceName); | |
| if (rl.headMode && typeof rl.toggleHeadMode === "function") { | |
| rl.toggleHeadMode(); | |
| } | |
| // Manual sequence: | |
| // start music only if there isn't already a soundtrack running. | |
| if (!hadController && !musicStartedRef.current) { | |
| void startMusic(true); | |
| } | |
| const token = launchToken.current; | |
| try { | |
| const controller = fn(); | |
| if (token !== launchToken.current) { | |
| controller?.cancel?.(); | |
| return; | |
| } | |
| activeController.current = controller ?? null; | |
| controller?.finished?.finally?.(() => { | |
| if ( | |
| token === launchToken.current && | |
| activeController.current === controller | |
| ) { | |
| activeController.current = null; | |
| setActiveDance(null); | |
| } | |
| }); | |
| } catch (err) { | |
| console.error(`Failed to start ${dance.method}:`, err); | |
| activeController.current = null; | |
| setActiveDance(null); | |
| setError(String(err?.message ?? err)); | |
| if (!hadController) { | |
| stopMusic(true); | |
| } | |
| } | |
| }, | |
| [ | |
| cancelCurrentDance, | |
| startMusic, | |
| stopMusic, | |
| ], | |
| ); | |
| /** | |
| * K = automatic video-performance mode. | |
| * | |
| * It deliberately restarts the soundtrack at the first detected beat so | |
| * choreography and camera begin from a deterministic musical timeline. | |
| */ | |
| const startPerformance = useCallback(async () => { | |
| const rl = window.rl; | |
| if (!rl) { | |
| setError("The simulator is not ready yet."); | |
| return; | |
| } | |
| if (!rl.headTarget || !rl.camera || !rl.controls) { | |
| setError( | |
| "Performance mode requires headTarget, camera and controls from window.rl.", | |
| ); | |
| return; | |
| } | |
| cancelCurrentDance(); | |
| stopMusic(true); | |
| stopRecording(); | |
| setError(""); | |
| setActiveDance("performance"); | |
| if (rl.headMode && typeof rl.toggleHeadMode === "function") { | |
| rl.toggleHeadMode(); | |
| } | |
| const token = launchToken.current; | |
| try { | |
| // Start screen/tab recording BEFORE starting Queen or the choreography. | |
| // This prevents missing the first musical beat. | |
| await startRecording(); | |
| } catch (err) { | |
| console.warn("Recording could not start:", err); | |
| if (token === launchToken.current) { | |
| setActiveDance(null); | |
| setError(String(err?.message ?? err)); | |
| } | |
| return; | |
| } | |
| if (token !== launchToken.current) { | |
| stopRecording(); | |
| return; | |
| } | |
| // Start Queen only once capture is active. | |
| const musicStarted = await startMusic(true); | |
| if (token !== launchToken.current) { | |
| stopRecording(); | |
| return; | |
| } | |
| // Music is optional. performanceMode() already falls back to its own | |
| // monotonic clock whenever audio is missing, paused or unavailable. | |
| if (!musicStarted) { | |
| console.info( | |
| "Optional dance music is unavailable. Starting Performance without audio.", | |
| ); | |
| } | |
| try { | |
| const controller = performanceMode( | |
| rl, | |
| audioRef.current, | |
| { | |
| musicStartSeconds: MUSIC_START_SECONDS, | |
| }, | |
| ); | |
| if (token !== launchToken.current) { | |
| controller?.cancel?.(); | |
| stopRecording(); | |
| return; | |
| } | |
| activeController.current = controller; | |
| controller.finished.finally(() => { | |
| if ( | |
| token === launchToken.current && | |
| activeController.current === controller | |
| ) { | |
| activeController.current = null; | |
| setActiveDance(null); | |
| } | |
| }); | |
| } catch (err) { | |
| console.error("Performance mode failed:", err); | |
| activeController.current = null; | |
| setActiveDance(null); | |
| stopMusic(true); | |
| stopRecording(); | |
| setError(String(err?.message ?? err)); | |
| } | |
| }, [ | |
| cancelCurrentDance, | |
| startMusic, | |
| startRecording, | |
| stopMusic, | |
| stopRecording, | |
| ]); | |
| useEffect(() => { | |
| const audio = audioRef.current; | |
| if (!audio) return undefined; | |
| audio.volume = 0.85; | |
| audio.muted = !musicEnabledRef.current; | |
| const onCanPlay = () => { | |
| audioAvailableRef.current = true; | |
| }; | |
| const onError = () => { | |
| // Audio is optional in the public repository. | |
| audioAvailableRef.current = false; | |
| musicStartedRef.current = false; | |
| console.info( | |
| `Optional dance music not found at ${MUSIC_URL}. ` + | |
| "Dance controls remain fully functional.", | |
| ); | |
| }; | |
| const onEnded = () => { | |
| if (!audioAvailableRef.current) return; | |
| if (!musicStartedRef.current) return; | |
| try { | |
| audio.currentTime = MUSIC_START_SECONDS; | |
| } catch { | |
| return; | |
| } | |
| audio.play()?.catch?.((err) => { | |
| console.warn("Could not restart dance music:", err); | |
| }); | |
| }; | |
| audio.addEventListener("canplay", onCanPlay); | |
| audio.addEventListener("error", onError); | |
| audio.addEventListener("ended", onEnded); | |
| audio.load(); | |
| return () => { | |
| audio.removeEventListener("canplay", onCanPlay); | |
| audio.removeEventListener("error", onError); | |
| audio.removeEventListener("ended", onEnded); | |
| }; | |
| }, []); | |
| // Place dance buttons next to Feet / Rollers. | |
| useEffect(() => { | |
| if (!entered || menuOpen) { | |
| setPosition(null); | |
| return undefined; | |
| } | |
| let resizeObserver = null; | |
| let raf = 0; | |
| const updatePosition = () => { | |
| const rollersButton = findRollersButton(); | |
| const controls = rootRef.current; | |
| if (!rollersButton || !controls) { | |
| raf = requestAnimationFrame(updatePosition); | |
| return; | |
| } | |
| const rollerRect = rollersButton.getBoundingClientRect(); | |
| const controlsRect = controls.getBoundingClientRect(); | |
| const gap = 14; | |
| const margin = 16; | |
| let left = rollerRect.right + gap; | |
| let top = | |
| rollerRect.top + | |
| rollerRect.height / 2 - | |
| controlsRect.height / 2; | |
| if (left + controlsRect.width > window.innerWidth - margin) { | |
| left = Math.max( | |
| margin, | |
| rollerRect.right - controlsRect.width, | |
| ); | |
| top = Math.max( | |
| margin, | |
| rollerRect.top - controlsRect.height - 12, | |
| ); | |
| } | |
| setPosition({ left, top }); | |
| resizeObserver?.disconnect(); | |
| resizeObserver = new ResizeObserver(updatePosition); | |
| resizeObserver.observe(rollersButton); | |
| resizeObserver.observe(controls); | |
| }; | |
| raf = requestAnimationFrame(updatePosition); | |
| window.addEventListener("resize", updatePosition); | |
| return () => { | |
| cancelAnimationFrame(raf); | |
| resizeObserver?.disconnect(); | |
| window.removeEventListener("resize", updatePosition); | |
| }; | |
| }, [entered, menuOpen]); | |
| // N = neck | |
| // H = circle | |
| // K = automatic performance | |
| // P = sound on/off | |
| // Escape = stop everything | |
| useEffect(() => { | |
| if (!entered || menuOpen) return undefined; | |
| const onKeyDown = (event) => { | |
| if (event.repeat || isTypingTarget(event.target)) return; | |
| if (event.code === DANCES.neck.key) { | |
| event.preventDefault(); | |
| startDance("neck"); | |
| } else if (event.code === DANCES.circle.key) { | |
| event.preventDefault(); | |
| startDance("circle"); | |
| } else if (event.code === "KeyK") { | |
| event.preventDefault(); | |
| startPerformance(); | |
| } else if (event.code === "KeyP") { | |
| event.preventDefault(); | |
| toggleMusic(); | |
| } else if ( | |
| event.code === "Escape" && | |
| ( | |
| activeController.current || | |
| musicStartedRef.current || | |
| activeDance || | |
| isRecording | |
| ) | |
| ) { | |
| event.preventDefault(); | |
| stopEverything(); | |
| } | |
| }; | |
| window.addEventListener("keydown", onKeyDown); | |
| return () => { | |
| window.removeEventListener("keydown", onKeyDown); | |
| }; | |
| }, [ | |
| activeDance, | |
| entered, | |
| isRecording, | |
| menuOpen, | |
| startDance, | |
| startPerformance, | |
| stopEverything, | |
| toggleMusic, | |
| ]); | |
| useEffect( | |
| () => () => { | |
| activeController.current?.cancel?.(); | |
| stopMusic(true); | |
| stopRecording(); | |
| }, | |
| [stopMusic, stopRecording], | |
| ); | |
| if (!entered || menuOpen) { | |
| return ( | |
| <audio | |
| ref={audioRef} | |
| src={MUSIC_URL} | |
| preload="metadata" | |
| playsInline | |
| style={{ display: "none" }} | |
| /> | |
| ); | |
| } | |
| return ( | |
| <> | |
| <audio | |
| ref={audioRef} | |
| src={MUSIC_URL} | |
| preload="metadata" | |
| playsInline | |
| style={{ display: "none" }} | |
| /> | |
| <Box | |
| ref={rootRef} | |
| sx={{ | |
| position: "fixed", | |
| left: position?.left ?? 0, | |
| top: position?.top ?? 0, | |
| zIndex: 25, | |
| pointerEvents: "auto", | |
| visibility: position ? "visible" : "hidden", | |
| maxWidth: "calc(100vw - 32px)", | |
| }} | |
| > | |
| <Stack | |
| direction="row" | |
| spacing={0.5} | |
| sx={{ | |
| flexWrap: "wrap", | |
| gap: 0.5, | |
| }} | |
| > | |
| <Button | |
| type="button" | |
| variant={ | |
| activeDance === "neck" | |
| ? "contained" | |
| : "outlined" | |
| } | |
| onClick={() => startDance("neck")} | |
| title="Neck isolation (N)" | |
| sx={{ | |
| minHeight: 28, | |
| minWidth: 0, | |
| px: 1, | |
| py: 0.25, | |
| fontSize: 11, | |
| lineHeight: 1.15, | |
| fontWeight: 800, | |
| textTransform: "none", | |
| backdropFilter: "blur(8px)", | |
| }} | |
| > | |
| N · Neck | |
| </Button> | |
| <Button | |
| type="button" | |
| variant={ | |
| activeDance === "circle" | |
| ? "contained" | |
| : "outlined" | |
| } | |
| onClick={() => startDance("circle")} | |
| title="Circular neck isolation (H)" | |
| sx={{ | |
| minHeight: 28, | |
| minWidth: 0, | |
| px: 1, | |
| py: 0.25, | |
| fontSize: 11, | |
| lineHeight: 1.15, | |
| fontWeight: 800, | |
| textTransform: "none", | |
| backdropFilter: "blur(8px)", | |
| }} | |
| > | |
| H · Circle | |
| </Button> | |
| <Button | |
| type="button" | |
| variant={ | |
| activeDance === "performance" | |
| ? "contained" | |
| : "outlined" | |
| } | |
| onClick={startPerformance} | |
| title="Automatic video performance (K)" | |
| sx={{ | |
| minHeight: 28, | |
| minWidth: 0, | |
| px: 1, | |
| py: 0.25, | |
| fontSize: 11, | |
| lineHeight: 1.15, | |
| fontWeight: 800, | |
| textTransform: "none", | |
| backdropFilter: "blur(8px)", | |
| }} | |
| > | |
| {isRecording ? "● REC" : "K · Perf"} | |
| </Button> | |
| {(activeDance || musicStartedRef.current) && ( | |
| <Button | |
| type="button" | |
| variant="text" | |
| onClick={stopEverything} | |
| sx={{ | |
| minHeight: 28, | |
| minWidth: 0, | |
| px: 0.75, | |
| py: 0.25, | |
| fontSize: 11, | |
| lineHeight: 1.15, | |
| fontWeight: 700, | |
| textTransform: "none", | |
| }} | |
| > | |
| Stop | |
| </Button> | |
| )} | |
| </Stack> | |
| {error && ( | |
| <Box | |
| role="alert" | |
| sx={{ | |
| mt: 1, | |
| px: 1.25, | |
| py: 0.75, | |
| maxWidth: 480, | |
| bgcolor: "rgba(0,0,0,0.82)", | |
| color: "#fff", | |
| borderRadius: 1, | |
| fontSize: 12, | |
| lineHeight: 1.35, | |
| }} | |
| > | |
| {error} | |
| </Box> | |
| )} | |
| </Box> | |
| </> | |
| ); | |
| } | |