This commit is contained in:
Flatlogic Bot 2026-02-17 19:15:53 +00:00
parent 09fa2a7096
commit 099f307a09
7 changed files with 239 additions and 63 deletions

View File

@ -10,7 +10,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
echo json_encode([]);
exit;
}
$stmt = db()->prepare("SELECT * FROM channels WHERE server_id = ?");
$stmt = db()->prepare("SELECT c.*, (SELECT MAX(id) FROM messages WHERE channel_id = c.id) as last_message_id FROM channels c WHERE server_id = ?");
$stmt->execute([$server_id]);
echo json_encode($stmt->fetchAll());
exit;

View File

@ -1413,3 +1413,21 @@ body {
.form-range::-moz-range-thumb {
background: var(--blurple);
}
/* Unread indicator */
.unread-indicator {
width: 8px;
height: 8px;
background-color: #ffffff;
border-radius: 50%;
margin-left: auto;
margin-right: 8px;
display: none;
}
.channel-item.unread .unread-indicator {
display: block;
}
.channel-item.unread .channel-name-text {
color: #ffffff;
font-weight: 600;
}

View File

@ -2,7 +2,7 @@ console.log('voice.js loaded');
class VoiceChannel {
constructor(ws, settings) {
// ws is ignored now as we use PHP signaling, but kept for compatibility
this.ws = ws;
this.settings = settings || { mode: 'vox', pttKey: 'v', voxThreshold: 0.1 };
console.log('VoiceChannel constructor called with settings:', this.settings);
this.localStream = null;
@ -13,6 +13,7 @@ class VoiceChannel {
this.myPeerId = null;
this.pollInterval = null;
this.remoteAudios = {}; // userId -> Audio element
this.pendingCandidates = {}; // userId -> array of candidates
this.audioContext = null;
this.analyser = null;
@ -132,20 +133,40 @@ class VoiceChannel {
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);
}
});
// If new people joined, initiate offer if I'm the "older" one (prevent glare by comparing IDs)
newPs.forEach(pid => {
if (pid !== this.myPeerId && !this.peers[pid]) {
// Only initiate if my ID is greater than theirs (lexicographical comparison)
if (this.myPeerId > pid) {
console.log('Initiating offer to new peer:', pid);
this.createPeerConnection(pid, true);
} else {
console.log('Waiting for offer from peer:', pid);
// Just create the PC without offering, it will handle the incoming offer
this.createPeerConnection(pid, false);
}
}
});
// Cleanup left peers
oldPs.forEach(pid => {
if (!this.participants[pid] && this.peers[pid]) {
console.log('Peer left:', pid);
this.peers[pid].close();
try {
this.peers[pid].close();
} catch(e) {}
delete this.peers[pid];
if (this.remoteAudios[pid]) {
try {
this.remoteAudios[pid].pause();
this.remoteAudios[pid].srcObject = null;
this.remoteAudios[pid].remove();
} catch(e) {}
delete this.remoteAudios[pid];
}
// Also cleanup by class just in case
document.querySelectorAll(`.voice-remote-audio[data-peer-id="${pid}"]`).forEach(el => el.remove());
}
});
@ -168,6 +189,25 @@ class VoiceChannel {
await fetch(`api_v1_voice.php?action=signal&room=${this.currentChannelId}&peer_id=${this.myPeerId}&to=${to}&data=${encodeURIComponent(JSON.stringify(data))}`);
}
async handleSignaling(sig) {
const { from, data } = sig;
console.log('Handling signaling from:', from, 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;
}
}
createPeerConnection(userId, isOfferor) {
if (this.peers[userId]) return this.peers[userId];
@ -180,6 +220,7 @@ class VoiceChannel {
});
this.peers[userId] = pc;
this.pendingCandidates[userId] = [];
pc.oniceconnectionstatechange = () => {
console.log(`ICE Connection State with ${userId}: ${pc.iceConnectionState}`);
@ -200,17 +241,34 @@ class VoiceChannel {
pc.ontrack = (event) => {
console.log('Received remote track from:', userId, event);
const stream = event.streams[0] || new MediaStream([event.track]);
if (this.remoteAudios[userId]) {
this.remoteAudios[userId].pause();
this.remoteAudios[userId].remove();
this.remoteAudios[userId].srcObject = null;
console.log('Updating existing remote audio for:', userId);
this.remoteAudios[userId].srcObject = stream;
} else {
console.log('Creating new remote audio for:', userId);
const remoteAudio = new Audio();
remoteAudio.classList.add('voice-remote-audio');
remoteAudio.dataset.peerId = userId;
remoteAudio.autoplay = true;
remoteAudio.playsInline = true;
remoteAudio.srcObject = stream;
document.body.appendChild(remoteAudio);
this.remoteAudios[userId] = remoteAudio;
}
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));
this.remoteAudios[userId].play().then(() => {
console.log('Audio playing for peer:', userId);
}).catch(e => {
console.warn('Autoplay prevented or failed for:', userId, e);
// Try again on any click
const retry = () => {
this.remoteAudios[userId].play();
window.removeEventListener('click', retry);
};
window.addEventListener('click', retry);
});
};
if (isOfferor) {
@ -224,44 +282,70 @@ class VoiceChannel {
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) {
console.log('Received offer from:', from);
const pc = this.createPeerConnection(from, false);
try {
await pc.setRemoteDescription(new RTCSessionDescription(offer));
console.log('Remote description set for offer from:', from);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
console.log('Local description (answer) set for:', from);
this.sendSignal(from, { type: 'answer', answer: pc.localDescription });
await this.processPendingCandidates(from);
} catch (e) {
console.error('Error handling offer from', from, e);
}
}
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) {
console.log('Received answer from:', from);
const pc = this.peers[from];
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
if (pc) {
try {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
console.log('Remote description (answer) set for:', from);
await this.processPendingCandidates(from);
} catch (e) {
console.error('Error handling answer from', from, e);
}
} else {
console.warn('Received answer but no peer connection for:', from);
}
}
async handleCandidate(from, candidate) {
console.log('Received ICE candidate from:', from);
const pc = this.peers[from];
if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate));
if (pc && pc.remoteDescription) {
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
console.log('Added ICE candidate for:', from);
} catch (e) {
console.error('Error adding ice candidate from', from, e);
}
} else if (pc) {
console.log('Queuing ICE candidate for:', from);
this.pendingCandidates[from].push(candidate);
} else {
console.warn('Received ICE candidate but no peer connection for:', from);
}
}
async processPendingCandidates(userId) {
const pc = this.peers[userId];
const candidates = this.pendingCandidates[userId];
if (pc && pc.remoteDescription && candidates && candidates.length > 0) {
console.log(`Processing ${candidates.length} pending candidates for ${userId}`);
while (candidates.length > 0) {
const cand = candidates.shift();
try {
await pc.addIceCandidate(new RTCIceCandidate(cand));
} catch (e) {
console.error('Error processing pending candidate', e);
}
}
}
}
setupVOX() {
@ -362,7 +446,6 @@ class VoiceChannel {
// 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 {
@ -382,13 +465,31 @@ class VoiceChannel {
leave() {
if (!this.currentChannelId) return;
console.log('Leaving voice channel:', this.currentChannelId);
if (this.pollInterval) clearInterval(this.pollInterval);
const roomToLeave = this.currentChannelId;
const myIdToLeave = this.myPeerId;
console.log('Leaving voice channel:', roomToLeave);
fetch(`api_v1_voice.php?action=leave&room=${this.currentChannelId}&peer_id=${this.myPeerId}`);
// 1. Clear interval and set state to null immediately to stop any ongoing poll/logic
if (this.pollInterval) clearInterval(this.pollInterval);
this.pollInterval = null;
this.currentChannelId = null;
this.myPeerId = null;
// 2. Notify server
const leaveUrl = `api_v1_voice.php?action=leave&room=${roomToLeave}&peer_id=${myIdToLeave}`;
if (navigator.sendBeacon) {
navigator.sendBeacon(leaveUrl);
} else {
fetch(leaveUrl);
}
// 3. Stop all local audio tracks
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
console.log('Stopping localStream tracks');
this.localStream.getTracks().forEach(track => {
track.enabled = false;
track.stop();
});
this.localStream = null;
}
if (this.analysisStream) {
@ -396,10 +497,11 @@ class VoiceChannel {
this.analysisStream = null;
}
// 4. Cleanup VOX/AudioContext nodes
if (this.scriptProcessor) {
try {
this.scriptProcessor.disconnect();
this.scriptProcessor.onaudioprocess = null;
this.scriptProcessor.disconnect();
} catch(e) {}
this.scriptProcessor = null;
}
@ -408,22 +510,47 @@ class VoiceChannel {
this.microphone = null;
}
if (this.audioContext && this.audioContext.state !== 'closed') {
// Keep AudioContext alive but suspended to reuse it
this.audioContext.suspend();
try { this.audioContext.suspend(); } catch(e) {}
}
Object.values(this.peers).forEach(pc => pc.close());
Object.values(this.remoteAudios).forEach(audio => {
audio.pause();
audio.remove();
audio.srcObject = null;
// 5. Close all peer connections
console.log('Closing all peer connections:', Object.keys(this.peers));
Object.keys(this.peers).forEach(pid => {
try {
this.peers[pid].onicecandidate = null;
this.peers[pid].ontrack = null;
this.peers[pid].oniceconnectionstatechange = null;
this.peers[pid].close();
} catch(e) { console.error('Error closing peer:', pid, e); }
delete this.peers[pid];
});
this.peers = {};
// 6. Remove all remote audio elements
document.querySelectorAll('.voice-remote-audio').forEach(audio => {
try {
audio.pause();
audio.srcObject = null;
audio.remove();
} catch(e) {}
});
this.remoteAudios = {};
this.pendingCandidates = {};
this.participants = {};
this.currentChannelId = null;
this.myPeerId = null;
this.speakingUsers.clear();
this.isTalking = false;
this.voxActive = false;
// Final notify speaking false
if (roomToLeave) {
const msg = { type: 'voice_speaking', channel_id: roomToLeave, user_id: window.currentUserId, speaking: false };
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try { this.ws.send(JSON.stringify(msg)); } catch(e) {}
}
}
// 7. Update UI
this.updateVoiceUI();
}

View File

@ -0,0 +1,10 @@
-- Unread messages tracking
CREATE TABLE IF NOT EXISTS channel_reads (
user_id INT NOT NULL,
channel_id INT NOT NULL,
last_read_message_id INT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, channel_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);

View File

@ -527,6 +527,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<?php endif; ?>
<span class="channel-name-text"><?php echo htmlspecialchars($c['name']); ?></span>
</span>
<span class="unread-indicator"></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; ?>

View File

@ -632,3 +632,14 @@
{"date":"2026-02-17 15:47:36","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.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
{"date":"2026-02-17 15:48:00","method":"POST","post":{"avatar_url":"","display_name":"swefheim","theme":"light","voice_mode":"vox","voice_ptt_key":"v","voice_vox_threshold":"0","dnd_mode":"0","sound_notifications":"0"},"session":{"user_id":3},"user_id":3,"db_success":true}
{"date":"2026-02-17 15:48: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.06","dnd_mode":"1","sound_notifications":"1"},"session":{"user_id":2},"user_id":2,"db_success":true}
2026-02-17 16:55:24 - GET / - POST: []
2026-02-17 17:03:39 - GET /?fl_project=38527 - POST: []
2026-02-17 17:03:47 - GET / - POST: []
2026-02-17 17:09:03 - GET /?fl_project=38527 - POST: []
2026-02-17 18:06:34 - GET /?fl_project=38527 - POST: []
2026-02-17 18:36:12 - GET /?fl_project=38527 - POST: []
2026-02-17 18:56:15 - GET /?fl_project=38527 - POST: []
2026-02-17 18:58:00 - GET /?fl_project=38527 - POST: []
2026-02-17 19:13:31 - GET /?fl_project=38527 - POST: []
2026-02-17 19:14:39 - GET /?fl_project=38527 - POST: []
2026-02-17 19:15:28 - GET /?fl_project=38527 - POST: []

9
run_migration.php Normal file
View File

@ -0,0 +1,9 @@
<?php
require_once 'db/config.php';
try {
$sql = file_get_contents('db/migrations/20260217_unread_indicators.sql');
db()->exec($sql);
echo "Migration successful\n";
} catch (Exception $e) {
echo "Migration failed: " . $e->getMessage() . "\n";
}