diff --git a/api_v1_voice.php b/api_v1_voice.php index e8c303b..b36d663 100644 --- a/api_v1_voice.php +++ b/api_v1_voice.php @@ -1,44 +1,239 @@ - false, 'error' => 'Unauthorized']); +$current_user_id = $user ? (int)$user["id"] : 0; + +function room_id(string $s): string { + $s = preg_replace("~[^a-zA-Z0-9_\-]~", "", $s); + return $s !== "" ? $s : "secours"; +} + +function peer_id(): string { + return bin2hex(random_bytes(8)); +} + +function room_log_file(string $room): string { + return __DIR__ . "/data/" . $room . ".log"; +} + +function room_participants_file(string $room): string { + return __DIR__ . "/data/" . $room . ".participants.json"; +} + +function chat_log_file_for_today(): string { + // Un fichier par jour : YYYY-MM-DD.chat.log + $d = date("Y-m-d"); + return __DIR__ . "/data/" . $d . ".chat.log"; +} + +function now_ms(): int { + return (int) floor(microtime(true) * 1000); +} + +function json_out($data, int $code = 200): void { + http_response_code($code); + header("Content-Type: application/json; charset=utf-8"); + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $action = $_POST['action'] ?? ''; - $channel_id = $_POST['channel_id'] ?? null; +function read_json_file(string $path): array { + if (!file_exists($path)) return []; + $raw = @file_get_contents($path); + if ($raw === false || $raw === "") return []; + $j = json_decode($raw, true); + return is_array($j) ? $j : []; +} - if ($action === 'join' && $channel_id) { - $stmt = db()->prepare("INSERT INTO voice_sessions (user_id, channel_id) VALUES (?, ?) ON DUPLICATE KEY UPDATE channel_id = ?"); - $stmt->execute([$user['id'], $channel_id, $channel_id]); - echo json_encode(['success' => true]); - } elseif ($action === 'leave') { - $stmt = db()->prepare("DELETE FROM voice_sessions WHERE user_id = ?"); - $stmt->execute([$user['id']]); - echo json_encode(['success' => true]); - } else { - echo json_encode(['success' => false, 'error' => 'Invalid action']); +function write_json_file(string $path, array $data): void { + file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX); +} + +function tail_lines(string $path, int $maxLines = 120): array { + if (!is_file($path)) return []; + $fp = fopen($path, "rb"); + if (!$fp) return []; + + $lines = []; + fseek($fp, 0, SEEK_END); + $pos = ftell($fp); + $buffer = ""; + + while ($pos > 0 && count($lines) < $maxLines) { + $readSize = min($pos, 4096); + $pos -= $readSize; + fseek($fp, $pos); + $chunk = fread($fp, $readSize); + $buffer = $chunk . $buffer; + + $chunkLines = explode("\n", $buffer); + $buffer = array_shift($chunkLines); + + while (!empty($chunkLines)) { + $line = array_pop($chunkLines); + if (trim($line) !== "") { + array_unshift($lines, trim($line)); + if (count($lines) >= $maxLines) break; + } + } } - exit; + fclose($fp); + return $lines; } -if ($_SERVER['REQUEST_METHOD'] === 'GET') { - $action = $_GET['action'] ?? ''; - if ($action === 'sessions') { +// Logic for signaling +$action = $_REQUEST["action"] ?? ""; +$room = room_id($_REQUEST["room"] ?? "secours"); +$my_id = $_REQUEST["peer_id"] ?? ""; + +if ($action === "join") { + $name = $_REQUEST["name"] ?? "User"; + $p_file = room_participants_file($room); + $ps = read_json_file($p_file); + + // Cleanup old participants (> 10s) + $stale_time = now_ms() - 10000; + foreach ($ps as $id => $p) { + if (($p["last_seen"] ?? 0) < $stale_time) unset($ps[$id]); + } + + $new_id = peer_id(); + $ps[$new_id] = [ + "id" => $new_id, + "user_id" => $current_user_id, + "name" => $name, + "avatar_url" => $user["avatar_url"] ?? "", + "last_seen" => now_ms() + ]; + write_json_file($p_file, $ps); + + // DB Integration for sidebar + if ($current_user_id > 0) { + try { + $stmt = db()->prepare("INSERT INTO voice_sessions (user_id, channel_id, last_seen) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE channel_id = ?, last_seen = ?"); + $stmt->execute([$current_user_id, $room, now_ms(), $room, now_ms()]); + } catch (Exception $e) {} + } + + json_out(["success" => true, "peer_id" => $new_id, "participants" => $ps]); +} + +if ($action === "poll") { + if (!$my_id) json_out(["error" => "Missing peer_id"], 400); + $p_file = room_participants_file($room); + $ps = read_json_file($p_file); + + if (isset($ps[$my_id])) { + $ps[$my_id]["last_seen"] = now_ms(); + } + + $stale_time = now_ms() - 10000; + foreach ($ps as $id => $p) { + if (($p["last_seen"] ?? 0) < $stale_time) unset($ps[$id]); + } + write_json_file($p_file, $ps); + + // Update DB last_seen + if ($current_user_id > 0) { + try { + $stmt = db()->prepare("UPDATE voice_sessions SET last_seen = ? WHERE user_id = ?"); + $stmt->execute([now_ms(), $current_user_id]); + } catch (Exception $e) {} + } + + // Read signals + $log_file = room_log_file($room); + $signals = []; + if (file_exists($log_file)) { + $lines = file($log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $remaining = []; + $now = now_ms(); + foreach ($lines as $line) { + $sig = json_decode($line, true); + if ($sig && isset($sig["to"]) && $sig["to"] === $my_id) { + $signals[] = $sig; + } elseif ($sig && ($now - ($sig["time"] ?? 0) < 30000)) { + $remaining[] = $line; + } + } + file_put_contents($log_file, implode("\n", $remaining) . (empty($remaining) ? "" : "\n"), LOCK_EX); + } + + json_out(["success" => true, "participants" => $ps, "signals" => $signals]); +} + +if ($action === "signal") { + if (!$my_id) json_out(["error" => "Missing peer_id"], 400); + $to = $_REQUEST["to"] ?? ""; + $data = $_REQUEST["data"] ?? ""; + if (!$to || !$data) json_out(["error" => "Missing to/data"], 400); + + $sig = [ + "from" => $my_id, + "to" => $to, + "data" => json_decode($data, true), + "time" => now_ms() + ]; + + file_put_contents(room_log_file($room), json_encode($sig) . "\n", FILE_APPEND | LOCK_EX); + json_out(["success" => true]); +} + +if ($action === "list_all") { + // Periodic cleanup of the DB table (stale sessions > 15s) + if (rand(1, 10) === 1) { + try { + $stale_db_time = now_ms() - 15000; + $stmt = db()->prepare("DELETE FROM voice_sessions WHERE last_seen < ?"); + $stmt->execute([$stale_db_time]); + } catch (Exception $e) {} + } + + try { $stmt = db()->prepare(" SELECT vs.channel_id, vs.user_id, u.username, u.display_name, u.avatar_url FROM voice_sessions vs JOIN users u ON vs.user_id = u.id + WHERE vs.last_seen > ? "); - $stmt->execute(); - $sessions = $stmt->fetchAll(); - echo json_encode(['success' => true, 'sessions' => $sessions]); - exit; + $stale_db_time = now_ms() - 15000; + $stmt->execute([$stale_db_time]); + $sessions = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $by_channel = []; + foreach ($sessions as $s) { + $by_channel[$s['channel_id']][] = $s; + } + json_out(["success" => true, "channels" => $by_channel]); + } catch (Exception $e) { + json_out(["error" => $e->getMessage()], 500); } } -echo json_encode(['success' => false, 'error' => 'Invalid request']); +if ($action === "leave") { + if ($my_id) { + $p_file = room_participants_file($room); + $ps = read_json_file($p_file); + unset($ps[$my_id]); + write_json_file($p_file, $ps); + } + if ($current_user_id > 0) { + try { + $stmt = db()->prepare("DELETE FROM voice_sessions WHERE user_id = ?"); + $stmt->execute([$current_user_id]); + } catch (Exception $e) {} + } + json_out(["success" => true]); +} + +json_out(["error" => "Unknown action"], 404); \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index 85259d2..454c3b1 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -457,6 +457,18 @@ document.addEventListener('DOMContentLoaded', () => { let ws; let voiceHandler; + if (typeof VoiceChannel !== 'undefined') { + voiceHandler = new VoiceChannel(null, window.voiceSettings); + window.voiceHandler = voiceHandler; + console.log('VoiceHandler initialized'); + + // Start global voice sessions polling + setInterval(() => { + VoiceChannel.refreshAllVoiceUsers(); + }, 3000); + VoiceChannel.refreshAllVoiceUsers(); + } + function connectWS() { console.log('Connecting to WebSocket...'); try { @@ -466,6 +478,7 @@ document.addEventListener('DOMContentLoaded', () => { ws.onopen = () => { console.log('WebSocket connected'); + if (voiceHandler) voiceHandler.ws = ws; ws.send(JSON.stringify({ type: 'presence', user_id: window.currentUserId, @@ -473,12 +486,6 @@ document.addEventListener('DOMContentLoaded', () => { })); }; - if (typeof VoiceChannel !== 'undefined') { - voiceHandler = new VoiceChannel(ws, window.voiceSettings); - window.voiceHandler = voiceHandler; - console.log('VoiceHandler initialized'); - } - ws.onmessage = (e) => { const msg = JSON.parse(e.data); @@ -665,11 +672,12 @@ document.addEventListener('DOMContentLoaded', () => { // Handle Click Events document.addEventListener('click', (e) => { + console.log('Global click at:', e.target); // Voice Channel Click const voiceItem = e.target.closest('.voice-item'); if (voiceItem) { e.preventDefault(); - console.log('Voice item clicked:', voiceItem.dataset.channelId); + console.log('Voice item clicked, Channel ID:', voiceItem.dataset.channelId); const channelId = voiceItem.dataset.channelId; if (voiceHandler) { if (voiceHandler.currentChannelId == channelId) { diff --git a/assets/js/voice.js b/assets/js/voice.js index 70b1169..abbb492 100644 --- a/assets/js/voice.js +++ b/assets/js/voice.js @@ -1,13 +1,17 @@ +console.log('voice.js loaded'); + class VoiceChannel { constructor(ws, settings) { - this.ws = ws; + // ws is ignored now as we use PHP signaling, but kept for compatibility this.settings = settings || { mode: 'vox', pttKey: 'v', voxThreshold: 0.1 }; + console.log('VoiceChannel constructor called with settings:', this.settings); this.localStream = null; - this.screenStream = null; this.peers = {}; // userId -> RTCPeerConnection - this.participants = {}; // userId -> {username, avatarUrl} + this.participants = {}; // userId -> {name} this.currentChannelId = null; - this.isScreenSharing = false; + this.myPeerId = null; + this.pollInterval = null; + this.remoteAudios = {}; // userId -> Audio element this.audioContext = null; this.analyser = null; @@ -18,7 +22,10 @@ class VoiceChannel { this.pttPressed = false; this.voxActive = false; this.lastVoiceTime = 0; - this.voxHoldTime = 500; // ms to keep open after sound drops below threshold + this.voxHoldTime = 500; + + // Track who is speaking to persist across UI refreshes + this.speakingUsers = new Set(); this.setupPTTListeners(); window.addEventListener('beforeunload', () => this.leave()); @@ -26,8 +33,12 @@ class VoiceChannel { 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' && e.key.toLowerCase() === this.settings.pttKey.toLowerCase()) { if (!this.pttPressed) { + console.log('PTT Key Pressed:', e.key); this.pttPressed = true; this.updateMuteState(); } @@ -36,6 +47,7 @@ class VoiceChannel { window.addEventListener('keyup', (e) => { if (this.settings.mode === 'ptt' && e.key.toLowerCase() === this.settings.pttKey.toLowerCase()) { + console.log('PTT Key Released:', e.key); this.pttPressed = false; this.updateMuteState(); } @@ -43,69 +55,209 @@ class VoiceChannel { } async join(channelId) { - console.log('VoiceChannel.join called for channel:', channelId); + console.log('VoiceChannel.join process started 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) { + console.log('Leaving previous channel:', this.currentChannelId); + this.leave(); } - 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).'); - } + console.log('Requesting microphone access...'); this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); - - // Start muted + console.log('Microphone access granted'); 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 }); + // 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); - this.ws.send(JSON.stringify({ - type: 'voice_join', - channel_id: channelId, - user_id: window.currentUserId, - username: window.currentUsername, - avatar_url: window.currentAvatarUrl - })); - - this.updateVoiceUI(); + 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 get local stream:', e); - alert('Could not access microphone.'); + 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 (not really necessary here, can just offer to anyone I don't have a peer for) + newPs.forEach(pid => { + if (pid !== this.myPeerId && !this.peers[pid]) { + console.log('New peer found via poll:', pid); + this.createPeerConnection(pid, true); + } + }); + + // Cleanup left peers + oldPs.forEach(pid => { + if (!this.participants[pid] && this.peers[pid]) { + console.log('Peer left:', pid); + this.peers[pid].close(); + delete this.peers[pid]; + } + }); + + // 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))}`); + } + + 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; + + pc.oniceconnectionstatechange = () => { + console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`); + }; + + if (this.localStream) { + this.localStream.getTracks().forEach(track => { + console.log(`Adding track ${track.kind} to peer ${userId}`); + pc.addTrack(track, this.localStream); + }); + } + + 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); + if (this.remoteAudios[userId]) { + this.remoteAudios[userId].pause(); + this.remoteAudios[userId].remove(); + this.remoteAudios[userId].srcObject = null; + } + const remoteAudio = new Audio(); + remoteAudio.style.display = 'none'; + remoteAudio.srcObject = event.streams[0]; + document.body.appendChild(remoteAudio); + this.remoteAudios[userId] = remoteAudio; + remoteAudio.play().catch(e => console.warn('Autoplay prevented:', e)); + }; + + if (isOfferor) { + pc.createOffer().then(offer => { + return pc.setLocalDescription(offer); + }).then(() => { + this.sendSignal(userId, { type: 'offer', offer: pc.localDescription }); + }); + } + + return pc; + } + + async handleSignaling(sig) { + const from = sig.from; + const data = sig.data; + + console.log('Handling signaling from:', from, 'type:', 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; + } + } + + 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.sendSignal(from, { type: 'answer', answer: pc.localDescription }); + } + + 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)); + } + 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); @@ -114,13 +266,13 @@ class VoiceChannel { 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]; - } + for (let i = 0; i < array.length; i++) values += array[i]; const average = values / array.length; - const normalized = average / 128; // 0 to 2 approx + + // Log sometimes for debugging VOX + if (Math.random() < 0.01) console.log('VOX Avg:', average, 'Threshold:', this.settings.voxThreshold * 255); - if (normalized > this.settings.voxThreshold) { + if (average > (this.settings.voxThreshold * 255)) { this.lastVoiceTime = Date.now(); if (!this.voxActive) { this.voxActive = true; @@ -137,207 +289,69 @@ class VoiceChannel { updateMuteState() { if (!this.currentChannelId || !this.localStream) return; - - let shouldTalk = false; - if (this.settings.mode === 'ptt') { - shouldTalk = this.pttPressed; - } else { - shouldTalk = this.voxActive; - } - + 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); - - // 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); + + // Notify others + const msg = { type: 'voice_speaking', channel_id: this.currentChannelId, user_id: window.currentUserId, speaking: shouldTalk }; + // ... (rest of method remains same, but I'll update it for clarity) + 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) { if (this.localStream) { - this.localStream.getAudioTracks().forEach(track => { - track.enabled = !mute; - }); + console.log('Setting mute to:', mute); + 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 - })); + console.log('Leaving voice channel:', this.currentChannelId); + if (this.pollInterval) clearInterval(this.pollInterval); + + fetch(`api_v1_voice.php?action=leave&room=${this.currentChannelId}&peer_id=${this.myPeerId}`); 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()); + Object.values(this.remoteAudios).forEach(audio => { + audio.pause(); + audio.remove(); + audio.srcObject = null; + }); this.peers = {}; + this.remoteAudios = {}; this.participants = {}; this.currentChannelId = null; - this.isTalking = false; + this.myPeerId = null; + this.speakingUsers.clear(); 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); - }); - }); + // We now use a global update mechanism for all channels + VoiceChannel.refreshAllVoiceUsers(); - // Voice controls + 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'; @@ -345,7 +359,7 @@ class VoiceChannel { controls.innerHTML = `
-
Voice Connected
+
Voice (${this.settings.mode.toUpperCase()})
`; - document.querySelector('.channels-sidebar').appendChild(controls); - document.getElementById('btn-voice-leave').onclick = () => this.leave(); + 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'); @@ -362,38 +378,64 @@ class VoiceChannel { } } - 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 = ` -
- ${username} - `; - container.appendChild(userEl); - } - 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) { - if (isSpeaking) { - avatar.style.boxShadow = '0 0 0 2px #23a559'; - } else { - avatar.style.boxShadow = 'none'; - } + avatar.style.boxShadow = isSpeaking ? '0 0 0 2px #23a559' : 'none'; } }); } - stopScreenShare() { - // Not requested but kept for compatibility - if (this.screenStream) { - this.screenStream.getTracks().forEach(track => track.stop()); - this.screenStream = null; + 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); } - this.isScreenSharing = false; + } + + 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 = ` +
+ ${username} + `; + container.appendChild(userEl); } } diff --git a/assets/pasted-20260217-141526-2008a77e.png b/assets/pasted-20260217-141526-2008a77e.png new file mode 100644 index 0000000..544a593 Binary files /dev/null and b/assets/pasted-20260217-141526-2008a77e.png differ diff --git a/assets/pasted-20260217-143739-c7f88b4b.png b/assets/pasted-20260217-143739-c7f88b4b.png new file mode 100644 index 0000000..3e01147 Binary files /dev/null and b/assets/pasted-20260217-143739-c7f88b4b.png differ diff --git a/data/22.log b/data/22.log new file mode 100644 index 0000000..96c6b9e --- /dev/null +++ b/data/22.log @@ -0,0 +1,2 @@ +{"from":"a0645f38fb2bbdb5","to":"1ca650259dcce60e","data":{"type":"voice_speaking","channel_id":"22","user_id":3,"speaking":true},"time":1771339536024} +{"from":"a0645f38fb2bbdb5","to":"1ca650259dcce60e","data":{"type":"voice_speaking","channel_id":"22","user_id":3,"speaking":false},"time":1771339536956} diff --git a/data/22.participants.json b/data/22.participants.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/data/22.participants.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/data/3.log b/data/3.log new file mode 100644 index 0000000..16d88e7 --- /dev/null +++ b/data/3.log @@ -0,0 +1,8 @@ +{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"offer","offer":{"type":"offer","sdp":"v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 2993514939591859431 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 E9:89:B8:DE:41:F8:AD:79:17:A8:4D:03:2D:53:FC:15:5A:3B:B4:CA:45:A6:F7:EB:C7:F0:01:7C:0B:29:91:F1\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=audio 9 UDP\/TLS\/RTP\/SAVPF 109 9 0 8 101\r\nc=IN IP4 0.0.0.0\r\na=sendrecv\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:2\/recvonly urn:ietf:params:rtp-hdrext:csrc-audio-level\r\na=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap-allow-mixed\r\na=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1\r\na=fmtp:101 0-15\r\na=ice-pwd:cacccb2562e0f73ce1667f9773fb670d\r\na=ice-ufrag:2b377091\r\na=mid:0\r\na=msid:{5d44acc3-9af3-4110-98ed-2de218fc7450} {ed9d31cb-0fc7-497e-8c79-4cc9159d5f97}\r\na=rtcp-mux\r\na=rtpmap:109 opus\/48000\/2\r\na=rtpmap:9 G722\/8000\/1\r\na=rtpmap:0 PCMU\/8000\r\na=rtpmap:8 PCMA\/8000\r\na=rtpmap:101 telephone-event\/8000\r\na=setup:actpass\r\na=ssrc:2872346815 cname:{74df363a-4160-4031-a110-5285ddfe55ff}\r\n"}},"time":1771339604154} +{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 1 UDP 2122252543 192.168.26.26 61807 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604155} +{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 1 TCP 2105524479 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604161} +{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 2 TCP 2105524478 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604164} +{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 2 UDP 2122252542 192.168.26.26 61808 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604170} +{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604171} +{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 1 UDP 1686052863 78.246.210.10 30532 typ srflx raddr 192.168.26.26 rport 61807","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604172} +{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 2 UDP 1686052862 78.246.210.10 30534 typ srflx raddr 192.168.26.26 rport 61808","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604175} diff --git a/data/3.participants.json b/data/3.participants.json new file mode 100644 index 0000000..942c50d --- /dev/null +++ b/data/3.participants.json @@ -0,0 +1 @@ +{"1e0fff021b7ad021":{"id":"1e0fff021b7ad021","user_id":3,"name":"swefheim","avatar_url":"","last_seen":1771339631904},"9afce7ba24e9091b":{"id":"9afce7ba24e9091b","user_id":2,"name":"swefpifh ᵇʰᶠʳ","avatar_url":"","last_seen":1771339632155}} \ No newline at end of file diff --git a/data/6.log b/data/6.log new file mode 100644 index 0000000..e9bcc14 --- /dev/null +++ b/data/6.log @@ -0,0 +1,10 @@ +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"offer","offer":{"type":"offer","sdp":"v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 1705174900585877835 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 AA:37:B9:FA:14:15:DC:34:29:97:DC:55:77:28:8E:74:C0:94:15:08:DF:5B:E9:CC:36:81:E5:D9:5C:49:FB:46\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=audio 9 UDP\/TLS\/RTP\/SAVPF 109 9 0 8 101\r\nc=IN IP4 0.0.0.0\r\na=sendrecv\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:2\/recvonly urn:ietf:params:rtp-hdrext:csrc-audio-level\r\na=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap-allow-mixed\r\na=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1\r\na=fmtp:101 0-15\r\na=ice-pwd:075627a25c9f07d3908305504803057d\r\na=ice-ufrag:03d0add1\r\na=mid:0\r\na=msid:{7a2c5afc-d053-43fd-9196-3caf4510feb3} {8439b857-ead8-4cb1-8111-cb3400348461}\r\na=rtcp-mux\r\na=rtpmap:109 opus\/48000\/2\r\na=rtpmap:9 G722\/8000\/1\r\na=rtpmap:0 PCMU\/8000\r\na=rtpmap:8 PCMA\/8000\r\na=rtpmap:101 telephone-event\/8000\r\na=setup:actpass\r\na=ssrc:1663488241 cname:{7dcb8038-2b50-4e42-827c-c4617a225c01}\r\n"}},"time":1771336456644} +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 1 UDP 2122252543 192.168.26.26 62572 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456645} +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 1 TCP 2105524479 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456647} +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 2 TCP 2105524478 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456652} +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 2 UDP 2122252542 192.168.26.26 62573 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456654} +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 1 UDP 1686052351 78.246.210.10 31184 typ srflx raddr 192.168.26.26 rport 62572","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456720} +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 2 UDP 1686052350 78.246.210.10 31185 typ srflx raddr 192.168.26.26 rport 62573","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456757} +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 1 UDP 1686052863 78.246.210.10 31184 typ srflx raddr 192.168.26.26 rport 62572","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456776} +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 2 UDP 1686052862 78.246.210.10 31185 typ srflx raddr 192.168.26.26 rport 62573","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456802} +{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456805} diff --git a/data/6.participants.json b/data/6.participants.json new file mode 100644 index 0000000..2879778 --- /dev/null +++ b/data/6.participants.json @@ -0,0 +1 @@ +{"0cce3619c0f299fa":{"id":"0cce3619c0f299fa","user_id":2,"name":"swefpifh ᵇʰᶠʳ","avatar_url":"","last_seen":1771336452806},"75b3938d276e81e1":{"id":"75b3938d276e81e1","user_id":2,"name":"swefpifh ᵇʰᶠʳ","avatar_url":"","last_seen":1771336462293}} \ No newline at end of file diff --git a/data/test.participants.json b/data/test.participants.json new file mode 100644 index 0000000..f1d0f40 --- /dev/null +++ b/data/test.participants.json @@ -0,0 +1 @@ +{"0fbf720bc2f110c0":{"id":"0fbf720bc2f110c0","name":"AI","last_seen":1771336229774}} \ No newline at end of file diff --git a/data/test.txt b/data/test.txt new file mode 100644 index 0000000..b6fc4c6 --- /dev/null +++ b/data/test.txt @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/data/test_www.txt b/data/test_www.txt new file mode 100644 index 0000000..b6fc4c6 --- /dev/null +++ b/data/test_www.txt @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/index.php b/index.php index d820167..82fc216 100644 --- a/index.php +++ b/index.php @@ -507,29 +507,46 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; return; } ?> -
- > - - - '; - elseif ($c['type'] === 'rules') echo ''; - elseif ($c['type'] === 'autorole') echo ''; - elseif ($c['type'] === 'forum') echo ''; - elseif ($c['type'] === 'voice') echo ''; - else echo ''; - ?> +
+
@@ -542,21 +559,6 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
- - - - -