199 lines
6.5 KiB
JavaScript
199 lines
6.5 KiB
JavaScript
class VoiceChannel {
|
|
constructor(ws) {
|
|
this.ws = ws;
|
|
this.localStream = null;
|
|
this.peers = {}; // userId -> RTCPeerConnection
|
|
this.participants = {}; // userId -> username
|
|
this.currentChannelId = null;
|
|
}
|
|
|
|
async join(channelId) {
|
|
if (this.currentChannelId === channelId) return;
|
|
if (this.currentChannelId) this.leave();
|
|
|
|
console.log('Joining voice channel:', channelId);
|
|
this.currentChannelId = channelId;
|
|
|
|
try {
|
|
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
|
|
this.ws.send(JSON.stringify({
|
|
type: 'voice_join',
|
|
channel_id: channelId,
|
|
user_id: window.currentUserId,
|
|
username: window.currentUsername
|
|
}));
|
|
|
|
this.updateVoiceUI();
|
|
} catch (e) {
|
|
console.error('Failed to get local stream:', e);
|
|
alert('Could not access microphone.');
|
|
this.currentChannelId = null;
|
|
}
|
|
}
|
|
|
|
leave() {
|
|
if (!this.currentChannelId) return;
|
|
|
|
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;
|
|
}
|
|
|
|
Object.values(this.peers).forEach(pc => pc.close());
|
|
this.peers = {};
|
|
this.participants = {};
|
|
this.currentChannelId = null;
|
|
this.updateVoiceUI();
|
|
}
|
|
|
|
async handleSignaling(data) {
|
|
const { type, from, to, offer, answer, candidate, channel_id, username } = 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 || `User ${from}`;
|
|
this.createPeerConnection(from, true);
|
|
this.updateVoiceUI();
|
|
}
|
|
break;
|
|
case 'voice_offer':
|
|
this.participants[from] = username || `User ${from}`;
|
|
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_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;
|
|
|
|
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) => {
|
|
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,
|
|
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 = '');
|
|
|
|
if (this.currentChannelId) {
|
|
const channelEl = document.querySelector(`.voice-item[data-channel-id="${this.currentChannelId}"]`);
|
|
if (channelEl) {
|
|
let listEl = channelEl.querySelector('.voice-users-list');
|
|
if (!listEl) {
|
|
listEl = document.createElement('div');
|
|
listEl.className = 'voice-users-list ms-3';
|
|
channelEl.appendChild(listEl);
|
|
}
|
|
|
|
// Me
|
|
this.addVoiceUserToUI(listEl, window.currentUserId, window.currentUsername);
|
|
|
|
// Others
|
|
Object.entries(this.participants).forEach(([uid, name]) => {
|
|
this.addVoiceUserToUI(listEl, uid, name);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
addVoiceUserToUI(container, userId, username) {
|
|
const userEl = document.createElement('div');
|
|
userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1';
|
|
userEl.innerHTML = `
|
|
<div class="message-avatar me-2" style="width: 16px; height: 16px;"></div>
|
|
<span>${username}</span>
|
|
`;
|
|
container.appendChild(userEl);
|
|
}
|
|
}
|