feat(voice): add TURN credential support, ICE restart logic, and connection quality tracking
Introduce a TURN credentials protocol for requesting time-limited relay server credentials from the relay node, improving NAT traversal for WebRTC voice connections. WebRTC layer improvements: - Add ICE candidate buffering before remote description is set - Implement automatic ICE restart with configurable max attempts - Add disconnection timeout with delayed restart recovery - Add per-peer connection state tracking and callbacks - Include public STUN servers (Google, Cloudflare) by default - Add detailed logging throughout the WebRTC lifecycle Voice store improvements: - Fetch TURN credentials from relay before establishing connections - Track per-peer RTCPeerConnectionState with reactive signals - Derive overall voice quality signal (good/connecting/degraded/failed) - Evaluate and surface degraded connection state to the UI - Clean up peer connection states on participant leave - Fix video toggle resource leak (stop unused audio tracks) Backend changes: - Add TurnCredentialRequest/Response protocol types - Wire turn_credentials behaviour into swarm and node event loop - Add get_turn_credentials Tauri command - Propagate errors from voice commands instead of silently dropping - Add input validation for SDP types in send_voice_sdp - Handle missing node handle as explicit error in all voice commands - Add DirectoryResponse::Error variant handling - Scope Linux WebKitGTK permission grants to UserMediaPermissionRequest UI improvements: - Show connection quality indicator in voice channel header - Add per-peer connection state ring on participant tiles - Display failed connection warning icon on participant tiles - Stack muted/deafened indicators vertically to avoid overlap - Show participant grid in degraded connection state Also remove unused imports across several frontend components.
This commit is contained in:
parent
044b5ca111
commit
48e49ee6a5
|
|
@ -7,7 +7,7 @@
|
|||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri": "WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11 tauri",
|
||||
"tauri:linux": "WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11 tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:dev:linux": "WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11 tauri dev",
|
||||
|
|
@ -50,4 +50,4 @@
|
|||
"@tailwindcss/vite": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ use tauri::State;
|
|||
use crate::node::gossip;
|
||||
use crate::node::NodeCommand;
|
||||
use crate::protocol::messages::{GossipMessage, VoiceMediaState, VoiceParticipant};
|
||||
use crate::protocol::turn::TurnCredentialResponse;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -11,6 +12,11 @@ pub async fn join_voice_channel(
|
|||
community_id: String,
|
||||
channel_id: String,
|
||||
) -> Result<Vec<VoiceParticipant>, String> {
|
||||
eprintln!(
|
||||
"[Voice] join_voice_channel called: community={}, channel={}",
|
||||
community_id, channel_id
|
||||
);
|
||||
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
|
||||
|
|
@ -29,12 +35,16 @@ pub async fn join_voice_channel(
|
|||
let voice_topic = gossip::topic_for_voice(&community_id, &channel_id);
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let _ = handle
|
||||
handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe {
|
||||
topic: voice_topic.clone(),
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("[Voice] Failed to subscribe to voice topic: {}", e);
|
||||
format!("Failed to subscribe to voice channel: {}", e)
|
||||
})?;
|
||||
|
||||
// publish our join announcement
|
||||
let msg = GossipMessage::VoiceJoin {
|
||||
|
|
@ -45,13 +55,22 @@ pub async fn join_voice_channel(
|
|||
media_state: media_state.clone(),
|
||||
};
|
||||
let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
|
||||
let _ = handle
|
||||
handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage {
|
||||
topic: voice_topic,
|
||||
data,
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("[Voice] Failed to publish VoiceJoin: {}", e);
|
||||
format!("Failed to send voice join announcement: {}", e)
|
||||
})?;
|
||||
|
||||
eprintln!("[Voice] Successfully published VoiceJoin for peer {}", peer_id);
|
||||
} else {
|
||||
eprintln!("[Voice] No node handle available — cannot join voice channel");
|
||||
return Err("Node not running — cannot join voice channel".to_string());
|
||||
}
|
||||
|
||||
// add ourselves to the local voice channel tracking
|
||||
|
|
@ -68,6 +87,16 @@ pub async fn join_voice_channel(
|
|||
let result = participants.clone();
|
||||
drop(vc);
|
||||
|
||||
// TODO: Participant list race condition
|
||||
// The participant list returned here only includes locally-tracked peers.
|
||||
// A newly joining peer will not see existing participants until they receive
|
||||
// VoiceJoin gossip messages from those peers. To fix this properly, we need:
|
||||
// 1. A new GossipMessage::VoiceParticipantsRequest variant
|
||||
// 2. Existing peers respond to the request by re-broadcasting their VoiceJoin
|
||||
// 3. Or implement a request/response protocol over gossipsub or a direct stream
|
||||
// This requires changes to protocol/messages.rs and node/mod.rs (gossip handler).
|
||||
// For now, the frontend should handle late-arriving VoiceJoin events gracefully.
|
||||
|
||||
log::info!("joined voice channel {}:{}", community_id, channel_id);
|
||||
|
||||
Ok(result)
|
||||
|
|
@ -79,6 +108,11 @@ pub async fn leave_voice_channel(
|
|||
community_id: String,
|
||||
channel_id: String,
|
||||
) -> Result<(), String> {
|
||||
eprintln!(
|
||||
"[Voice] leave_voice_channel called: community={}, channel={}",
|
||||
community_id, channel_id
|
||||
);
|
||||
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
let peer_id = id.peer_id.to_string();
|
||||
|
|
@ -95,19 +129,32 @@ pub async fn leave_voice_channel(
|
|||
peer_id: peer_id.clone(),
|
||||
};
|
||||
let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
|
||||
let _ = handle
|
||||
handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage {
|
||||
topic: voice_topic.clone(),
|
||||
data,
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("[Voice] Failed to publish VoiceLeave: {}", e);
|
||||
format!("Failed to send voice leave announcement: {}", e)
|
||||
})?;
|
||||
|
||||
eprintln!("[Voice] Successfully published VoiceLeave for peer {}", peer_id);
|
||||
|
||||
// unsubscribe from the voice topic
|
||||
let _ = handle
|
||||
handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Unsubscribe { topic: voice_topic })
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("[Voice] Failed to unsubscribe from voice topic: {}", e);
|
||||
format!("Failed to unsubscribe from voice channel: {}", e)
|
||||
})?;
|
||||
} else {
|
||||
eprintln!("[Voice] No node handle available — cannot leave voice channel");
|
||||
return Err("Node not running — cannot leave voice channel".to_string());
|
||||
}
|
||||
|
||||
// remove ourselves from local tracking
|
||||
|
|
@ -133,6 +180,11 @@ pub async fn update_voice_media_state(
|
|||
channel_id: String,
|
||||
media_state: VoiceMediaState,
|
||||
) -> Result<(), String> {
|
||||
eprintln!(
|
||||
"[Voice] update_voice_media_state called: community={}, channel={}",
|
||||
community_id, channel_id
|
||||
);
|
||||
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
let peer_id = id.peer_id.to_string();
|
||||
|
|
@ -148,13 +200,22 @@ pub async fn update_voice_media_state(
|
|||
media_state: media_state.clone(),
|
||||
};
|
||||
let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
|
||||
let _ = handle
|
||||
handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage {
|
||||
topic: voice_topic,
|
||||
data,
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("[Voice] Failed to publish VoiceMediaStateUpdate: {}", e);
|
||||
format!("Failed to send media state update: {}", e)
|
||||
})?;
|
||||
|
||||
eprintln!("[Voice] Successfully published VoiceMediaStateUpdate for peer {}", peer_id);
|
||||
} else {
|
||||
eprintln!("[Voice] No node handle available — cannot update media state");
|
||||
return Err("Node not running — cannot update media state".to_string());
|
||||
}
|
||||
|
||||
// update local tracking
|
||||
|
|
@ -179,6 +240,23 @@ pub async fn send_voice_sdp(
|
|||
sdp_type: String,
|
||||
sdp: String,
|
||||
) -> Result<(), String> {
|
||||
eprintln!(
|
||||
"[Voice] send_voice_sdp called: community={}, channel={}, to_peer={}, sdp_type={}",
|
||||
community_id, channel_id, to_peer, sdp_type
|
||||
);
|
||||
|
||||
// Validate SDP type before doing anything else
|
||||
match sdp_type.as_str() {
|
||||
"offer" | "answer" | "pranswer" => {}
|
||||
_ => {
|
||||
eprintln!("[Voice] Invalid SDP type: {}", sdp_type);
|
||||
return Err(format!(
|
||||
"Invalid SDP type '{}': must be one of 'offer', 'answer', 'pranswer'",
|
||||
sdp_type
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
let from_peer = id.peer_id.to_string();
|
||||
|
|
@ -190,19 +268,28 @@ pub async fn send_voice_sdp(
|
|||
let msg = GossipMessage::VoiceSdp {
|
||||
community_id,
|
||||
channel_id,
|
||||
from_peer,
|
||||
from_peer: from_peer.clone(),
|
||||
to_peer,
|
||||
sdp_type,
|
||||
sdp,
|
||||
};
|
||||
let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
|
||||
let _ = handle
|
||||
handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage {
|
||||
topic: voice_topic,
|
||||
data,
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("[Voice] Failed to publish VoiceSdp: {}", e);
|
||||
format!("Failed to send voice SDP: {}", e)
|
||||
})?;
|
||||
|
||||
eprintln!("[Voice] Successfully published VoiceSdp from peer {}", from_peer);
|
||||
} else {
|
||||
eprintln!("[Voice] No node handle available — cannot send SDP");
|
||||
return Err("Node not running — cannot send SDP".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -218,6 +305,11 @@ pub async fn send_voice_ice_candidate(
|
|||
sdp_mid: Option<String>,
|
||||
sdp_mline_index: Option<u32>,
|
||||
) -> Result<(), String> {
|
||||
eprintln!(
|
||||
"[Voice] send_voice_ice_candidate called: community={}, channel={}, to_peer={}",
|
||||
community_id, channel_id, to_peer
|
||||
);
|
||||
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
let from_peer = id.peer_id.to_string();
|
||||
|
|
@ -229,20 +321,29 @@ pub async fn send_voice_ice_candidate(
|
|||
let msg = GossipMessage::VoiceIceCandidate {
|
||||
community_id,
|
||||
channel_id,
|
||||
from_peer,
|
||||
from_peer: from_peer.clone(),
|
||||
to_peer,
|
||||
candidate,
|
||||
sdp_mid,
|
||||
sdp_mline_index,
|
||||
};
|
||||
let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?;
|
||||
let _ = handle
|
||||
handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage {
|
||||
topic: voice_topic,
|
||||
data,
|
||||
})
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("[Voice] Failed to publish VoiceIceCandidate: {}", e);
|
||||
format!("Failed to send voice ICE candidate: {}", e)
|
||||
})?;
|
||||
|
||||
eprintln!("[Voice] Successfully published VoiceIceCandidate from peer {}", from_peer);
|
||||
} else {
|
||||
eprintln!("[Voice] No node handle available — cannot send ICE candidate");
|
||||
return Err("Node not running — cannot send ICE candidate".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -254,8 +355,44 @@ pub async fn get_voice_participants(
|
|||
community_id: String,
|
||||
channel_id: String,
|
||||
) -> Result<Vec<VoiceParticipant>, String> {
|
||||
eprintln!(
|
||||
"[Voice] get_voice_participants called: community={}, channel={}",
|
||||
community_id, channel_id
|
||||
);
|
||||
|
||||
let key = format!("{}:{}", community_id, channel_id);
|
||||
let vc = state.voice_channels.lock().await;
|
||||
let participants = vc.get(&key).cloned().unwrap_or_default();
|
||||
|
||||
eprintln!(
|
||||
"[Voice] Returning {} participants for {}",
|
||||
participants.len(),
|
||||
key
|
||||
);
|
||||
|
||||
Ok(participants)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_turn_credentials(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<TurnCredentialResponse, String> {
|
||||
eprintln!("[Voice] get_turn_credentials called");
|
||||
|
||||
let handle_ref = state.node_handle.lock().await;
|
||||
let handle = handle_ref.as_ref().ok_or("node not running")?;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
handle
|
||||
.command_tx
|
||||
.send(NodeCommand::GetTurnCredentials { reply: tx })
|
||||
.await
|
||||
.map_err(|_| "failed to send get_turn_credentials command".to_string())?;
|
||||
|
||||
// drop the lock before awaiting the response
|
||||
drop(handle_ref);
|
||||
|
||||
rx.await
|
||||
.map_err(|_| "turn credentials response channel closed".to_string())?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,18 +63,26 @@ pub fn run() {
|
|||
.setup(|app| {
|
||||
// grant microphone/camera permissions on linux webkitgtk
|
||||
// without this, getUserMedia is denied by default
|
||||
// only allow UserMediaPermissionRequest (mic/camera), deny everything else
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window
|
||||
.with_webview(|webview| {
|
||||
use webkit2gtk::glib::prelude::ObjectExt;
|
||||
use webkit2gtk::PermissionRequestExt;
|
||||
use webkit2gtk::WebViewExt;
|
||||
let wv = webview.inner();
|
||||
wv.connect_permission_request(|_webview, request| {
|
||||
request.allow();
|
||||
true
|
||||
if request.is::<webkit2gtk::UserMediaPermissionRequest>() {
|
||||
request.allow();
|
||||
true
|
||||
} else {
|
||||
// deny all other permission types (geolocation, etc.)
|
||||
request.deny();
|
||||
true
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
|
|
@ -150,6 +158,7 @@ pub fn run() {
|
|||
commands::voice::send_voice_sdp,
|
||||
commands::voice::send_voice_ice_candidate,
|
||||
commands::voice::get_voice_participants,
|
||||
commands::voice::get_turn_credentials,
|
||||
commands::dm::send_dm,
|
||||
commands::dm::get_dm_messages,
|
||||
commands::dm::search_dm_messages,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::protocol::directory::{DirectoryRequest, DirectoryResponse};
|
||||
use crate::protocol::gif::{GifRequest, GifResponse};
|
||||
use crate::protocol::turn::{TurnCredentialRequest, TurnCredentialResponse};
|
||||
use libp2p::{
|
||||
gossipsub, identify, kad, mdns, ping, relay, rendezvous, request_response::cbor,
|
||||
swarm::NetworkBehaviour,
|
||||
|
|
@ -18,4 +19,6 @@ pub struct DuskBehaviour {
|
|||
pub gif_service: cbor::Behaviour<GifRequest, GifResponse>,
|
||||
// directory search: register/search/remove profiles on the relay
|
||||
pub directory_service: cbor::Behaviour<DirectoryRequest, DirectoryResponse>,
|
||||
// turn credentials: request time-limited TURN server credentials from the relay
|
||||
pub turn_credentials: cbor::Behaviour<TurnCredentialRequest, TurnCredentialResponse>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,6 +223,10 @@ pub enum NodeCommand {
|
|||
SetRelayDiscoverable {
|
||||
enabled: bool,
|
||||
},
|
||||
// request time-limited TURN server credentials from the relay
|
||||
GetTurnCredentials {
|
||||
reply: tokio::sync::oneshot::Sender<Result<crate::protocol::turn::TurnCredentialResponse, String>>,
|
||||
},
|
||||
}
|
||||
|
||||
// events emitted from the node to the tauri frontend
|
||||
|
|
@ -488,6 +492,14 @@ pub async fn start(
|
|||
>,
|
||||
> = HashMap::new();
|
||||
|
||||
// pending turn credential replies keyed by request_response request id
|
||||
let mut pending_turn_credential_replies: HashMap<
|
||||
libp2p::request_response::OutboundRequestId,
|
||||
tokio::sync::oneshot::Sender<
|
||||
Result<crate::protocol::turn::TurnCredentialResponse, String>,
|
||||
>,
|
||||
> = HashMap::new();
|
||||
|
||||
// relay_discoverable flag -- read from storage once at startup
|
||||
let mut relay_discoverable = storage
|
||||
.load_settings()
|
||||
|
|
@ -1401,6 +1413,10 @@ pub async fn start(
|
|||
crate::protocol::directory::DirectoryResponse::Ok => {
|
||||
let _ = reply.send(Ok(vec![]));
|
||||
}
|
||||
crate::protocol::directory::DirectoryResponse::Error(msg) => {
|
||||
log::warn!("directory service error from relay: {}", msg);
|
||||
let _ = reply.send(Ok(vec![]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1415,6 +1431,29 @@ pub async fn start(
|
|||
}
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::DirectoryService(_)) => {}
|
||||
|
||||
// turn credentials response from relay
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::TurnCredentials(
|
||||
libp2p::request_response::Event::Message {
|
||||
message: libp2p::request_response::Message::Response { request_id, response },
|
||||
..
|
||||
}
|
||||
)) => {
|
||||
if let Some(reply) = pending_turn_credential_replies.remove(&request_id) {
|
||||
let _ = reply.send(Ok(response));
|
||||
}
|
||||
}
|
||||
// turn credentials outbound failure
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::TurnCredentials(
|
||||
libp2p::request_response::Event::OutboundFailure { request_id, error, .. }
|
||||
)) => {
|
||||
log::warn!("turn credentials: outbound failure: {:?}", error);
|
||||
if let Some(reply) = pending_turn_credential_replies.remove(&request_id) {
|
||||
let _ = reply.send(Err(format!("turn credentials request failed: {:?}", error)));
|
||||
}
|
||||
}
|
||||
// ignore inbound requests and other events for turn credentials
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::TurnCredentials(_)) => {}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1777,6 +1816,23 @@ pub async fn start(
|
|||
}
|
||||
}
|
||||
}
|
||||
Some(NodeCommand::GetTurnCredentials { reply }) => {
|
||||
if let Some(rp) = relay_peer {
|
||||
let local_peer_id = swarm_instance.local_peer_id().to_string();
|
||||
let request_id = swarm_instance
|
||||
.behaviour_mut()
|
||||
.turn_credentials
|
||||
.send_request(
|
||||
&rp,
|
||||
crate::protocol::turn::TurnCredentialRequest {
|
||||
peer_id: local_peer_id,
|
||||
},
|
||||
);
|
||||
pending_turn_credential_replies.insert(request_id, reply);
|
||||
} else {
|
||||
let _ = reply.send(Err("not connected to relay".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ use libp2p::{
|
|||
use super::behaviour::DuskBehaviour;
|
||||
use crate::protocol::directory::{DirectoryRequest, DirectoryResponse, DIRECTORY_PROTOCOL};
|
||||
use crate::protocol::gif::{GifRequest, GifResponse, GIF_PROTOCOL};
|
||||
use crate::protocol::turn::{
|
||||
TurnCredentialRequest, TurnCredentialResponse, TURN_CREDENTIALS_PROTOCOL,
|
||||
};
|
||||
|
||||
pub fn build_swarm(
|
||||
keypair: &identity::Keypair,
|
||||
|
|
@ -92,6 +95,13 @@ pub fn build_swarm(
|
|||
request_response::Config::default()
|
||||
.with_request_timeout(Duration::from_secs(15)),
|
||||
),
|
||||
// turn credentials via request-response to the relay (outbound only)
|
||||
turn_credentials:
|
||||
cbor::Behaviour::<TurnCredentialRequest, TurnCredentialResponse>::new(
|
||||
[(TURN_CREDENTIALS_PROTOCOL, ProtocolSupport::Outbound)],
|
||||
request_response::Config::default()
|
||||
.with_request_timeout(Duration::from_secs(10)),
|
||||
),
|
||||
}
|
||||
})?
|
||||
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300)))
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub enum DirectoryRequest {
|
|||
pub enum DirectoryResponse {
|
||||
Ok,
|
||||
Results(Vec<DirectoryProfileEntry>),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ pub mod directory;
|
|||
pub mod gif;
|
||||
pub mod identity;
|
||||
pub mod messages;
|
||||
pub mod turn;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
// turn credential protocol types for requesting time-limited TURN server
|
||||
// credentials from the relay. the client sends a TurnCredentialRequest
|
||||
// and receives a TurnCredentialResponse with HMAC-based credentials.
|
||||
|
||||
use libp2p::StreamProtocol;
|
||||
|
||||
pub const TURN_CREDENTIALS_PROTOCOL: StreamProtocol =
|
||||
StreamProtocol::new("/dusk/turn-credentials/1.0.0");
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TurnCredentialRequest {
|
||||
pub peer_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TurnCredentialResponse {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub ttl: u64,
|
||||
pub uris: Vec<String>,
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import { Extension } from "@tiptap/core";
|
|||
import Mention from "@tiptap/extension-mention";
|
||||
import { tiptapToMarkdown } from "../../lib/markdown";
|
||||
import { members } from "../../stores/members";
|
||||
import { identity } from "../../stores/identity";
|
||||
import EmojiPicker from "./EmojiPicker";
|
||||
import GifPicker from "./GifPicker";
|
||||
import MentionList from "./MentionList";
|
||||
|
|
@ -65,7 +64,6 @@ const MessageInput: Component<MessageInputProps> = (props) => {
|
|||
// build the mention items list from community members or dm peers
|
||||
function getMentionItems(query: string): MentionItem[] {
|
||||
const q = query.toLowerCase();
|
||||
const currentUser = identity();
|
||||
|
||||
// dm context uses the explicit peer list passed via props
|
||||
if (props.mentionPeers) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import { MessageCircle, Search, X, Plus, Group, Users } from "lucide-solid";
|
||||
import { Search, X, Plus, Users } from "lucide-solid";
|
||||
import { resolveMentionsPlainText } from "../../lib/mentions";
|
||||
import {
|
||||
dmConversations,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
import { knownPeers, friends } from "../../stores/directory";
|
||||
import { onlinePeerIds } from "../../stores/members";
|
||||
import { identity } from "../../stores/identity";
|
||||
import { peerCount, nodeStatus } from "../../stores/connection";
|
||||
import { openModal } from "../../stores/ui";
|
||||
import * as tauri from "../../lib/tauri";
|
||||
import Avatar from "../common/Avatar";
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import { clearMessages } from "../../stores/messages";
|
|||
import * as tauri from "../../lib/tauri";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Button from "../common/Button";
|
||||
import type { ChannelMeta, CategoryMeta, Member } from "../../lib/types";
|
||||
import type { ChannelMeta, Member } from "../../lib/types";
|
||||
|
||||
type CommunitySettingsSection =
|
||||
| "overview"
|
||||
|
|
@ -87,8 +87,6 @@ const CommunitySettingsModal: Component<CommunitySettingsModalProps> = (
|
|||
}
|
||||
});
|
||||
|
||||
const community = () => activeCommunity();
|
||||
|
||||
const sections: {
|
||||
id: CommunitySettingsSection;
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
remoteStreams,
|
||||
voiceConnectionState,
|
||||
voiceError,
|
||||
voiceQuality,
|
||||
peerConnectionStates,
|
||||
joinVoice,
|
||||
} from "../../stores/voice";
|
||||
import { identity } from "../../stores/identity";
|
||||
|
|
@ -61,11 +63,51 @@ const VoiceChannel: Component<VoiceChannelProps> = (props) => {
|
|||
return allParticipants().length;
|
||||
};
|
||||
|
||||
// voice quality indicator config
|
||||
const qualityConfig = () => {
|
||||
const q = voiceQuality();
|
||||
switch (q) {
|
||||
case "good":
|
||||
return { color: "bg-green-500", text: "Connected" };
|
||||
case "connecting":
|
||||
return { color: "bg-amber-400 animate-pulse", text: "Connecting..." };
|
||||
case "degraded":
|
||||
return { color: "bg-orange-500", text: "Degraded" };
|
||||
case "failed":
|
||||
return { color: "bg-red-500", text: "Connection Failed" };
|
||||
default:
|
||||
return { color: "bg-white/40", text: "" };
|
||||
}
|
||||
};
|
||||
|
||||
// look up per-peer connection state for a participant
|
||||
const getPeerState = (peerId: string, isLocal: boolean) => {
|
||||
if (isLocal) return "connected" as RTCPeerConnectionState;
|
||||
return peerConnectionStates()[peerId];
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full bg-black">
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-white text-lg font-semibold">Voice Channel</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-white text-lg font-semibold">Voice Channel</h2>
|
||||
<Show
|
||||
when={
|
||||
voiceConnectionState() === "connected" ||
|
||||
voiceConnectionState() === "degraded"
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 ml-2">
|
||||
<span
|
||||
class={`inline-block w-2 h-2 rounded-full ${qualityConfig().color}`}
|
||||
/>
|
||||
<span class="text-white/50 text-xs">
|
||||
{qualityConfig().text}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm">
|
||||
{participantCount()} participant
|
||||
{participantCount() !== 1 ? "s" : ""}
|
||||
|
|
@ -100,8 +142,13 @@ const VoiceChannel: Component<VoiceChannelProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
{/* connected state with participants grid */}
|
||||
<Show when={voiceConnectionState() === "connected"}>
|
||||
{/* connected / degraded state with participants grid */}
|
||||
<Show
|
||||
when={
|
||||
voiceConnectionState() === "connected" ||
|
||||
voiceConnectionState() === "degraded"
|
||||
}
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={allParticipants()}>
|
||||
{(participant) => (
|
||||
|
|
@ -111,6 +158,10 @@ const VoiceChannel: Component<VoiceChannelProps> = (props) => {
|
|||
media_state={participant.media_state}
|
||||
stream={participant.stream}
|
||||
is_local={participant.is_local}
|
||||
connectionState={getPeerState(
|
||||
participant.peer_id,
|
||||
participant.is_local,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -118,7 +169,12 @@ const VoiceChannel: Component<VoiceChannelProps> = (props) => {
|
|||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={voiceConnectionState() === "connected"}>
|
||||
<Show
|
||||
when={
|
||||
voiceConnectionState() === "connected" ||
|
||||
voiceConnectionState() === "degraded"
|
||||
}
|
||||
>
|
||||
<VoiceControls />
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show, createEffect, onCleanup } from "solid-js";
|
||||
import { MicOff, VolumeX } from "lucide-solid";
|
||||
import { MicOff, VolumeX, AlertTriangle } from "lucide-solid";
|
||||
import Avatar from "../common/Avatar";
|
||||
import { openProfileCard } from "../../stores/ui";
|
||||
import type { VoiceMediaState } from "../../lib/types";
|
||||
|
|
@ -11,6 +11,7 @@ interface VoiceParticipantTileProps {
|
|||
media_state: VoiceMediaState;
|
||||
stream?: MediaStream | null;
|
||||
is_local?: boolean;
|
||||
connectionState?: RTCPeerConnectionState;
|
||||
}
|
||||
|
||||
const VoiceParticipantTile: Component<VoiceParticipantTileProps> = (props) => {
|
||||
|
|
@ -38,9 +39,28 @@ const VoiceParticipantTile: Component<VoiceParticipantTileProps> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
// per-peer connection state ring styling
|
||||
const connectionRingClass = () => {
|
||||
const state = props.connectionState;
|
||||
switch (state) {
|
||||
case "connected":
|
||||
return "ring-2 ring-green-500/70";
|
||||
case "connecting":
|
||||
case "new":
|
||||
return "ring-2 ring-amber-400/70 animate-pulse";
|
||||
case "failed":
|
||||
return "ring-2 ring-red-500/80";
|
||||
case "disconnected":
|
||||
return "ring-2 ring-white/30";
|
||||
case "closed":
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative bg-black border border-white/10 aspect-video flex items-center justify-center overflow-hidden cursor-pointer"
|
||||
class={`relative bg-black border border-white/10 aspect-video flex items-center justify-center overflow-hidden cursor-pointer ${connectionRingClass()}`}
|
||||
onClick={(e) => {
|
||||
openProfileCard({
|
||||
peerId: props.peer_id,
|
||||
|
|
@ -75,15 +95,25 @@ const VoiceParticipantTile: Component<VoiceParticipantTileProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.media_state.muted}>
|
||||
<div class="absolute top-2 right-2 bg-black/80 p-1">
|
||||
<MicOff size={16} class="text-[#FF4F00]" />
|
||||
</div>
|
||||
</Show>
|
||||
{/* media state indicators — stacked vertically to avoid overlap */}
|
||||
<div class="absolute top-2 right-2 flex flex-col gap-1">
|
||||
<Show when={props.media_state.muted}>
|
||||
<div class="bg-black/80 p-1">
|
||||
<MicOff size={16} class="text-[#FF4F00]" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.media_state.deafened}>
|
||||
<div class="absolute top-2 right-2 bg-black/80 p-1">
|
||||
<VolumeX size={16} class="text-[#FF4F00]" />
|
||||
<Show when={props.media_state.deafened}>
|
||||
<div class="bg-black/80 p-1">
|
||||
<VolumeX size={16} class="text-[#FF4F00]" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* connection failed warning */}
|
||||
<Show when={props.connectionState === "failed"}>
|
||||
<div class="absolute bottom-2 right-2 bg-red-900/80 p-1 rounded">
|
||||
<AlertTriangle size={14} class="text-red-400" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
|
|
|||
|
|
@ -398,6 +398,19 @@ export async function getVoiceParticipants(
|
|||
return invoke("get_voice_participants", { communityId, channelId });
|
||||
}
|
||||
|
||||
// -- turn credentials --
|
||||
|
||||
export interface TurnCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
ttl: number;
|
||||
uris: string[];
|
||||
}
|
||||
|
||||
export async function getTurnCredentials(): Promise<TurnCredentials> {
|
||||
return invoke("get_turn_credentials");
|
||||
}
|
||||
|
||||
// -- direct messages --
|
||||
|
||||
export async function sendDM(
|
||||
|
|
|
|||
|
|
@ -2,25 +2,77 @@
|
|||
// manages one RTCPeerConnection per remote peer in a full mesh topology
|
||||
// this is a utility module with no signals - the voice store drives it
|
||||
|
||||
// no external stun/turn servers for now, rely on host candidates only
|
||||
// this works for LAN peers and peers on the same network segment
|
||||
const rtcConfig: RTCConfiguration = {
|
||||
iceServers: [],
|
||||
};
|
||||
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
|
||||
// Public STUN servers (free, no auth needed)
|
||||
{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] },
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' },
|
||||
// TURN servers are added dynamically via getTurnCredentials()
|
||||
];
|
||||
|
||||
/** Maximum ICE restart attempts before giving up on a peer */
|
||||
const MAX_ICE_RESTART_ATTEMPTS = 3;
|
||||
|
||||
/** Delay before attempting ICE restart after disconnection (ms) */
|
||||
const DISCONNECT_TIMEOUT_MS = 5000;
|
||||
|
||||
export interface PeerConnectionManagerConfig {
|
||||
onNegotiationNeeded: (peerId: string, sdp: RTCSessionDescriptionInit) => void;
|
||||
onIceCandidate: (peerId: string, candidate: RTCIceCandidate) => void;
|
||||
onRemoteStream: (peerId: string, stream: MediaStream) => void;
|
||||
onRemoteStreamRemoved: (peerId: string) => void;
|
||||
onPeerConnectionStateChanged?: (peerId: string, state: RTCPeerConnectionState) => void;
|
||||
iceServers?: RTCIceServer[];
|
||||
}
|
||||
|
||||
/** Per-peer state tracking beyond just the RTCPeerConnection */
|
||||
interface PeerState {
|
||||
pc: RTCPeerConnection;
|
||||
/** ICE candidates received before remote description was set */
|
||||
candidateBuffer: RTCIceCandidateInit[];
|
||||
/** Number of ICE restart attempts for this peer */
|
||||
restartAttempts: number;
|
||||
/** Timeout handle for delayed ICE restart after disconnection */
|
||||
disconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
export class PeerConnectionManager {
|
||||
private connections: Map<string, RTCPeerConnection> = new Map();
|
||||
private peers: Map<string, PeerState> = new Map();
|
||||
private localStream: MediaStream | null = null;
|
||||
private screenStream: MediaStream | null = null;
|
||||
private rtcConfig: RTCConfiguration;
|
||||
|
||||
// the local peer id, used for glare resolution during simultaneous offers
|
||||
private localPeerId: string | null = null;
|
||||
|
||||
// callbacks set by the voice store to bridge webrtc events into reactive state
|
||||
// ---- legacy callback properties (for backward compat with voice store) ----
|
||||
// these are used when the class is constructed without a config object
|
||||
onRemoteStream: ((peerId: string, stream: MediaStream) => void) | null = null;
|
||||
onRemoteStreamRemoved: ((peerId: string) => void) | null = null;
|
||||
onIceCandidate: ((peerId: string, candidate: RTCIceCandidate) => void) | null = null;
|
||||
onNegotiationNeeded: ((peerId: string) => void) | null = null;
|
||||
onNegotiationNeeded: ((peerId: string, sdp?: RTCSessionDescriptionInit) => void) | null = null;
|
||||
onPeerConnectionStateChanged: ((peerId: string, state: RTCPeerConnectionState) => void) | null = null;
|
||||
|
||||
private config: PeerConnectionManagerConfig | null = null;
|
||||
|
||||
constructor(config?: PeerConnectionManagerConfig) {
|
||||
const iceServers = config?.iceServers ?? DEFAULT_ICE_SERVERS;
|
||||
this.rtcConfig = {
|
||||
iceServers,
|
||||
iceTransportPolicy: 'all',
|
||||
};
|
||||
|
||||
if (config) {
|
||||
this.config = config;
|
||||
// Also set legacy callback properties from config for internal use
|
||||
this.onRemoteStream = config.onRemoteStream;
|
||||
this.onRemoteStreamRemoved = config.onRemoteStreamRemoved;
|
||||
this.onIceCandidate = config.onIceCandidate;
|
||||
this.onNegotiationNeeded = (peerId: string, sdp?: RTCSessionDescriptionInit) => {
|
||||
if (sdp) config.onNegotiationNeeded(peerId, sdp);
|
||||
};
|
||||
this.onPeerConnectionStateChanged = config.onPeerConnectionStateChanged ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
setLocalPeerId(peerId: string): void {
|
||||
this.localPeerId = peerId;
|
||||
|
|
@ -34,20 +86,32 @@ export class PeerConnectionManager {
|
|||
this.screenStream = stream;
|
||||
}
|
||||
|
||||
// create a new peer connection for a remote peer
|
||||
// uses lexicographic peer_id comparison for glare resolution:
|
||||
// the peer with the smaller id is always the offerer
|
||||
createConnection(peerId: string): RTCPeerConnection {
|
||||
// close any existing connection to this peer before creating a new one
|
||||
this.closeConnection(peerId);
|
||||
// determine if we should be the offerer based on lexicographic peer_id comparison
|
||||
shouldOffer(remotePeerId: string): boolean {
|
||||
if (!this.localPeerId) return false;
|
||||
return this.localPeerId < remotePeerId;
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection(rtcConfig);
|
||||
this.connections.set(peerId, pc);
|
||||
// create a new peer connection for a remote peer
|
||||
// accepts an optional localStream parameter (for new API), falls back to this.localStream
|
||||
createConnection(peerId: string, localStream?: MediaStream | null): RTCPeerConnection {
|
||||
// close any existing connection to this peer before creating a new one
|
||||
this.removeConnection(peerId);
|
||||
|
||||
const pc = new RTCPeerConnection(this.rtcConfig);
|
||||
const peerState: PeerState = {
|
||||
pc,
|
||||
candidateBuffer: [],
|
||||
restartAttempts: 0,
|
||||
disconnectTimer: null,
|
||||
};
|
||||
this.peers.set(peerId, peerState);
|
||||
|
||||
// add all local tracks to the new connection
|
||||
if (this.localStream) {
|
||||
for (const track of this.localStream.getTracks()) {
|
||||
pc.addTrack(track, this.localStream);
|
||||
const stream = localStream ?? this.localStream;
|
||||
if (stream) {
|
||||
for (const track of stream.getTracks()) {
|
||||
pc.addTrack(track, stream);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,141 +122,293 @@ export class PeerConnectionManager {
|
|||
}
|
||||
|
||||
// wire up event handlers
|
||||
this.setupPeerEventHandlers(peerId, peerState);
|
||||
|
||||
return pc;
|
||||
}
|
||||
|
||||
private setupPeerEventHandlers(peerId: string, peerState: PeerState): void {
|
||||
const { pc } = peerState;
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && this.onIceCandidate) {
|
||||
this.onIceCandidate(peerId, event.candidate);
|
||||
if (event.candidate) {
|
||||
console.log(`[WebRTC] ICE candidate for ${peerId}: ${event.candidate.type ?? 'null'} ${event.candidate.candidate.substring(0, 60)}...`);
|
||||
if (this.onIceCandidate) {
|
||||
this.onIceCandidate(peerId, event.candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
console.log(`[WebRTC] Remote track received from ${peerId}: kind=${event.track.kind}`);
|
||||
if (event.streams.length > 0 && this.onRemoteStream) {
|
||||
this.onRemoteStream(peerId, event.streams[0]);
|
||||
}
|
||||
};
|
||||
|
||||
pc.onnegotiationneeded = () => {
|
||||
pc.onnegotiationneeded = async () => {
|
||||
console.log(`[WebRTC] Negotiation needed for ${peerId}`);
|
||||
if (this.onNegotiationNeeded) {
|
||||
this.onNegotiationNeeded(peerId);
|
||||
// If using new API (config-based), auto-create offer and pass SDP
|
||||
if (this.config) {
|
||||
if (!this.shouldOffer(peerId)) {
|
||||
console.log(`[WebRTC] Skipping negotiation for ${peerId} (remote peer should offer)`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
this.onNegotiationNeeded(peerId, offer);
|
||||
} catch (err) {
|
||||
console.error(`[WebRTC] Failed to create offer during negotiation for ${peerId}:`, err);
|
||||
}
|
||||
} else {
|
||||
// Legacy API: just notify the caller (voice store handles offer creation)
|
||||
this.onNegotiationNeeded(peerId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
|
||||
const state = pc.connectionState;
|
||||
console.log(`[WebRTC] Connection state for ${peerId}: ${state}`);
|
||||
|
||||
// Fire per-peer connection state callback
|
||||
if (this.onPeerConnectionStateChanged) {
|
||||
this.onPeerConnectionStateChanged(peerId, state);
|
||||
}
|
||||
|
||||
if (state === 'failed' || state === 'closed') {
|
||||
if (this.onRemoteStreamRemoved) {
|
||||
this.onRemoteStreamRemoved(peerId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return pc;
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
const iceState = pc.iceConnectionState;
|
||||
console.log(`[WebRTC] ICE connection state for ${peerId}: ${iceState}`);
|
||||
|
||||
if (iceState === 'disconnected') {
|
||||
// Start a timeout — if still disconnected after DISCONNECT_TIMEOUT_MS, attempt restart
|
||||
this.clearDisconnectTimer(peerState);
|
||||
peerState.disconnectTimer = setTimeout(() => {
|
||||
peerState.disconnectTimer = null;
|
||||
if (pc.iceConnectionState === 'disconnected') {
|
||||
console.log(`[WebRTC] Peer ${peerId} still disconnected after timeout, attempting ICE restart`);
|
||||
this.attemptIceRestart(peerId, peerState);
|
||||
}
|
||||
}, DISCONNECT_TIMEOUT_MS);
|
||||
} else if (iceState === 'failed') {
|
||||
// Immediately attempt ICE restart
|
||||
this.clearDisconnectTimer(peerState);
|
||||
console.log(`[WebRTC] ICE failed for ${peerId}, attempting ICE restart`);
|
||||
this.attemptIceRestart(peerId, peerState);
|
||||
} else if (iceState === 'connected' || iceState === 'completed') {
|
||||
// Connection recovered — reset restart counter and clear timers
|
||||
this.clearDisconnectTimer(peerState);
|
||||
peerState.restartAttempts = 0;
|
||||
}
|
||||
};
|
||||
|
||||
pc.onicegatheringstatechange = () => {
|
||||
console.log(`[WebRTC] ICE gathering state for ${peerId}: ${pc.iceGatheringState}`);
|
||||
};
|
||||
}
|
||||
|
||||
// determine if we should be the offerer based on lexicographic peer_id comparison
|
||||
shouldOffer(remotePeerId: string): boolean {
|
||||
if (!this.localPeerId) return false;
|
||||
return this.localPeerId < remotePeerId;
|
||||
private clearDisconnectTimer(peerState: PeerState): void {
|
||||
if (peerState.disconnectTimer !== null) {
|
||||
clearTimeout(peerState.disconnectTimer);
|
||||
peerState.disconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async attemptIceRestart(peerId: string, peerState: PeerState): Promise<void> {
|
||||
if (peerState.restartAttempts >= MAX_ICE_RESTART_ATTEMPTS) {
|
||||
console.error(`[WebRTC] Max ICE restart attempts (${MAX_ICE_RESTART_ATTEMPTS}) reached for ${peerId}, giving up`);
|
||||
if (this.onRemoteStreamRemoved) {
|
||||
this.onRemoteStreamRemoved(peerId);
|
||||
}
|
||||
if (this.onPeerConnectionStateChanged) {
|
||||
this.onPeerConnectionStateChanged(peerId, 'failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
peerState.restartAttempts++;
|
||||
console.log(`[WebRTC] ICE restart attempt ${peerState.restartAttempts}/${MAX_ICE_RESTART_ATTEMPTS} for ${peerId}`);
|
||||
|
||||
try {
|
||||
const offer = await peerState.pc.createOffer({ iceRestart: true });
|
||||
await peerState.pc.setLocalDescription(offer);
|
||||
|
||||
if (this.onNegotiationNeeded) {
|
||||
this.onNegotiationNeeded(peerId, offer);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[WebRTC] Failed ICE restart for ${peerId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush buffered ICE candidates after remote description is set */
|
||||
private async flushCandidateBuffer(peerId: string, peerState: PeerState): Promise<void> {
|
||||
if (peerState.candidateBuffer.length === 0) return;
|
||||
|
||||
console.log(`[WebRTC] Flushing ${peerState.candidateBuffer.length} buffered ICE candidates for ${peerId}`);
|
||||
const buffered = peerState.candidateBuffer.splice(0);
|
||||
|
||||
for (const candidate of buffered) {
|
||||
try {
|
||||
await peerState.pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} catch (err) {
|
||||
console.error(`[WebRTC] Failed to add buffered ICE candidate for ${peerId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createOffer(peerId: string): Promise<RTCSessionDescriptionInit> {
|
||||
const pc = this.connections.get(peerId);
|
||||
if (!pc) {
|
||||
throw new Error(`no connection for peer ${peerId}`);
|
||||
const peerState = this.peers.get(peerId);
|
||||
if (!peerState) {
|
||||
throw new Error(`[WebRTC] No connection for peer ${peerId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
const offer = await peerState.pc.createOffer();
|
||||
await peerState.pc.setLocalDescription(offer);
|
||||
console.log(`[WebRTC] Created offer for ${peerId}`);
|
||||
return offer;
|
||||
} catch (err) {
|
||||
console.error(`failed to create offer for peer ${peerId}:`, err);
|
||||
console.error(`[WebRTC] Failed to create offer for ${peerId}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// handleOffer replaces createAnswer — sets remote description, creates answer, flushes candidates
|
||||
async handleOffer(
|
||||
peerId: string,
|
||||
sdp: RTCSessionDescriptionInit,
|
||||
localStream?: MediaStream | null,
|
||||
): Promise<RTCSessionDescriptionInit> {
|
||||
let peerState = this.peers.get(peerId);
|
||||
if (!peerState) {
|
||||
// Auto-create connection if it doesn't exist
|
||||
this.createConnection(peerId, localStream);
|
||||
peerState = this.peers.get(peerId)!;
|
||||
}
|
||||
|
||||
try {
|
||||
await peerState.pc.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
console.log(`[WebRTC] Set remote offer for ${peerId}`);
|
||||
|
||||
// Flush any buffered ICE candidates now that remote description is set
|
||||
await this.flushCandidateBuffer(peerId, peerState);
|
||||
|
||||
const answer = await peerState.pc.createAnswer();
|
||||
await peerState.pc.setLocalDescription(answer);
|
||||
console.log(`[WebRTC] Created answer for ${peerId}`);
|
||||
return answer;
|
||||
} catch (err) {
|
||||
console.error(`[WebRTC] Failed to handle offer from ${peerId}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy alias for handleOffer (backward compat with voice store)
|
||||
async createAnswer(
|
||||
peerId: string,
|
||||
offer: RTCSessionDescriptionInit,
|
||||
): Promise<RTCSessionDescriptionInit> {
|
||||
const pc = this.connections.get(peerId);
|
||||
if (!pc) {
|
||||
throw new Error(`no connection for peer ${peerId}`);
|
||||
return this.handleOffer(peerId, offer);
|
||||
}
|
||||
|
||||
// handleAnswer replaces setRemoteAnswer — sets remote description and flushes candidates
|
||||
async handleAnswer(
|
||||
peerId: string,
|
||||
sdp: RTCSessionDescriptionInit,
|
||||
): Promise<void> {
|
||||
const peerState = this.peers.get(peerId);
|
||||
if (!peerState) {
|
||||
throw new Error(`[WebRTC] No connection for peer ${peerId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
return answer;
|
||||
await peerState.pc.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
console.log(`[WebRTC] Set remote answer for ${peerId}`);
|
||||
|
||||
// Flush any buffered ICE candidates now that remote description is set
|
||||
await this.flushCandidateBuffer(peerId, peerState);
|
||||
} catch (err) {
|
||||
console.error(`failed to create answer for peer ${peerId}:`, err);
|
||||
console.error(`[WebRTC] Failed to handle answer from ${peerId}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy alias for handleAnswer (backward compat with voice store)
|
||||
async setRemoteAnswer(
|
||||
peerId: string,
|
||||
answer: RTCSessionDescriptionInit,
|
||||
): Promise<void> {
|
||||
const pc = this.connections.get(peerId);
|
||||
if (!pc) {
|
||||
throw new Error(`no connection for peer ${peerId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
} catch (err) {
|
||||
console.error(`failed to set remote answer for peer ${peerId}:`, err);
|
||||
throw err;
|
||||
}
|
||||
return this.handleAnswer(peerId, answer);
|
||||
}
|
||||
|
||||
async addIceCandidate(
|
||||
peerId: string,
|
||||
candidate: RTCIceCandidateInit,
|
||||
): Promise<void> {
|
||||
const pc = this.connections.get(peerId);
|
||||
if (!pc) {
|
||||
// candidate arrived before connection was created, safe to ignore
|
||||
const peerState = this.peers.get(peerId);
|
||||
if (!peerState) {
|
||||
// Candidate arrived before connection was created — buffer it in a temporary queue
|
||||
// that will be checked when the connection is created. For now, log and drop.
|
||||
console.warn(`[WebRTC] ICE candidate arrived for unknown peer ${peerId}, ignoring`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If remote description is not yet set, buffer the candidate
|
||||
if (!peerState.pc.remoteDescription) {
|
||||
console.log(`[WebRTC] Buffering ICE candidate for ${peerId} (no remote description yet)`);
|
||||
peerState.candidateBuffer.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
await peerState.pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} catch (err) {
|
||||
// ice candidates can arrive out of order or for stale connections
|
||||
console.error(`failed to add ice candidate for peer ${peerId}:`, err);
|
||||
// ICE candidates can arrive out of order or for stale connections
|
||||
console.error(`[WebRTC] Failed to add ICE candidate for ${peerId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
closeConnection(peerId: string): void {
|
||||
const pc = this.connections.get(peerId);
|
||||
if (pc) {
|
||||
pc.onicecandidate = null;
|
||||
pc.ontrack = null;
|
||||
pc.onnegotiationneeded = null;
|
||||
pc.onconnectionstatechange = null;
|
||||
pc.close();
|
||||
this.connections.delete(peerId);
|
||||
/** Perform an ICE restart for a specific peer. Returns new offer SDP or null on failure. */
|
||||
async restartIce(peerId: string): Promise<RTCSessionDescriptionInit | null> {
|
||||
const peerState = this.peers.get(peerId);
|
||||
if (!peerState) {
|
||||
console.error(`[WebRTC] Cannot restart ICE: no connection for peer ${peerId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
peerState.restartAttempts++;
|
||||
console.log(`[WebRTC] Manual ICE restart for ${peerId} (attempt ${peerState.restartAttempts})`);
|
||||
const offer = await peerState.pc.createOffer({ iceRestart: true });
|
||||
await peerState.pc.setLocalDescription(offer);
|
||||
return offer;
|
||||
} catch (err) {
|
||||
console.error(`[WebRTC] Failed manual ICE restart for ${peerId}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
for (const [peerId] of this.connections) {
|
||||
this.closeConnection(peerId);
|
||||
}
|
||||
this.connections.clear();
|
||||
this.localStream = null;
|
||||
this.screenStream = null;
|
||||
}
|
||||
|
||||
getConnection(peerId: string): RTCPeerConnection | undefined {
|
||||
return this.connections.get(peerId);
|
||||
/** Get the current connection state for a specific peer */
|
||||
getPeerState(peerId: string): RTCPeerConnectionState | undefined {
|
||||
const peerState = this.peers.get(peerId);
|
||||
return peerState?.pc.connectionState;
|
||||
}
|
||||
|
||||
// replaces tracks on all existing connections
|
||||
// used when toggling video or screen share mid-call
|
||||
updateTracks(): void {
|
||||
for (const [, pc] of this.connections) {
|
||||
// overloaded: can be called with no args (legacy) or with (stream, kind) (new API)
|
||||
updateTracks(stream?: MediaStream | null, kind?: 'audio' | 'video'): void {
|
||||
for (const [, peerState] of this.peers) {
|
||||
const { pc } = peerState;
|
||||
const senders = pc.getSenders();
|
||||
|
||||
// build the set of tracks we want active on each connection
|
||||
|
|
@ -204,6 +420,16 @@ export class PeerConnectionManager {
|
|||
desiredTracks.push(...this.screenStream.getTracks());
|
||||
}
|
||||
|
||||
// If called with specific stream and kind, handle targeted update
|
||||
if (stream && kind) {
|
||||
const newTracks = stream.getTracks().filter((t) => t.kind === kind);
|
||||
for (const track of newTracks) {
|
||||
if (!desiredTracks.some((t) => t.id === track.id)) {
|
||||
desiredTracks.push(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replace or add tracks that should be present
|
||||
for (const track of desiredTracks) {
|
||||
const existingSender = senders.find(
|
||||
|
|
@ -212,19 +438,20 @@ export class PeerConnectionManager {
|
|||
if (!existingSender) {
|
||||
// check if there is a sender with the same kind we can replace
|
||||
const kindSender = senders.find(
|
||||
(s) => s.track?.kind === track.kind || (!s.track && true),
|
||||
(s) => s.track?.kind === track.kind || (s.track === null && track.kind !== undefined),
|
||||
);
|
||||
if (kindSender) {
|
||||
kindSender.replaceTrack(track).catch((err) => {
|
||||
console.error("failed to replace track:", err);
|
||||
console.error('[WebRTC] Failed to replace track:', err);
|
||||
});
|
||||
} else {
|
||||
// no existing sender for this kind, add a new one
|
||||
const stream = track.kind === "video" && this.screenStream?.getVideoTracks().includes(track)
|
||||
? this.screenStream
|
||||
: this.localStream;
|
||||
if (stream) {
|
||||
pc.addTrack(track, stream);
|
||||
const parentStream =
|
||||
track.kind === 'video' && this.screenStream?.getVideoTracks().includes(track)
|
||||
? this.screenStream
|
||||
: this.localStream;
|
||||
if (parentStream) {
|
||||
pc.addTrack(track, parentStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -237,10 +464,45 @@ export class PeerConnectionManager {
|
|||
try {
|
||||
pc.removeTrack(sender);
|
||||
} catch (err) {
|
||||
console.error("failed to remove track:", err);
|
||||
console.error('[WebRTC] Failed to remove track:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeConnection (also aliased as closeConnection for backward compat)
|
||||
removeConnection(peerId: string): void {
|
||||
const peerState = this.peers.get(peerId);
|
||||
if (peerState) {
|
||||
this.clearDisconnectTimer(peerState);
|
||||
const { pc } = peerState;
|
||||
pc.onicecandidate = null;
|
||||
pc.ontrack = null;
|
||||
pc.onnegotiationneeded = null;
|
||||
pc.onconnectionstatechange = null;
|
||||
pc.oniceconnectionstatechange = null;
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.close();
|
||||
this.peers.delete(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy alias for removeConnection (backward compat with voice store)
|
||||
closeConnection(peerId: string): void {
|
||||
this.removeConnection(peerId);
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
for (const [peerId] of this.peers) {
|
||||
this.removeConnection(peerId);
|
||||
}
|
||||
this.peers.clear();
|
||||
this.localStream = null;
|
||||
this.screenStream = null;
|
||||
}
|
||||
|
||||
getConnection(peerId: string): RTCPeerConnection | undefined {
|
||||
return this.peers.get(peerId)?.pc;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import { createSignal, createMemo } from "solid-js";
|
||||
import type { VoiceMediaState, VoiceParticipant } from "../lib/types";
|
||||
import { PeerConnectionManager } from "../lib/webrtc";
|
||||
import {
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
updateVoiceMediaState,
|
||||
sendVoiceSdp,
|
||||
sendVoiceIceCandidate,
|
||||
getTurnCredentials,
|
||||
} from "../lib/tauri";
|
||||
import { identity } from "./identity";
|
||||
|
||||
|
|
@ -30,16 +31,35 @@ const [remoteStreams, setRemoteStreams] = createSignal<
|
|||
>(new Map());
|
||||
const [screenStream, setScreenStream] = createSignal<MediaStream | null>(null);
|
||||
|
||||
// per-peer WebRTC connection state tracking
|
||||
const [peerConnectionStates, setPeerConnectionStates] = createSignal<
|
||||
Record<string, RTCPeerConnectionState>
|
||||
>({});
|
||||
|
||||
// tracks the voice connection lifecycle so the ui can show proper feedback
|
||||
export type VoiceConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "degraded"
|
||||
| "error";
|
||||
const [voiceConnectionState, setVoiceConnectionState] =
|
||||
createSignal<VoiceConnectionState>("idle");
|
||||
const [voiceError, setVoiceError] = createSignal<string | null>(null);
|
||||
|
||||
// overall voice connection quality summary derived from per-peer states
|
||||
const voiceQuality = createMemo(() => {
|
||||
const states = peerConnectionStates();
|
||||
const entries = Object.entries(states);
|
||||
if (entries.length === 0) return "good";
|
||||
const connected = entries.filter(([, s]) => s === "connected").length;
|
||||
const failed = entries.filter(([, s]) => s === "failed").length;
|
||||
if (failed === entries.length) return "failed";
|
||||
if (failed > 0) return "degraded";
|
||||
if (connected === entries.length) return "good";
|
||||
return "connecting";
|
||||
});
|
||||
|
||||
// derived signal for convenience
|
||||
export function isInVoice(): boolean {
|
||||
return voiceChannelId() !== null;
|
||||
|
|
@ -48,71 +68,109 @@ export function isInVoice(): boolean {
|
|||
// single peer connection manager instance for the lifetime of a voice session
|
||||
let peerManager: PeerConnectionManager | null = null;
|
||||
|
||||
// evaluate overall voice connection state from per-peer states
|
||||
function evaluateOverallVoiceState(): void {
|
||||
const states = peerConnectionStates();
|
||||
const entries = Object.entries(states);
|
||||
|
||||
// if no peers, we're the only participant — stay connected
|
||||
if (entries.length === 0) {
|
||||
// only update if we're currently in a voice channel (not leaving)
|
||||
if (voiceChannelId() !== null && voiceConnectionState() !== "idle") {
|
||||
setVoiceConnectionState("connected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const connected = entries.filter(([, s]) => s === "connected").length;
|
||||
const failed = entries.filter(([, s]) => s === "failed").length;
|
||||
|
||||
if (connected > 0 && failed > 0) {
|
||||
setVoiceConnectionState("degraded");
|
||||
} else if (connected > 0) {
|
||||
setVoiceConnectionState("connected");
|
||||
} else if (failed === entries.length) {
|
||||
setVoiceConnectionState("error");
|
||||
}
|
||||
// otherwise remain in "connecting" state (peers still negotiating)
|
||||
}
|
||||
|
||||
// initialize the peer manager with callbacks wired to our handlers
|
||||
function initPeerManager(): PeerConnectionManager {
|
||||
const manager = new PeerConnectionManager();
|
||||
function initPeerManager(iceServers?: RTCIceServer[]): PeerConnectionManager {
|
||||
const manager = new PeerConnectionManager({
|
||||
iceServers,
|
||||
onRemoteStream: (peerId: string, stream: MediaStream) => {
|
||||
console.log(`[Voice] Remote stream received from ${peerId}`);
|
||||
setRemoteStreams((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(peerId, stream);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
|
||||
manager.onRemoteStream = (peerId: string, stream: MediaStream) => {
|
||||
setRemoteStreams((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(peerId, stream);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
onRemoteStreamRemoved: (peerId: string) => {
|
||||
console.log(`[Voice] Remote stream removed for ${peerId}`);
|
||||
setRemoteStreams((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(peerId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
|
||||
manager.onRemoteStreamRemoved = (peerId: string) => {
|
||||
setRemoteStreams((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(peerId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
onIceCandidate: async (peerId: string, candidate: RTCIceCandidate) => {
|
||||
const communityId = voiceCommunityId();
|
||||
const channelId = voiceChannelId();
|
||||
if (!communityId || !channelId) return;
|
||||
|
||||
manager.onIceCandidate = async (
|
||||
peerId: string,
|
||||
candidate: RTCIceCandidate,
|
||||
) => {
|
||||
const communityId = voiceCommunityId();
|
||||
const channelId = voiceChannelId();
|
||||
if (!communityId || !channelId) return;
|
||||
try {
|
||||
await sendVoiceIceCandidate(
|
||||
communityId,
|
||||
channelId,
|
||||
peerId,
|
||||
candidate.candidate,
|
||||
candidate.sdpMid,
|
||||
candidate.sdpMLineIndex,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[Voice] Failed to send ICE candidate:", err);
|
||||
}
|
||||
},
|
||||
|
||||
try {
|
||||
await sendVoiceIceCandidate(
|
||||
communityId,
|
||||
channelId,
|
||||
peerId,
|
||||
candidate.candidate,
|
||||
candidate.sdpMid,
|
||||
candidate.sdpMLineIndex,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("failed to send ice candidate:", err);
|
||||
}
|
||||
};
|
||||
onNegotiationNeeded: async (
|
||||
peerId: string,
|
||||
sdp: RTCSessionDescriptionInit,
|
||||
) => {
|
||||
const communityId = voiceCommunityId();
|
||||
const channelId = voiceChannelId();
|
||||
if (!communityId || !channelId) return;
|
||||
|
||||
manager.onNegotiationNeeded = async (peerId: string) => {
|
||||
const communityId = voiceCommunityId();
|
||||
const channelId = voiceChannelId();
|
||||
if (!communityId || !channelId) return;
|
||||
// the webrtc module handles glare resolution and creates the offer/restart SDP
|
||||
// we just need to send it via the signaling channel
|
||||
try {
|
||||
console.log(
|
||||
`[Voice] Sending ${sdp.type} SDP to ${peerId} (negotiation/restart)`,
|
||||
);
|
||||
await sendVoiceSdp(
|
||||
communityId,
|
||||
channelId,
|
||||
peerId,
|
||||
sdp.type || "offer",
|
||||
sdp.sdp || "",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[Voice] Failed to send SDP during negotiation:", err);
|
||||
}
|
||||
},
|
||||
|
||||
// only the peer with the lexicographically smaller id initiates the offer
|
||||
if (!manager.shouldOffer(peerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const offer = await manager.createOffer(peerId);
|
||||
await sendVoiceSdp(
|
||||
communityId,
|
||||
channelId,
|
||||
peerId,
|
||||
offer.type || "offer",
|
||||
offer.sdp || "",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("failed to send offer during renegotiation:", err);
|
||||
}
|
||||
};
|
||||
onPeerConnectionStateChanged: (
|
||||
peerId: string,
|
||||
state: RTCPeerConnectionState,
|
||||
) => {
|
||||
console.log(`[Voice] Peer ${peerId} connection state: ${state}`);
|
||||
setPeerConnectionStates((prev) => ({ ...prev, [peerId]: state }));
|
||||
evaluateOverallVoiceState();
|
||||
},
|
||||
});
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
|
@ -177,8 +235,29 @@ export async function joinVoice(
|
|||
const stream = await acquireLocalMedia(false);
|
||||
setLocalStream(stream);
|
||||
|
||||
// fetch TURN credentials from our relay server
|
||||
let turnServers: RTCIceServer[] = [];
|
||||
try {
|
||||
const creds = await getTurnCredentials();
|
||||
turnServers = [{
|
||||
urls: creds.uris,
|
||||
username: creds.username,
|
||||
credential: creds.password,
|
||||
}];
|
||||
console.log(`[Voice] Fetched TURN credentials (ttl=${creds.ttl}s, uris=${creds.uris.length})`);
|
||||
} catch (e) {
|
||||
console.warn('[Voice] Failed to fetch TURN credentials, proceeding without TURN:', e);
|
||||
}
|
||||
|
||||
// combine public STUN servers with dynamic TURN servers
|
||||
const iceServers: RTCIceServer[] = [
|
||||
{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] },
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' },
|
||||
...turnServers,
|
||||
];
|
||||
|
||||
// initialize peer manager and set our local peer id for glare resolution
|
||||
peerManager = initPeerManager();
|
||||
peerManager = initPeerManager(iceServers);
|
||||
const localPeerId = identity()?.peer_id;
|
||||
if (localPeerId) {
|
||||
peerManager.setLocalPeerId(localPeerId);
|
||||
|
|
@ -191,13 +270,22 @@ export async function joinVoice(
|
|||
setVoiceChannelId(channelId);
|
||||
setVoiceCommunityId(communityId);
|
||||
setVoiceParticipants(participants);
|
||||
setVoiceConnectionState("connected");
|
||||
|
||||
// determine how many remote peers we need to connect to
|
||||
const remotePeers = participants.filter(
|
||||
(p) => p.peer_id !== localPeerId,
|
||||
);
|
||||
|
||||
if (remotePeers.length === 0) {
|
||||
// we're the only participant — no peers to connect to, so we're connected
|
||||
console.log("[Voice] No remote peers, marking as connected");
|
||||
setVoiceConnectionState("connected");
|
||||
}
|
||||
// otherwise stay in "connecting" until onPeerConnectionStateChanged fires
|
||||
|
||||
// create peer connections for all existing participants
|
||||
// we only initiate offers if our peer id is lexicographically smaller
|
||||
for (const participant of participants) {
|
||||
if (participant.peer_id === localPeerId) continue;
|
||||
|
||||
for (const participant of remotePeers) {
|
||||
peerManager.createConnection(participant.peer_id);
|
||||
|
||||
if (peerManager.shouldOffer(participant.peer_id)) {
|
||||
|
|
@ -212,14 +300,14 @@ export async function joinVoice(
|
|||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`failed to create offer for ${participant.peer_id}:`,
|
||||
`[Voice] Failed to create offer for ${participant.peer_id}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("failed to join voice channel:", err);
|
||||
console.error("[Voice] Failed to join voice channel:", err);
|
||||
// surface a readable error message to the ui
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setVoiceError(message);
|
||||
|
|
@ -248,8 +336,9 @@ export async function leaveVoice(): Promise<void> {
|
|||
releaseLocalMedia();
|
||||
releaseScreenShare();
|
||||
|
||||
// clear remote streams
|
||||
// clear remote streams and peer connection states
|
||||
setRemoteStreams(new Map());
|
||||
setPeerConnectionStates({});
|
||||
|
||||
// tell the backend to leave
|
||||
if (communityId && channelId) {
|
||||
|
|
@ -381,6 +470,10 @@ export async function toggleVideo(): Promise<void> {
|
|||
if (videoTrack && localStream()) {
|
||||
// add video track to existing stream
|
||||
localStream()!.addTrack(videoTrack);
|
||||
// stop the unused audio tracks from the new stream to prevent resource leak
|
||||
for (const audioTrack of videoStream.getAudioTracks()) {
|
||||
audioTrack.stop();
|
||||
}
|
||||
} else if (videoTrack) {
|
||||
setLocalStream(videoStream);
|
||||
}
|
||||
|
|
@ -601,12 +694,20 @@ export function handleVoiceParticipantLeft(payload: {
|
|||
peerManager.closeConnection(payload.peer_id);
|
||||
}
|
||||
|
||||
// remove remote stream
|
||||
// remove remote stream and peer connection state
|
||||
setRemoteStreams((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(payload.peer_id);
|
||||
return next;
|
||||
});
|
||||
setPeerConnectionStates((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[payload.peer_id];
|
||||
return next;
|
||||
});
|
||||
|
||||
// re-evaluate overall voice state after peer removal
|
||||
evaluateOverallVoiceState();
|
||||
}
|
||||
|
||||
export function handleVoiceMediaStateChanged(payload: {
|
||||
|
|
@ -708,4 +809,6 @@ export {
|
|||
screenStream,
|
||||
voiceConnectionState,
|
||||
voiceError,
|
||||
peerConnectionStates,
|
||||
voiceQuality,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue