microduck-simulator / app /src /ui /DanceControls.jsx
laureen
Allow performance mode without soundtrack
b35829b
Raw History Blame Contribute Delete
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>
</>
);
}