Projet final V7 + Whisper

This commit is contained in:
Flatlogic Bot 2026-02-19 15:35:10 +00:00
parent 9dfebe6d21
commit 29d6cdef20
6 changed files with 482 additions and 43 deletions

View File

@ -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([]);

View File

@ -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) {

View File

@ -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);

View File

@ -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);

View 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
View File

@ -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>