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 (