Final V2
This commit is contained in:
parent
09fa2a7096
commit
099f307a09
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
10
db/migrations/20260217_unread_indicators.sql
Normal file
10
db/migrations/20260217_unread_indicators.sql
Normal 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
|
||||
);
|
||||
@ -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; ?>
|
||||
|
||||
11
requests.log
11
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: []
|
||||
|
||||
9
run_migration.php
Normal file
9
run_migration.php
Normal 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";
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user