volume controls for voice and screen sharing

This commit is contained in:
2026-03-31 11:22:13 -04:00
parent a7941100eb
commit 4f586a8495
5 changed files with 146 additions and 48 deletions
+36 -14
View File
@@ -668,11 +668,6 @@ body {
}
.volume-slider {
width: 0;
opacity: 0;
transition:
width 0.2s,
opacity 0.2s;
cursor: pointer;
height: 4px;
-webkit-appearance: none;
@@ -681,6 +676,16 @@ body {
outline: none;
margin: 0;
padding: 0;
accent-color: white;
transition:
width 0.2s,
opacity 0.2s;
}
/* Default for VideoTile is hidden until hover */
.volume-control-container .volume-slider {
width: 0;
opacity: 0;
}
.volume-control-container:hover .volume-slider {
@@ -688,7 +693,14 @@ body {
opacity: 1;
}
/* Chrome/Safari */
/* Context menu slider is always visible and full width */
.context-menu-section .volume-slider {
width: 100%;
opacity: 1;
display: block;
margin: 4px 0;
}
.volume-slider::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
@@ -699,15 +711,16 @@ body {
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
cursor: pointer;
margin-top: -4px; /* Centers thumb on 4px track */
margin-top: -5px; /* Centers thumb on 4px track */
border: none;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
/* Firefox */
.volume-slider::-moz-range-track {
width: 100%;
height: 4px;
@@ -717,12 +730,13 @@ body {
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.volume-control-container .mute-tile-btn {
@@ -1156,7 +1170,15 @@ body {
margin-bottom: 8px;
}
.context-menu-section input[type="range"] {
.context-menu-section .volume-slider {
width: 100%;
cursor: pointer;
opacity: 1;
display: block;
margin: 4px 0;
}
.context-menu-section div:last-child {
font-size: 0.7rem;
text-align: right;
margin-top: 4px;
}
+1
View File
@@ -95,6 +95,7 @@ const VoiceContextMenu: React.FC<{
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
<div
style={{ fontSize: "0.7rem", textAlign: "right", marginTop: "4px" }}
+2 -2
View File
@@ -52,7 +52,7 @@ const VideoTile = ({
// Control volume directly on the video element for screen sharing
video.muted = isMuted;
video.volume = isMuted ? 0 : volume;
video.volume = Math.min(1, isMuted ? 0 : volume);
video.play().catch((err) => {
if (err.name !== "AbortError") {
@@ -150,7 +150,7 @@ const VideoTile = ({
<input
type="range"
min="0"
max="1"
max="2"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
+1
View File
@@ -3,6 +3,7 @@ import { Identity } from "spacetimedb";
export interface Peer {
pc: RTCPeerConnection;
audio?: HTMLAudioElement;
gainNode?: GainNode;
videoStream?: MediaStream;
}
+106 -32
View File
@@ -25,6 +25,18 @@ export const usePeerManager = (
const peerAudioPreferencesRef = useRef<
Map<string, { volume: number; muted: boolean }>
>(new Map());
const audioContextRef = useRef<AudioContext | null>(null);
const getAudioContext = useCallback(() => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext ||
(window as any).webkitAudioContext)();
}
if (audioContextRef.current.state === "suspended") {
audioContextRef.current.resume();
}
return audioContextRef.current;
}, []);
// Use refs for callbacks to avoid re-creating PC when UI state changes
const onNegotiationNeededRef = useRef(onNegotiationNeeded);
@@ -50,10 +62,20 @@ export const usePeerManager = (
const next = { ...current, ...preference };
peerAudioPreferencesRef.current.set(peerIdHex, next);
const ctx = audioContextRef.current;
if (ctx && ctx.state === "suspended") {
ctx.resume();
}
const peer = peersRef.current.get(peerIdHex);
if (peer?.audio) {
peer.audio.volume = next.volume;
peer.audio.muted = isDeafenedRef.current || next.muted;
if (peer) {
if (peer.gainNode) {
peer.gainNode.gain.value =
isDeafenedRef.current || next.muted ? 0 : next.volume;
} else if (peer.audio) {
peer.audio.volume = Math.min(1, next.volume);
peer.audio.muted = isDeafenedRef.current || next.muted;
}
}
// For screen sharing audio, the VideoTile component handles its own volume/mute,
// so we only manage the background voice audio here.
@@ -69,6 +91,9 @@ export const usePeerManager = (
`[WebRTC][${mediaType}] Closing peer connection for ${peerIdHex}`,
);
peer.pc.close();
if (peer.gainNode) {
peer.gainNode.disconnect();
}
if (peer.audio) {
peer.audio.pause();
peer.audio.srcObject = null;
@@ -158,35 +183,81 @@ export const usePeerManager = (
if (event.track.kind === "audio") {
if (mediaType === "voice") {
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.autoplay = true;
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
volume: 1,
muted: false,
};
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
volume: 1,
muted: false,
};
existingPeer.audio.volume = pref.volume;
existingPeer.audio.muted = isDeafenedRef.current || pref.muted;
}
// Use Web Audio API for voice to support > 100% volume
try {
const ctx = getAudioContext();
const stream = new MediaStream([event.track]);
const stream = new MediaStream([event.track]);
existingPeer.audio.srcObject = stream;
existingPeer.audio
.play()
.then(() => {
console.log(
`[WebRTC][voice] Background voice audio playing for ${peerIdHex}`,
);
})
.catch((e) => {
if (e.name !== "AbortError")
console.error(
`[WebRTC][voice] Error playing audio for ${peerIdHex}`,
e,
// Chrome quirk: Remote WebRTC streams must be attached to an HTMLMediaElement
// to "pump" the audio, even if you're using Web Audio API for destination.
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.muted = true; // Mute the element, we'll play through Web Audio
}
existingPeer.audio.srcObject = stream;
existingPeer.audio.play().catch((err) => {
if (err.name !== "AbortError")
console.warn(
`[WebRTC][voice] Dummy audio play failed for ${peerIdHex}`,
err,
);
});
const source = ctx.createMediaStreamSource(stream);
const gainNode = ctx.createGain();
gainNode.gain.value =
isDeafenedRef.current || pref.muted ? 0 : pref.volume;
source.connect(gainNode);
gainNode.connect(ctx.destination);
existingPeer.gainNode = gainNode;
console.log(
`[WebRTC][voice] Web Audio graph connected for ${peerIdHex} (volume: ${pref.volume})`,
);
} catch (e) {
console.error(
`[WebRTC][voice] Failed to setup Web Audio for ${peerIdHex}, falling back to HTMLAudioElement`,
e,
);
if (!existingPeer.audio) {
existingPeer.audio = new Audio();
existingPeer.audio.autoplay = true;
const pref = peerAudioPreferencesRef.current.get(
peerIdHex,
) || {
volume: 1,
muted: false,
};
existingPeer.audio.volume = Math.min(1, pref.volume);
existingPeer.audio.muted =
isDeafenedRef.current || pref.muted;
}
const stream = new MediaStream([event.track]);
existingPeer.audio.srcObject = stream;
existingPeer.audio
.play()
.then(() => {
console.log(
`[WebRTC][voice] Background voice audio playing for ${peerIdHex}`,
);
})
.catch((err) => {
if (err.name !== "AbortError")
console.error(
`[WebRTC][voice] Error playing audio for ${peerIdHex}`,
err,
);
});
}
} else {
// For screen sharing, we add the audio track to the video stream object.
// VideoTile will manage the volume/mute state of the <video> element.
@@ -254,10 +325,13 @@ export const usePeerManager = (
useEffect(() => {
if (mediaType === "voice") {
peersRef.current.forEach((peer, peerIdHex) => {
if (peer.audio) {
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
muted: false,
};
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
volume: 1,
muted: false,
};
if (peer.gainNode) {
peer.gainNode.gain.value = isDeafened || pref.muted ? 0 : pref.volume;
} else if (peer.audio) {
peer.audio.muted = isDeafened || pref.muted;
}
});