volume controls for voice and screen sharing
This commit is contained in:
+36
-14
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Identity } from "spacetimedb";
|
||||
export interface Peer {
|
||||
pc: RTCPeerConnection;
|
||||
audio?: HTMLAudioElement;
|
||||
gainNode?: GainNode;
|
||||
videoStream?: MediaStream;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user