volume controls for voice and screen sharing
This commit is contained in:
+36
-14
@@ -668,11 +668,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider {
|
.volume-slider {
|
||||||
width: 0;
|
|
||||||
opacity: 0;
|
|
||||||
transition:
|
|
||||||
width 0.2s,
|
|
||||||
opacity 0.2s;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
@@ -681,6 +676,16 @@ body {
|
|||||||
outline: none;
|
outline: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 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 {
|
.volume-control-container:hover .volume-slider {
|
||||||
@@ -688,7 +693,14 @@ body {
|
|||||||
opacity: 1;
|
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 {
|
.volume-slider::-webkit-slider-runnable-track {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
@@ -699,15 +711,16 @@ body {
|
|||||||
|
|
||||||
.volume-slider::-webkit-slider-thumb {
|
.volume-slider::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
width: 12px;
|
width: 14px;
|
||||||
height: 12px;
|
height: 14px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
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 {
|
.volume-slider::-moz-range-track {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
@@ -717,12 +730,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider::-moz-range-thumb {
|
.volume-slider::-moz-range-thumb {
|
||||||
width: 12px;
|
width: 14px;
|
||||||
height: 12px;
|
height: 14px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-control-container .mute-tile-btn {
|
.volume-control-container .mute-tile-btn {
|
||||||
@@ -1156,7 +1170,15 @@ body {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-section input[type="range"] {
|
.context-menu-section .volume-slider {
|
||||||
width: 100%;
|
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"
|
step="0.01"
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
|
className="volume-slider"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{ fontSize: "0.7rem", textAlign: "right", marginTop: "4px" }}
|
style={{ fontSize: "0.7rem", textAlign: "right", marginTop: "4px" }}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const VideoTile = ({
|
|||||||
|
|
||||||
// Control volume directly on the video element for screen sharing
|
// Control volume directly on the video element for screen sharing
|
||||||
video.muted = isMuted;
|
video.muted = isMuted;
|
||||||
video.volume = isMuted ? 0 : volume;
|
video.volume = Math.min(1, isMuted ? 0 : volume);
|
||||||
|
|
||||||
video.play().catch((err) => {
|
video.play().catch((err) => {
|
||||||
if (err.name !== "AbortError") {
|
if (err.name !== "AbortError") {
|
||||||
@@ -150,7 +150,7 @@ const VideoTile = ({
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="2"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={isMuted ? 0 : volume}
|
value={isMuted ? 0 : volume}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Identity } from "spacetimedb";
|
|||||||
export interface Peer {
|
export interface Peer {
|
||||||
pc: RTCPeerConnection;
|
pc: RTCPeerConnection;
|
||||||
audio?: HTMLAudioElement;
|
audio?: HTMLAudioElement;
|
||||||
|
gainNode?: GainNode;
|
||||||
videoStream?: MediaStream;
|
videoStream?: MediaStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ export const usePeerManager = (
|
|||||||
const peerAudioPreferencesRef = useRef<
|
const peerAudioPreferencesRef = useRef<
|
||||||
Map<string, { volume: number; muted: boolean }>
|
Map<string, { volume: number; muted: boolean }>
|
||||||
>(new Map());
|
>(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
|
// Use refs for callbacks to avoid re-creating PC when UI state changes
|
||||||
const onNegotiationNeededRef = useRef(onNegotiationNeeded);
|
const onNegotiationNeededRef = useRef(onNegotiationNeeded);
|
||||||
@@ -50,10 +62,20 @@ export const usePeerManager = (
|
|||||||
const next = { ...current, ...preference };
|
const next = { ...current, ...preference };
|
||||||
peerAudioPreferencesRef.current.set(peerIdHex, next);
|
peerAudioPreferencesRef.current.set(peerIdHex, next);
|
||||||
|
|
||||||
|
const ctx = audioContextRef.current;
|
||||||
|
if (ctx && ctx.state === "suspended") {
|
||||||
|
ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
const peer = peersRef.current.get(peerIdHex);
|
const peer = peersRef.current.get(peerIdHex);
|
||||||
if (peer?.audio) {
|
if (peer) {
|
||||||
peer.audio.volume = next.volume;
|
if (peer.gainNode) {
|
||||||
peer.audio.muted = isDeafenedRef.current || next.muted;
|
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,
|
// For screen sharing audio, the VideoTile component handles its own volume/mute,
|
||||||
// so we only manage the background voice audio here.
|
// so we only manage the background voice audio here.
|
||||||
@@ -69,6 +91,9 @@ export const usePeerManager = (
|
|||||||
`[WebRTC][${mediaType}] Closing peer connection for ${peerIdHex}`,
|
`[WebRTC][${mediaType}] Closing peer connection for ${peerIdHex}`,
|
||||||
);
|
);
|
||||||
peer.pc.close();
|
peer.pc.close();
|
||||||
|
if (peer.gainNode) {
|
||||||
|
peer.gainNode.disconnect();
|
||||||
|
}
|
||||||
if (peer.audio) {
|
if (peer.audio) {
|
||||||
peer.audio.pause();
|
peer.audio.pause();
|
||||||
peer.audio.srcObject = null;
|
peer.audio.srcObject = null;
|
||||||
@@ -158,35 +183,81 @@ export const usePeerManager = (
|
|||||||
|
|
||||||
if (event.track.kind === "audio") {
|
if (event.track.kind === "audio") {
|
||||||
if (mediaType === "voice") {
|
if (mediaType === "voice") {
|
||||||
if (!existingPeer.audio) {
|
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
|
||||||
existingPeer.audio = new Audio();
|
volume: 1,
|
||||||
existingPeer.audio.autoplay = true;
|
muted: false,
|
||||||
|
};
|
||||||
|
|
||||||
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
|
// Use Web Audio API for voice to support > 100% volume
|
||||||
volume: 1,
|
try {
|
||||||
muted: false,
|
const ctx = getAudioContext();
|
||||||
};
|
const stream = new MediaStream([event.track]);
|
||||||
existingPeer.audio.volume = pref.volume;
|
|
||||||
existingPeer.audio.muted = isDeafenedRef.current || pref.muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = new MediaStream([event.track]);
|
// Chrome quirk: Remote WebRTC streams must be attached to an HTMLMediaElement
|
||||||
existingPeer.audio.srcObject = stream;
|
// to "pump" the audio, even if you're using Web Audio API for destination.
|
||||||
|
if (!existingPeer.audio) {
|
||||||
existingPeer.audio
|
existingPeer.audio = new Audio();
|
||||||
.play()
|
existingPeer.audio.muted = true; // Mute the element, we'll play through Web Audio
|
||||||
.then(() => {
|
}
|
||||||
console.log(
|
existingPeer.audio.srcObject = stream;
|
||||||
`[WebRTC][voice] Background voice audio playing for ${peerIdHex}`,
|
existingPeer.audio.play().catch((err) => {
|
||||||
);
|
if (err.name !== "AbortError")
|
||||||
})
|
console.warn(
|
||||||
.catch((e) => {
|
`[WebRTC][voice] Dummy audio play failed for ${peerIdHex}`,
|
||||||
if (e.name !== "AbortError")
|
err,
|
||||||
console.error(
|
|
||||||
`[WebRTC][voice] Error playing audio for ${peerIdHex}`,
|
|
||||||
e,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
// For screen sharing, we add the audio track to the video stream object.
|
// For screen sharing, we add the audio track to the video stream object.
|
||||||
// VideoTile will manage the volume/mute state of the <video> element.
|
// VideoTile will manage the volume/mute state of the <video> element.
|
||||||
@@ -254,10 +325,13 @@ export const usePeerManager = (
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mediaType === "voice") {
|
if (mediaType === "voice") {
|
||||||
peersRef.current.forEach((peer, peerIdHex) => {
|
peersRef.current.forEach((peer, peerIdHex) => {
|
||||||
if (peer.audio) {
|
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
|
||||||
const pref = peerAudioPreferencesRef.current.get(peerIdHex) || {
|
volume: 1,
|
||||||
muted: false,
|
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;
|
peer.audio.muted = isDeafened || pref.muted;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user