mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
volume stuff
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-RGOkx1Qn.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CyMJCyW9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bz1ixYh7.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+1
-4
@@ -7,7 +7,6 @@ import MobileControls, {
|
||||
MobileRightColumn,
|
||||
} from './components/MobileControls.jsx';
|
||||
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
|
||||
import { SettingsProvider } from './settings/index.js';
|
||||
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
|
||||
import LogPanel from './components/LogPanel.jsx';
|
||||
import DriverVideoPanel from './components/DriverVideoPanel.jsx';
|
||||
@@ -173,9 +172,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
|
||||
<SettingsProvider>
|
||||
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
|
||||
</SettingsProvider>
|
||||
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import SocketLogPanel from './SocketLogPanel.jsx';
|
||||
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
|
||||
const manualTabs = [
|
||||
{ key: 'start', label: 'Start OI' },
|
||||
@@ -32,7 +33,16 @@ export default function SettingsPanel() {
|
||||
hudMapDesktop: false,
|
||||
connectionTransport: 'websocket',
|
||||
});
|
||||
const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const connectionTransport = pageSettings?.connectionTransport || 'websocket';
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||
const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume;
|
||||
const autoLevelEnabled =
|
||||
typeof audioSettings?.autoLevelEnabled === 'boolean'
|
||||
? audioSettings.autoLevelEnabled
|
||||
: AUDIO_SETTINGS_DEFAULTS.autoLevelEnabled;
|
||||
const autoLevelMode = audioSettings?.autoLevelMode === 'duck' ? 'duck' : 'compressor';
|
||||
|
||||
const sensorButtons = useMemo(
|
||||
() => [
|
||||
@@ -55,6 +65,22 @@ export default function SettingsPanel() {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
};
|
||||
|
||||
const handleAudioRange = (key) => (event) => {
|
||||
const raw = Number(event.target.value);
|
||||
const next = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0;
|
||||
saveAudioSettings((current) => ({ ...(current ?? {}), [key]: next }));
|
||||
};
|
||||
|
||||
const handleAutoLevelEnabled = (event) => {
|
||||
const checked = Boolean(event.target.checked);
|
||||
saveAudioSettings((current) => ({ ...(current ?? {}), autoLevelEnabled: checked }));
|
||||
};
|
||||
|
||||
const handleAutoLevelMode = (event) => {
|
||||
const next = event.target.value === 'duck' ? 'duck' : 'compressor';
|
||||
saveAudioSettings((current) => ({ ...(current ?? {}), autoLevelMode: next }));
|
||||
};
|
||||
return (
|
||||
<Tabs defaultTab="keybindings">
|
||||
<TabList>
|
||||
@@ -89,6 +115,78 @@ export default function SettingsPanel() {
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">Mobile HUD keeps the map on by default.</p>
|
||||
</section>
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-slate-400">Audio</p>
|
||||
<label className="grid gap-0.5 text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Master volume</span>
|
||||
<span className="text-xs text-slate-400">{Math.round(masterVolume * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={masterVolume}
|
||||
onChange={handleAudioRange('masterVolume')}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-0.5 text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Alert/page sounds</span>
|
||||
<span className="text-xs text-slate-400">{Math.round(alertVolume * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={alertVolume}
|
||||
onChange={handleAudioRange('alertVolume')}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-0.5 text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Rover audio</span>
|
||||
<span className="text-xs text-slate-400">{Math.round(roverVolume * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={roverVolume}
|
||||
onChange={handleAudioRange('roverVolume')}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||
<span>Auto-level rover audio</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-emerald-500"
|
||||
checked={autoLevelEnabled}
|
||||
onChange={handleAutoLevelEnabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||
<span>Auto-level mode</span>
|
||||
<select
|
||||
value={autoLevelMode}
|
||||
onChange={handleAutoLevelMode}
|
||||
className="field-input text-sm"
|
||||
disabled={!autoLevelEnabled}
|
||||
>
|
||||
<option value="compressor">Compressor (preferred)</option>
|
||||
<option value="duck">Brush/vacuum ducking</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
Compressor evens out incoming audio; ducking lowers volume when cleaning motors are active.
|
||||
</p>
|
||||
</section>
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-slate-400">Connection</p>
|
||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import turnSound from '../assets/turn_alert.mp3';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
|
||||
function useAudio(src) {
|
||||
function useAudio(src, volume = 1) {
|
||||
const audioRef = useRef(null);
|
||||
useEffect(() => {
|
||||
audioRef.current = new Audio(src);
|
||||
audioRef.current.volume = volume;
|
||||
audioRef.current.load();
|
||||
}, [src]);
|
||||
}, [src, volume]);
|
||||
const play = () => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.volume = volume;
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.play().catch(() => {});
|
||||
};
|
||||
@@ -18,7 +22,11 @@ function useAudio(src) {
|
||||
|
||||
export default function TurnAlertListener() {
|
||||
const { session, pushAlert } = useSession();
|
||||
const playSound = useAudio(turnSound);
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
|
||||
const playSound = useAudio(turnSound, effectiveAlertVolume);
|
||||
const seenRoversRef = useRef(new Set());
|
||||
|
||||
const assignments = useMemo(() => session?.turnQueues || {}, [session?.turnQueues]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
||||
import { useChat } from '../context/ChatContext.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
import SocialButton from './SocialButton.jsx';
|
||||
import BatteryBar from './BatteryBar.jsx';
|
||||
import { buildBatteryVisual } from '../lib/battery.js';
|
||||
@@ -12,6 +13,9 @@ import { buildBatteryVisual } from '../lib/battery.js';
|
||||
const RESTART_DELAY_MS = 2000;
|
||||
const UNMUTE_RETRY_MS = 3000;
|
||||
const AUDIO_RETRY_MS = 3000;
|
||||
const AUDIO_DUCK_FACTOR = 0.55;
|
||||
const BRUSH_CURRENT_THRESHOLD_MA = 40;
|
||||
const COMPRESSOR_REDUCTION_ACTIVE_DB = -0.75;
|
||||
|
||||
export default function VideoTile({
|
||||
sessionInfo,
|
||||
@@ -44,15 +48,34 @@ export default function VideoTile({
|
||||
const audioRestartTimer = useRef(null);
|
||||
const audioPlayInterval = useRef(null);
|
||||
const unmuteTimer = useRef(null);
|
||||
const audioContextRef = useRef(null);
|
||||
const audioSourceNodeRef = useRef(null);
|
||||
const audioCompressorRef = useRef(null);
|
||||
const audioGainRef = useRef(null);
|
||||
const reductionPollRef = useRef(null);
|
||||
const graphFailedRef = useRef(false);
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [audioStatus, setAudioStatus] = useState('idle');
|
||||
const [audioDetail, setAudioDetail] = useState(null);
|
||||
const [levelIndicator, setLevelIndicator] = useState(null);
|
||||
const [restartToken, setRestartToken] = useState(0);
|
||||
const [audioRestartToken, setAudioRestartToken] = useState(0);
|
||||
const [muted, setMuted] = useState(true);
|
||||
const usingSnapshot = videoMode === 'snapshot';
|
||||
const sensors = telemetryFrame?.sensors;
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
|
||||
? audioSettings.masterVolume
|
||||
: AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||
const roverVolume = Number.isFinite(audioSettings?.roverVolume)
|
||||
? audioSettings.roverVolume
|
||||
: AUDIO_SETTINGS_DEFAULTS.roverVolume;
|
||||
const autoLevelEnabled = typeof audioSettings?.autoLevelEnabled === 'boolean'
|
||||
? audioSettings.autoLevelEnabled
|
||||
: AUDIO_SETTINGS_DEFAULTS.autoLevelEnabled;
|
||||
const autoLevelMode = audioSettings?.autoLevelMode === 'duck' ? 'duck' : 'compressor';
|
||||
const baseRoverGain = Math.max(0, Math.min(1, masterVolume * roverVolume));
|
||||
const batteryCharge = sensors?.batteryChargeMah ?? null;
|
||||
const desktopLayout = layoutFormat === 'desktop';
|
||||
const mobileHud = !desktopLayout;
|
||||
@@ -87,6 +110,14 @@ export default function VideoTile({
|
||||
const overlayMotors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
|
||||
const overlayFill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
|
||||
const overlayVisible = Boolean(overlayMotors.length);
|
||||
const brushOrVacuumActive = Boolean(
|
||||
(Number(sensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
|
||||
(Number(sensors?.sideBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
|
||||
sensors?.wheelOvercurrents?.mainBrush ||
|
||||
sensors?.wheelOvercurrents?.sideBrush,
|
||||
);
|
||||
const duckGain = autoLevelEnabled && autoLevelMode === 'duck' && brushOrVacuumActive ? AUDIO_DUCK_FACTOR : 1;
|
||||
const effectiveRoverGain = Math.max(0, Math.min(1, baseRoverGain * duckGain));
|
||||
const discordUrl =
|
||||
session?.socials?.find((entry) => {
|
||||
const key = String(entry?.id || entry?.label || '').toLowerCase();
|
||||
@@ -116,6 +147,50 @@ export default function VideoTile({
|
||||
clearTimeout(audioRestartTimer.current);
|
||||
audioRestartTimer.current = setTimeout(() => setAudioRestartToken(Date.now()), RESTART_DELAY_MS);
|
||||
}, []);
|
||||
const ensureAudioGraph = useCallback(() => {
|
||||
if (graphFailedRef.current) return false;
|
||||
const audioEl = audioRef.current;
|
||||
if (!audioEl || typeof window === 'undefined' || typeof window.AudioContext !== 'function') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new window.AudioContext();
|
||||
}
|
||||
const ctx = audioContextRef.current;
|
||||
if (!audioSourceNodeRef.current) {
|
||||
audioSourceNodeRef.current = ctx.createMediaElementSource(audioEl);
|
||||
}
|
||||
if (!audioCompressorRef.current) {
|
||||
const compressor = ctx.createDynamicsCompressor();
|
||||
compressor.threshold.value = -25;
|
||||
compressor.knee.value = 20;
|
||||
compressor.ratio.value = 5;
|
||||
compressor.attack.value = 0.003;
|
||||
compressor.release.value = 0.25;
|
||||
audioCompressorRef.current = compressor;
|
||||
}
|
||||
if (!audioGainRef.current) {
|
||||
audioGainRef.current = ctx.createGain();
|
||||
}
|
||||
audioSourceNodeRef.current.disconnect();
|
||||
audioCompressorRef.current.disconnect();
|
||||
audioGainRef.current.disconnect();
|
||||
audioSourceNodeRef.current.connect(audioCompressorRef.current);
|
||||
audioCompressorRef.current.connect(audioGainRef.current);
|
||||
audioGainRef.current.connect(ctx.destination);
|
||||
return true;
|
||||
} catch {
|
||||
graphFailedRef.current = true;
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resumeAudioContext = useCallback(async () => {
|
||||
const ctx = audioContextRef.current;
|
||||
if (!ctx || ctx.state !== 'suspended') return;
|
||||
await ctx.resume().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const ensurePlayback = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
@@ -168,6 +243,13 @@ export default function VideoTile({
|
||||
clearTimeout(audioRestartTimer.current);
|
||||
clearTimeout(unmuteTimer.current);
|
||||
clearInterval(audioPlayInterval.current);
|
||||
clearInterval(reductionPollRef.current);
|
||||
audioSourceNodeRef.current?.disconnect?.();
|
||||
audioCompressorRef.current?.disconnect?.();
|
||||
audioGainRef.current?.disconnect?.();
|
||||
if (audioContextRef.current?.state && audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close().catch(() => {});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -231,6 +313,72 @@ export default function VideoTile({
|
||||
}
|
||||
}, [status, sessionInfo?.url, scheduleRestart]);
|
||||
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
clearInterval(reductionPollRef.current);
|
||||
reductionPollRef.current = null;
|
||||
if (!audioEl || !audioSessionInfo?.url) {
|
||||
setLevelIndicator(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const compressorMode = autoLevelEnabled && autoLevelMode === 'compressor';
|
||||
const duckMode = autoLevelEnabled && autoLevelMode === 'duck';
|
||||
const graphReady = compressorMode && ensureAudioGraph();
|
||||
|
||||
if (graphReady) {
|
||||
const compressor = audioCompressorRef.current;
|
||||
const gainNode = audioGainRef.current;
|
||||
audioEl.volume = 1;
|
||||
if (gainNode) {
|
||||
gainNode.gain.value = effectiveRoverGain;
|
||||
}
|
||||
if (compressor) {
|
||||
compressor.threshold.value = -25;
|
||||
compressor.knee.value = 20;
|
||||
compressor.ratio.value = 5;
|
||||
compressor.attack.value = 0.003;
|
||||
compressor.release.value = 0.25;
|
||||
}
|
||||
resumeAudioContext();
|
||||
reductionPollRef.current = setInterval(() => {
|
||||
const reduction = compressor?.reduction;
|
||||
if (typeof reduction === 'number' && reduction <= COMPRESSOR_REDUCTION_ACTIVE_DB) {
|
||||
setLevelIndicator(`Level: ${Math.round(Math.abs(reduction))}dB`);
|
||||
} else {
|
||||
setLevelIndicator(null);
|
||||
}
|
||||
}, 180);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasGraph = Boolean(audioSourceNodeRef.current && audioGainRef.current);
|
||||
audioEl.volume = hasGraph ? 1 : effectiveRoverGain;
|
||||
if (audioCompressorRef.current) {
|
||||
audioCompressorRef.current.threshold.value = 0;
|
||||
audioCompressorRef.current.knee.value = 0;
|
||||
audioCompressorRef.current.ratio.value = 1;
|
||||
audioCompressorRef.current.attack.value = 0.003;
|
||||
audioCompressorRef.current.release.value = 0.25;
|
||||
}
|
||||
if (audioGainRef.current) {
|
||||
audioGainRef.current.gain.value = effectiveRoverGain;
|
||||
}
|
||||
if (duckMode && brushOrVacuumActive) {
|
||||
setLevelIndicator('Level: ducking');
|
||||
} else {
|
||||
setLevelIndicator(null);
|
||||
}
|
||||
}, [
|
||||
audioSessionInfo?.url,
|
||||
autoLevelEnabled,
|
||||
autoLevelMode,
|
||||
brushOrVacuumActive,
|
||||
effectiveRoverGain,
|
||||
ensureAudioGraph,
|
||||
resumeAudioContext,
|
||||
]);
|
||||
|
||||
// Audio-only WHEP (no pausing/muting; keeps trying to play)
|
||||
useEffect(() => {
|
||||
if (!audioSessionInfo?.url || !audioRef.current) {
|
||||
@@ -251,6 +399,7 @@ export default function VideoTile({
|
||||
return nextStatus;
|
||||
});
|
||||
if (nextStatus === 'playing') {
|
||||
resumeAudioContext();
|
||||
audioRef.current?.play().catch(() => {});
|
||||
}
|
||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
||||
@@ -277,7 +426,7 @@ export default function VideoTile({
|
||||
active = false;
|
||||
player?.stop();
|
||||
};
|
||||
}, [audioSessionInfo?.url, audioSessionInfo?.token, audioRestartToken, scheduleAudioRestart]);
|
||||
}, [audioSessionInfo?.url, audioSessionInfo?.token, audioRestartToken, scheduleAudioRestart, resumeAudioContext]);
|
||||
|
||||
// Keep nudging the audio element to play in case autoplay was blocked.
|
||||
useEffect(() => {
|
||||
@@ -292,10 +441,11 @@ export default function VideoTile({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const attemptPlay = () => {
|
||||
const attemptPlay = async () => {
|
||||
const target = audioRef.current;
|
||||
if (!target) return;
|
||||
if (!target.paused && !target.ended) return;
|
||||
await resumeAudioContext();
|
||||
target
|
||||
.play()
|
||||
.then(() => {
|
||||
@@ -311,7 +461,7 @@ export default function VideoTile({
|
||||
audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS);
|
||||
|
||||
return () => clearInterval(audioPlayInterval.current);
|
||||
}, [audioSessionInfo?.url, audioStatus]);
|
||||
}, [audioSessionInfo?.url, audioStatus, resumeAudioContext]);
|
||||
|
||||
// Reflect audio element events back into status/detail so the HUD stays accurate.
|
||||
useEffect(() => {
|
||||
@@ -423,6 +573,7 @@ export default function VideoTile({
|
||||
label={label}
|
||||
status={renderedStatus}
|
||||
audioStatus={renderedAudioStatus}
|
||||
levelStatus={levelIndicator}
|
||||
desktopLayout={desktopLayout}
|
||||
layoutFormat={layoutFormat}
|
||||
variant={hudVariant}
|
||||
@@ -537,6 +688,7 @@ function HudOverlay({
|
||||
label,
|
||||
status,
|
||||
audioStatus,
|
||||
levelStatus,
|
||||
desktopLayout = true,
|
||||
layoutFormat = 'desktop',
|
||||
variant = 'default',
|
||||
@@ -626,6 +778,7 @@ function HudOverlay({
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -682,6 +835,7 @@ function HudOverlay({
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{turnTimerText ? (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||
import { useSocket } from './SocketContext.jsx';
|
||||
import { useSession } from './SessionContext.jsx';
|
||||
import messageSound from '../assets/message.mp3';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
|
||||
const ChatContext = createContext({
|
||||
messages: [],
|
||||
@@ -21,6 +23,7 @@ const ChatContext = createContext({
|
||||
export function ChatProvider({ children }) {
|
||||
const socket = useSocket();
|
||||
const { session, pushAlert } = useSession();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [typing, setTyping] = useState([]);
|
||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||
@@ -30,6 +33,9 @@ export function ChatProvider({ children }) {
|
||||
const typingRef = useRef(new Map());
|
||||
const typingAlertRef = useRef(new Map());
|
||||
const typingStateRef = useRef({ isTyping: false, lastSent: 0 });
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
|
||||
|
||||
const rebuildTyping = useCallback(() => {
|
||||
const entries = Array.from(typingRef.current.values())
|
||||
@@ -49,15 +55,17 @@ export function ChatProvider({ children }) {
|
||||
|
||||
useEffect(() => {
|
||||
audioRef.current = new Audio(messageSound);
|
||||
audioRef.current.volume = effectiveAlertVolume;
|
||||
audioRef.current.load();
|
||||
}, []);
|
||||
}, [effectiveAlertVolume]);
|
||||
|
||||
const playSound = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
audio.volume = effectiveAlertVolume;
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => {});
|
||||
}, []);
|
||||
}, [effectiveAlertVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMessage(payload = {}) {
|
||||
|
||||
+12
-9
@@ -9,21 +9,24 @@ import { TelemetryProvider } from './context/TelemetryContext.jsx'
|
||||
import { ChatProvider } from './context/ChatContext.jsx'
|
||||
import SpectatorApp from './spectate/SpectatorApp.jsx'
|
||||
import MiniSummaryApp from './mini/MiniSummaryApp.jsx'
|
||||
import { SettingsProvider } from './settings/index.js'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<SocketProvider>
|
||||
<SessionProvider>
|
||||
<TelemetryProvider>
|
||||
<ChatProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ChatProvider>
|
||||
<SettingsProvider>
|
||||
<ChatProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ChatProvider>
|
||||
</SettingsProvider>
|
||||
</TelemetryProvider>
|
||||
</SessionProvider>
|
||||
</SocketProvider>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SettingsProvider } from '../settings/index.js';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||
@@ -410,12 +409,10 @@ export default function MiniSummaryApp() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<>
|
||||
<MiniSummaryContent />
|
||||
<AlertFeed scale={3} />
|
||||
</>
|
||||
</SettingsProvider>
|
||||
<>
|
||||
<MiniSummaryContent />
|
||||
<AlertFeed scale={3} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,3 +90,11 @@ export const HORN_SETTINGS_DEFAULTS = {
|
||||
waveform: 'saw',
|
||||
freqs: [440, 550, 660, 0],
|
||||
};
|
||||
|
||||
export const AUDIO_SETTINGS_DEFAULTS = {
|
||||
masterVolume: 1,
|
||||
alertVolume: 0.3,
|
||||
roverVolume: 1,
|
||||
autoLevelEnabled: true,
|
||||
autoLevelMode: 'compressor',
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SettingsProvider } from '../settings/index.js';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
|
||||
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
||||
@@ -222,9 +221,5 @@ function SpectatorContent() {
|
||||
}
|
||||
|
||||
export default function SpectatorApp() {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<SpectatorContent />
|
||||
</SettingsProvider>
|
||||
);
|
||||
return <SpectatorContent />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user