Projet final V7 + Whisper
This commit is contained in:
parent
9dfebe6d21
commit
29d6cdef20
@ -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([]);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -205,4 +205,67 @@ if ($action === "leave") {
|
||||
json_out(["success" => true]);
|
||||
}
|
||||
|
||||
json_out(["error" => "Unknown action"], 404);
|
||||
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);
|
||||
|
||||
@ -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);
|
||||
|
||||
10
db/migrations/20260219_voice_whispers.sql
Normal file
10
db/migrations/20260219_voice_whispers.sql
Normal file
@ -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;
|
||||
175
index.php
175
index.php
@ -1216,6 +1216,9 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
<li class="nav-item mb-1">
|
||||
<button class="nav-link w-100 text-start text-white border-0 py-2 px-3" data-bs-toggle="pill" data-bs-target="#settings-voice" type="button">Voice & Video</button>
|
||||
</li>
|
||||
<li class="nav-item mb-1">
|
||||
<button class="nav-link w-100 text-start text-white border-0 py-2 px-3" data-bs-toggle="pill" data-bs-target="#settings-whispers" type="button" onclick="loadWhisperSettings()">Whispers (TeamSpeak Style)</button>
|
||||
</li>
|
||||
<li class="nav-item mb-1">
|
||||
<button class="nav-link w-100 text-start text-white border-0 py-2 px-3" data-bs-toggle="pill" data-bs-target="#settings-notifications" type="button">Notifications</button>
|
||||
</li>
|
||||
@ -1375,6 +1378,56 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Whispers Tab -->
|
||||
<div class="tab-pane fade" id="settings-whispers" role="tabpanel">
|
||||
<h5 class="mb-4 fw-bold text-uppercase" style="font-size: 0.8em; color: var(--text-muted);">Whisper Configurations</h5>
|
||||
|
||||
<div class="p-3 rounded mb-4" style="background-color: #232428;">
|
||||
<p class="small text-muted mb-3">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.</p>
|
||||
|
||||
<div id="whisper-list" class="mb-4">
|
||||
<!-- Whisper entries will be loaded here -->
|
||||
<div class="text-center py-3 text-muted small">Loading whispers...</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-secondary my-4">
|
||||
|
||||
<h6 class="text-white small fw-bold mb-3">Add New Whisper</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-muted">Target Type</label>
|
||||
<select id="new-whisper-type" class="form-select bg-dark text-white border-0" onchange="updateWhisperTargetOptions()">
|
||||
<option value="user">User</option>
|
||||
<option value="channel">Channel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-muted">Target</label>
|
||||
<select id="new-whisper-target" class="form-select bg-dark text-white border-0">
|
||||
<!-- Options populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">Hotkeys</label>
|
||||
<input type="text" id="new-whisper-key" class="form-control bg-dark text-white border-0" placeholder="Press a key..." readonly style="cursor: pointer; caret-color: transparent;">
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-primary w-100" onclick="addWhisperSetting()"><i class="fa-solid fa-plus"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 rounded border border-warning border-opacity-25" style="background-color: rgba(255, 170, 0, 0.05);">
|
||||
<div class="d-flex">
|
||||
<i class="fa-solid fa-triangle-exclamation text-warning me-3 mt-1"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-warning small mb-1">Whisper Notice</div>
|
||||
<div class="text-muted small">Whispering uses additional bandwidth and connections. Avoid setting too many whisper hotkeys if you have a slow connection.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications Tab -->
|
||||
<div class="tab-pane fade" id="settings-notifications" role="tabpanel">
|
||||
<h5 class="mb-4 fw-bold text-uppercase" style="font-size: 0.8em; color: var(--text-muted);">Notifications</h5>
|
||||
@ -1632,6 +1685,128 @@ async function handleSaveUserSettings(btn) {
|
||||
btn.innerHTML = originalContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Whisper Logic
|
||||
async function loadWhisperSettings() {
|
||||
const listEl = document.getElementById('whisper-list');
|
||||
listEl.innerHTML = '<div class="text-center py-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span> Loading whispers...</div>';
|
||||
|
||||
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 = `
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-info me-2 text-uppercase" style="font-size: 0.7em;">${w.target_type}</span>
|
||||
<span class="text-white small fw-bold">${targetName}</span>
|
||||
<span class="text-muted small ms-3">Key: <span class="badge bg-secondary">${w.whisper_key}</span></span>
|
||||
</div>
|
||||
<button class="btn btn-sm text-danger border-0 p-1" onclick="deleteWhisperSetting(${w.id})"><i class="fa-solid fa-trash-can"></i></button>
|
||||
`;
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
listEl.innerHTML = '<div class="text-center py-3 text-muted small">No whispers configured yet.</div>';
|
||||
}
|
||||
|
||||
// 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 = '<div class="text-center py-3 text-danger small">Error loading settings.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user