Autosave: 20260217-122132
This commit is contained in:
parent
920e26ada3
commit
d8c5bbb218
@ -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);
|
||||
|
||||
44
api_v1_voice.php
Normal file
44
api_v1_voice.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
require_once 'auth/session.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$user = getCurrentUser();
|
||||
if (!$user) {
|
||||
echo json_encode(['success' => 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']);
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = `
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="voice-status-icon text-success me-2">●</div>
|
||||
<div class="small">Voice Connected</div>
|
||||
<div class="voice-status-icon text-success me-2" style="font-size: 8px;">●</div>
|
||||
<div class="small fw-bold" style="font-size: 11px; color: #248046;">Voice Connected</div>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-light me-2" id="btn-screen-share">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" id="btn-voice-leave">
|
||||
<button class="btn btn-sm text-muted" id="btn-voice-leave" title="Disconnect">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path><line x1="23" y1="1" x2="1" y2="23"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.querySelector('.channels-sidebar').appendChild(controls);
|
||||
|
||||
document.getElementById('btn-screen-share').onclick = () => this.toggleScreenShare();
|
||||
document.getElementById('btn-voice-leave').onclick = () => this.leave();
|
||||
}
|
||||
} else {
|
||||
const controls = document.querySelector('.voice-controls');
|
||||
if (controls) controls.remove();
|
||||
const grid = document.getElementById('video-grid');
|
||||
if (grid) grid.remove();
|
||||
}
|
||||
}
|
||||
|
||||
addVoiceUserToUI(container, userId, username) {
|
||||
addVoiceUserToUI(container, userId, username, avatarUrl) {
|
||||
const userEl = document.createElement('div');
|
||||
userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1';
|
||||
userEl.className = 'voice-user small d-flex align-items-center mb-1';
|
||||
userEl.dataset.userId = userId;
|
||||
userEl.style.paddingLeft = '8px';
|
||||
userEl.innerHTML = `
|
||||
<div class="message-avatar me-2" style="width: 16px; height: 16px;"></div>
|
||||
<span>${username}</span>
|
||||
<div class="message-avatar me-2" style="width: 18px; height: 18px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarUrl ? `background-image: url('${avatarUrl}');` : ""}"></div>
|
||||
<span style="color: var(--text-muted); font-size: 13px;">${username}</span>
|
||||
`;
|
||||
container.appendChild(userEl);
|
||||
}
|
||||
|
||||
updateSpeakingUI(userId, isSpeaking) {
|
||||
const userEls = document.querySelectorAll(`.voice-user[data-user-id="${userId}"]`);
|
||||
userEls.forEach(el => {
|
||||
const avatar = el.querySelector('.message-avatar');
|
||||
if (avatar) {
|
||||
if (isSpeaking) {
|
||||
avatar.style.boxShadow = '0 0 0 2px #23a559';
|
||||
} else {
|
||||
avatar.style.boxShadow = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stopScreenShare() {
|
||||
// Not requested but kept for compatibility
|
||||
if (this.screenStream) {
|
||||
this.screenStream.getTracks().forEach(track => track.stop());
|
||||
this.screenStream = null;
|
||||
}
|
||||
this.isScreenSharing = false;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
assets/pasted-20260217-121543-09802912.png
Normal file
BIN
assets/pasted-20260217-121543-09802912.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
12
db/migrations/20260217_voice_system.sql
Normal file
12
db/migrations/20260217_voice_system.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- Add voice settings to users and create voice sessions table
|
||||
ALTER TABLE users ADD COLUMN voice_mode ENUM('vox', 'ptt') DEFAULT 'vox';
|
||||
ALTER TABLE users ADD COLUMN voice_ptt_key VARCHAR(20) DEFAULT 'v';
|
||||
ALTER TABLE users ADD COLUMN voice_vox_threshold FLOAT DEFAULT 0.1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voice_sessions (
|
||||
user_id INT PRIMARY KEY,
|
||||
channel_id INT NOT NULL,
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
|
||||
);
|
||||
296
index.php
296
index.php
@ -293,6 +293,7 @@ if ($is_dm_view) {
|
||||
} else {
|
||||
// Fetch messages
|
||||
$display_limit = !empty($active_channel['message_limit']) ? (int)$active_channel['message_limit'] : 50;
|
||||
|
||||
$stmt = db()->prepare("
|
||||
SELECT m.*, u.display_name as username, u.username as login_name, u.avatar_url,
|
||||
(SELECT r.color FROM roles r JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.server_id = ? ORDER BY r.position DESC LIMIT 1) as role_color,
|
||||
@ -310,6 +311,19 @@ if ($is_dm_view) {
|
||||
$current_channel_name = 'general';
|
||||
foreach($channels as $c) if($c['id'] == $active_channel_id) $current_channel_name = $c['name'];
|
||||
|
||||
// Fetch voice sessions for the sidebar
|
||||
$stmt_vs = db()->prepare("
|
||||
SELECT vs.channel_id, vs.user_id, u.username, u.display_name, u.avatar_url
|
||||
FROM voice_sessions vs
|
||||
JOIN users u ON vs.user_id = u.id
|
||||
");
|
||||
$stmt_vs->execute();
|
||||
$voice_sessions = $stmt_vs->fetchAll();
|
||||
$voice_users_by_channel = [];
|
||||
foreach($voice_sessions as $vs) {
|
||||
$voice_users_by_channel[$vs['channel_id']][] = $vs;
|
||||
}
|
||||
|
||||
// Fetch members
|
||||
$stmt = db()->prepare("
|
||||
SELECT u.id, u.display_name as username, u.username as login_name, u.avatar_url, u.status,
|
||||
@ -468,6 +482,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
<?php
|
||||
// Helper to render a channel item
|
||||
function renderChannelItem($c, $active_channel_id, $active_server_id, $can_manage_channels) {
|
||||
global $voice_users_by_channel;
|
||||
if ($c['type'] === 'separator') {
|
||||
?>
|
||||
<div class="channel-item-container separator-item d-flex align-items-center justify-content-between px-2 py-1" data-id="<?php echo $c['id']; ?>" data-type="separator" style="min-height: 24px;">
|
||||
@ -515,6 +530,18 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
<div class="channel-status small text-muted ms-4" style="font-size: 0.75em; margin-top: -2px;"><?php echo htmlspecialchars($c['status']); ?></div>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php if ($c['type'] === 'voice'): ?>
|
||||
<div class="voice-users-list ms-4 mb-1">
|
||||
<?php if (isset($voice_users_by_channel[$c['id']])): ?>
|
||||
<?php foreach($voice_users_by_channel[$c['id']] as $v_user): ?>
|
||||
<div class="voice-user small text-muted d-flex align-items-center mb-1" data-user-id="<?php echo $v_user['user_id']; ?>">
|
||||
<div class="message-avatar me-2" style="width: 16px; height: 16px; <?php echo $v_user['avatar_url'] ? "background-image: url('{$v_user['avatar_url']}');" : ""; ?>"></div>
|
||||
<span><?php echo htmlspecialchars($v_user['display_name'] ?? $v_user['username']); ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($can_manage_channels): ?>
|
||||
<span class="channel-settings-btn ms-1" style="cursor: pointer; color: var(--text-muted);"
|
||||
data-bs-toggle="modal" data-bs-target="#editChannelModal"
|
||||
@ -1090,85 +1117,205 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
|
||||
<!-- User Settings Modal -->
|
||||
<div class="modal fade" id="userSettingsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">User Settings</h5>
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg" style="background-color: #313338; min-height: 500px;">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title fw-bold">User Settings</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="user-settings-form">
|
||||
<div class="row">
|
||||
<div class="col-md-4 text-center">
|
||||
<div class="message-avatar mx-auto mb-3" id="settings-avatar-preview" style="width: 100px; height: 100px; <?php echo $user['avatar_url'] ? "background-image: url('{$user['avatar_url']}');" : ""; ?>"></div>
|
||||
<input type="hidden" name="avatar_url" id="settings-avatar-url" value="<?php echo htmlspecialchars($user['avatar_url'] ?? ''); ?>">
|
||||
<p class="small text-muted">Pick an avatar from Pexels or search for one.</p>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">ID connexion</label>
|
||||
<input type="text" class="form-control" value="<?php echo htmlspecialchars($user['username']); ?>" readonly>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Username display</label>
|
||||
<input type="text" name="display_name" class="form-control" value="<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Settings</label>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="dnd_mode" id="dnd-switch" value="1" <?php echo ($user['dnd_mode'] ?? 0) ? 'checked' : ''; ?>>
|
||||
<label class="form-check-label text-white" for="dnd-switch">Do Not Disturb</label>
|
||||
<div class="form-text text-muted" style="font-size: 0.8em;">Mute all desktop notifications.</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="d-flex flex-row h-100" style="min-height: 450px;">
|
||||
<!-- Settings Sidebar -->
|
||||
<div class="settings-nav-sidebar p-3 border-end border-secondary" style="width: 200px; background-color: #2b2d31;">
|
||||
<ul class="nav flex-column nav-pills" id="userSettingsTabs" role="tablist">
|
||||
<li class="nav-item mb-1">
|
||||
<button class="nav-link active w-100 text-start text-white border-0 py-2 px-3" data-bs-toggle="pill" data-bs-target="#settings-profile" type="button">My Profile</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-appearance" type="button">Appearance</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-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-notifications" type="button">Notifications</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Settings Content -->
|
||||
<div class="flex-grow-1 p-4 overflow-auto custom-scrollbar">
|
||||
<form id="user-settings-form">
|
||||
<div class="tab-content" id="userSettingsContent">
|
||||
<!-- Profile Tab -->
|
||||
<div class="tab-pane fade show active" id="settings-profile" role="tabpanel">
|
||||
<h5 class="mb-4 fw-bold text-uppercase" style="font-size: 0.8em; color: var(--text-muted);">User Profile</h5>
|
||||
<div class="row align-items-center mb-4 p-3 rounded" style="background-color: #232428;">
|
||||
<div class="col-md-3 text-center">
|
||||
<div class="message-avatar mx-auto mb-2" id="settings-avatar-preview" style="width: 80px; height: 80px; <?php echo $user['avatar_url'] ? "background-image: url('{$user['avatar_url']}');" : ""; ?>"></div>
|
||||
<input type="hidden" name="avatar_url" id="settings-avatar-url" value="<?php echo htmlspecialchars($user['avatar_url'] ?? ''); ?>">
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Username display</label>
|
||||
<input type="text" name="display_name" class="form-control bg-dark text-white border-0" value="<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Search Avatars</label>
|
||||
<div class="input-group mb-2 shadow-sm">
|
||||
<input type="text" id="avatar-search-query" class="form-control bg-dark border-0 text-white" placeholder="e.g. cat, abstract, gamer">
|
||||
<button class="btn btn-primary px-3" type="button" id="search-avatar-btn">Search</button>
|
||||
</div>
|
||||
<div id="avatar-results" class="d-flex flex-wrap gap-2 overflow-auto p-2 rounded" style="max-height: 180px; background-color: #1e1f22;">
|
||||
<div class="w-100 text-center text-muted py-3 small">Search for images to change your avatar.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="sound_notifications" id="sound-switch" value="1" <?php echo ($user['sound_notifications'] ?? 0) ? 'checked' : ''; ?>>
|
||||
<label class="form-check-label text-white" for="sound-switch">Sound Notifications</label>
|
||||
<div class="form-text text-muted" style="font-size: 0.8em;">Play a sound when you are mentioned.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Appearance</label>
|
||||
<div class="row g-2">
|
||||
|
||||
<!-- Appearance Tab -->
|
||||
<div class="tab-pane fade" id="settings-appearance" role="tabpanel">
|
||||
<h5 class="mb-4 fw-bold text-uppercase" style="font-size: 0.8em; color: var(--text-muted);">Appearance Settings</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<input type="radio" class="btn-check" name="theme" id="theme-dark" value="dark" <?php echo ($user['theme'] ?? 'dark') == 'dark' ? 'checked' : ''; ?> onchange="document.body.setAttribute('data-theme', 'dark')">
|
||||
<label class="btn btn-outline-secondary w-100 py-3" for="theme-dark">
|
||||
<i class="fa-solid fa-moon d-block mb-1"></i>
|
||||
Dark
|
||||
<label class="btn btn-outline-secondary w-100 py-4 d-flex flex-column align-items-center" for="theme-dark">
|
||||
<i class="fa-solid fa-moon mb-2 fs-3"></i>
|
||||
<span>Dark Theme</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<input type="radio" class="btn-check" name="theme" id="theme-light" value="light" <?php echo ($user['theme'] ?? 'dark') == 'light' ? 'checked' : ''; ?> onchange="document.body.setAttribute('data-theme', 'light')">
|
||||
<label class="btn btn-outline-secondary w-100 py-3" for="theme-light">
|
||||
<i class="fa-solid fa-sun d-block mb-1"></i>
|
||||
Light
|
||||
<label class="btn btn-outline-secondary w-100 py-4 d-flex flex-column align-items-center" for="theme-light">
|
||||
<i class="fa-solid fa-sun mb-2 fs-3"></i>
|
||||
<span>Light Theme</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Search Avatars</label>
|
||||
<div class="input-group mb-2">
|
||||
<input type="text" id="avatar-search-query" class="form-control" placeholder="e.g. cat, abstract, gamer">
|
||||
<button class="btn btn-outline-secondary" type="button" id="search-avatar-btn">Search</button>
|
||||
</div>
|
||||
<div id="avatar-results" class="d-flex flex-wrap gap-2 overflow-auto" style="max-height: 200px;">
|
||||
<!-- Pexels results here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<!-- Voice Tab -->
|
||||
<div class="tab-pane fade" id="settings-voice" role="tabpanel">
|
||||
<h5 class="mb-4 fw-bold text-uppercase" style="font-size: 0.8em; color: var(--text-muted);">Voice Settings</h5>
|
||||
|
||||
<div class="p-3 rounded mb-4" style="background-color: #232428;">
|
||||
<label class="form-label text-uppercase fw-bold mb-3" style="font-size: 0.7em; color: var(--text-muted);">Input Mode</label>
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<div class="form-check custom-radio-card flex-grow-1">
|
||||
<input class="form-check-input d-none" type="radio" name="voice_mode" id="voice-mode-vox" value="vox" <?php echo ($user['voice_mode'] ?? 'vox') == 'vox' ? 'checked' : ''; ?> onchange="togglePTTSettings('vox')">
|
||||
<label class="form-check-label w-100 p-3 rounded border border-secondary text-center cursor-pointer" for="voice-mode-vox" style="cursor: pointer;">
|
||||
<i class="fa-solid fa-microphone mb-2 d-block"></i>
|
||||
Voice Activity
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check custom-radio-card flex-grow-1">
|
||||
<input class="form-check-input d-none" type="radio" name="voice_mode" id="voice-mode-ptt" value="ptt" <?php echo ($user['voice_mode'] ?? 'vox') == 'ptt' ? 'checked' : ''; ?> onchange="togglePTTSettings('ptt')">
|
||||
<label class="form-check-label w-100 p-3 rounded border border-secondary text-center cursor-pointer" for="voice-mode-ptt" style="cursor: pointer;">
|
||||
<i class="fa-solid fa-keyboard mb-2 d-block"></i>
|
||||
Push to Talk
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ptt-settings-container" style="<?php echo ($user['voice_mode'] ?? 'vox') == 'ptt' ? '' : 'display: none;'; ?>">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-white fw-bold">Shortcut Key</label>
|
||||
<input type="text" name="voice_ptt_key" id="voice_ptt_key_input" class="form-control bg-dark text-white border-0" value="<?php echo htmlspecialchars($user['voice_ptt_key'] ?? 'v'); ?>" placeholder="Click and press a key..." readonly style="cursor: pointer; caret-color: transparent;">
|
||||
<div class="form-text text-muted" style="font-size: 0.8em;">Click the box and press any key to set your PTT shortcut.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vox-settings-container" style="<?php echo ($user['voice_mode'] ?? 'vox') == 'vox' ? '' : 'display: none;'; ?>">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-white fw-bold">Input Sensitivity</label>
|
||||
<input type="range" name="voice_vox_threshold" class="form-range" min="0" max="1" step="0.01" value="<?php echo $user['voice_vox_threshold'] ?? 0.1; ?>">
|
||||
<div class="d-flex justify-content-between small text-muted mt-1">
|
||||
<span>Loud</span>
|
||||
<span>Quiet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 rounded border border-info border-opacity-25" style="background-color: rgba(0, 168, 252, 0.05);">
|
||||
<div class="d-flex">
|
||||
<i class="fa-solid fa-circle-info text-info me-3 mt-1"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-info small mb-1">Microphone Access</div>
|
||||
<div class="text-muted small">Voice channels require microphone permission. If you don't hear anything, check your browser's site settings.</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>
|
||||
<div class="p-3 rounded" style="background-color: #232428;">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="dnd_mode" id="dnd-switch" value="1" <?php echo ($user['dnd_mode'] ?? 0) ? 'checked' : ''; ?>>
|
||||
<label class="form-check-label text-white" for="dnd-switch">Do Not Disturb</label>
|
||||
<div class="form-text text-muted" style="font-size: 0.8em;">Mute all desktop notifications.</div>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="sound_notifications" id="sound-switch" value="1" <?php echo ($user['sound_notifications'] ?? 0) ? 'checked' : ''; ?>>
|
||||
<label class="form-check-label text-white" for="sound-switch">Sound Notifications</label>
|
||||
<div class="form-text text-muted" style="font-size: 0.8em;">Play a sound when you are mentioned.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0" style="background-color: #2b2d31;">
|
||||
<button type="button" class="btn btn-link text-white text-decoration-none" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" onclick="handleSaveUserSettings(this)" class="btn btn-primary" style="background-color: var(--blurple); border: none; padding: 10px 24px;">Save Changes</button>
|
||||
<button type="button" onclick="handleSaveUserSettings(this)" class="btn btn-primary" style="background-color: var(--blurple); border: none; padding: 10px 32px; font-weight: 600;">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.custom-radio-card input:checked + label {
|
||||
background-color: var(--blurple) !important;
|
||||
border-color: var(--blurple) !important;
|
||||
color: white !important;
|
||||
}
|
||||
.custom-radio-card label:hover {
|
||||
background-color: rgba(255,255,255,0.05);
|
||||
}
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function togglePTTSettings(mode) {
|
||||
console.log('Toggling voice mode to:', mode);
|
||||
const pttContainer = document.getElementById('ptt-settings-container');
|
||||
const voxContainer = document.getElementById('vox-settings-container');
|
||||
if (pttContainer) pttContainer.style.display = (mode === 'ptt' ? 'block' : 'none');
|
||||
if (voxContainer) voxContainer.style.display = (mode === 'vox' ? 'block' : 'none');
|
||||
}
|
||||
|
||||
// Special handler for PTT key input to make it more intuitive
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const pttInput = document.getElementById('voice_ptt_key_input');
|
||||
if (pttInput) {
|
||||
pttInput.addEventListener('keydown', (e) => {
|
||||
e.preventDefault();
|
||||
pttInput.value = e.key;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function handlePTTKeyCapture(e, input) {
|
||||
e.preventDefault();
|
||||
input.value = e.key;
|
||||
}
|
||||
|
||||
async function handleSaveUserSettings(btn) {
|
||||
const originalContent = btn.innerHTML;
|
||||
const form = document.getElementById('user-settings-form');
|
||||
@ -1187,19 +1334,19 @@ async function handleSaveUserSettings(btn) {
|
||||
formData.set('dnd_mode', dndMode);
|
||||
formData.set('sound_notifications', soundNotifications);
|
||||
|
||||
// Explicitly get theme to ensure it's captured
|
||||
// Explicitly get theme and voice_mode to ensure they are captured
|
||||
const themeInput = form.querySelector('input[name="theme"]:checked');
|
||||
const theme = themeInput ? themeInput.value : 'dark';
|
||||
formData.set('theme', theme);
|
||||
if (themeInput) formData.set('theme', themeInput.value);
|
||||
|
||||
const voiceModeInput = form.querySelector('input[name="voice_mode"]:checked');
|
||||
if (voiceModeInput) formData.set('voice_mode', voiceModeInput.value);
|
||||
|
||||
try {
|
||||
console.log('Sending save request...');
|
||||
const resp = await fetch('api_v1_user.php?v=' + Date.now(), {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const result = await resp.json();
|
||||
console.log('Response received:', result);
|
||||
|
||||
if (result.success) {
|
||||
btn.innerHTML = '<i class="fa-solid fa-check me-2"></i> Saved!';
|
||||
@ -1218,6 +1365,7 @@ async function handleSaveUserSettings(btn) {
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<!-- Server Settings Modal -->
|
||||
<!-- Server Settings Modal -->
|
||||
<div class="modal fade" id="serverSettingsModal" tabindex="-1">
|
||||
@ -1768,7 +1916,6 @@ async function handleSaveUserSettings(btn) {
|
||||
<ul class="dropdown-menu dropdown-menu-dark shadow border-secondary" id="add-permission-role-list" style="max-height: 300px; overflow-y: auto; min-width: 200px;">
|
||||
<!-- Roles loaded here -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div id="channel-permissions-roles-list" class="list-group list-group-flush overflow-auto flex-grow-1" style="max-height: 350px; overflow-x: hidden;">
|
||||
<!-- List of roles with overrides -->
|
||||
@ -1989,14 +2136,19 @@ async function handleSaveUserSettings(btn) {
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
window.currentUserId = <?php echo $current_user_id; ?>;
|
||||
window.currentUsername = "<?php echo addslashes($user['display_name'] ?? $user['username']); ?>";
|
||||
window.currentChannelName = "<?php echo addslashes($current_channel_name); ?>";
|
||||
window.isServerOwner = <?php echo ($is_owner ?? false) ? 'true' : 'false'; ?>;
|
||||
window.canManageServer = <?php echo ($can_manage_server ?? false) ? 'true' : 'false'; ?>;
|
||||
window.isDndMode = <?php echo ($user['dnd_mode'] ?? 0) ? 'true' : 'false'; ?>;
|
||||
</script>
|
||||
<script>
|
||||
window.currentUserId = <?php echo $current_user_id; ?>;
|
||||
window.currentUsername = '<?php echo addslashes($user['display_name'] ?? $user['username']); ?>';
|
||||
window.currentAvatarUrl = '<?php echo addslashes($user['avatar_url'] ?? ''); ?>';
|
||||
window.activeChannelId = <?php echo $active_channel_id; ?>;
|
||||
window.serverRoles = <?php echo json_encode($server_roles ?? []); ?>;
|
||||
window.voiceSettings = {
|
||||
mode: '<?php echo $user['voice_mode'] ?? 'vox'; ?>',
|
||||
pttKey: '<?php echo $user['voice_ptt_key'] ?? 'v'; ?>',
|
||||
voxThreshold: <?php echo $user['voice_vox_threshold'] ?? 0.1; ?>
|
||||
};
|
||||
</script>
|
||||
|
||||
<script src="assets/js/voice.js?v=<?php echo time(); ?>"></script>
|
||||
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
|
||||
<script>
|
||||
|
||||
20
requests.log
20
requests.log
@ -481,3 +481,23 @@
|
||||
2026-02-17 09:48:03 - GET /index.php?server_id=1&channel_id=6 - POST: []
|
||||
2026-02-17 09:56:28 - GET /?fl_project=38443 - POST: []
|
||||
2026-02-17 09:57:00 - GET /index.php?server_id=1&channel_id=6 - POST: []
|
||||
2026-02-17 11:37:20 - GET /index.php?server_id=1&channel_id=21 - POST: []
|
||||
2026-02-17 11:43:18 - GET /?fl_project=38443 - POST: []
|
||||
2026-02-17 11:43:46 - GET /?fl_project=38443 - POST: []
|
||||
2026-02-17 11:46:49 - GET /index.php?server_id=1&channel_id=22 - POST: []
|
||||
2026-02-17 11:46:58 - GET /index.php?server_id=1&channel_id=3 - POST: []
|
||||
2026-02-17 11:49:14 - GET /?fl_project=38443 - POST: []
|
||||
2026-02-17 11:56:37 - GET /?fl_project=38443 - POST: []
|
||||
2026-02-17 11:56:49 - GET /index.php?server_id=1&channel_id=3 - POST: []
|
||||
2026-02-17 11:56:58 - GET /index.php?server_id=1&channel_id=3 - POST: []
|
||||
2026-02-17 11:57:08 - GET /index.php?server_id=1&channel_id=6 - POST: []
|
||||
2026-02-17 11:57:14 - GET /index.php?server_id=1&channel_id=6 - POST: []
|
||||
2026-02-17 12:01:03 - GET /?fl_project=38443 - POST: []
|
||||
2026-02-17 12:08:31 - GET /?fl_project=38443 - POST: []
|
||||
2026-02-17 12:08:36 - GET /index.php?server_id=1&channel_id=6 - POST: []
|
||||
{"date":"2026-02-17 12:09:05","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"0","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
|
||||
2026-02-17 12:09:05 - GET /index.php?server_id=1&channel_id=6 - POST: []
|
||||
2026-02-17 12:14:02 - GET /?fl_project=38443 - POST: []
|
||||
2026-02-17 12:14:47 - GET /index.php?server_id=1&channel_id=6 - POST: []
|
||||
2026-02-17 12:15:05 - GET /index.php?server_id=1&channel_id=6 - POST: []
|
||||
2026-02-17 12:18:43 - GET /?fl_project=38443 - POST: []
|
||||
|
||||
@ -28,7 +28,7 @@ while (true) {
|
||||
}
|
||||
|
||||
foreach ($read as $client_socket) {
|
||||
$data = socket_read($client_socket, 1024);
|
||||
$data = socket_read($client_socket, 65536);
|
||||
if ($data === false || strlen($data) === 0) {
|
||||
$key = array_search($client_socket, $clients);
|
||||
unset($clients[$key]);
|
||||
@ -67,6 +67,8 @@ function perform_handshake($receved_header, $client_conn, $host, $port) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($headers['Sec-WebSocket-Key'])) return;
|
||||
|
||||
$secKey = $headers['Sec-WebSocket-Key'];
|
||||
$secAccept = base64_encode(pack('H*', sha1($secKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
|
||||
$upgrade = "HTTP/1.1 101 Switching Protocols\r\n" .
|
||||
@ -77,26 +79,32 @@ function perform_handshake($receved_header, $client_conn, $host, $port) {
|
||||
}
|
||||
|
||||
function unmask($text) {
|
||||
if (strlen($text) < 2) return null;
|
||||
$length = ord($text[1]) & 127;
|
||||
|
||||
if ($length == 126) {
|
||||
if (strlen($text) < 8) return null;
|
||||
$masks = substr($text, 4, 4);
|
||||
$data = substr($text, 8);
|
||||
} elseif ($length == 127) {
|
||||
if (strlen($text) < 14) return null;
|
||||
$masks = substr($text, 10, 4);
|
||||
$data = substr($text, 14);
|
||||
} else {
|
||||
if (strlen($text) < 6) return null;
|
||||
$masks = substr($text, 2, 4);
|
||||
$data = substr($text, 6);
|
||||
}
|
||||
$text = "";
|
||||
|
||||
$decoded = "";
|
||||
for ($i = 0; $i < strlen($data); ++$i) {
|
||||
$text .= $data[$i] ^ $masks[$i % 4];
|
||||
$decoded .= $data[$i] ^ $masks[$i % 4];
|
||||
}
|
||||
return $text;
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
function mask($text) {
|
||||
$b1 = 0x80 | (0x1 & 0x0f);
|
||||
$b1 = 0x81; // FIN + Opcode 1 (text)
|
||||
$length = strlen($text);
|
||||
if ($length <= 125)
|
||||
$header = pack('CC', $b1, $length);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user