38443-vm/assets/js/voice.js
Flatlogic Bot 0911f86785 V4
2026-02-15 11:01:34 +00:00

366 lines
14 KiB
JavaScript

class VoiceChannel {
constructor(ws) {
this.ws = ws;
this.localStream = null;
this.screenStream = null;
this.peers = {}; // userId -> RTCPeerConnection
this.participants = {}; // userId -> username
this.currentChannelId = null;
this.isScreenSharing = false;
}
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;
}
}
async toggleScreenShare() {
if (!this.currentChannelId) return;
if (this.isScreenSharing) {
this.stopScreenShare();
} else {
try {
this.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
this.isScreenSharing = true;
const videoTrack = this.screenStream.getVideoTracks()[0];
videoTrack.onended = () => this.stopScreenShare();
// Replace or add track to all peers
Object.values(this.peers).forEach(pc => {
pc.addTrack(videoTrack, this.screenStream);
// Renegotiate
this.renegotiate(pc);
});
this.updateVoiceUI();
this.showLocalVideo();
} catch (e) {
console.error('Failed to share screen:', e);
}
}
}
stopScreenShare() {
if (this.screenStream) {
this.screenStream.getTracks().forEach(track => track.stop());
this.screenStream = null;
}
this.isScreenSharing = false;
// Remove video track from all peers
Object.entries(this.peers).forEach(([userId, pc]) => {
const senders = pc.getSenders();
const videoSender = senders.find(s => s.track && s.track.kind === 'video');
if (videoSender) {
pc.removeTrack(videoSender);
this.renegotiate(pc);
}
});
this.updateVoiceUI();
const localVideo = document.getElementById('local-video-container');
if (localVideo) localVideo.innerHTML = '';
}
renegotiate(pc) {
// Find which user this PC belongs to
const userId = Object.keys(this.peers).find(key => this.peers[key] === pc);
if (!userId) return;
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
}));
});
}
leave() {
if (!this.currentChannelId) return;
this.stopScreenShare();
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();
const remoteVideo = document.getElementById(`remote-video-${from}`);
if (remoteVideo) remoteVideo.remove();
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);
});
}
if (this.screenStream) {
this.screenStream.getTracks().forEach(track => {
pc.addTrack(track, this.screenStream);
});
}
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();
} else if (event.track.kind === 'video') {
this.handleRemoteVideo(userId, event.streams[0]);
}
};
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));
}
handleRemoteVideo(userId, stream) {
let container = document.getElementById('video-grid');
if (!container) {
container = document.createElement('div');
container.id = 'video-grid';
container.className = 'video-grid';
const chatContainer = document.querySelector('.chat-container');
if (chatContainer) {
chatContainer.insertBefore(container, document.getElementById('messages-list'));
} else {
document.body.appendChild(container);
}
}
let video = document.getElementById(`remote-video-${userId}`);
if (!video) {
video = document.createElement('video');
video.id = `remote-video-${userId}`;
video.autoplay = true;
video.playsinline = true;
container.appendChild(video);
}
video.srcObject = stream;
}
showLocalVideo() {
let container = document.getElementById('video-grid');
if (!container) {
container = document.createElement('div');
container.id = 'video-grid';
container.className = 'video-grid';
const chatContainer = document.querySelector('.chat-container');
if (chatContainer) {
chatContainer.insertBefore(container, document.getElementById('messages-list'));
} else {
document.body.appendChild(container);
}
}
let video = document.getElementById('local-video');
if (!video) {
video = document.createElement('video');
video.id = 'local-video';
video.autoplay = true;
video.playsinline = true;
video.muted = true;
container.appendChild(video);
}
video.srcObject = this.screenStream;
}
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);
});
}
// Show voice controls if not already there
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.innerHTML = `
<div class="d-flex align-items-center">
<div class="voice-status-icon text-success me-2">●</div>
<div class="small">Voice Connected</div>
</div>
<div>
<button class="btn btn-sm btn-outline-light me-2" id="btn-screen-share">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
</button>
<button class="btn btn-sm btn-danger" id="btn-voice-leave">
<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-screen-share').onclick = () => this.toggleScreenShare();
document.getElementById('btn-voice-leave').onclick = () => this.leave();
}
} else {
const controls = document.querySelector('.voice-controls');
if (controls) controls.remove();
const grid = document.getElementById('video-grid');
if (grid) grid.remove();
}
}
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);
}
}