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 = `