diff --git a/api_v1_channels.php b/api_v1_channels.php index 87e0e60..ca8c44b 100644 --- a/api_v1_channels.php +++ b/api_v1_channels.php @@ -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; diff --git a/assets/css/discord.css b/assets/css/discord.css index 409eba9..458adab 100644 --- a/assets/css/discord.css +++ b/assets/css/discord.css @@ -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; +} diff --git a/assets/js/voice.js b/assets/js/voice.js index 7232825..f42f043 100644 --- a/assets/js/voice.js +++ b/assets/js/voice.js @@ -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(); } diff --git a/db/migrations/20260217_unread_indicators.sql b/db/migrations/20260217_unread_indicators.sql new file mode 100644 index 0000000..5cc8d9a --- /dev/null +++ b/db/migrations/20260217_unread_indicators.sql @@ -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 +); diff --git a/index.php b/index.php index 3576530..296b613 100644 --- a/index.php +++ b/index.php @@ -527,6 +527,7 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; +
diff --git a/requests.log b/requests.log index d8b5db0..dd9dbfa 100644 --- a/requests.log +++ b/requests.log @@ -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: [] diff --git a/run_migration.php b/run_migration.php new file mode 100644 index 0000000..0adfffe --- /dev/null +++ b/run_migration.php @@ -0,0 +1,9 @@ +exec($sql); + echo "Migration successful\n"; +} catch (Exception $e) { + echo "Migration failed: " . $e->getMessage() . "\n"; +}