400 lines
15 KiB
JavaScript
400 lines
15 KiB
JavaScript
class VoiceChannel {
|
|
constructor(ws, settings) {
|
|
this.ws = ws;
|
|
this.settings = settings || { mode: 'vox', pttKey: 'v', voxThreshold: 0.1 };
|
|
this.localStream = null;
|
|
this.screenStream = null;
|
|
this.peers = {}; // userId -> RTCPeerConnection
|
|
this.participants = {}; // userId -> {username, avatarUrl}
|
|
this.currentChannelId = null;
|
|
this.isScreenSharing = false;
|
|
|
|
this.audioContext = null;
|
|
this.analyser = null;
|
|
this.microphone = null;
|
|
this.scriptProcessor = null;
|
|
|
|
this.isTalking = false;
|
|
this.pttPressed = false;
|
|
this.voxActive = false;
|
|
this.lastVoiceTime = 0;
|
|
this.voxHoldTime = 500; // ms to keep open after sound drops below threshold
|
|
|
|
this.setupPTTListeners();
|
|
window.addEventListener('beforeunload', () => this.leave());
|
|
}
|
|
|
|
setupPTTListeners() {
|
|
window.addEventListener('keydown', (e) => {
|
|
if (this.settings.mode === 'ptt' && e.key.toLowerCase() === this.settings.pttKey.toLowerCase()) {
|
|
if (!this.pttPressed) {
|
|
this.pttPressed = true;
|
|
this.updateMuteState();
|
|
}
|
|
}
|
|
});
|
|
|
|
window.addEventListener('keyup', (e) => {
|
|
if (this.settings.mode === 'ptt' && e.key.toLowerCase() === this.settings.pttKey.toLowerCase()) {
|
|
this.pttPressed = false;
|
|
this.updateMuteState();
|
|
}
|
|
});
|
|
}
|
|
|
|
async join(channelId) {
|
|
console.log('VoiceChannel.join called for channel:', channelId);
|
|
if (this.currentChannelId === channelId) {
|
|
console.log('Already in this channel');
|
|
return;
|
|
}
|
|
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
console.error('WebSocket not connected. State:', this.ws ? this.ws.readyState : 'null');
|
|
alert('Unable to join voice: Connection to signaling server not established. Please wait a few seconds and try again.');
|
|
return;
|
|
}
|
|
|
|
if (this.currentChannelId) this.leave();
|
|
|
|
console.log('Joining voice channel:', channelId);
|
|
this.currentChannelId = channelId;
|
|
|
|
try {
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
throw new Error('Microphone access is only available on secure origins (HTTPS).');
|
|
}
|
|
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
|
|
// Start muted
|
|
this.setMute(true);
|
|
|
|
if (this.settings.mode === 'vox') {
|
|
this.setupVOX();
|
|
}
|
|
|
|
// Persist in DB
|
|
const fd = new FormData();
|
|
fd.append('action', 'join');
|
|
fd.append('channel_id', channelId);
|
|
fetch('api_v1_voice.php', { method: 'POST', body: fd });
|
|
|
|
this.ws.send(JSON.stringify({
|
|
type: 'voice_join',
|
|
channel_id: channelId,
|
|
user_id: window.currentUserId,
|
|
username: window.currentUsername,
|
|
avatar_url: window.currentAvatarUrl
|
|
}));
|
|
|
|
this.updateVoiceUI();
|
|
} catch (e) {
|
|
console.error('Failed to get local stream:', e);
|
|
alert('Could not access microphone.');
|
|
this.currentChannelId = null;
|
|
}
|
|
}
|
|
|
|
setupVOX() {
|
|
if (this.audioContext) this.audioContext.close();
|
|
|
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
this.analyser = this.audioContext.createAnalyser();
|
|
this.microphone = this.audioContext.createMediaStreamSource(this.localStream);
|
|
this.scriptProcessor = this.audioContext.createScriptProcessor(2048, 1, 1);
|
|
|
|
this.analyser.smoothingTimeConstant = 0.8;
|
|
this.analyser.fftSize = 1024;
|
|
|
|
this.microphone.connect(this.analyser);
|
|
this.analyser.connect(this.scriptProcessor);
|
|
this.scriptProcessor.connect(this.audioContext.destination);
|
|
|
|
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;
|
|
const normalized = average / 128; // 0 to 2 approx
|
|
|
|
if (normalized > 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();
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
updateMuteState() {
|
|
if (!this.currentChannelId || !this.localStream) return;
|
|
|
|
let shouldTalk = false;
|
|
if (this.settings.mode === 'ptt') {
|
|
shouldTalk = this.pttPressed;
|
|
} else {
|
|
shouldTalk = this.voxActive;
|
|
}
|
|
|
|
if (this.isTalking !== shouldTalk) {
|
|
this.isTalking = shouldTalk;
|
|
this.setMute(!shouldTalk);
|
|
|
|
// Notify others
|
|
this.ws.send(JSON.stringify({
|
|
type: 'voice_speaking',
|
|
channel_id: this.currentChannelId,
|
|
user_id: window.currentUserId,
|
|
speaking: shouldTalk
|
|
}));
|
|
|
|
this.updateSpeakingUI(window.currentUserId, shouldTalk);
|
|
}
|
|
}
|
|
|
|
setMute(mute) {
|
|
if (this.localStream) {
|
|
this.localStream.getAudioTracks().forEach(track => {
|
|
track.enabled = !mute;
|
|
});
|
|
}
|
|
}
|
|
|
|
leave() {
|
|
if (!this.currentChannelId) return;
|
|
|
|
this.stopScreenShare();
|
|
|
|
// Persist in DB
|
|
const fd = new FormData();
|
|
fd.append('action', 'leave');
|
|
fetch('api_v1_voice.php', { method: 'POST', body: fd });
|
|
|
|
this.ws.send(JSON.stringify({
|
|
type: 'voice_leave',
|
|
channel_id: this.currentChannelId,
|
|
user_id: window.currentUserId
|
|
}));
|
|
|
|
if (this.localStream) {
|
|
this.localStream.getTracks().forEach(track => track.stop());
|
|
this.localStream = null;
|
|
}
|
|
|
|
if (this.audioContext) {
|
|
this.audioContext.close();
|
|
this.audioContext = null;
|
|
}
|
|
|
|
Object.values(this.peers).forEach(pc => pc.close());
|
|
this.peers = {};
|
|
this.participants = {};
|
|
this.currentChannelId = null;
|
|
this.isTalking = false;
|
|
this.updateVoiceUI();
|
|
}
|
|
|
|
async handleSignaling(data) {
|
|
const { type, from, to, offer, answer, candidate, channel_id, username, avatar_url, speaking } = data;
|
|
|
|
if (channel_id != this.currentChannelId) return;
|
|
if (to && to != window.currentUserId) return;
|
|
|
|
switch (type) {
|
|
case 'voice_join':
|
|
if (from != window.currentUserId) {
|
|
this.participants[from] = { username: username || `User ${from}`, avatar_url: avatar_url };
|
|
this.createPeerConnection(from, true);
|
|
this.updateVoiceUI();
|
|
}
|
|
break;
|
|
case 'voice_offer':
|
|
this.participants[from] = { username: username || `User ${from}`, avatar_url: avatar_url };
|
|
await this.handleOffer(from, offer);
|
|
this.updateVoiceUI();
|
|
break;
|
|
case 'voice_answer':
|
|
await this.handleAnswer(from, answer);
|
|
break;
|
|
case 'voice_ice_candidate':
|
|
await this.handleCandidate(from, candidate);
|
|
break;
|
|
case 'voice_speaking':
|
|
this.updateSpeakingUI(from, speaking);
|
|
break;
|
|
case 'voice_leave':
|
|
if (this.peers[from]) {
|
|
this.peers[from].close();
|
|
delete this.peers[from];
|
|
}
|
|
delete this.participants[from];
|
|
this.updateVoiceUI();
|
|
break;
|
|
}
|
|
}
|
|
|
|
createPeerConnection(userId, isOfferor) {
|
|
if (this.peers[userId]) return this.peers[userId];
|
|
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
});
|
|
|
|
this.peers[userId] = pc;
|
|
|
|
if (this.localStream) {
|
|
this.localStream.getTracks().forEach(track => {
|
|
pc.addTrack(track, this.localStream);
|
|
});
|
|
}
|
|
|
|
pc.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
this.ws.send(JSON.stringify({
|
|
type: 'voice_ice_candidate',
|
|
to: userId,
|
|
from: window.currentUserId,
|
|
candidate: event.candidate,
|
|
channel_id: this.currentChannelId
|
|
}));
|
|
}
|
|
};
|
|
|
|
pc.ontrack = (event) => {
|
|
if (event.track.kind === 'audio') {
|
|
const remoteAudio = new Audio();
|
|
remoteAudio.srcObject = event.streams[0];
|
|
remoteAudio.play();
|
|
}
|
|
};
|
|
|
|
if (isOfferor) {
|
|
pc.createOffer().then(offer => {
|
|
return pc.setLocalDescription(offer);
|
|
}).then(() => {
|
|
this.ws.send(JSON.stringify({
|
|
type: 'voice_offer',
|
|
to: userId,
|
|
from: window.currentUserId,
|
|
username: window.currentUsername,
|
|
avatar_url: window.currentAvatarUrl,
|
|
offer: pc.localDescription,
|
|
channel_id: this.currentChannelId
|
|
}));
|
|
});
|
|
}
|
|
|
|
return pc;
|
|
}
|
|
|
|
async handleOffer(from, offer) {
|
|
const pc = this.createPeerConnection(from, false);
|
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
this.ws.send(JSON.stringify({
|
|
type: 'voice_answer',
|
|
to: from,
|
|
from: window.currentUserId,
|
|
answer: pc.localDescription,
|
|
channel_id: this.currentChannelId
|
|
}));
|
|
}
|
|
|
|
async handleAnswer(from, answer) {
|
|
const pc = this.peers[from];
|
|
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
|
}
|
|
|
|
async handleCandidate(from, candidate) {
|
|
const pc = this.peers[from];
|
|
if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
|
}
|
|
|
|
updateVoiceUI() {
|
|
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
|
|
|
|
// Fetch all sessions to update all channels (or just rely on signaling for current one)
|
|
// For simplicity, we update the current channel from participants
|
|
if (this.currentChannelId) {
|
|
const listEls = document.querySelectorAll(`.voice-item[data-channel-id="${this.currentChannelId}"] + .voice-users-list`);
|
|
listEls.forEach(listEl => {
|
|
// Me
|
|
this.addVoiceUserToUI(listEl, window.currentUserId, window.currentUsername, window.currentAvatarUrl);
|
|
|
|
// Others
|
|
Object.entries(this.participants).forEach(([uid, data]) => {
|
|
this.addVoiceUserToUI(listEl, uid, data.username, data.avatar_url);
|
|
});
|
|
});
|
|
|
|
// Voice controls
|
|
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 Connected</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>
|
|
`;
|
|
document.querySelector('.channels-sidebar').appendChild(controls);
|
|
document.getElementById('btn-voice-leave').onclick = () => this.leave();
|
|
}
|
|
} else {
|
|
const controls = document.querySelector('.voice-controls');
|
|
if (controls) controls.remove();
|
|
}
|
|
}
|
|
|
|
addVoiceUserToUI(container, userId, username, avatarUrl) {
|
|
const userEl = document.createElement('div');
|
|
userEl.className = 'voice-user small d-flex align-items-center mb-1';
|
|
userEl.dataset.userId = userId;
|
|
userEl.style.paddingLeft = '8px';
|
|
userEl.innerHTML = `
|
|
<div class="message-avatar me-2" style="width: 18px; height: 18px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarUrl ? `background-image: url('${avatarUrl}');` : ""}"></div>
|
|
<span style="color: var(--text-muted); font-size: 13px;">${username}</span>
|
|
`;
|
|
container.appendChild(userEl);
|
|
}
|
|
|
|
updateSpeakingUI(userId, isSpeaking) {
|
|
const userEls = document.querySelectorAll(`.voice-user[data-user-id="${userId}"]`);
|
|
userEls.forEach(el => {
|
|
const avatar = el.querySelector('.message-avatar');
|
|
if (avatar) {
|
|
if (isSpeaking) {
|
|
avatar.style.boxShadow = '0 0 0 2px #23a559';
|
|
} else {
|
|
avatar.style.boxShadow = 'none';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
stopScreenShare() {
|
|
// Not requested but kept for compatibility
|
|
if (this.screenStream) {
|
|
this.screenStream.getTracks().forEach(track => track.stop());
|
|
this.screenStream = null;
|
|
}
|
|
this.isScreenSharing = false;
|
|
}
|
|
}
|