671 lines
27 KiB
JavaScript
671 lines
27 KiB
JavaScript
console.log('voice.js loaded');
|
|
|
|
class VoiceChannel {
|
|
constructor(ws, settings) {
|
|
this.ws = ws;
|
|
this.settings = settings || { mode: 'vox', pttKey: 'v', voxThreshold: 0.1 };
|
|
console.log('VoiceChannel constructor called with settings:', this.settings);
|
|
this.localStream = null;
|
|
this.analysisStream = null;
|
|
this.peers = {}; // userId -> RTCPeerConnection
|
|
this.participants = {}; // userId -> {name}
|
|
this.currentChannelId = null;
|
|
this.myPeerId = null;
|
|
this.pollInterval = null;
|
|
this.remoteAudios = {}; // userId -> Audio element
|
|
this.pendingCandidates = {}; // userId -> array of candidates
|
|
|
|
this.audioContext = null;
|
|
this.analyser = null;
|
|
this.microphone = null;
|
|
this.scriptProcessor = null;
|
|
this.delayNode = null;
|
|
this.voxDestination = null;
|
|
|
|
this.isTalking = false;
|
|
this.pttPressed = false;
|
|
this.voxActive = false;
|
|
this.lastVoiceTime = 0;
|
|
this.voxHoldTime = 500;
|
|
|
|
// Track who is speaking to persist across UI refreshes
|
|
this.speakingUsers = new Set();
|
|
|
|
this.setupPTTListeners();
|
|
window.addEventListener('beforeunload', () => this.leave());
|
|
}
|
|
|
|
setupPTTListeners() {
|
|
window.addEventListener('keydown', (e) => {
|
|
// Ignore if in input field
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
|
|
if (this.settings.mode !== 'ptt') return;
|
|
|
|
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
|
|
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
|
|
(this.settings.pttKey === '0' && e.code === 'Numpad0');
|
|
|
|
if (isMatch) {
|
|
if (!this.pttPressed) {
|
|
console.log('PTT Key Pressed:', e.key, e.code, 'Expected:', this.settings.pttKey);
|
|
this.pttPressed = true;
|
|
this.updateMuteState();
|
|
}
|
|
}
|
|
});
|
|
|
|
window.addEventListener('keyup', (e) => {
|
|
if (this.settings.mode !== 'ptt') return;
|
|
|
|
const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() ||
|
|
(e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) ||
|
|
(this.settings.pttKey === '0' && e.code === 'Numpad0');
|
|
|
|
if (isMatch) {
|
|
console.log('PTT Key Released:', e.key, e.code, 'Expected:', this.settings.pttKey);
|
|
this.pttPressed = false;
|
|
this.updateMuteState();
|
|
}
|
|
});
|
|
}
|
|
|
|
async join(channelId) {
|
|
console.log('VoiceChannel.join process started for channel:', channelId);
|
|
if (this.currentChannelId === channelId) {
|
|
console.log('Already in this channel');
|
|
return;
|
|
}
|
|
if (this.currentChannelId) {
|
|
console.log('Leaving previous channel:', this.currentChannelId);
|
|
this.leave();
|
|
}
|
|
|
|
this.currentChannelId = channelId;
|
|
|
|
try {
|
|
console.log('Requesting microphone access...');
|
|
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
console.log('Microphone access granted');
|
|
|
|
// Always setup VOX logic for volume meter and detection
|
|
this.setupVOX();
|
|
|
|
// Initial mute (on the buffered stream if it exists)
|
|
this.setMute(true);
|
|
|
|
// Join via PHP
|
|
console.log('Calling API join...');
|
|
const url = `api_v1_voice.php?action=join&room=${channelId}&name=${encodeURIComponent(window.currentUsername || 'Unknown')}`;
|
|
const resp = await fetch(url);
|
|
const data = await resp.json();
|
|
console.log('API join response:', data);
|
|
|
|
if (data.success) {
|
|
this.myPeerId = data.peer_id;
|
|
console.log('Joined room with peer_id:', this.myPeerId);
|
|
|
|
// Start polling
|
|
this.startPolling();
|
|
this.updateVoiceUI();
|
|
} else {
|
|
console.error('API join failed:', data.error);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to join voice:', e);
|
|
alert('Microphone access required for voice channels. Error: ' + e.message);
|
|
this.currentChannelId = null;
|
|
}
|
|
}
|
|
|
|
startPolling() {
|
|
if (this.pollInterval) clearInterval(this.pollInterval);
|
|
this.pollInterval = setInterval(() => this.poll(), 1000);
|
|
this.poll(); // Initial poll
|
|
}
|
|
|
|
async poll() {
|
|
if (!this.myPeerId || !this.currentChannelId) return;
|
|
|
|
try {
|
|
const resp = await fetch(`api_v1_voice.php?action=poll&room=${this.currentChannelId}&peer_id=${this.myPeerId}`);
|
|
const data = await resp.json();
|
|
|
|
if (data.success) {
|
|
// Update participants
|
|
const oldPs = Object.keys(this.participants);
|
|
this.participants = data.participants;
|
|
const newPs = Object.keys(this.participants);
|
|
|
|
// If new people joined, initiate offer if I'm the "older" one (prevent glare by comparing IDs)
|
|
newPs.forEach(pid => {
|
|
if (pid !== this.myPeerId && !this.peers[pid]) {
|
|
// Only initiate if my ID is greater than theirs (lexicographical comparison)
|
|
if (this.myPeerId > pid) {
|
|
console.log('Initiating offer to new peer:', pid);
|
|
this.createPeerConnection(pid, true);
|
|
} else {
|
|
console.log('Waiting for offer from peer:', pid);
|
|
// Just create the PC without offering, it will handle the incoming offer
|
|
this.createPeerConnection(pid, false);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Cleanup left peers
|
|
oldPs.forEach(pid => {
|
|
if (!this.participants[pid] && this.peers[pid]) {
|
|
console.log('Peer left:', pid);
|
|
try {
|
|
this.peers[pid].close();
|
|
} catch(e) {}
|
|
delete this.peers[pid];
|
|
|
|
if (this.remoteAudios[pid]) {
|
|
try {
|
|
this.remoteAudios[pid].pause();
|
|
this.remoteAudios[pid].srcObject = null;
|
|
this.remoteAudios[pid].remove();
|
|
} catch(e) {}
|
|
delete this.remoteAudios[pid];
|
|
}
|
|
// Also cleanup by class just in case
|
|
document.querySelectorAll(`.voice-remote-audio[data-peer-id="${pid}"]`).forEach(el => el.remove());
|
|
}
|
|
});
|
|
|
|
// Handle incoming signals
|
|
if (data.signals && data.signals.length > 0) {
|
|
for (const sig of data.signals) {
|
|
await this.handleSignaling(sig);
|
|
}
|
|
}
|
|
|
|
this.updateVoiceUI();
|
|
}
|
|
} catch (e) {
|
|
console.error('Polling error:', e);
|
|
}
|
|
}
|
|
|
|
async sendSignal(to, data) {
|
|
if (!this.myPeerId || !this.currentChannelId) return;
|
|
await fetch(`api_v1_voice.php?action=signal&room=${this.currentChannelId}&peer_id=${this.myPeerId}&to=${to}&data=${encodeURIComponent(JSON.stringify(data))}`);
|
|
}
|
|
|
|
async handleSignaling(sig) {
|
|
const { from, data } = sig;
|
|
console.log('Handling signaling from:', from, data.type);
|
|
switch (data.type) {
|
|
case 'offer':
|
|
await this.handleOffer(from, data.offer);
|
|
break;
|
|
case 'answer':
|
|
await this.handleAnswer(from, data.answer);
|
|
break;
|
|
case 'ice_candidate':
|
|
await this.handleCandidate(from, data.candidate);
|
|
break;
|
|
case 'voice_speaking':
|
|
this.updateSpeakingUI(data.user_id, data.speaking);
|
|
break;
|
|
}
|
|
}
|
|
|
|
createPeerConnection(userId, isOfferor) {
|
|
if (this.peers[userId]) return this.peers[userId];
|
|
|
|
console.log('Creating PeerConnection for:', userId, 'as offeror:', isOfferor);
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: [
|
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
]
|
|
});
|
|
|
|
this.peers[userId] = pc;
|
|
this.pendingCandidates[userId] = [];
|
|
|
|
pc.oniceconnectionstatechange = () => {
|
|
console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`);
|
|
};
|
|
|
|
if (this.localStream) {
|
|
const streamToShare = (this.voxDestination && this.voxDestination.stream) ? this.voxDestination.stream : this.localStream;
|
|
streamToShare.getTracks().forEach(track => {
|
|
console.log(`Adding track ${track.kind} to peer ${userId}`);
|
|
pc.addTrack(track, streamToShare);
|
|
});
|
|
}
|
|
|
|
pc.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate });
|
|
}
|
|
};
|
|
|
|
pc.ontrack = (event) => {
|
|
console.log('Received remote track from:', userId, event);
|
|
const stream = event.streams[0] || new MediaStream([event.track]);
|
|
|
|
if (this.remoteAudios[userId]) {
|
|
console.log('Updating existing remote audio for:', userId);
|
|
this.remoteAudios[userId].srcObject = stream;
|
|
} else {
|
|
console.log('Creating new remote audio for:', userId);
|
|
const remoteAudio = new Audio();
|
|
remoteAudio.classList.add('voice-remote-audio');
|
|
remoteAudio.dataset.peerId = userId;
|
|
remoteAudio.autoplay = true;
|
|
remoteAudio.playsInline = true;
|
|
remoteAudio.srcObject = stream;
|
|
document.body.appendChild(remoteAudio);
|
|
this.remoteAudios[userId] = remoteAudio;
|
|
}
|
|
|
|
this.remoteAudios[userId].play().then(() => {
|
|
console.log('Audio playing for peer:', userId);
|
|
}).catch(e => {
|
|
console.warn('Autoplay prevented or failed for:', userId, e);
|
|
// Try again on any click
|
|
const retry = () => {
|
|
this.remoteAudios[userId].play();
|
|
window.removeEventListener('click', retry);
|
|
};
|
|
window.addEventListener('click', retry);
|
|
});
|
|
};
|
|
|
|
if (isOfferor) {
|
|
pc.createOffer().then(offer => {
|
|
return pc.setLocalDescription(offer);
|
|
}).then(() => {
|
|
this.sendSignal(userId, { type: 'offer', offer: pc.localDescription });
|
|
});
|
|
}
|
|
|
|
return pc;
|
|
}
|
|
|
|
async handleOffer(from, offer) {
|
|
console.log('Received offer from:', from);
|
|
const pc = this.createPeerConnection(from, false);
|
|
try {
|
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
|
console.log('Remote description set for offer from:', from);
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
console.log('Local description (answer) set for:', from);
|
|
this.sendSignal(from, { type: 'answer', answer: pc.localDescription });
|
|
await this.processPendingCandidates(from);
|
|
} catch (e) {
|
|
console.error('Error handling offer from', from, e);
|
|
}
|
|
}
|
|
|
|
async handleAnswer(from, answer) {
|
|
console.log('Received answer from:', from);
|
|
const pc = this.peers[from];
|
|
if (pc) {
|
|
try {
|
|
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
|
console.log('Remote description (answer) set for:', from);
|
|
await this.processPendingCandidates(from);
|
|
} catch (e) {
|
|
console.error('Error handling answer from', from, e);
|
|
}
|
|
} else {
|
|
console.warn('Received answer but no peer connection for:', from);
|
|
}
|
|
}
|
|
|
|
async handleCandidate(from, candidate) {
|
|
console.log('Received ICE candidate from:', from);
|
|
const pc = this.peers[from];
|
|
if (pc && pc.remoteDescription) {
|
|
try {
|
|
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
console.log('Added ICE candidate for:', from);
|
|
} catch (e) {
|
|
console.error('Error adding ice candidate from', from, e);
|
|
}
|
|
} else if (pc) {
|
|
console.log('Queuing ICE candidate for:', from);
|
|
this.pendingCandidates[from].push(candidate);
|
|
} else {
|
|
console.warn('Received ICE candidate but no peer connection for:', from);
|
|
}
|
|
}
|
|
|
|
async processPendingCandidates(userId) {
|
|
const pc = this.peers[userId];
|
|
const candidates = this.pendingCandidates[userId];
|
|
if (pc && pc.remoteDescription && candidates && candidates.length > 0) {
|
|
console.log(`Processing ${candidates.length} pending candidates for ${userId}`);
|
|
while (candidates.length > 0) {
|
|
const cand = candidates.shift();
|
|
try {
|
|
await pc.addIceCandidate(new RTCIceCandidate(cand));
|
|
} catch (e) {
|
|
console.error('Error processing pending candidate', e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
setupVOX() {
|
|
if (!this.localStream) {
|
|
console.warn('Cannot setup VOX: no localStream');
|
|
return;
|
|
}
|
|
|
|
console.log('Setting up VOX logic...');
|
|
try {
|
|
if (!this.audioContext) {
|
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
|
|
// Re-ensure context is running
|
|
if (this.audioContext.state === 'suspended') {
|
|
this.audioContext.resume().then(() => console.log('AudioContext resumed'));
|
|
}
|
|
|
|
// Cleanup old nodes
|
|
if (this.scriptProcessor) {
|
|
this.scriptProcessor.onaudioprocess = null;
|
|
try { this.scriptProcessor.disconnect(); } catch(e) {}
|
|
}
|
|
if (this.microphone) {
|
|
try { this.microphone.disconnect(); } catch(e) {}
|
|
}
|
|
|
|
this.analyser = this.audioContext.createAnalyser();
|
|
this.analyser.fftSize = 512;
|
|
|
|
// Use a cloned stream for analysis so VOX works even when localStream is muted/disabled
|
|
if (this.analysisStream) {
|
|
this.analysisStream.getTracks().forEach(t => t.stop());
|
|
}
|
|
this.analysisStream = this.localStream.clone();
|
|
this.analysisStream.getAudioTracks().forEach(t => t.enabled = true); // Ensure analysis stream is NOT muted
|
|
|
|
this.microphone = this.audioContext.createMediaStreamSource(this.analysisStream);
|
|
this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
|
|
|
|
// Setup Delay Buffer for VOX
|
|
this.delayNode = this.audioContext.createDelay(1.0);
|
|
this.delayNode.delayTime.value = 0.3; // 300ms buffer
|
|
this.voxDestination = this.audioContext.createMediaStreamDestination();
|
|
|
|
this.microphone.connect(this.delayNode);
|
|
this.delayNode.connect(this.voxDestination);
|
|
|
|
this.microphone.connect(this.analyser);
|
|
this.analyser.connect(this.scriptProcessor);
|
|
|
|
// Avoid feedback: connect to a gain node with 0 volume then to destination
|
|
const silence = this.audioContext.createGain();
|
|
silence.gain.value = 0;
|
|
this.scriptProcessor.connect(silence);
|
|
silence.connect(this.audioContext.destination);
|
|
|
|
this.voxActive = false;
|
|
this.currentVolume = 0;
|
|
|
|
this.scriptProcessor.onaudioprocess = () => {
|
|
const array = new Uint8Array(this.analyser.frequencyBinCount);
|
|
this.analyser.getByteFrequencyData(array);
|
|
let values = 0;
|
|
for (let i = 0; i < array.length; i++) values += array[i];
|
|
const average = values / array.length;
|
|
this.currentVolume = average / 255;
|
|
|
|
if (this.settings.mode !== 'vox') {
|
|
this.voxActive = false;
|
|
return;
|
|
}
|
|
|
|
if (this.currentVolume > this.settings.voxThreshold) {
|
|
this.lastVoiceTime = Date.now();
|
|
if (!this.voxActive) {
|
|
this.voxActive = true;
|
|
this.updateMuteState();
|
|
}
|
|
} else {
|
|
if (this.voxActive && Date.now() - this.lastVoiceTime > this.voxHoldTime) {
|
|
this.voxActive = false;
|
|
this.updateMuteState();
|
|
}
|
|
}
|
|
};
|
|
console.log('VOX logic setup complete');
|
|
} catch (e) {
|
|
console.error('Failed to setup VOX:', e);
|
|
}
|
|
}
|
|
|
|
getVolume() {
|
|
return this.currentVolume || 0;
|
|
}
|
|
|
|
updateMuteState() {
|
|
if (!this.currentChannelId || !this.localStream) return;
|
|
let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive;
|
|
console.log('updateMuteState: shouldTalk =', shouldTalk, 'mode =', this.settings.mode);
|
|
if (this.isTalking !== shouldTalk) {
|
|
this.isTalking = shouldTalk;
|
|
this.setMute(!shouldTalk);
|
|
this.updateSpeakingUI(window.currentUserId, shouldTalk);
|
|
|
|
// Notify others
|
|
const msg = { type: 'voice_speaking', channel_id: this.currentChannelId, user_id: window.currentUserId, speaking: shouldTalk };
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(msg));
|
|
} else {
|
|
Object.keys(this.peers).forEach(pid => {
|
|
this.sendSignal(pid, msg);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setMute(mute) {
|
|
// We mute the destination stream (delayed) instead of the localStream source
|
|
// to ensure the delay buffer keeps filling with live audio.
|
|
const streamToMute = (this.voxDestination && this.voxDestination.stream) ? this.voxDestination.stream : this.localStream;
|
|
if (streamToMute) {
|
|
console.log('Setting mute to:', mute, 'on stream:', streamToMute.id);
|
|
streamToMute.getAudioTracks().forEach(track => { track.enabled = !mute; });
|
|
}
|
|
}
|
|
|
|
leave() {
|
|
if (!this.currentChannelId) return;
|
|
const roomToLeave = this.currentChannelId;
|
|
const myIdToLeave = this.myPeerId;
|
|
console.log('Leaving voice channel:', roomToLeave);
|
|
|
|
// 1. Clear interval and set state to null immediately to stop any ongoing poll/logic
|
|
if (this.pollInterval) clearInterval(this.pollInterval);
|
|
this.pollInterval = null;
|
|
this.currentChannelId = null;
|
|
this.myPeerId = null;
|
|
|
|
// 2. Notify server
|
|
const leaveUrl = `api_v1_voice.php?action=leave&room=${roomToLeave}&peer_id=${myIdToLeave}`;
|
|
if (navigator.sendBeacon) {
|
|
navigator.sendBeacon(leaveUrl);
|
|
} else {
|
|
fetch(leaveUrl);
|
|
}
|
|
|
|
// 3. Stop all local audio tracks
|
|
if (this.localStream) {
|
|
console.log('Stopping localStream tracks');
|
|
this.localStream.getTracks().forEach(track => {
|
|
track.enabled = false;
|
|
track.stop();
|
|
});
|
|
this.localStream = null;
|
|
}
|
|
if (this.analysisStream) {
|
|
this.analysisStream.getTracks().forEach(track => track.stop());
|
|
this.analysisStream = null;
|
|
}
|
|
|
|
// 4. Cleanup VOX/AudioContext nodes
|
|
if (this.scriptProcessor) {
|
|
try {
|
|
this.scriptProcessor.onaudioprocess = null;
|
|
this.scriptProcessor.disconnect();
|
|
} catch(e) {}
|
|
this.scriptProcessor = null;
|
|
}
|
|
if (this.microphone) {
|
|
try { this.microphone.disconnect(); } catch(e) {}
|
|
this.microphone = null;
|
|
}
|
|
if (this.delayNode) {
|
|
try { this.delayNode.disconnect(); } catch(e) {}
|
|
this.delayNode = null;
|
|
}
|
|
this.voxDestination = null;
|
|
|
|
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
try { this.audioContext.suspend(); } catch(e) {}
|
|
}
|
|
|
|
// 5. Close all peer connections
|
|
console.log('Closing all peer connections:', Object.keys(this.peers));
|
|
Object.keys(this.peers).forEach(pid => {
|
|
try {
|
|
this.peers[pid].onicecandidate = null;
|
|
this.peers[pid].ontrack = null;
|
|
this.peers[pid].oniceconnectionstatechange = null;
|
|
this.peers[pid].close();
|
|
} catch(e) { console.error('Error closing peer:', pid, e); }
|
|
delete this.peers[pid];
|
|
});
|
|
this.peers = {};
|
|
|
|
// 6. Remove all remote audio elements
|
|
document.querySelectorAll('.voice-remote-audio').forEach(audio => {
|
|
try {
|
|
audio.pause();
|
|
audio.srcObject = null;
|
|
audio.remove();
|
|
} catch(e) {}
|
|
});
|
|
this.remoteAudios = {};
|
|
this.pendingCandidates = {};
|
|
|
|
this.participants = {};
|
|
this.speakingUsers.clear();
|
|
this.isTalking = false;
|
|
this.voxActive = false;
|
|
|
|
// Final notify speaking false
|
|
if (roomToLeave) {
|
|
const msg = { type: 'voice_speaking', channel_id: roomToLeave, user_id: window.currentUserId, speaking: false };
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
try { this.ws.send(JSON.stringify(msg)); } catch(e) {}
|
|
}
|
|
}
|
|
|
|
// 7. Update UI
|
|
this.updateVoiceUI();
|
|
}
|
|
|
|
updateVoiceUI() {
|
|
// We now use a global update mechanism for all channels
|
|
VoiceChannel.refreshAllVoiceUsers();
|
|
|
|
if (this.currentChannelId) {
|
|
if (!document.querySelector('.voice-controls')) {
|
|
const controls = document.createElement('div');
|
|
controls.className = 'voice-controls p-2 d-flex justify-content-between align-items-center border-top bg-dark';
|
|
controls.style.backgroundColor = '#232428';
|
|
controls.innerHTML = `
|
|
<div class="d-flex align-items-center">
|
|
<div class="voice-status-icon text-success me-2" style="font-size: 8px;">●</div>
|
|
<div class="small fw-bold" style="font-size: 11px; color: #248046;">Voice (${this.settings.mode.toUpperCase()})</div>
|
|
</div>
|
|
<div>
|
|
<button class="btn btn-sm text-muted" id="btn-voice-leave" title="Disconnect">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path><line x1="23" y1="1" x2="1" y2="23"></line></svg>
|
|
</button>
|
|
</div>
|
|
`;
|
|
const sidebar = document.querySelector('.channels-sidebar');
|
|
if (sidebar) sidebar.appendChild(controls);
|
|
const btnLeave = document.getElementById('btn-voice-leave');
|
|
if (btnLeave) btnLeave.onclick = () => this.leave();
|
|
}
|
|
} else {
|
|
const controls = document.querySelector('.voice-controls');
|
|
if (controls) controls.remove();
|
|
}
|
|
}
|
|
|
|
updateSpeakingUI(userId, isSpeaking) {
|
|
if (isSpeaking) {
|
|
this.speakingUsers.add(userId);
|
|
} else {
|
|
this.speakingUsers.delete(userId);
|
|
}
|
|
|
|
const userEls = document.querySelectorAll(`.voice-user[data-user-id="${userId}"]`);
|
|
userEls.forEach(el => {
|
|
const avatar = el.querySelector('.message-avatar');
|
|
if (avatar) {
|
|
avatar.style.boxShadow = isSpeaking ? '0 0 0 2px #23a559' : 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
static async refreshAllVoiceUsers() {
|
|
try {
|
|
const resp = await fetch('api_v1_voice.php?action=list_all');
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
// Clear all lists first
|
|
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
|
|
|
|
// Populate based on data
|
|
Object.keys(data.channels).forEach(channelId => {
|
|
// Fix: The voice-users-list is a sibling of the container of the voice-item
|
|
const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`);
|
|
if (voiceItem) {
|
|
const container = voiceItem.closest('.channel-item-container');
|
|
if (container) {
|
|
const listEl = container.querySelector('.voice-users-list');
|
|
if (listEl) {
|
|
data.channels[channelId].forEach(p => {
|
|
const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(p.user_id);
|
|
VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to refresh voice users:', e);
|
|
}
|
|
}
|
|
|
|
static renderUserToUI(container, userId, username, avatarUrl, isSpeaking = false) {
|
|
const userEl = document.createElement('div');
|
|
userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1';
|
|
userEl.dataset.userId = userId;
|
|
userEl.style.paddingLeft = '8px';
|
|
const avatarStyle = avatarUrl ? `background-image: url('${avatarUrl}'); background-size: cover;` : "background-color: #555;";
|
|
const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : '';
|
|
userEl.innerHTML = `
|
|
<div class="message-avatar me-2" style="width: 16px; height: 16px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarStyle} ${boxShadow}"></div>
|
|
<span style="font-size: 13px;">${username}</span>
|
|
`;
|
|
container.appendChild(userEl);
|
|
}
|
|
}
|