Autosave: 20260327-200428
This commit is contained in:
parent
26e09bc00f
commit
af65d18996
@ -480,7 +480,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
let voiceHandler;
|
let voiceHandler;
|
||||||
|
|
||||||
if (typeof VoiceChannel !== 'undefined') {
|
if (typeof VoiceChannel !== 'undefined') {
|
||||||
voiceHandler = new VoiceChannel(null, window.voiceParamètres);
|
voiceHandler = new VoiceChannel(null, window.voiceSettings);
|
||||||
window.voiceHandler = voiceHandler;
|
window.voiceHandler = voiceHandler;
|
||||||
console.log('VoiceHandler initialized');
|
console.log('VoiceHandler initialized');
|
||||||
|
|
||||||
|
|||||||
@ -45,18 +45,13 @@ class VoiceChannel {
|
|||||||
this.pttPressed = false;
|
this.pttPressed = false;
|
||||||
this.voxActive = false;
|
this.voxActive = false;
|
||||||
this.lastVoiceTime = 0;
|
this.lastVoiceTime = 0;
|
||||||
this.voxHoldTime = 500;
|
this.voxHoldTime = 400; // Slightly shorter hold time for better responsiveness
|
||||||
|
|
||||||
// Track who is speaking to persist across UI refreshes
|
// Track who is speaking to persist across UI refreshes
|
||||||
this.speakingUsers = new Set();
|
this.speakingUsers = new Set();
|
||||||
|
|
||||||
this.setupPTTListeners();
|
this.setupPTTListeners();
|
||||||
this.loadWhisperSettings();
|
this.loadWhisperSettings();
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
// We don't want to leave on page refresh if we want persistence
|
|
||||||
// but we might want to tell the server we are "still here" soon.
|
|
||||||
// Actually, for a simple refresh, we just let the session timeout or re-join.
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-rejoin if we were in a channel
|
// Auto-rejoin if we were in a channel
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -70,6 +65,11 @@ class VoiceChannel {
|
|||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alias for index.php compatibility
|
||||||
|
set whisperParamètres(val) {
|
||||||
|
this.whisperSettings = val;
|
||||||
|
}
|
||||||
|
|
||||||
getAudioConstraints() {
|
getAudioConstraints() {
|
||||||
const useAdvanced = this.settings.advancedFilters !== false;
|
const useAdvanced = this.settings.advancedFilters !== false;
|
||||||
|
|
||||||
@ -86,15 +86,14 @@ class VoiceChannel {
|
|||||||
googAudioMirroring: { ideal: false },
|
googAudioMirroring: { ideal: false },
|
||||||
googNoiseReduction: { ideal: this.settings.noiseSuppression },
|
googNoiseReduction: { ideal: this.settings.noiseSuppression },
|
||||||
googAutoGainControl2: { ideal: useAdvanced },
|
googAutoGainControl2: { ideal: useAdvanced },
|
||||||
googAudioMirroring: { ideal: false },
|
|
||||||
// Standard constraints
|
// Standard constraints
|
||||||
channelCount: { ideal: 1 },
|
channelCount: { ideal: 1 },
|
||||||
sampleRate: { ideal: 48000 },
|
sampleRate: { ideal: 48000 },
|
||||||
sampleSize: { ideal: 16 }
|
sampleSize: { ideal: 16 }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.settings.inputDevice !== 'default') {
|
if (this.settings.inputDevice && this.settings.inputDevice !== 'default') {
|
||||||
constraints.deviceId = { exact: this.settings.inputDevice };
|
constraints.deviceId = { ideal: this.settings.inputDevice };
|
||||||
}
|
}
|
||||||
|
|
||||||
return constraints;
|
return constraints;
|
||||||
@ -206,9 +205,6 @@ class VoiceChannel {
|
|||||||
this.isWhispering = false;
|
this.isWhispering = false;
|
||||||
this.whisperPeers.clear();
|
this.whisperPeers.clear();
|
||||||
this.updateMuteState();
|
this.updateMuteState();
|
||||||
|
|
||||||
// Optionally cleanup peers that are NOT in current channel
|
|
||||||
// For now, keep them for future whispers to avoid re-handshake
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async join(channelId, isAutoRejoin = false) {
|
async join(channelId, isAutoRejoin = false) {
|
||||||
@ -231,7 +227,14 @@ class VoiceChannel {
|
|||||||
audio: this.getAudioConstraints(),
|
audio: this.getAudioConstraints(),
|
||||||
video: false
|
video: false
|
||||||
};
|
};
|
||||||
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
||||||
|
try {
|
||||||
|
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Advanced constraints failed, falling back to basic audio:', err);
|
||||||
|
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Microphone access granted');
|
console.log('Microphone access granted');
|
||||||
this.setMute(false); // Join unmuted by default (self-mute off)
|
this.setMute(false); // Join unmuted by default (self-mute off)
|
||||||
|
|
||||||
@ -284,7 +287,7 @@ class VoiceChannel {
|
|||||||
this.participants = data.participants;
|
this.participants = data.participants;
|
||||||
const newPs = Object.keys(this.participants);
|
const newPs = Object.keys(this.participants);
|
||||||
|
|
||||||
// If new people joined, initiate offer if I'm the "older" one (not really necessary here, can just offer to anyone I don't have a peer for)
|
// If new people joined, initiate offer
|
||||||
newPs.forEach(pid => {
|
newPs.forEach(pid => {
|
||||||
if (pid !== this.myPeerId && !this.peers[pid]) {
|
if (pid !== this.myPeerId && !this.peers[pid]) {
|
||||||
console.log('New peer found via poll:', pid);
|
console.log('New peer found via poll:', pid);
|
||||||
@ -337,7 +340,8 @@ class VoiceChannel {
|
|||||||
const pc = new RTCPeerConnection({
|
const pc = new RTCPeerConnection({
|
||||||
iceServers: [
|
iceServers: [
|
||||||
{ urls: 'stun:stun.l.google.com:19302' },
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||||
|
{ urls: 'stun:stun2.l.google.com:19302' }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -345,10 +349,6 @@ class VoiceChannel {
|
|||||||
|
|
||||||
pc.oniceconnectionstatechange = () => {
|
pc.oniceconnectionstatechange = () => {
|
||||||
console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`);
|
console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`);
|
||||||
if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
|
|
||||||
console.log(`ICE failure with ${userId}, attempting to restart...`);
|
|
||||||
// If it failed, we could try to renegotiate, but for now let's just wait for poll to maybe clean it up
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pc.onnegotiationneeded = async () => {
|
pc.onnegotiationneeded = async () => {
|
||||||
@ -365,7 +365,6 @@ class VoiceChannel {
|
|||||||
|
|
||||||
if (this.localStream) {
|
if (this.localStream) {
|
||||||
this.localStream.getTracks().forEach(track => {
|
this.localStream.getTracks().forEach(track => {
|
||||||
console.log(`Adding track ${track.kind} to peer ${userId}`);
|
|
||||||
pc.addTrack(track, this.localStream);
|
pc.addTrack(track, this.localStream);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -377,16 +376,14 @@ class VoiceChannel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pc.ontrack = (event) => {
|
pc.ontrack = (event) => {
|
||||||
console.log('Received remote track from:', userId, 'Stream count:', event.streams.length);
|
console.log('Received remote track from:', userId);
|
||||||
const stream = event.streams[0] || new MediaStream([event.track]);
|
const stream = event.streams[0] || new MediaStream([event.track]);
|
||||||
|
|
||||||
// Ensure AudioContext is running
|
|
||||||
if (this.audioContext && this.audioContext.state === 'suspended') {
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||||
this.audioContext.resume();
|
this.audioContext.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.remoteAudios[userId]) {
|
if (this.remoteAudios[userId]) {
|
||||||
console.log('Replacing existing audio element for:', userId);
|
|
||||||
this.remoteAudios[userId].pause();
|
this.remoteAudios[userId].pause();
|
||||||
this.remoteAudios[userId].srcObject = null;
|
this.remoteAudios[userId].srcObject = null;
|
||||||
this.remoteAudios[userId].remove();
|
this.remoteAudios[userId].remove();
|
||||||
@ -404,16 +401,11 @@ class VoiceChannel {
|
|||||||
document.body.appendChild(remoteAudio);
|
document.body.appendChild(remoteAudio);
|
||||||
this.remoteAudios[userId] = remoteAudio;
|
this.remoteAudios[userId] = remoteAudio;
|
||||||
|
|
||||||
console.log('Playing remote audio for:', userId);
|
remoteAudio.play().catch(e => {
|
||||||
remoteAudio.play().then(() => {
|
console.warn('Autoplay prevented for:', userId, e);
|
||||||
console.log('Remote audio playing successfully for:', userId);
|
|
||||||
}).catch(e => {
|
|
||||||
console.warn('Autoplay prevented or play failed for:', userId, e);
|
|
||||||
// In case of autoplay prevention, we might need a user gesture
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Manual offer if explicitly requested (though onnegotiationneeded should handle it)
|
|
||||||
if (isOfferor && pc.signalingState === 'stable') {
|
if (isOfferor && pc.signalingState === 'stable') {
|
||||||
pc.onnegotiationneeded();
|
pc.onnegotiationneeded();
|
||||||
}
|
}
|
||||||
@ -425,8 +417,6 @@ class VoiceChannel {
|
|||||||
const from = sig.from;
|
const from = sig.from;
|
||||||
const data = sig.data;
|
const data = sig.data;
|
||||||
|
|
||||||
console.log('Handling signaling from:', from, 'type:', data.type);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'offer':
|
case 'offer':
|
||||||
@ -450,18 +440,11 @@ class VoiceChannel {
|
|||||||
async handleOffer(from, offer) {
|
async handleOffer(from, offer) {
|
||||||
const pc = this.createPeerConnection(from, false);
|
const pc = this.createPeerConnection(from, false);
|
||||||
const state = this.peerStates[from];
|
const state = this.peerStates[from];
|
||||||
|
const offerCollision = (offer.type === "offer") && (state.makingOffer || pc.signalingState !== "stable");
|
||||||
const offerCollision = (offer.type === "offer") &&
|
|
||||||
(state.makingOffer || pc.signalingState !== "stable");
|
|
||||||
|
|
||||||
// Politeness: higher peer_id is polite
|
|
||||||
const isPolite = this.myPeerId > from;
|
const isPolite = this.myPeerId > from;
|
||||||
state.ignoreOffer = !isPolite && offerCollision;
|
state.ignoreOffer = !isPolite && offerCollision;
|
||||||
|
|
||||||
if (state.ignoreOffer) {
|
if (state.ignoreOffer) return;
|
||||||
console.log('Polite peer: ignoring offer from impolite peer to avoid collision', from);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||||
if (offer.type === "offer") {
|
if (offer.type === "offer") {
|
||||||
@ -479,36 +462,25 @@ class VoiceChannel {
|
|||||||
|
|
||||||
async handleCandidate(from, candidate) {
|
async handleCandidate(from, candidate) {
|
||||||
const pc = this.peers[from];
|
const pc = this.peers[from];
|
||||||
const state = this.peerStates[from];
|
|
||||||
try {
|
try {
|
||||||
if (pc) {
|
if (pc) {
|
||||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
if (!state || !state.ignoreOffer) {
|
|
||||||
console.warn('Failed to add ICE candidate', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupVOX() {
|
setupVOX() {
|
||||||
if (!this.localStream) {
|
if (!this.localStream) return;
|
||||||
console.warn('Cannot setup VOX: no localStream');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Setting up VOX logic...');
|
|
||||||
try {
|
try {
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext) {
|
||||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-ensure context is running
|
|
||||||
if (this.audioContext.state === 'suspended') {
|
if (this.audioContext.state === 'suspended') {
|
||||||
this.audioContext.resume().then(() => console.log('AudioContext resumed'));
|
this.audioContext.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup old nodes
|
|
||||||
if (this.scriptProcessor) {
|
if (this.scriptProcessor) {
|
||||||
this.scriptProcessor.onaudioprocess = null;
|
this.scriptProcessor.onaudioprocess = null;
|
||||||
try { this.scriptProcessor.disconnect(); } catch(e) {}
|
try { this.scriptProcessor.disconnect(); } catch(e) {}
|
||||||
@ -518,14 +490,14 @@ class VoiceChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.analyser = this.audioContext.createAnalyser();
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
this.analyser.fftSize = 512;
|
this.analyser.fftSize = 1024; // Better resolution
|
||||||
|
this.analyser.smoothingTimeConstant = 0.3; // Less jitter
|
||||||
|
|
||||||
// Use a cloned stream for analysis so VOX works even when localStream is muted/disabled
|
|
||||||
if (this.analysisStream) {
|
if (this.analysisStream) {
|
||||||
this.analysisStream.getTracks().forEach(t => t.stop());
|
this.analysisStream.getTracks().forEach(t => t.stop());
|
||||||
}
|
}
|
||||||
this.analysisStream = this.localStream.clone();
|
this.analysisStream = this.localStream.clone();
|
||||||
this.analysisStream.getAudioTracks().forEach(t => t.enabled = true); // Ensure analysis stream is NOT muted
|
this.analysisStream.getAudioTracks().forEach(t => t.enabled = true);
|
||||||
|
|
||||||
this.microphone = this.audioContext.createMediaStreamSource(this.analysisStream);
|
this.microphone = this.audioContext.createMediaStreamSource(this.analysisStream);
|
||||||
this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
|
this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
|
||||||
@ -533,7 +505,6 @@ class VoiceChannel {
|
|||||||
this.microphone.connect(this.analyser);
|
this.microphone.connect(this.analyser);
|
||||||
this.analyser.connect(this.scriptProcessor);
|
this.analyser.connect(this.scriptProcessor);
|
||||||
|
|
||||||
// Avoid feedback: connect to a gain node with 0 volume then to destination
|
|
||||||
const silence = this.audioContext.createGain();
|
const silence = this.audioContext.createGain();
|
||||||
silence.gain.value = 0;
|
silence.gain.value = 0;
|
||||||
this.scriptProcessor.connect(silence);
|
this.scriptProcessor.connect(silence);
|
||||||
@ -542,13 +513,21 @@ class VoiceChannel {
|
|||||||
this.voxActive = false;
|
this.voxActive = false;
|
||||||
this.currentVolume = 0;
|
this.currentVolume = 0;
|
||||||
|
|
||||||
|
const buffer = new Float32Array(this.analyser.fftSize);
|
||||||
|
|
||||||
this.scriptProcessor.onaudioprocess = () => {
|
this.scriptProcessor.onaudioprocess = () => {
|
||||||
const array = new Uint8Array(this.analyser.frequencyBinCount);
|
// Use Time Domain Data (Waveform) for better volume measurement (RMS)
|
||||||
this.analyser.getByteFrequencyData(array);
|
this.analyser.getFloatTimeDomainData(buffer);
|
||||||
let values = 0;
|
|
||||||
for (let i = 0; i < array.length; i++) values += array[i];
|
let sum = 0;
|
||||||
const average = values / array.length;
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
this.currentVolume = average / 255;
|
sum += buffer[i] * buffer[i];
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sum / buffer.length);
|
||||||
|
|
||||||
|
// Scale RMS to 0-1. Speech peak is usually around 0.1-0.3 RMS.
|
||||||
|
// We'll normalize it so the slider at 0.1 feels natural.
|
||||||
|
this.currentVolume = Math.min(1.0, rms * 3);
|
||||||
|
|
||||||
if (this.settings.mode !== 'vox') {
|
if (this.settings.mode !== 'vox') {
|
||||||
this.voxActive = false;
|
this.voxActive = false;
|
||||||
@ -568,7 +547,6 @@ class VoiceChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
console.log('VOX logic setup complete');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to setup VOX:', e);
|
console.error('Failed to setup VOX:', e);
|
||||||
}
|
}
|
||||||
@ -581,21 +559,10 @@ class VoiceChannel {
|
|||||||
updateMuteState() {
|
updateMuteState() {
|
||||||
if (!this.localStream) return;
|
if (!this.localStream) return;
|
||||||
|
|
||||||
// If we are not in a channel, we can still whisper!
|
|
||||||
// But for normal talking, we need currentChannelId.
|
|
||||||
|
|
||||||
let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive;
|
let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive;
|
||||||
|
if (this.canSpeak === false) shouldTalk = false;
|
||||||
if (this.canSpeak === false) {
|
if (this.isWhispering) shouldTalk = true;
|
||||||
shouldTalk = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always allow talking if whispering
|
|
||||||
if (this.isWhispering) {
|
|
||||||
shouldTalk = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('updateMuteState: shouldTalk =', shouldTalk, 'isWhispering =', this.isWhispering);
|
|
||||||
if (this.isTalking !== shouldTalk || this.lastWhisperState !== this.isWhispering) {
|
if (this.isTalking !== shouldTalk || this.lastWhisperState !== this.isWhispering) {
|
||||||
this.isTalking = shouldTalk;
|
this.isTalking = shouldTalk;
|
||||||
this.lastWhisperState = this.isWhispering;
|
this.lastWhisperState = this.isWhispering;
|
||||||
@ -603,7 +570,6 @@ class VoiceChannel {
|
|||||||
this.applyAudioState();
|
this.applyAudioState();
|
||||||
this.updateSpeakingUI(window.currentUserId, shouldTalk, this.isWhispering);
|
this.updateSpeakingUI(window.currentUserId, shouldTalk, this.isWhispering);
|
||||||
|
|
||||||
// Notify others in current channel
|
|
||||||
const msg = {
|
const msg = {
|
||||||
type: 'voice_speaking',
|
type: 'voice_speaking',
|
||||||
channel_id: this.currentChannelId,
|
channel_id: this.currentChannelId,
|
||||||
@ -612,15 +578,11 @@ class VoiceChannel {
|
|||||||
is_whisper: this.isWhispering
|
is_whisper: this.isWhispering
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send to channel peers
|
|
||||||
Object.keys(this.peers).forEach(pid => {
|
Object.keys(this.peers).forEach(pid => {
|
||||||
// If we are whispering, only send voice_speaking to whisper targets
|
|
||||||
// but actually it's better to notify channel peers that we are NOT talking to them
|
|
||||||
if (this.isWhispering) {
|
if (this.isWhispering) {
|
||||||
if (this.whisperPeers.has(pid)) {
|
if (this.whisperPeers.has(pid)) {
|
||||||
this.sendSignal(pid, msg);
|
this.sendSignal(pid, msg);
|
||||||
} else {
|
} else {
|
||||||
// Tell channel peers we are silent to them
|
|
||||||
this.sendSignal(pid, { ...msg, speaking: false });
|
this.sendSignal(pid, { ...msg, speaking: false });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -628,14 +590,9 @@ class VoiceChannel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also notify whisper peers that are NOT in the channel
|
|
||||||
if (this.isWhispering) {
|
if (this.isWhispering) {
|
||||||
this.whisperPeers.forEach(pid => {
|
this.whisperPeers.forEach(pid => {
|
||||||
if (!this.peers[pid]) {
|
if (this.peers[pid]) this.sendSignal(pid, msg);
|
||||||
// This should have been established in startWhisper
|
|
||||||
} else {
|
|
||||||
this.sendSignal(pid, msg);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -644,24 +601,16 @@ class VoiceChannel {
|
|||||||
applyAudioState() {
|
applyAudioState() {
|
||||||
if (this.localStream) {
|
if (this.localStream) {
|
||||||
const shouldTransmit = !this.isSelfMuted && this.isTalking && (this.canSpeak || this.isWhispering);
|
const shouldTransmit = !this.isSelfMuted && this.isTalking && (this.canSpeak || this.isWhispering);
|
||||||
console.log('applyAudioState: transmitting =', shouldTransmit, '(whisper=', this.isWhispering, ')');
|
|
||||||
|
|
||||||
this.localStream.getAudioTracks().forEach(track => {
|
this.localStream.getAudioTracks().forEach(track => {
|
||||||
track.enabled = shouldTransmit;
|
track.enabled = shouldTransmit;
|
||||||
});
|
});
|
||||||
|
|
||||||
// We also need to ensure the audio only goes to the right peers
|
|
||||||
// In P2P, we do this by enabling/disabling the track in the peer connection
|
|
||||||
// or by simply enabling/disabling the local track (which affects all peers).
|
|
||||||
// To be truly private, we should only enable the track for whisper peers.
|
|
||||||
|
|
||||||
Object.entries(this.peers).forEach(([pid, pc]) => {
|
Object.entries(this.peers).forEach(([pid, pc]) => {
|
||||||
const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio');
|
const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio');
|
||||||
if (sender) {
|
if (sender) {
|
||||||
if (this.isWhispering) {
|
if (this.isWhispering) {
|
||||||
sender.track.enabled = this.whisperPeers.has(pid);
|
sender.track.enabled = this.whisperPeers.has(pid);
|
||||||
} else {
|
} else {
|
||||||
// Normal mode: only send to people in the current channel participants
|
|
||||||
sender.track.enabled = !!this.participants[pid];
|
sender.track.enabled = !!this.participants[pid];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -682,12 +631,10 @@ class VoiceChannel {
|
|||||||
|
|
||||||
toggleDeafen() {
|
toggleDeafen() {
|
||||||
this.isDeafened = !this.isDeafened;
|
this.isDeafened = !this.isDeafened;
|
||||||
console.log('Setting deafen to:', this.isDeafened);
|
|
||||||
Object.values(this.remoteAudios).forEach(audio => {
|
Object.values(this.remoteAudios).forEach(audio => {
|
||||||
audio.muted = this.isDeafened;
|
audio.muted = this.isDeafened;
|
||||||
if (!this.isDeafened) audio.volume = this.settings.outputVolume || 1.0;
|
if (!this.isDeafened) audio.volume = this.settings.outputVolume || 1.0;
|
||||||
});
|
});
|
||||||
// If we deafen, we usually also mute in Corvara
|
|
||||||
if (this.isDeafened && !this.isSelfMuted) {
|
if (this.isDeafened && !this.isSelfMuted) {
|
||||||
this.setMute(true);
|
this.setMute(true);
|
||||||
}
|
}
|
||||||
@ -708,10 +655,7 @@ class VoiceChannel {
|
|||||||
async setInputDevice(deviceId) {
|
async setInputDevice(deviceId) {
|
||||||
this.settings.inputDevice = deviceId;
|
this.settings.inputDevice = deviceId;
|
||||||
if (this.currentChannelId && this.localStream) {
|
if (this.currentChannelId && this.localStream) {
|
||||||
const constraints = {
|
const constraints = { audio: this.getAudioConstraints(), video: false };
|
||||||
audio: this.getAudioConstraints(),
|
|
||||||
video: false
|
|
||||||
};
|
|
||||||
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
const newTrack = newStream.getAudioTracks()[0];
|
const newTrack = newStream.getAudioTracks()[0];
|
||||||
|
|
||||||
@ -738,11 +682,7 @@ class VoiceChannel {
|
|||||||
|
|
||||||
async updateAudioConstraints() {
|
async updateAudioConstraints() {
|
||||||
if (this.currentChannelId && this.localStream) {
|
if (this.currentChannelId && this.localStream) {
|
||||||
console.log('Updating audio constraints:', this.settings.echoCancellation, this.settings.noiseSuppression, this.settings.advancedFilters);
|
const constraints = { audio: this.getAudioConstraints(), video: false };
|
||||||
const constraints = {
|
|
||||||
audio: this.getAudioConstraints(),
|
|
||||||
video: false
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
const newTrack = newStream.getAudioTracks()[0];
|
const newTrack = newStream.getAudioTracks()[0];
|
||||||
@ -767,24 +707,14 @@ class VoiceChannel {
|
|||||||
const btnDeafen = document.getElementById('btn-panel-deafen');
|
const btnDeafen = document.getElementById('btn-panel-deafen');
|
||||||
|
|
||||||
let displayMuted = this.isSelfMuted;
|
let displayMuted = this.isSelfMuted;
|
||||||
if (this.canSpeak === false) {
|
if (this.canSpeak === false) displayMuted = true;
|
||||||
displayMuted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btnMute) {
|
if (btnMute) {
|
||||||
btnMute.classList.toggle('active', displayMuted);
|
btnMute.classList.toggle('active', displayMuted);
|
||||||
btnMute.style.color = displayMuted ? '#f23f43' : 'var(--text-muted)';
|
btnMute.style.color = displayMuted ? '#f23f43' : 'var(--text-muted)';
|
||||||
btnMute.innerHTML = displayMuted ?
|
btnMute.innerHTML = displayMuted ?
|
||||||
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>' :
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="1" y1="1" x2="23" y2="23"></line><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>' :
|
||||||
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>';
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="19" x2="16" y2="19"></line></svg>';
|
||||||
|
|
||||||
if (this.canSpeak === false) {
|
|
||||||
btnMute.title = "You do not have permission to speak in this channel";
|
|
||||||
btnMute.style.opacity = '0.5';
|
|
||||||
} else {
|
|
||||||
btnMute.title = "Mute";
|
|
||||||
btnMute.style.opacity = '1';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btnDeafen) {
|
if (btnDeafen) {
|
||||||
@ -797,11 +727,7 @@ class VoiceChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
leave() {
|
leave() {
|
||||||
if (!this.currentChannelId) {
|
if (!this.currentChannelId) return;
|
||||||
console.log('VoiceChannel.leave called but no active channel');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('Leaving voice channel:', this.currentChannelId, 'myPeerId:', this.myPeerId);
|
|
||||||
const cid = this.currentChannelId;
|
const cid = this.currentChannelId;
|
||||||
const pid = this.myPeerId;
|
const pid = this.myPeerId;
|
||||||
|
|
||||||
@ -809,15 +735,10 @@ class VoiceChannel {
|
|||||||
sessionStorage.removeItem('activeVoicePeerId');
|
sessionStorage.removeItem('activeVoicePeerId');
|
||||||
if (this.pollInterval) clearInterval(this.pollInterval);
|
if (this.pollInterval) clearInterval(this.pollInterval);
|
||||||
|
|
||||||
// Use keepalive for the leave fetch to ensure it reaches the server during page unload
|
|
||||||
fetch(`api_v1_voice.php?action=leave&room=${cid}&peer_id=${pid}`, { keepalive: true });
|
fetch(`api_v1_voice.php?action=leave&room=${cid}&peer_id=${pid}`, { keepalive: true });
|
||||||
|
|
||||||
if (this.localStream) {
|
if (this.localStream) {
|
||||||
console.log('Stopping local stream tracks');
|
this.localStream.getTracks().forEach(track => track.stop());
|
||||||
this.localStream.getTracks().forEach(track => {
|
|
||||||
track.stop();
|
|
||||||
console.log('Track stopped:', track.kind);
|
|
||||||
});
|
|
||||||
this.localStream = null;
|
this.localStream = null;
|
||||||
}
|
}
|
||||||
if (this.analysisStream) {
|
if (this.analysisStream) {
|
||||||
@ -836,10 +757,6 @@ class VoiceChannel {
|
|||||||
try { this.microphone.disconnect(); } catch(e) {}
|
try { this.microphone.disconnect(); } catch(e) {}
|
||||||
this.microphone = null;
|
this.microphone = null;
|
||||||
}
|
}
|
||||||
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
||||||
// Keep AudioContext alive but suspended to reuse it
|
|
||||||
this.audioContext.suspend();
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.values(this.peers).forEach(pc => pc.close());
|
Object.values(this.peers).forEach(pc => pc.close());
|
||||||
Object.values(this.remoteAudios).forEach(audio => {
|
Object.values(this.remoteAudios).forEach(audio => {
|
||||||
@ -854,16 +771,12 @@ class VoiceChannel {
|
|||||||
this.myPeerId = null;
|
this.myPeerId = null;
|
||||||
this.speakingUsers.clear();
|
this.speakingUsers.clear();
|
||||||
|
|
||||||
// Also remove 'active' class from all voice items
|
|
||||||
document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('active'));
|
document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('active'));
|
||||||
|
|
||||||
this.updateVoiceUI();
|
this.updateVoiceUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateVoiceUI() {
|
updateVoiceUI() {
|
||||||
// We now use a global update mechanism for all channels
|
|
||||||
VoiceChannel.refreshAllVoiceUsers();
|
VoiceChannel.refreshAllVoiceUsers();
|
||||||
|
|
||||||
if (this.currentChannelId) {
|
if (this.currentChannelId) {
|
||||||
if (!document.querySelector('.voice-controls')) {
|
if (!document.querySelector('.voice-controls')) {
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
@ -909,8 +822,6 @@ class VoiceChannel {
|
|||||||
avatar.style.boxShadow = 'none';
|
avatar.style.boxShadow = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show whisper indicator text if whispering to me
|
|
||||||
if (isWhisper && isSpeaking && userId !== String(window.currentUserId)) {
|
if (isWhisper && isSpeaking && userId !== String(window.currentUserId)) {
|
||||||
if (!el.querySelector('.whisper-label')) {
|
if (!el.querySelector('.whisper-label')) {
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
@ -931,31 +842,21 @@ class VoiceChannel {
|
|||||||
const resp = await fetch('api_v1_voice.php?action=list_all');
|
const resp = await fetch('api_v1_voice.php?action=list_all');
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Clear all lists first
|
|
||||||
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
|
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
|
||||||
|
document.querySelectorAll('.voice-item').forEach(el => el.classList.remove('connected'));
|
||||||
// Remove connected highlight from all voice items
|
|
||||||
document.querySelectorAll('.voice-item').forEach(el => {
|
|
||||||
el.classList.remove('connected');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Populate based on data
|
|
||||||
const processedUserIds = new Set();
|
|
||||||
Object.keys(data.channels).forEach(channelId => {
|
Object.keys(data.channels).forEach(channelId => {
|
||||||
const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`);
|
const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`);
|
||||||
if (voiceItem) {
|
if (voiceItem) {
|
||||||
// Highlight channel as connected only if I am in it
|
|
||||||
if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) {
|
if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) {
|
||||||
voiceItem.classList.add('connected');
|
voiceItem.classList.add('connected');
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = voiceItem.closest('.channel-item-container');
|
const container = voiceItem.closest('.channel-item-container');
|
||||||
if (container) {
|
if (container) {
|
||||||
const listEl = container.querySelector('.voice-users-list');
|
const listEl = container.querySelector('.voice-users-list');
|
||||||
if (listEl) {
|
if (listEl) {
|
||||||
data.channels[channelId].forEach(p => {
|
data.channels[channelId].forEach(p => {
|
||||||
const pid = String(p.user_id);
|
const pid = String(p.user_id);
|
||||||
processedUserIds.add(pid);
|
|
||||||
const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(pid);
|
const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(pid);
|
||||||
VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking, p.is_muted, p.is_deafened);
|
VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking, p.is_muted, p.is_deafened);
|
||||||
});
|
});
|
||||||
@ -963,19 +864,6 @@ class VoiceChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle users whispering to me from other channels or not in any channel
|
|
||||||
if (window.voiceHandler && window.voiceHandler.speakingUsers.size > 0) {
|
|
||||||
window.voiceHandler.speakingUsers.forEach(uid => {
|
|
||||||
if (!processedUserIds.has(uid)) {
|
|
||||||
// Find where to show this user. For now, let's put them in their own channel if possible,
|
|
||||||
// or just a "Whispers" section if we had one.
|
|
||||||
// Actually, let's just show them in whatever channel they are currently in.
|
|
||||||
// The `data.channels` already contains everyone.
|
|
||||||
// If they are not in `processedUserIds` it means their channel is not rendered or they are not in a channel.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to refresh voice users:', e);
|
console.error('Failed to refresh voice users:', e);
|
||||||
@ -991,11 +879,8 @@ class VoiceChannel {
|
|||||||
const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : '';
|
const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : '';
|
||||||
|
|
||||||
let icons = '';
|
let icons = '';
|
||||||
if (isDeafened) {
|
if (isDeafened) icons += '<i class="fa-solid fa-volume-xmark ms-auto text-danger" style="font-size: 10px;"></i>';
|
||||||
icons += '<i class="fa-solid fa-volume-xmark ms-auto text-danger" style="font-size: 10px;"></i>';
|
else if (isMuted) icons += '<i class="fa-solid fa-microphone-slash ms-auto text-danger" style="font-size: 10px;"></i>';
|
||||||
} else if (isMuted) {
|
|
||||||
icons += '<i class="fa-solid fa-microphone-slash ms-auto text-danger" style="font-size: 10px;"></i>';
|
|
||||||
}
|
|
||||||
|
|
||||||
userEl.innerHTML = `
|
userEl.innerHTML = `
|
||||||
<div class="message-avatar me-2" style="width: 16px; height: 16px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarStyle} ${boxShadow}"></div>
|
<div class="message-avatar me-2" style="width: 16px; height: 16px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarStyle} ${boxShadow}"></div>
|
||||||
@ -1004,4 +889,4 @@ class VoiceChannel {
|
|||||||
`;
|
`;
|
||||||
container.appendChild(userEl);
|
container.appendChild(userEl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -476,7 +476,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
|||||||
window.currentChannelName = "<?php echo addslashes($current_channel_name); ?>";
|
window.currentChannelName = "<?php echo addslashes($current_channel_name); ?>";
|
||||||
window.isDndMode = <?php echo ($user['dnd_mode'] ?? 0) ? 'true' : 'false'; ?>;
|
window.isDndMode = <?php echo ($user['dnd_mode'] ?? 0) ? 'true' : 'false'; ?>;
|
||||||
window.soundNotifications = <?php echo ($user['sound_notifications'] ?? 0) ? 'true' : 'false'; ?>;
|
window.soundNotifications = <?php echo ($user['sound_notifications'] ?? 0) ? 'true' : 'false'; ?>;
|
||||||
window.voiceParamètres = {
|
window.voiceSettings = {
|
||||||
mode: "<?php echo $user['voice_mode'] ?? 'vox'; ?>",
|
mode: "<?php echo $user['voice_mode'] ?? 'vox'; ?>",
|
||||||
pttKey: "<?php echo addslashes($user['voice_ptt_key'] ?? 'v'); ?>",
|
pttKey: "<?php echo addslashes($user['voice_ptt_key'] ?? 'v'); ?>",
|
||||||
voxThreshold: <?php echo $user['voice_vox_threshold'] ?? 0.1; ?>,
|
voxThreshold: <?php echo $user['voice_vox_threshold'] ?? 0.1; ?>,
|
||||||
@ -2145,7 +2145,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// But actually 1.0 means high threshold, so quiet needs more voice
|
// But actually 1.0 means high threshold, so quiet needs more voice
|
||||||
// 0.1 means low threshold, easy to trigger.
|
// 0.1 means low threshold, easy to trigger.
|
||||||
// Meter is 0 to 100%.
|
// Meter is 0 to 100%.
|
||||||
meterThreshold.style.left = (voxThresholdInput.value * 100) + '%';
|
meterThreshold.style.left = (voxThresholdInput.value * 100) + '%'; if (window.voiceHandler) window.voiceHandler.settings.voxThreshold = parseFloat(voxThresholdInput.value);
|
||||||
};
|
};
|
||||||
voxThresholdInput.addEventListener('input', updateThresholdPos);
|
voxThresholdInput.addEventListener('input', updateThresholdPos);
|
||||||
updateThresholdPos();
|
updateThresholdPos();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user