PTT semi focntionnel

This commit is contained in:
Flatlogic Bot 2026-02-17 14:47:12 +00:00
parent 08664dda0d
commit 04cad1c49b
16 changed files with 649 additions and 308 deletions

View File

@ -1,44 +1,239 @@
<?php
require_once 'auth/session.php';
header('Content-Type: application/json');
<?php // Vocal secours — WebRTC P2P + signalisation PHP + participants + PTT + VOX (gating via GainNode)
// Mutualisé OVH : nécessite ./data écrivable.
declare(strict_types=1);
require_once "auth/session.php";
header("X-Content-Type-Options: nosniff");
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
$DATA_DIR = __DIR__ . "/data";
if (!is_dir($DATA_DIR)) @mkdir($DATA_DIR, 0775, true);
$user = getCurrentUser();
if (!$user) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
$current_user_id = $user ? (int)$user["id"] : 0;
function room_id(string $s): string {
$s = preg_replace("~[^a-zA-Z0-9_\-]~", "", $s);
return $s !== "" ? $s : "secours";
}
function peer_id(): string {
return bin2hex(random_bytes(8));
}
function room_log_file(string $room): string {
return __DIR__ . "/data/" . $room . ".log";
}
function room_participants_file(string $room): string {
return __DIR__ . "/data/" . $room . ".participants.json";
}
function chat_log_file_for_today(): string {
// Un fichier par jour : YYYY-MM-DD.chat.log
$d = date("Y-m-d");
return __DIR__ . "/data/" . $d . ".chat.log";
}
function now_ms(): int {
return (int) floor(microtime(true) * 1000);
}
function json_out($data, int $code = 200): void {
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$channel_id = $_POST['channel_id'] ?? null;
function read_json_file(string $path): array {
if (!file_exists($path)) return [];
$raw = @file_get_contents($path);
if ($raw === false || $raw === "") return [];
$j = json_decode($raw, true);
return is_array($j) ? $j : [];
}
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']);
function write_json_file(string $path, array $data): void {
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
}
function tail_lines(string $path, int $maxLines = 120): array {
if (!is_file($path)) return [];
$fp = fopen($path, "rb");
if (!$fp) return [];
$lines = [];
fseek($fp, 0, SEEK_END);
$pos = ftell($fp);
$buffer = "";
while ($pos > 0 && count($lines) < $maxLines) {
$readSize = min($pos, 4096);
$pos -= $readSize;
fseek($fp, $pos);
$chunk = fread($fp, $readSize);
$buffer = $chunk . $buffer;
$chunkLines = explode("\n", $buffer);
$buffer = array_shift($chunkLines);
while (!empty($chunkLines)) {
$line = array_pop($chunkLines);
if (trim($line) !== "") {
array_unshift($lines, trim($line));
if (count($lines) >= $maxLines) break;
}
}
}
exit;
fclose($fp);
return $lines;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$action = $_GET['action'] ?? '';
if ($action === 'sessions') {
// Logic for signaling
$action = $_REQUEST["action"] ?? "";
$room = room_id($_REQUEST["room"] ?? "secours");
$my_id = $_REQUEST["peer_id"] ?? "";
if ($action === "join") {
$name = $_REQUEST["name"] ?? "User";
$p_file = room_participants_file($room);
$ps = read_json_file($p_file);
// Cleanup old participants (> 10s)
$stale_time = now_ms() - 10000;
foreach ($ps as $id => $p) {
if (($p["last_seen"] ?? 0) < $stale_time) unset($ps[$id]);
}
$new_id = peer_id();
$ps[$new_id] = [
"id" => $new_id,
"user_id" => $current_user_id,
"name" => $name,
"avatar_url" => $user["avatar_url"] ?? "",
"last_seen" => now_ms()
];
write_json_file($p_file, $ps);
// DB Integration for sidebar
if ($current_user_id > 0) {
try {
$stmt = db()->prepare("INSERT INTO voice_sessions (user_id, channel_id, last_seen) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE channel_id = ?, last_seen = ?");
$stmt->execute([$current_user_id, $room, now_ms(), $room, now_ms()]);
} catch (Exception $e) {}
}
json_out(["success" => true, "peer_id" => $new_id, "participants" => $ps]);
}
if ($action === "poll") {
if (!$my_id) json_out(["error" => "Missing peer_id"], 400);
$p_file = room_participants_file($room);
$ps = read_json_file($p_file);
if (isset($ps[$my_id])) {
$ps[$my_id]["last_seen"] = now_ms();
}
$stale_time = now_ms() - 10000;
foreach ($ps as $id => $p) {
if (($p["last_seen"] ?? 0) < $stale_time) unset($ps[$id]);
}
write_json_file($p_file, $ps);
// Update DB last_seen
if ($current_user_id > 0) {
try {
$stmt = db()->prepare("UPDATE voice_sessions SET last_seen = ? WHERE user_id = ?");
$stmt->execute([now_ms(), $current_user_id]);
} catch (Exception $e) {}
}
// Read signals
$log_file = room_log_file($room);
$signals = [];
if (file_exists($log_file)) {
$lines = file($log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$remaining = [];
$now = now_ms();
foreach ($lines as $line) {
$sig = json_decode($line, true);
if ($sig && isset($sig["to"]) && $sig["to"] === $my_id) {
$signals[] = $sig;
} elseif ($sig && ($now - ($sig["time"] ?? 0) < 30000)) {
$remaining[] = $line;
}
}
file_put_contents($log_file, implode("\n", $remaining) . (empty($remaining) ? "" : "\n"), LOCK_EX);
}
json_out(["success" => true, "participants" => $ps, "signals" => $signals]);
}
if ($action === "signal") {
if (!$my_id) json_out(["error" => "Missing peer_id"], 400);
$to = $_REQUEST["to"] ?? "";
$data = $_REQUEST["data"] ?? "";
if (!$to || !$data) json_out(["error" => "Missing to/data"], 400);
$sig = [
"from" => $my_id,
"to" => $to,
"data" => json_decode($data, true),
"time" => now_ms()
];
file_put_contents(room_log_file($room), json_encode($sig) . "\n", FILE_APPEND | LOCK_EX);
json_out(["success" => true]);
}
if ($action === "list_all") {
// Periodic cleanup of the DB table (stale sessions > 15s)
if (rand(1, 10) === 1) {
try {
$stale_db_time = now_ms() - 15000;
$stmt = db()->prepare("DELETE FROM voice_sessions WHERE last_seen < ?");
$stmt->execute([$stale_db_time]);
} catch (Exception $e) {}
}
try {
$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
WHERE vs.last_seen > ?
");
$stmt->execute();
$sessions = $stmt->fetchAll();
echo json_encode(['success' => true, 'sessions' => $sessions]);
exit;
$stale_db_time = now_ms() - 15000;
$stmt->execute([$stale_db_time]);
$sessions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$by_channel = [];
foreach ($sessions as $s) {
$by_channel[$s['channel_id']][] = $s;
}
json_out(["success" => true, "channels" => $by_channel]);
} catch (Exception $e) {
json_out(["error" => $e->getMessage()], 500);
}
}
echo json_encode(['success' => false, 'error' => 'Invalid request']);
if ($action === "leave") {
if ($my_id) {
$p_file = room_participants_file($room);
$ps = read_json_file($p_file);
unset($ps[$my_id]);
write_json_file($p_file, $ps);
}
if ($current_user_id > 0) {
try {
$stmt = db()->prepare("DELETE FROM voice_sessions WHERE user_id = ?");
$stmt->execute([$current_user_id]);
} catch (Exception $e) {}
}
json_out(["success" => true]);
}
json_out(["error" => "Unknown action"], 404);

View File

@ -457,6 +457,18 @@ document.addEventListener('DOMContentLoaded', () => {
let ws;
let voiceHandler;
if (typeof VoiceChannel !== 'undefined') {
voiceHandler = new VoiceChannel(null, window.voiceSettings);
window.voiceHandler = voiceHandler;
console.log('VoiceHandler initialized');
// Start global voice sessions polling
setInterval(() => {
VoiceChannel.refreshAllVoiceUsers();
}, 3000);
VoiceChannel.refreshAllVoiceUsers();
}
function connectWS() {
console.log('Connecting to WebSocket...');
try {
@ -466,6 +478,7 @@ document.addEventListener('DOMContentLoaded', () => {
ws.onopen = () => {
console.log('WebSocket connected');
if (voiceHandler) voiceHandler.ws = ws;
ws.send(JSON.stringify({
type: 'presence',
user_id: window.currentUserId,
@ -473,12 +486,6 @@ document.addEventListener('DOMContentLoaded', () => {
}));
};
if (typeof VoiceChannel !== 'undefined') {
voiceHandler = new VoiceChannel(ws, window.voiceSettings);
window.voiceHandler = voiceHandler;
console.log('VoiceHandler initialized');
}
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
@ -665,11 +672,12 @@ document.addEventListener('DOMContentLoaded', () => {
// Handle Click Events
document.addEventListener('click', (e) => {
console.log('Global click at:', e.target);
// Voice Channel Click
const voiceItem = e.target.closest('.voice-item');
if (voiceItem) {
e.preventDefault();
console.log('Voice item clicked:', voiceItem.dataset.channelId);
console.log('Voice item clicked, Channel ID:', voiceItem.dataset.channelId);
const channelId = voiceItem.dataset.channelId;
if (voiceHandler) {
if (voiceHandler.currentChannelId == channelId) {

View File

@ -1,13 +1,17 @@
console.log('voice.js loaded');
class VoiceChannel {
constructor(ws, settings) {
this.ws = ws;
// ws is ignored now as we use PHP signaling, but kept for compatibility
this.settings = settings || { mode: 'vox', pttKey: 'v', voxThreshold: 0.1 };
console.log('VoiceChannel constructor called with settings:', this.settings);
this.localStream = null;
this.screenStream = null;
this.peers = {}; // userId -> RTCPeerConnection
this.participants = {}; // userId -> {username, avatarUrl}
this.participants = {}; // userId -> {name}
this.currentChannelId = null;
this.isScreenSharing = false;
this.myPeerId = null;
this.pollInterval = null;
this.remoteAudios = {}; // userId -> Audio element
this.audioContext = null;
this.analyser = null;
@ -18,7 +22,10 @@ class VoiceChannel {
this.pttPressed = false;
this.voxActive = false;
this.lastVoiceTime = 0;
this.voxHoldTime = 500; // ms to keep open after sound drops below threshold
this.voxHoldTime = 500;
// Track who is speaking to persist across UI refreshes
this.speakingUsers = new Set();
this.setupPTTListeners();
window.addEventListener('beforeunload', () => this.leave());
@ -26,8 +33,12 @@ class VoiceChannel {
setupPTTListeners() {
window.addEventListener('keydown', (e) => {
// Ignore if in input field
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (this.settings.mode === 'ptt' && e.key.toLowerCase() === this.settings.pttKey.toLowerCase()) {
if (!this.pttPressed) {
console.log('PTT Key Pressed:', e.key);
this.pttPressed = true;
this.updateMuteState();
}
@ -36,6 +47,7 @@ class VoiceChannel {
window.addEventListener('keyup', (e) => {
if (this.settings.mode === 'ptt' && e.key.toLowerCase() === this.settings.pttKey.toLowerCase()) {
console.log('PTT Key Released:', e.key);
this.pttPressed = false;
this.updateMuteState();
}
@ -43,69 +55,209 @@ class VoiceChannel {
}
async join(channelId) {
console.log('VoiceChannel.join called for channel:', channelId);
console.log('VoiceChannel.join process started 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) {
console.log('Leaving previous channel:', this.currentChannelId);
this.leave();
}
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).');
}
console.log('Requesting microphone access...');
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
// Start muted
console.log('Microphone access granted');
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 });
// Join via PHP
console.log('Calling API join...');
const url = `api_v1_voice.php?action=join&room=${channelId}&name=${encodeURIComponent(window.currentUsername || 'Unknown')}`;
const resp = await fetch(url);
const data = await resp.json();
console.log('API join response:', data);
this.ws.send(JSON.stringify({
type: 'voice_join',
channel_id: channelId,
user_id: window.currentUserId,
username: window.currentUsername,
avatar_url: window.currentAvatarUrl
}));
this.updateVoiceUI();
if (data.success) {
this.myPeerId = data.peer_id;
console.log('Joined room with peer_id:', this.myPeerId);
// Start polling
this.startPolling();
this.updateVoiceUI();
} else {
console.error('API join failed:', data.error);
}
} catch (e) {
console.error('Failed to get local stream:', e);
alert('Could not access microphone.');
console.error('Failed to join voice:', e);
alert('Microphone access required for voice channels. Error: ' + e.message);
this.currentChannelId = null;
}
}
startPolling() {
if (this.pollInterval) clearInterval(this.pollInterval);
this.pollInterval = setInterval(() => this.poll(), 1000);
this.poll(); // Initial poll
}
async poll() {
if (!this.myPeerId || !this.currentChannelId) return;
try {
const resp = await fetch(`api_v1_voice.php?action=poll&room=${this.currentChannelId}&peer_id=${this.myPeerId}`);
const data = await resp.json();
if (data.success) {
// Update participants
const oldPs = Object.keys(this.participants);
this.participants = data.participants;
const newPs = Object.keys(this.participants);
// If new people joined, initiate offer if I'm the "older" one (not really necessary here, can just offer to anyone I don't have a peer for)
newPs.forEach(pid => {
if (pid !== this.myPeerId && !this.peers[pid]) {
console.log('New peer found via poll:', pid);
this.createPeerConnection(pid, true);
}
});
// Cleanup left peers
oldPs.forEach(pid => {
if (!this.participants[pid] && this.peers[pid]) {
console.log('Peer left:', pid);
this.peers[pid].close();
delete this.peers[pid];
}
});
// Handle incoming signals
if (data.signals && data.signals.length > 0) {
for (const sig of data.signals) {
await this.handleSignaling(sig);
}
}
this.updateVoiceUI();
}
} catch (e) {
console.error('Polling error:', e);
}
}
async sendSignal(to, data) {
if (!this.myPeerId || !this.currentChannelId) return;
await fetch(`api_v1_voice.php?action=signal&room=${this.currentChannelId}&peer_id=${this.myPeerId}&to=${to}&data=${encodeURIComponent(JSON.stringify(data))}`);
}
createPeerConnection(userId, isOfferor) {
if (this.peers[userId]) return this.peers[userId];
console.log('Creating PeerConnection for:', userId, 'as offeror:', isOfferor);
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
});
this.peers[userId] = pc;
pc.oniceconnectionstatechange = () => {
console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`);
};
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
console.log(`Adding track ${track.kind} to peer ${userId}`);
pc.addTrack(track, this.localStream);
});
}
pc.onicecandidate = (event) => {
if (event.candidate) {
this.sendSignal(userId, { type: 'ice_candidate', candidate: event.candidate });
}
};
pc.ontrack = (event) => {
console.log('Received remote track from:', userId, event);
if (this.remoteAudios[userId]) {
this.remoteAudios[userId].pause();
this.remoteAudios[userId].remove();
this.remoteAudios[userId].srcObject = null;
}
const remoteAudio = new Audio();
remoteAudio.style.display = 'none';
remoteAudio.srcObject = event.streams[0];
document.body.appendChild(remoteAudio);
this.remoteAudios[userId] = remoteAudio;
remoteAudio.play().catch(e => console.warn('Autoplay prevented:', e));
};
if (isOfferor) {
pc.createOffer().then(offer => {
return pc.setLocalDescription(offer);
}).then(() => {
this.sendSignal(userId, { type: 'offer', offer: pc.localDescription });
});
}
return pc;
}
async handleSignaling(sig) {
const from = sig.from;
const data = sig.data;
console.log('Handling signaling from:', from, 'type:', data.type);
switch (data.type) {
case 'offer':
await this.handleOffer(from, data.offer);
break;
case 'answer':
await this.handleAnswer(from, data.answer);
break;
case 'ice_candidate':
await this.handleCandidate(from, data.candidate);
break;
case 'voice_speaking':
this.updateSpeakingUI(data.user_id, data.speaking);
break;
}
}
async handleOffer(from, offer) {
const pc = this.createPeerConnection(from, false);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.sendSignal(from, { type: 'answer', answer: pc.localDescription });
}
async handleAnswer(from, answer) {
const pc = this.peers[from];
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
async handleCandidate(from, candidate) {
const pc = this.peers[from];
if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
setupVOX() {
if (this.audioContext) this.audioContext.close();
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.analyser.smoothingTimeConstant = 0.8;
this.analyser.fftSize = 1024;
this.microphone.connect(this.analyser);
this.analyser.connect(this.scriptProcessor);
this.scriptProcessor.connect(this.audioContext.destination);
@ -114,13 +266,13 @@ class VoiceChannel {
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];
}
for (let i = 0; i < array.length; i++) values += array[i];
const average = values / array.length;
const normalized = average / 128; // 0 to 2 approx
// Log sometimes for debugging VOX
if (Math.random() < 0.01) console.log('VOX Avg:', average, 'Threshold:', this.settings.voxThreshold * 255);
if (normalized > this.settings.voxThreshold) {
if (average > (this.settings.voxThreshold * 255)) {
this.lastVoiceTime = Date.now();
if (!this.voxActive) {
this.voxActive = true;
@ -137,207 +289,69 @@ class VoiceChannel {
updateMuteState() {
if (!this.currentChannelId || !this.localStream) return;
let shouldTalk = false;
if (this.settings.mode === 'ptt') {
shouldTalk = this.pttPressed;
} else {
shouldTalk = this.voxActive;
}
let shouldTalk = (this.settings.mode === 'ptt') ? this.pttPressed : this.voxActive;
console.log('updateMuteState: shouldTalk =', shouldTalk, 'mode =', this.settings.mode);
if (this.isTalking !== shouldTalk) {
this.isTalking = shouldTalk;
this.setMute(!shouldTalk);
// Notify others
this.ws.send(JSON.stringify({
type: 'voice_speaking',
channel_id: this.currentChannelId,
user_id: window.currentUserId,
speaking: shouldTalk
}));
this.updateSpeakingUI(window.currentUserId, shouldTalk);
// Notify others
const msg = { type: 'voice_speaking', channel_id: this.currentChannelId, user_id: window.currentUserId, speaking: shouldTalk };
// ... (rest of method remains same, but I'll update it for clarity)
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
} else {
Object.keys(this.peers).forEach(pid => {
this.sendSignal(pid, msg);
});
}
}
}
setMute(mute) {
if (this.localStream) {
this.localStream.getAudioTracks().forEach(track => {
track.enabled = !mute;
});
console.log('Setting mute to:', mute);
this.localStream.getAudioTracks().forEach(track => { track.enabled = !mute; });
}
}
leave() {
if (!this.currentChannelId) return;
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,
user_id: window.currentUserId
}));
console.log('Leaving voice channel:', this.currentChannelId);
if (this.pollInterval) clearInterval(this.pollInterval);
fetch(`api_v1_voice.php?action=leave&room=${this.currentChannelId}&peer_id=${this.myPeerId}`);
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
this.localStream = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
Object.values(this.peers).forEach(pc => pc.close());
Object.values(this.remoteAudios).forEach(audio => {
audio.pause();
audio.remove();
audio.srcObject = null;
});
this.peers = {};
this.remoteAudios = {};
this.participants = {};
this.currentChannelId = null;
this.isTalking = false;
this.myPeerId = null;
this.speakingUsers.clear();
this.updateVoiceUI();
}
async handleSignaling(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;
switch (type) {
case 'voice_join':
if (from != window.currentUserId) {
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: username || `User ${from}`, avatar_url: avatar_url };
await this.handleOffer(from, offer);
this.updateVoiceUI();
break;
case 'voice_answer':
await this.handleAnswer(from, answer);
break;
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();
delete this.peers[from];
}
delete this.participants[from];
this.updateVoiceUI();
break;
}
}
createPeerConnection(userId, isOfferor) {
if (this.peers[userId]) return this.peers[userId];
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
this.peers[userId] = pc;
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream);
});
}
pc.onicecandidate = (event) => {
if (event.candidate) {
this.ws.send(JSON.stringify({
type: 'voice_ice_candidate',
to: userId,
from: window.currentUserId,
candidate: event.candidate,
channel_id: this.currentChannelId
}));
}
};
pc.ontrack = (event) => {
if (event.track.kind === 'audio') {
const remoteAudio = new Audio();
remoteAudio.srcObject = event.streams[0];
remoteAudio.play();
}
};
if (isOfferor) {
pc.createOffer().then(offer => {
return pc.setLocalDescription(offer);
}).then(() => {
this.ws.send(JSON.stringify({
type: 'voice_offer',
to: userId,
from: window.currentUserId,
username: window.currentUsername,
avatar_url: window.currentAvatarUrl,
offer: pc.localDescription,
channel_id: this.currentChannelId
}));
});
}
return pc;
}
async handleOffer(from, offer) {
const pc = this.createPeerConnection(from, false);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.ws.send(JSON.stringify({
type: 'voice_answer',
to: from,
from: window.currentUserId,
answer: pc.localDescription,
channel_id: this.currentChannelId
}));
}
async handleAnswer(from, answer) {
const pc = this.peers[from];
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
async handleCandidate(from, candidate) {
const pc = this.peers[from];
if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
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 listEls = document.querySelectorAll(`.voice-item[data-channel-id="${this.currentChannelId}"] + .voice-users-list`);
listEls.forEach(listEl => {
// Me
this.addVoiceUserToUI(listEl, window.currentUserId, window.currentUsername, window.currentAvatarUrl);
// Others
Object.entries(this.participants).forEach(([uid, data]) => {
this.addVoiceUserToUI(listEl, uid, data.username, data.avatar_url);
});
});
// We now use a global update mechanism for all channels
VoiceChannel.refreshAllVoiceUsers();
// Voice controls
if (this.currentChannelId) {
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';
@ -345,7 +359,7 @@ class VoiceChannel {
controls.innerHTML = `
<div class="d-flex align-items-center">
<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 class="small fw-bold" style="font-size: 11px; color: #248046;">Voice (${this.settings.mode.toUpperCase()})</div>
</div>
<div>
<button class="btn btn-sm text-muted" id="btn-voice-leave" title="Disconnect">
@ -353,8 +367,10 @@ class VoiceChannel {
</button>
</div>
`;
document.querySelector('.channels-sidebar').appendChild(controls);
document.getElementById('btn-voice-leave').onclick = () => this.leave();
const sidebar = document.querySelector('.channels-sidebar');
if (sidebar) sidebar.appendChild(controls);
const btnLeave = document.getElementById('btn-voice-leave');
if (btnLeave) btnLeave.onclick = () => this.leave();
}
} else {
const controls = document.querySelector('.voice-controls');
@ -362,38 +378,64 @@ class VoiceChannel {
}
}
addVoiceUserToUI(container, userId, username, avatarUrl) {
const userEl = document.createElement('div');
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: 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) {
if (isSpeaking) {
this.speakingUsers.add(userId);
} else {
this.speakingUsers.delete(userId);
}
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';
}
avatar.style.boxShadow = isSpeaking ? '0 0 0 2px #23a559' : 'none';
}
});
}
stopScreenShare() {
// Not requested but kept for compatibility
if (this.screenStream) {
this.screenStream.getTracks().forEach(track => track.stop());
this.screenStream = null;
static async refreshAllVoiceUsers() {
try {
const resp = await fetch('api_v1_voice.php?action=list_all');
const data = await resp.json();
if (data.success) {
// Clear all lists first
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
// Populate based on data
Object.keys(data.channels).forEach(channelId => {
// Fix: The voice-users-list is a sibling of the container of the voice-item
const voiceItem = document.querySelector(`.voice-item[data-channel-id="${channelId}"]`);
if (voiceItem) {
const container = voiceItem.closest('.channel-item-container');
if (container) {
const listEl = container.querySelector('.voice-users-list');
if (listEl) {
data.channels[channelId].forEach(p => {
const isSpeaking = window.voiceHandler && window.voiceHandler.speakingUsers.has(p.user_id);
VoiceChannel.renderUserToUI(listEl, p.user_id, p.display_name || p.username, p.avatar_url, isSpeaking);
});
}
}
}
});
}
} catch (e) {
console.error('Failed to refresh voice users:', e);
}
this.isScreenSharing = false;
}
static renderUserToUI(container, userId, username, avatarUrl, isSpeaking = false) {
const userEl = document.createElement('div');
userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1';
userEl.dataset.userId = userId;
userEl.style.paddingLeft = '8px';
const avatarStyle = avatarUrl ? `background-image: url('${avatarUrl}'); background-size: cover;` : "background-color: #555;";
const boxShadow = isSpeaking ? 'box-shadow: 0 0 0 2px #23a559;' : '';
userEl.innerHTML = `
<div class="message-avatar me-2" style="width: 16px; height: 16px; border-radius: 50%; transition: box-shadow 0.2s; ${avatarStyle} ${boxShadow}"></div>
<span style="font-size: 13px;">${username}</span>
`;
container.appendChild(userEl);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

2
data/22.log Normal file
View File

@ -0,0 +1,2 @@
{"from":"a0645f38fb2bbdb5","to":"1ca650259dcce60e","data":{"type":"voice_speaking","channel_id":"22","user_id":3,"speaking":true},"time":1771339536024}
{"from":"a0645f38fb2bbdb5","to":"1ca650259dcce60e","data":{"type":"voice_speaking","channel_id":"22","user_id":3,"speaking":false},"time":1771339536956}

View File

@ -0,0 +1 @@
[]

8
data/3.log Normal file
View File

@ -0,0 +1,8 @@
{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"offer","offer":{"type":"offer","sdp":"v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 2993514939591859431 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 E9:89:B8:DE:41:F8:AD:79:17:A8:4D:03:2D:53:FC:15:5A:3B:B4:CA:45:A6:F7:EB:C7:F0:01:7C:0B:29:91:F1\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=audio 9 UDP\/TLS\/RTP\/SAVPF 109 9 0 8 101\r\nc=IN IP4 0.0.0.0\r\na=sendrecv\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:2\/recvonly urn:ietf:params:rtp-hdrext:csrc-audio-level\r\na=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap-allow-mixed\r\na=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1\r\na=fmtp:101 0-15\r\na=ice-pwd:cacccb2562e0f73ce1667f9773fb670d\r\na=ice-ufrag:2b377091\r\na=mid:0\r\na=msid:{5d44acc3-9af3-4110-98ed-2de218fc7450} {ed9d31cb-0fc7-497e-8c79-4cc9159d5f97}\r\na=rtcp-mux\r\na=rtpmap:109 opus\/48000\/2\r\na=rtpmap:9 G722\/8000\/1\r\na=rtpmap:0 PCMU\/8000\r\na=rtpmap:8 PCMA\/8000\r\na=rtpmap:101 telephone-event\/8000\r\na=setup:actpass\r\na=ssrc:2872346815 cname:{74df363a-4160-4031-a110-5285ddfe55ff}\r\n"}},"time":1771339604154}
{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 1 UDP 2122252543 192.168.26.26 61807 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604155}
{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 1 TCP 2105524479 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604161}
{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 2 TCP 2105524478 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604164}
{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 2 UDP 2122252542 192.168.26.26 61808 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604170}
{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604171}
{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 1 UDP 1686052863 78.246.210.10 30532 typ srflx raddr 192.168.26.26 rport 61807","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604172}
{"from":"9afce7ba24e9091b","to":"a39fca0ca87f4e62","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 2 UDP 1686052862 78.246.210.10 30534 typ srflx raddr 192.168.26.26 rport 61808","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"2b377091"}},"time":1771339604175}

1
data/3.participants.json Normal file
View File

@ -0,0 +1 @@
{"1e0fff021b7ad021":{"id":"1e0fff021b7ad021","user_id":3,"name":"swefheim","avatar_url":"","last_seen":1771339631904},"9afce7ba24e9091b":{"id":"9afce7ba24e9091b","user_id":2,"name":"swefpifh ᵇʰᶠʳ","avatar_url":"","last_seen":1771339632155}}

10
data/6.log Normal file
View File

@ -0,0 +1,10 @@
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"offer","offer":{"type":"offer","sdp":"v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 1705174900585877835 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 AA:37:B9:FA:14:15:DC:34:29:97:DC:55:77:28:8E:74:C0:94:15:08:DF:5B:E9:CC:36:81:E5:D9:5C:49:FB:46\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=audio 9 UDP\/TLS\/RTP\/SAVPF 109 9 0 8 101\r\nc=IN IP4 0.0.0.0\r\na=sendrecv\r\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\na=extmap:2\/recvonly urn:ietf:params:rtp-hdrext:csrc-audio-level\r\na=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid\r\na=extmap-allow-mixed\r\na=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1\r\na=fmtp:101 0-15\r\na=ice-pwd:075627a25c9f07d3908305504803057d\r\na=ice-ufrag:03d0add1\r\na=mid:0\r\na=msid:{7a2c5afc-d053-43fd-9196-3caf4510feb3} {8439b857-ead8-4cb1-8111-cb3400348461}\r\na=rtcp-mux\r\na=rtpmap:109 opus\/48000\/2\r\na=rtpmap:9 G722\/8000\/1\r\na=rtpmap:0 PCMU\/8000\r\na=rtpmap:8 PCMA\/8000\r\na=rtpmap:101 telephone-event\/8000\r\na=setup:actpass\r\na=ssrc:1663488241 cname:{7dcb8038-2b50-4e42-827c-c4617a225c01}\r\n"}},"time":1771336456644}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 1 UDP 2122252543 192.168.26.26 62572 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456645}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 1 TCP 2105524479 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456647}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:2 2 TCP 2105524478 192.168.26.26 9 typ host tcptype active","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456652}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:0 2 UDP 2122252542 192.168.26.26 62573 typ host","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456654}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 1 UDP 1686052351 78.246.210.10 31184 typ srflx raddr 192.168.26.26 rport 62572","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456720}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 2 UDP 1686052350 78.246.210.10 31185 typ srflx raddr 192.168.26.26 rport 62573","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456757}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 1 UDP 1686052863 78.246.210.10 31184 typ srflx raddr 192.168.26.26 rport 62572","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456776}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"candidate:1 2 UDP 1686052862 78.246.210.10 31185 typ srflx raddr 192.168.26.26 rport 62573","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456802}
{"from":"75b3938d276e81e1","to":"0cce3619c0f299fa","data":{"type":"ice_candidate","candidate":{"candidate":"","sdpMLineIndex":0,"sdpMid":"0","usernameFragment":"03d0add1"}},"time":1771336456805}

1
data/6.participants.json Normal file
View File

@ -0,0 +1 @@
{"0cce3619c0f299fa":{"id":"0cce3619c0f299fa","user_id":2,"name":"swefpifh ᵇʰᶠʳ","avatar_url":"","last_seen":1771336452806},"75b3938d276e81e1":{"id":"75b3938d276e81e1","user_id":2,"name":"swefpifh ᵇʰᶠʳ","avatar_url":"","last_seen":1771336462293}}

View File

@ -0,0 +1 @@
{"0fbf720bc2f110c0":{"id":"0fbf720bc2f110c0","name":"AI","last_seen":1771336229774}}

1
data/test.txt Normal file
View File

@ -0,0 +1 @@
hello

1
data/test_www.txt Normal file
View File

@ -0,0 +1 @@
hello

View File

@ -507,29 +507,46 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
return;
}
?>
<div class="channel-item-container d-flex align-items-center" data-id="<?php echo $c['id']; ?>">
<a href="?server_id=<?php echo $active_server_id; ?>&channel_id=<?php echo $c['id']; ?>"
class="channel-item flex-grow-1 <?php echo ($c['id'] == $active_channel_id) ? 'active' : ''; ?> <?php echo ($c['type'] === 'voice') ? 'voice-item' : ''; ?>" <?php echo ($c['type'] === 'voice') ? 'data-channel-id="'.$c['id'].'"' : ''; ?>>
<span class="d-flex align-items-center">
<span class="me-1" style="width: 20px; display: inline-block; text-align: center;">
<?php
if ($c['type'] === 'announcement') echo '<i class="fa-solid fa-bullhorn"></i>';
elseif ($c['type'] === 'rules') echo '<i class="fa-solid fa-gavel"></i>';
elseif ($c['type'] === 'autorole') echo '<i class="fa-solid fa-shield-halved"></i>';
elseif ($c['type'] === 'forum') echo '<i class="fa-solid fa-comments"></i>';
elseif ($c['type'] === 'voice') echo '<i class="fa-solid fa-volume-up"></i>';
else echo '<i class="fa-solid fa-hashtag"></i>';
?>
<div class="channel-item-container" data-id="<?php echo $c['id']; ?>">
<div class="d-flex align-items-center">
<a href="?server_id=<?php echo $active_server_id; ?>&channel_id=<?php echo $c['id']; ?>"
class="channel-item flex-grow-1 <?php echo ($c['id'] == $active_channel_id) ? 'active' : ''; ?> <?php echo ($c['type'] === 'voice') ? 'voice-item' : ''; ?>" <?php echo ($c['type'] === 'voice') ? 'data-channel-id="'.$c['id'].'"' : ''; ?>>
<span class="d-flex align-items-center">
<span class="me-1" style="width: 20px; display: inline-block; text-align: center;">
<?php
if ($c['type'] === 'announcement') echo '<i class="fa-solid fa-bullhorn"></i>';
elseif ($c['type'] === 'rules') echo '<i class="fa-solid fa-gavel"></i>';
elseif ($c['type'] === 'autorole') echo '<i class="fa-solid fa-shield-halved"></i>';
elseif ($c['type'] === 'forum') echo '<i class="fa-solid fa-comments"></i>';
elseif ($c['type'] === 'voice') echo '<i class="fa-solid fa-volume-up"></i>';
else echo '<i class="fa-solid fa-hashtag"></i>';
?>
</span>
<?php if (!empty($c['icon'])): ?>
<span class="me-1" style="font-size: 14px;"><?php echo renderRoleIcon($c['icon'], '14px'); ?></span>
<?php endif; ?>
<span class="channel-name-text"><?php echo htmlspecialchars($c['name']); ?></span>
</span>
<?php if (!empty($c['icon'])): ?>
<span class="me-1" style="font-size: 14px;"><?php echo renderRoleIcon($c['icon'], '14px'); ?></span>
<?php if ($c['type'] === 'voice' && !empty($c['status'])): ?>
<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; ?>
<span class="channel-name-text"><?php echo htmlspecialchars($c['name']); ?></span>
</span>
<?php if ($c['type'] === 'voice' && !empty($c['status'])): ?>
<div class="channel-status small text-muted ms-4" style="font-size: 0.75em; margin-top: -2px;"><?php echo htmlspecialchars($c['status']); ?></div>
</a>
<?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"
data-id="<?php echo $c['id']; ?>"
data-name="<?php echo htmlspecialchars($c['name']); ?>"
data-type="<?php echo $c['type']; ?>"
data-files="<?php echo $c['allow_file_sharing']; ?>"
data-limit="<?php echo $c['message_limit']; ?>"
data-status="<?php echo htmlspecialchars($c['status'] ?? ''); ?>"
data-icon="<?php echo htmlspecialchars($c['icon'] ?? ''); ?>"
data-rules-role="<?php echo $c['rules_role_id'] ?? ''; ?>"
data-category="<?php echo $c['category_id'] ?? ''; ?>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</span>
<?php endif; ?>
</a>
</div>
<?php if ($c['type'] === 'voice'): ?>
<div class="voice-users-list ms-4 mb-1">
<?php if (isset($voice_users_by_channel[$c['id']])): ?>
@ -542,21 +559,6 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<?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"
data-id="<?php echo $c['id']; ?>"
data-name="<?php echo htmlspecialchars($c['name']); ?>"
data-type="<?php echo $c['type']; ?>"
data-files="<?php echo $c['allow_file_sharing']; ?>"
data-limit="<?php echo $c['message_limit']; ?>"
data-status="<?php echo htmlspecialchars($c['status'] ?? ''); ?>"
data-icon="<?php echo htmlspecialchars($c['icon'] ?? ''); ?>"
data-rules-role="<?php echo $c['rules_role_id'] ?? ''; ?>"
data-category="<?php echo $c['category_id'] ?? ''; ?>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</span>
<?php endif; ?>
</div>
<?php
}

View File

@ -514,3 +514,71 @@
2026-02-17 12:44:53 - GET /?fl_project=38443 - POST: []
2026-02-17 12:45:22 - GET /?fl_project=38443 - POST: []
2026-02-17 12:50:07 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 12:51:03 - GET /?fl_project=38443 - POST: []
2026-02-17 13:29:32 - GET /?fl_project=38443 - POST: []
2026-02-17 13:34:34 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 13:35:23 - - POST: []
2026-02-17 13:35:28 - GET / - POST: []
2026-02-17 13:44:18 - GET /?fl_project=38443 - POST: []
2026-02-17 13:44:19 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 13:44:44 - GET /?fl_project=38443 - POST: []
2026-02-17 13:46:48 - GET /?fl_project=38443 - POST: []
2026-02-17 13:47:13 - GET /index.php - POST: []
2026-02-17 13:47:48 - GET /?fl_project=38443 - POST: []
2026-02-17 13:47:48 - GET /?fl_project=38443 - POST: []
2026-02-17 13:49:26 - GET /?fl_project=38443 - POST: []
2026-02-17 13:52:42 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:30 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:31 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:33 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:34 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:44 - GET /?fl_project=38443 - POST: []
2026-02-17 13:53:50 - GET /index.php - POST: []
2026-02-17 13:53:52 - GET /index.php - POST: []
2026-02-17 13:54:13 - GET /index.php?server_id=1&channel_id=15 - POST: []
2026-02-17 13:54:23 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 13:54:34 - GET /index.php?server_id=1&channel_id=3 - POST: []
2026-02-17 13:54:40 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 13:54:53","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 13:54:54 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 13:55:35 - GET /index.php?server_id=1&channel_id=6 - POST: []
{"date":"2026-02-17 13:56:01","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
2026-02-17 13:56:02 - GET /index.php?server_id=1&channel_id=6 - POST: []
2026-02-17 13:56:22 - GET /?fl_project=38443 - POST: []
2026-02-17 13:56:32 - GET /index.php - POST: []
2026-02-17 14:03:44 - GET /?fl_project=38443 - POST: []
2026-02-17 14:03:45 - GET /?fl_project=38443 - POST: []
2026-02-17 14:04:57 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:05:10 - GET /index.php - POST: []
2026-02-17 14:05:34 - GET /index.php - POST: []
2026-02-17 14:05:36 - GET /index.php - POST: []
2026-02-17 14:12:18 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:12:41","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:12:42 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:12:48 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:12:58","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 14:12:59 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:23:18 - GET / - POST: []
2026-02-17 14:23:53 - GET /?fl_project=38443 - POST: []
2026-02-17 14:23:54 - GET /?fl_project=38443 - POST: []
2026-02-17 14:34:38 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:34:58 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:35:14 - GET /index.php - POST: []
{"date":"2026-02-17 14:36:59","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:37:00 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:42:23 - GET /?fl_project=38443 - POST: []
2026-02-17 14:42:24 - GET /?fl_project=38443 - POST: []
2026-02-17 14:43:06 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:43:18","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:43:19 - GET /index.php?server_id=1&channel_id=22 - POST: []
2026-02-17 14:43:51 - GET /index.php - POST: []
{"date":"2026-02-17 14:44:07","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","voice_mode":"ptt","voice_ptt_key":"0","voice_vox_threshold":"0.1","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
2026-02-17 14:44:07 - GET /index.php - POST: []
{"date":"2026-02-17 14:44:43","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:44:43 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:45:25","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:45:26 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:46:17","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"ptt","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:46:17 - GET /index.php?server_id=1&channel_id=22 - POST: []
{"date":"2026-02-17 14:46:39","method":"POST","post":{"avatar_url":"","display_name":"swefpifh \u1d47\u02b0\u1da0\u02b3","theme":"dark","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0.1","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 14:46:40 - GET /index.php?server_id=1&channel_id=22 - POST: []