diff --git a/api_v1_channels.php b/api_v1_channels.php index 5cd4cea..7c6d8fa 100644 --- a/api_v1_channels.php +++ b/api_v1_channels.php @@ -5,6 +5,13 @@ require_once 'includes/permissions.php'; requireLogin(); if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $action = $_GET['action'] ?? ''; + if ($action === 'list_all') { + $stmt = db()->query("SELECT id, name FROM channels WHERE type = 'voice' ORDER BY name ASC"); + echo json_encode(['success' => true, 'channels' => $stmt->fetchAll(PDO::FETCH_ASSOC)]); + exit; + } + $server_id = $_GET['server_id'] ?? 0; if (!$server_id) { echo json_encode([]); diff --git a/api_v1_user.php b/api_v1_user.php index 6afd6f3..03732da 100644 --- a/api_v1_user.php +++ b/api_v1_user.php @@ -2,6 +2,15 @@ require_once 'auth/session.php'; header('Content-Type: application/json'); +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $action = $_GET['action'] ?? ''; + if ($action === 'list_all') { + $stmt = db()->query("SELECT id, username, display_name FROM users ORDER BY username ASC"); + echo json_encode(['success' => true, 'users' => $stmt->fetchAll(PDO::FETCH_ASSOC)]); + exit; + } +} + if ($_SERVER['REQUEST_METHOD'] === 'POST') { $user = getCurrentUser(); if (!$user) { diff --git a/api_v1_voice.php b/api_v1_voice.php index 471186e..d9dee20 100644 --- a/api_v1_voice.php +++ b/api_v1_voice.php @@ -205,4 +205,67 @@ if ($action === "leave") { json_out(["success" => true]); } -json_out(["error" => "Unknown action"], 404); \ No newline at end of file +if ($action === "get_whispers") { + if ($current_user_id <= 0) json_out(["error" => "Unauthorized"], 401); + try { + $stmt = db()->prepare("SELECT * FROM voice_whispers WHERE user_id = ?"); + $stmt->execute([$current_user_id]); + json_out(["success" => true, "whispers" => $stmt->fetchAll(PDO::FETCH_ASSOC)]); + } catch (Exception $e) { + json_out(["error" => $e->getMessage()], 500); + } +} + +if ($action === "save_whisper") { + if ($current_user_id <= 0) json_out(["error" => "Unauthorized"], 401); + $target_type = $_REQUEST["target_type"] ?? ""; + $target_id = (int)($_REQUEST["target_id"] ?? 0); + $key = $_REQUEST["key"] ?? ""; + + if (!$target_type || !$target_id || !$key) json_out(["error" => "Missing parameters"], 400); + + try { + $stmt = db()->prepare("INSERT INTO voice_whispers (user_id, target_type, target_id, whisper_key) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE target_type = ?, target_id = ?, whisper_key = ?"); + $stmt->execute([$current_user_id, $target_type, $target_id, $key, $target_type, $target_id, $key]); + json_out(["success" => true]); + } catch (Exception $e) { + json_out(["error" => $e->getMessage()], 500); + } +} + +if ($action === "delete_whisper") { + if ($current_user_id <= 0) json_out(["error" => "Unauthorized"], 401); + $id = (int)($_REQUEST["id"] ?? 0); + try { + $stmt = db()->prepare("DELETE FROM voice_whispers WHERE id = ? AND user_id = ?"); + $stmt->execute([$id, $current_user_id]); + json_out(["success" => true]); + } catch (Exception $e) { + json_out(["error" => $e->getMessage()], 500); + } +} + +if ($action === "find_whisper_targets") { + if ($current_user_id <= 0) json_out(["error" => "Unauthorized"], 401); + $target_type = $_REQUEST["target_type"] ?? ""; + $target_id = $_REQUEST["target_id"] ?? ""; // Can be channel name or user_id + + if (!$target_type || !$target_id) json_out(["error" => "Missing parameters"], 400); + + try { + $stale_time = now_ms() - 15000; + if ($target_type === 'user') { + $stmt = db()->prepare("SELECT peer_id, name FROM voice_sessions WHERE user_id = ? AND last_seen > ?"); + $stmt->execute([(int)$target_id, $stale_time]); + } else { + // target_id is channel_id (room) + $stmt = db()->prepare("SELECT peer_id, name FROM voice_sessions WHERE channel_id = ? AND last_seen > ?"); + $stmt->execute([(string)$target_id, $stale_time]); + } + json_out(["success" => true, "targets" => $stmt->fetchAll(PDO::FETCH_ASSOC)]); + } catch (Exception $e) { + json_out(["error" => $e->getMessage()], 500); + } +} + +json_out(["error" => "Unknown action"], 404); diff --git a/assets/js/voice.js b/assets/js/voice.js index ca2b539..0075823 100644 --- a/assets/js/voice.js +++ b/assets/js/voice.js @@ -27,6 +27,11 @@ class VoiceChannel { this.isSelfMuted = false; this.isDeafened = false; + this.whisperSettings = []; // from DB + this.whisperPeers = new Set(); // active whisper target peer_ids + this.isWhispering = false; + this.whisperListeners = []; + this.audioContext = null; this.analyser = null; this.microphone = null; @@ -43,6 +48,7 @@ class VoiceChannel { this.speakingUsers = new Set(); this.setupPTTListeners(); + this.loadWhisperSettings(); window.addEventListener('beforeunload', () => { // We don't want to leave on page refresh if we want persistence // but we might want to tell the server we are "still here" soon. @@ -66,36 +72,112 @@ class VoiceChannel { // Ignore if in input field if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; - if (this.settings.mode !== 'ptt') return; + // Normal PTT + if (this.settings.mode === 'ptt') { + const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || + (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || + (this.settings.pttKey === '0' && e.code === 'Numpad0'); - const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || - (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || - (this.settings.pttKey === '0' && e.code === 'Numpad0'); - - if (isMatch) { - if (!this.pttPressed) { - console.log('PTT Key Pressed:', e.key, e.code, 'Expected:', this.settings.pttKey); - this.pttPressed = true; - this.updateMuteState(); + if (isMatch) { + if (!this.pttPressed) { + this.pttPressed = true; + this.updateMuteState(); + } + return; } } + + // Whispers + this.whisperSettings.forEach(w => { + if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) { + this.startWhisper(w); + } + }); }); window.addEventListener('keyup', (e) => { - if (this.settings.mode !== 'ptt') return; + if (this.settings.mode === 'ptt') { + const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || + (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || + (this.settings.pttKey === '0' && e.code === 'Numpad0'); - const isMatch = e.key.toLowerCase() === this.settings.pttKey.toLowerCase() || - (e.code && e.code.toLowerCase() === this.settings.pttKey.toLowerCase()) || - (this.settings.pttKey === '0' && e.code === 'Numpad0'); - - if (isMatch) { - console.log('PTT Key Released:', e.key, e.code, 'Expected:', this.settings.pttKey); - this.pttPressed = false; - this.updateMuteState(); + if (isMatch) { + this.pttPressed = false; + this.updateMuteState(); + return; + } } + + // Whispers + this.whisperSettings.forEach(w => { + if (e.key.toLowerCase() === w.whisper_key.toLowerCase()) { + this.stopWhisper(w); + } + }); }); } + async loadWhisperSettings() { + try { + const resp = await fetch('api_v1_voice.php?action=get_whispers'); + const data = await resp.json(); + if (data.success) { + this.whisperSettings = data.whispers; + console.log('VoiceChannel: Loaded whispers:', this.whisperSettings); + } + } catch (e) { + console.error('Failed to load whispers in VoiceChannel:', e); + } + } + + setupWhisperListeners() { + // This is called when settings are updated in the UI + this.loadWhisperSettings(); + } + + async startWhisper(config) { + if (this.isWhispering) return; + console.log('Starting whisper to:', config.target_type, config.target_id); + + try { + const resp = await fetch(`api_v1_voice.php?action=find_whisper_targets&target_type=${config.target_type}&target_id=${config.target_id}`); + const data = await resp.json(); + + if (data.success && data.targets.length > 0) { + this.isWhispering = true; + this.whisperPeers.clear(); + + for (const target of data.targets) { + if (target.peer_id === this.myPeerId) continue; + this.whisperPeers.add(target.peer_id); + + // Establish connection if not exists + if (!this.peers[target.peer_id]) { + console.log('Establishing temporary connection for whisper to:', target.peer_id); + this.createPeerConnection(target.peer_id, true); + } + } + + this.updateMuteState(); + } else { + console.log('No active targets found for whisper.'); + } + } catch (e) { + console.error('Whisper start error:', e); + } + } + + stopWhisper(config) { + if (!this.isWhispering) return; + console.log('Stopping whisper'); + this.isWhispering = false; + this.whisperPeers.clear(); + this.updateMuteState(); + + // Optionally cleanup peers that are NOT in current channel + // For now, keep them for future whispers to avoid re-handshake + } + async join(channelId, isAutoRejoin = false) { console.log('VoiceChannel.join process started for channel:', channelId, 'isAutoRejoin:', isAutoRejoin); if (this.currentChannelId === channelId && !isAutoRejoin) { @@ -186,10 +268,15 @@ class VoiceChannel { // Cleanup left peers oldPs.forEach(pid => { - if (!this.participants[pid] && this.peers[pid]) { - console.log('Peer left:', pid); + if (!this.participants[pid] && this.peers[pid] && !this.whisperPeers.has(pid) && !this.speakingUsers.has(pid)) { + console.log('Peer left or not in channel anymore:', pid); this.peers[pid].close(); delete this.peers[pid]; + if (this.remoteAudios[pid]) { + this.remoteAudios[pid].pause(); + this.remoteAudios[pid].remove(); + delete this.remoteAudios[pid]; + } } }); @@ -303,7 +390,7 @@ class VoiceChannel { await this.handleCandidate(from, data.candidate); break; case 'voice_speaking': - this.updateSpeakingUI(data.user_id, data.speaking); + this.updateSpeakingUI(data.user_id, data.speaking, data.is_whisper); break; } } @@ -414,26 +501,63 @@ class VoiceChannel { } updateMuteState() { - if (!this.currentChannelId || !this.localStream) return; + if (!this.localStream) return; + + // If we are not in a channel, we can still whisper! + // But for normal talking, we need currentChannelId. + let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive; if (this.canSpeak === false) { shouldTalk = false; } - console.log('updateMuteState: shouldTalk =', shouldTalk, 'mode =', this.settings.mode, 'canSpeak =', this.canSpeak); - if (this.isTalking !== shouldTalk) { - this.isTalking = shouldTalk; - this.applyAudioState(); - this.updateSpeakingUI(window.currentUserId, shouldTalk); + // Always allow talking if whispering + if (this.isWhispering) { + shouldTalk = true; + } - // Notify others - const msg = { type: 'voice_speaking', channel_id: this.currentChannelId, user_id: window.currentUserId, speaking: shouldTalk }; - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(msg)); - } else { - Object.keys(this.peers).forEach(pid => { + console.log('updateMuteState: shouldTalk =', shouldTalk, 'isWhispering =', this.isWhispering); + if (this.isTalking !== shouldTalk || this.lastWhisperState !== this.isWhispering) { + this.isTalking = shouldTalk; + this.lastWhisperState = this.isWhispering; + + this.applyAudioState(); + this.updateSpeakingUI(window.currentUserId, shouldTalk, this.isWhispering); + + // Notify others in current channel + const msg = { + type: 'voice_speaking', + channel_id: this.currentChannelId, + user_id: window.currentUserId, + speaking: shouldTalk, + is_whisper: this.isWhispering + }; + + // Send to channel peers + Object.keys(this.peers).forEach(pid => { + // If we are whispering, only send voice_speaking to whisper targets + // but actually it's better to notify channel peers that we are NOT talking to them + if (this.isWhispering) { + if (this.whisperPeers.has(pid)) { + this.sendSignal(pid, msg); + } else { + // Tell channel peers we are silent to them + this.sendSignal(pid, { ...msg, speaking: false }); + } + } else { this.sendSignal(pid, msg); + } + }); + + // Also notify whisper peers that are NOT in the channel + if (this.isWhispering) { + this.whisperPeers.forEach(pid => { + if (!this.peers[pid]) { + // This should have been established in startWhisper + } else { + this.sendSignal(pid, msg); + } }); } } @@ -441,11 +565,29 @@ class VoiceChannel { applyAudioState() { if (this.localStream) { - const shouldTransmit = !this.isSelfMuted && this.isTalking && this.canSpeak; - console.log('applyAudioState: transmitting =', shouldTransmit, '(selfMuted=', this.isSelfMuted, 'talking=', this.isTalking, 'canSpeak=', this.canSpeak, ')'); + const shouldTransmit = !this.isSelfMuted && this.isTalking && (this.canSpeak || this.isWhispering); + console.log('applyAudioState: transmitting =', shouldTransmit, '(whisper=', this.isWhispering, ')'); + this.localStream.getAudioTracks().forEach(track => { track.enabled = shouldTransmit; }); + + // We also need to ensure the audio only goes to the right peers + // In P2P, we do this by enabling/disabling the track in the peer connection + // or by simply enabling/disabling the local track (which affects all peers). + // To be truly private, we should only enable the track for whisper peers. + + Object.entries(this.peers).forEach(([pid, pc]) => { + const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); + if (sender) { + if (this.isWhispering) { + sender.track.enabled = this.whisperPeers.has(pid); + } else { + // Normal mode: only send to people in the current channel participants + sender.track.enabled = !!this.participants[pid]; + } + } + }); } this.updateUserPanelButtons(); } @@ -685,7 +827,7 @@ class VoiceChannel { } } - updateSpeakingUI(userId, isSpeaking) { + updateSpeakingUI(userId, isSpeaking, isWhisper = false) { userId = String(userId); if (isSpeaking) { this.speakingUsers.add(userId); @@ -697,7 +839,25 @@ class VoiceChannel { userEls.forEach(el => { const avatar = el.querySelector('.message-avatar'); if (avatar) { - avatar.style.boxShadow = isSpeaking ? '0 0 0 2px #23a559' : 'none'; + if (isSpeaking) { + avatar.style.boxShadow = isWhisper ? '0 0 0 2px #00a8fc' : '0 0 0 2px #23a559'; + } else { + avatar.style.boxShadow = 'none'; + } + } + + // Show whisper indicator text if whispering to me + if (isWhisper && isSpeaking && userId !== String(window.currentUserId)) { + if (!el.querySelector('.whisper-label')) { + const label = document.createElement('span'); + label.className = 'whisper-label badge bg-info ms-1'; + label.style.fontSize = '8px'; + label.innerText = 'WHISPER'; + el.querySelector('span.text-truncate').after(label); + } + } else { + const label = el.querySelector('.whisper-label'); + if (label) label.remove(); } }); } @@ -716,13 +876,14 @@ class VoiceChannel { }); // Populate based on data + const processedUserIds = new Set(); Object.keys(data.channels).forEach(channelId => { const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`); if (voiceItem) { - // Highlight channel as connected only if I am in it - if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) { - voiceItem.classList.add('connected'); - } + // Highlight channel as connected only if I am in it + if (window.voiceHandler && window.voiceHandler.currentChannelId == channelId) { + voiceItem.classList.add('connected'); + } const container = voiceItem.closest('.channel-item-container'); if (container) { @@ -730,6 +891,7 @@ class VoiceChannel { if (listEl) { data.channels[channelId].forEach(p => { const pid = String(p.user_id); + processedUserIds.add(pid); const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(pid); VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking, p.is_muted, p.is_deafened); }); @@ -737,6 +899,19 @@ class VoiceChannel { } } }); + + // Handle users whispering to me from other channels or not in any channel + if (window.voiceHandler && window.voiceHandler.speakingUsers.size > 0) { + window.voiceHandler.speakingUsers.forEach(uid => { + if (!processedUserIds.has(uid)) { + // Find where to show this user. For now, let's put them in their own channel if possible, + // or just a "Whispers" section if we had one. + // Actually, let's just show them in whatever channel they are currently in. + // The `data.channels` already contains everyone. + // If they are not in `processedUserIds` it means their channel is not rendered or they are not in a channel. + } + }); + } } } catch (e) { console.error('Failed to refresh voice users:', e); diff --git a/db/migrations/20260219_voice_whispers.sql b/db/migrations/20260219_voice_whispers.sql new file mode 100644 index 0000000..af3282e --- /dev/null +++ b/db/migrations/20260219_voice_whispers.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS voice_whispers ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + target_type ENUM('user', 'channel') NOT NULL, + target_id INT NOT NULL, + whisper_key VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY (user_id, whisper_key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/index.php b/index.php index cd3e91d..e6ac6d1 100644 --- a/index.php +++ b/index.php @@ -1216,6 +1216,9 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; + @@ -1375,6 +1378,56 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; + +
+
Whisper Configurations
+ +
+

Whisper allows you to talk to specific users or entire channels regardless of which channel you are currently in. This only works in Push-to-Talk mode for the whisper itself.

+ +
+ +
Loading whispers...
+
+ +
+ +
Add New Whisper
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+ +
+
Whisper Notice
+
Whispering uses additional bandwidth and connections. Avoid setting too many whisper hotkeys if you have a slow connection.
+
+
+
+
+
Notifications
@@ -1632,6 +1685,128 @@ async function handleSaveUserSettings(btn) { btn.innerHTML = originalContent; } } + +// Whisper Logic +async function loadWhisperSettings() { + const listEl = document.getElementById('whisper-list'); + listEl.innerHTML = '
Loading whispers...
'; + + try { + const [whispersResp, usersResp, channelsResp] = await Promise.all([ + fetch('api_v1_voice.php?action=get_whispers'), + fetch('api_v1_user.php?action=list_all'), + fetch('api_v1_channels.php?action=list_all') + ]); + + const whispers = await whispersResp.json(); + const users = await usersResp.json(); + const channels = await channelsResp.json(); + + // Populate target selector + updateWhisperTargetOptions(users.users || [], channels.channels || []); + + // Store globally for mapping names + window.whisperUsersMap = {}; + if (users.users) users.users.forEach(u => window.whisperUsersMap[u.id] = u.display_name || u.username); + window.whisperChannelsMap = {}; + if (channels.channels) channels.channels.forEach(c => window.whisperChannelsMap[c.id] = c.name); + + if (whispers.success && whispers.whispers.length > 0) { + listEl.innerHTML = ''; + whispers.whispers.forEach(w => { + const targetName = w.target_type === 'user' ? (window.whisperUsersMap[w.target_id] || 'User #'+w.target_id) : ('#' + (window.whisperChannelsMap[w.target_id] || w.target_id)); + const row = document.createElement('div'); + row.className = 'd-flex justify-content-between align-items-center p-2 mb-1 rounded bg-dark border-start border-3 border-info'; + row.innerHTML = ` +
+ ${w.target_type} + ${targetName} + Key: ${w.whisper_key} +
+ + `; + listEl.appendChild(row); + }); + } else { + listEl.innerHTML = '
No whispers configured yet.
'; + } + + // Re-initialize whisper handlers in voiceHandler if active + if (window.voiceHandler) { + window.voiceHandler.whisperSettings = whispers.whispers || []; + window.voiceHandler.setupWhisperListeners(); + } + } catch (e) { + console.error('Failed to load whispers:', e); + listEl.innerHTML = '
Error loading settings.
'; + } +} + +function updateWhisperTargetOptions(users, channels) { + const type = document.getElementById('new-whisper-type').value; + const targetSelect = document.getElementById('new-whisper-target'); + targetSelect.innerHTML = ''; + + if (type === 'user') { + const usersList = users || Object.values(window.whisperUsersMap || {}).map((name, id) => ({id, username: name})); + usersList.forEach(u => { + if (u.id == window.currentUserId) return; + const opt = document.createElement('option'); + opt.value = u.id; + opt.text = u.display_name || u.username; + targetSelect.add(opt); + }); + } else { + const channelsList = channels || Object.values(window.whisperChannelsMap || {}).map((name, id) => ({id, name})); + channelsList.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.id; + opt.text = '#' + c.name; + targetSelect.add(opt); + }); + } +} + +async function addWhisperSetting() { + const type = document.getElementById('new-whisper-type').value; + const targetId = document.getElementById('new-whisper-target').value; + const key = document.getElementById('new-whisper-key').value; + + if (!targetId || !key) return alert('Please select a target and press a key.'); + + try { + const resp = await fetch('api_v1_voice.php?action=save_whisper&target_type='+type+'&target_id='+targetId+'&key='+encodeURIComponent(key)); + const data = await resp.json(); + if (data.success) { + document.getElementById('new-whisper-key').value = ''; + loadWhisperSettings(); + } else { + alert('Error: ' + data.error); + } + } catch (e) { console.error(e); } +} + +async function deleteWhisperSetting(id) { + if (!confirm('Delete this whisper configuration?')) return; + try { + const resp = await fetch('api_v1_voice.php?action=delete_whisper&id='+id); + const data = await resp.json(); + if (data.success) { + loadWhisperSettings(); + } + } catch (e) { console.error(e); } +} + +// Hotkey capture for new whisper +document.addEventListener('DOMContentLoaded', () => { + const whisperKeyInput = document.getElementById('new-whisper-key'); + if (whisperKeyInput) { + whisperKeyInput.addEventListener('keydown', (e) => { + e.preventDefault(); + whisperKeyInput.value = e.key; + }); + } +});