From d8c5bbb2186d9abf9b6f30cf88b9e890e2d6d3a3 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 17 Feb 2026 12:21:32 +0000 Subject: [PATCH] Autosave: 20260217-122132 --- api_v1_user.php | 7 +- api_v1_voice.php | 44 +++ assets/css/discord.css | 69 ++++ assets/js/main.js | 74 +++-- assets/js/voice.js | 346 +++++++++++---------- assets/pasted-20260217-121543-09802912.png | Bin 0 -> 17738 bytes db/migrations/20260217_voice_system.sql | 12 + index.php | 296 +++++++++++++----- requests.log | 20 ++ ws/server.php | 18 +- 10 files changed, 625 insertions(+), 261 deletions(-) create mode 100644 api_v1_voice.php create mode 100644 assets/pasted-20260217-121543-09802912.png create mode 100644 db/migrations/20260217_voice_system.sql diff --git a/api_v1_user.php b/api_v1_user.php index bb612e4..fc72c08 100644 --- a/api_v1_user.php +++ b/api_v1_user.php @@ -26,10 +26,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $dnd_mode = isset($_POST['dnd_mode']) ? (int)$_POST['dnd_mode'] : 0; $sound_notifications = isset($_POST['sound_notifications']) ? (int)$_POST['sound_notifications'] : 0; $theme = !empty($_POST['theme']) ? $_POST['theme'] : $user['theme']; + $voice_mode = !empty($_POST['voice_mode']) ? $_POST['voice_mode'] : ($user['voice_mode'] ?? 'vox'); + $voice_ptt_key = !empty($_POST['voice_ptt_key']) ? $_POST['voice_ptt_key'] : ($user['voice_ptt_key'] ?? 'v'); + $voice_vox_threshold = isset($_POST['voice_vox_threshold']) ? (float)$_POST['voice_vox_threshold'] : ($user['voice_vox_threshold'] ?? 0.1); try { - $stmt = db()->prepare("UPDATE users SET display_name = ?, avatar_url = ?, dnd_mode = ?, sound_notifications = ?, theme = ? WHERE id = ?"); - $success = $stmt->execute([$display_name, $avatar_url, $dnd_mode, $sound_notifications, $theme, $user['id']]); + $stmt = db()->prepare("UPDATE users SET display_name = ?, avatar_url = ?, dnd_mode = ?, sound_notifications = ?, theme = ?, voice_mode = ?, voice_ptt_key = ?, voice_vox_threshold = ? WHERE id = ?"); + $success = $stmt->execute([$display_name, $avatar_url, $dnd_mode, $sound_notifications, $theme, $voice_mode, $voice_ptt_key, $voice_vox_threshold, $user['id']]); $log['db_success'] = $success; file_put_contents('requests.log', json_encode($log) . "\n", FILE_APPEND); diff --git a/api_v1_voice.php b/api_v1_voice.php new file mode 100644 index 0000000..e8c303b --- /dev/null +++ b/api_v1_voice.php @@ -0,0 +1,44 @@ + false, 'error' => 'Unauthorized']); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + $channel_id = $_POST['channel_id'] ?? null; + + 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']); + } + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $action = $_GET['action'] ?? ''; + if ($action === 'sessions') { + $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 + "); + $stmt->execute(); + $sessions = $stmt->fetchAll(); + echo json_encode(['success' => true, 'sessions' => $sessions]); + exit; + } +} + +echo json_encode(['success' => false, 'error' => 'Invalid request']); diff --git a/assets/css/discord.css b/assets/css/discord.css index 39bb8db..409eba9 100644 --- a/assets/css/discord.css +++ b/assets/css/discord.css @@ -962,6 +962,29 @@ body { background-color: #232428 !important; } +.voice-status-icon { + animation: voice-pulse 2s infinite; +} + +@keyframes voice-pulse { + 0% { opacity: 0.4; } + 50% { opacity: 1; } + 100% { opacity: 0.4; } +} + +[data-theme="light"] .voice-controls { + background-color: #ebedef !important; + border-top: 1px solid rgba(0,0,0,0.05); +} + +.form-range::-webkit-slider-thumb { + background: var(--blurple); +} + +.form-range::-moz-range-thumb { + background: var(--blurple); +} + /* Roles Management */ #roles-list .list-group-item:hover { background-color: var(--separator-soft) !important; @@ -1344,3 +1367,49 @@ body { [data-theme="light"] .message-text h3 { color: #313338; } + +/* Voice System */ +.voice-users-list { + margin-top: 2px; +} + +.voice-user { + padding: 2px 0; + cursor: default; +} + +.voice-user .message-avatar { + background-size: cover; + background-position: center; + border-radius: 50%; + background-color: var(--bg-servers); +} + +.voice-controls { + margin-top: auto; + background-color: #232428; + z-index: 10; +} + +.voice-status-icon { + animation: voice-pulse 2s infinite; +} + +@keyframes voice-pulse { + 0% { opacity: 0.4; } + 50% { opacity: 1; } + 100% { opacity: 0.4; } +} + +[data-theme="light"] .voice-controls { + background-color: #ebedef; + border-top: 1px solid rgba(0,0,0,0.05); +} + +.form-range::-webkit-slider-thumb { + background: var(--blurple); +} + +.form-range::-moz-range-thumb { + background: var(--blurple); +} diff --git a/assets/js/main.js b/assets/js/main.js index 04e2943..5b15f36 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -458,11 +458,25 @@ document.addEventListener('DOMContentLoaded', () => { let voiceHandler; function connectWS() { + console.log('Connecting to WebSocket...'); try { - ws = new WebSocket('ws://' + window.location.hostname + ':8080'); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + // Try port 8080. If it fails due to WSS/WS mismatch or port block, we'll see it in console. + ws = new WebSocket(protocol + '//' + window.location.hostname + ':8080'); + ws.onopen = () => { + console.log('WebSocket connected'); + ws.send(JSON.stringify({ + type: 'presence', + user_id: window.currentUserId, + status: 'online' + })); + }; + if (typeof VoiceChannel !== 'undefined') { - voiceHandler = new VoiceChannel(ws); + voiceHandler = new VoiceChannel(ws, window.voiceSettings); + window.voiceHandler = voiceHandler; + console.log('VoiceHandler initialized'); } ws.onmessage = (e) => { @@ -508,16 +522,12 @@ document.addEventListener('DOMContentLoaded', () => { updatePresenceUI(msg.user_id, msg.status); } }; - ws.onopen = () => { - ws.send(JSON.stringify({ - type: 'presence', - user_id: window.currentUserId, - status: 'online' - })); + ws.onclose = () => { + console.log('WebSocket connection closed. Reconnecting...'); + setTimeout(connectWS, 3000); }; - ws.onclose = () => setTimeout(connectWS, 3000); } catch (e) { - console.warn('WebSocket connection failed.'); + console.warn('WebSocket connection failed:', e); } } connectWS(); @@ -653,8 +663,32 @@ document.addEventListener('DOMContentLoaded', () => { xhr.send(formData); }); - // Handle Reaction Clicks + // Handle Click Events document.addEventListener('click', (e) => { + // Voice Channel Click + const voiceItem = e.target.closest('.voice-item'); + if (voiceItem) { + e.preventDefault(); + console.log('Voice item clicked:', voiceItem.dataset.channelId); + const channelId = voiceItem.dataset.channelId; + if (voiceHandler) { + if (voiceHandler.currentChannelId == channelId) { + console.log('Leaving voice channel:', channelId); + voiceHandler.leave(); + voiceItem.classList.remove('active'); + } else { + console.log('Joining voice channel:', channelId); + voiceHandler.join(channelId); + // Update active state in UI + document.querySelectorAll('.voice-item').forEach(i => i.classList.remove('active')); + voiceItem.classList.add('active'); + } + } else { + console.error('voiceHandler not initialized'); + } + return; + } + const badge = e.target.closest('.reaction-badge'); if (badge) { const msgId = badge.parentElement.dataset.messageId; @@ -730,21 +764,9 @@ document.addEventListener('DOMContentLoaded', () => { } } - // Voice - if (voiceHandler) { - document.querySelectorAll('.voice-item').forEach(item => { - item.addEventListener('click', () => { - const cid = item.dataset.channelId; - if (voiceHandler.currentChannelId == cid) { - voiceHandler.leave(); - item.classList.remove('active'); - } else { - voiceHandler.join(cid); - document.querySelectorAll('.voice-item').forEach(i => i.classList.remove('active')); - item.classList.add('active'); - } - }); - }); + // Presence indicators initialization (can be expanded) + if (window.currentUserId) { + // ... (existing presence logic if any) } // Message Actions (Edit/Delete) diff --git a/assets/js/voice.js b/assets/js/voice.js index ab90396..70b1169 100644 --- a/assets/js/voice.js +++ b/assets/js/voice.js @@ -1,29 +1,90 @@ class VoiceChannel { - constructor(ws) { + 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 + 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) { - if (this.currentChannelId === channelId) return; + 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 + username: window.currentUsername, + avatar_url: window.currentAvatarUrl })); this.updateVoiceUI(); @@ -34,73 +95,78 @@ class VoiceChannel { } } - 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; + setupVOX() { + if (this.audioContext) this.audioContext.close(); - // 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.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.updateVoiceUI(); - const localVideo = document.getElementById('local-video-container'); - if (localVideo) localVideo.innerHTML = ''; + 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(); + } + } + }; } - renegotiate(pc) { - // Find which user this PC belongs to - const userId = Object.keys(this.peers).find(key => this.peers[key] === pc); - if (!userId) return; + updateMuteState() { + if (!this.currentChannelId || !this.localStream) return; - pc.createOffer().then(offer => { - return pc.setLocalDescription(offer); - }).then(() => { + 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_offer', - to: userId, - from: window.currentUserId, - username: window.currentUsername, - offer: pc.localDescription, - channel_id: this.currentChannelId + 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() { @@ -108,6 +174,11 @@ class VoiceChannel { 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, @@ -119,15 +190,21 @@ class VoiceChannel { 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 } = 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; @@ -135,13 +212,13 @@ class VoiceChannel { switch (type) { case 'voice_join': if (from != window.currentUserId) { - this.participants[from] = username || `User ${from}`; + 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 || `User ${from}`; + this.participants[from] = { username: username || `User ${from}`, avatar_url: avatar_url }; await this.handleOffer(from, offer); this.updateVoiceUI(); break; @@ -151,6 +228,9 @@ class VoiceChannel { 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(); @@ -158,8 +238,6 @@ class VoiceChannel { } delete this.participants[from]; this.updateVoiceUI(); - const remoteVideo = document.getElementById(`remote-video-${from}`); - if (remoteVideo) remoteVideo.remove(); break; } } @@ -179,12 +257,6 @@ class VoiceChannel { }); } - if (this.screenStream) { - this.screenStream.getTracks().forEach(track => { - pc.addTrack(track, this.screenStream); - }); - } - pc.onicecandidate = (event) => { if (event.candidate) { this.ws.send(JSON.stringify({ @@ -202,8 +274,6 @@ class VoiceChannel { const remoteAudio = new Audio(); remoteAudio.srcObject = event.streams[0]; remoteAudio.play(); - } else if (event.track.kind === 'video') { - this.handleRemoteVideo(userId, event.streams[0]); } }; @@ -216,6 +286,7 @@ class VoiceChannel { to: userId, from: window.currentUserId, username: window.currentUsername, + avatar_url: window.currentAvatarUrl, offer: pc.localDescription, channel_id: this.currentChannelId })); @@ -249,117 +320,80 @@ class VoiceChannel { 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 = ''); + // 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 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); - } - + 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); + this.addVoiceUserToUI(listEl, window.currentUserId, window.currentUsername, window.currentAvatarUrl); // Others - Object.entries(this.participants).forEach(([uid, name]) => { - this.addVoiceUserToUI(listEl, uid, name); + Object.entries(this.participants).forEach(([uid, data]) => { + this.addVoiceUserToUI(listEl, uid, data.username, data.avatar_url); }); - } + }); - // Show voice controls if not already there + // 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 = `
-
-
Voice Connected
+
+
Voice Connected
- -
`; 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) { + addVoiceUserToUI(container, userId, username, avatarUrl) { const userEl = document.createElement('div'); - userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1'; + userEl.className = 'voice-user small d-flex align-items-center mb-1'; + userEl.dataset.userId = userId; + userEl.style.paddingLeft = '8px'; userEl.innerHTML = ` -
- ${username} +
+ ${username} `; 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; + } } diff --git a/assets/pasted-20260217-121543-09802912.png b/assets/pasted-20260217-121543-09802912.png new file mode 100644 index 0000000000000000000000000000000000000000..4720fd29486f711505f23f0762771b3404582916 GIT binary patch literal 17738 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" SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url, (SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color, @@ -310,6 +311,19 @@ if ($is_dm_view) { $current_channel_name = 'general'; foreach($channels as $c) if($c['id'] == $active_channel_id) $current_channel_name = $c['name']; + // Fetch voice sessions for the sidebar + $stmt_vs = 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 + "); + $stmt_vs->execute(); + $voice_sessions = $stmt_vs->fetchAll(); + $voice_users_by_channel = []; + foreach($voice_sessions as $vs) { + $voice_users_by_channel[$vs['channel_id']][] = $vs; + } + // Fetch members $stmt = db()->prepare(" SELECT u.id, u.display_name as username, u.username as login_name, u.avatar_url, u.status, @@ -468,6 +482,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
@@ -515,6 +530,18 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
+ +
+ + +
+
">
+ +
+ + +
+