feat: centralize signaling service and update ui

This commit is contained in:
ghost 2025-11-02 16:58:50 +01:00
parent 328d5d1003
commit a75b995cb0
10 changed files with 868 additions and 620 deletions

View File

@ -20,6 +20,7 @@ web-sys = { version = "0.3.77", features = [
"BinaryType",
"ErrorEvent",
"Navigator",
"Clipboard",
"MediaDevices",
"MediaStream",
"MediaStreamConstraints",

6
assets/appsettings.json Normal file
View File

@ -0,0 +1,6 @@
{
"server": {
"stun_server": "stun:stun.l.google.com:19302",
"signaling_url": "ws://localhost:1900/ws"
}
}

View File

@ -1,196 +1,76 @@
use crate::models::SignalingMessage;
use crate::utils::MediaManager;
use crate::services::signaling::use_signaling;
use dioxus::prelude::*;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
#[component]
pub fn CallControls(
peer_id: Signal<String>,
remote_id: Signal<String>,
connected: Signal<bool>,
websocket: Signal<Option<BrowserWebSocket>>,
peer_connection: Signal<Option<RtcPeerConnection>>, // **INITIATOR CONNECTION**
local_media: Signal<Option<MediaStream>>,
) -> Element {
let mic_granted = use_signal(|| false);
pub fn CallControls() -> Element {
let service = use_signaling();
let state = service.state.clone();
let actions = service.actions.clone();
let peer_id_value = state.peer_id.read().clone();
let remote_id_value = state.remote_id.read().clone();
let remote_id_for_label = remote_id_value.clone();
let connected = *state.connected.read();
let mic_ready = *state.mic_granted.read();
let in_call = *state.in_call.read();
let has_target = !remote_id_value.is_empty();
let request_microphone = actions.request_microphone.clone();
let start_call = actions.start_call.clone();
let leave_call = actions.leave_call.clone();
let mut audio_muted = use_signal(|| false);
let mut in_call = use_signal(|| false);
let mut mute_signal = audio_muted.clone();
let mut reset_signal = audio_muted.clone();
let muted = *audio_muted.read();
rsx! {
div { class: "call-controls",
div { class: "call-controls__left",
span { class: "self-pill", "Your ID: {peer_id.read()}" }
if !remote_id.read().is_empty() {
span { class: "self-pill self-pill--target", "Target: {remote_id.read()}" }
span { class: "self-pill", "Your ID: {peer_id_value}" }
if !remote_id_for_label.is_empty() {
span { class: "self-pill self-pill--target", "Target: {remote_id_for_label}" }
}
}
div { class: "call-controls__center",
button {
class: if *mic_granted.read() { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" },
disabled: *mic_granted.read(),
class: if mic_ready { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" },
disabled: mic_ready,
onclick: move |_| {
log::info!("Requesting microphone permission");
let mut mm_state = mic_granted.clone();
let pc_signal = peer_connection.clone();
let mut local_media_signal = local_media.clone();
spawn(async move {
let mut manager = crate::utils::MediaManager::new();
match manager.request_microphone_access().await {
Ok(stream) => {
log::info!("Microphone granted");
mm_state.set(true);
local_media_signal.set(Some(stream.clone()));
if let Some(pc) = pc_signal.read().as_ref() {
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(pc, &stream) {
log::warn!("Failed to attach local tracks: {}", e);
}
}
}
Err(e) => {
log::error!("Microphone request failed: {}", e);
}
}
});
request_microphone();
},
if *mic_granted.read() { "Mic ready" } else { "Enable mic" }
if mic_ready { "Mic ready" } else { "Enable mic" }
}
button {
class: "ctrl-btn ctrl-btn--primary",
disabled: !*mic_granted.read() || !*connected.read() || remote_id.read().is_empty(),
disabled: !mic_ready || !connected || !has_target || in_call,
onclick: move |_| {
if in_call {
return;
}
log::info!("Launching WebRTC call as initiator");
let mut pc_signal = peer_connection.clone();
let ws_signal = websocket.clone();
let from_id = peer_id.read().clone();
let to_id = remote_id.read().clone();
let mut in_call_flag = in_call.clone();
spawn(async move {
let pc = if pc_signal.read().is_none() {
match MediaManager::create_peer_connection() {
Ok(new_pc) => {
let ws_clone = ws_signal.clone();
let to_clone = to_id.clone();
let from_clone = from_id.clone();
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
if let Ok(candidate_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("candidate")) {
if candidate_val.is_null() || candidate_val.is_undefined() { return; }
if let Some(ws) = ws_clone.read().as_ref() {
if let Ok(json_js) = js_sys::JSON::stringify(&candidate_val) {
if let Some(json) = json_js.as_string() {
let msg = SignalingMessage {
from: from_clone.clone(),
to: to_clone.clone(),
msg_type: "candidate".to_string(),
data: json,
};
if let Ok(text) = serde_json::to_string(&msg) { let _ = ws.send_with_str(&text); }
}
}
}
}
}) as Box<dyn FnMut(JsValue)>);
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
on_ice.forget();
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
if let Ok(streams_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("streams")) {
if streams_val.is_undefined() || streams_val.is_null() { return; }
let streams_array = js_sys::Array::from(&streams_val);
let first = streams_array.get(0);
if let Ok(stream) = first.clone().dyn_into::<web_sys::MediaStream>() {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Ok(audio_el) = document.create_element("audio") {
if let Ok(audio) = audio_el.dyn_into::<web_sys::HtmlAudioElement>() {
audio.set_autoplay(true);
audio.set_src_object(Some(&stream));
if let Some(body) = document.body() {
let _ = body.append_child(&audio).ok();
}
}
}
}
}
}
}
}) as Box<dyn FnMut(JsValue)>);
new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
on_track.forget();
pc_signal.set(Some(new_pc.clone()));
log::info!("Initiator PeerConnection ready");
new_pc
}
Err(e) => {
log::error!("Failed to create initiator peer connection: {}", e);
return;
}
}
} else {
pc_signal.read().as_ref().unwrap().clone()
};
if let Some(local) = local_media.read().as_ref() {
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(&pc, local) {
log::warn!("Failed to attach local tracks before offer: {}", e);
}
}
match MediaManager::create_offer(&pc).await {
Ok(offer_sdp) => {
if let Some(socket) = ws_signal.read().as_ref() {
let msg = SignalingMessage {
from: from_id.clone(),
to: to_id.clone(),
msg_type: "offer".to_string(),
data: offer_sdp,
};
if let Ok(json) = serde_json::to_string(&msg) {
let _ = socket.send_with_str(&json);
log::info!("Offer dispatched to {}", to_id);
in_call_flag.set(true);
}
}
}
Err(e) => log::error!("Offer creation failed: {}", e),
}
});
audio_muted.set(false);
start_call();
},
"Start call"
if in_call { "In call" } else { "Start call" }
}
button {
class: if *audio_muted.read() { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" },
disabled: !*in_call.read(),
class: if muted { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" },
disabled: !in_call,
onclick: move |_| {
let current_muted = *audio_muted.read();
audio_muted.set(!current_muted);
log::info!("Audio {}", if !current_muted { "muted" } else { "unmuted" });
let current = *mute_signal.read();
mute_signal.set(!current);
log::info!("Audio {}", if current { "unmuted" } else { "muted" });
},
if *audio_muted.read() { "Unmute" } else { "Mute" }
if muted { "Unmute" } else { "Mute" }
}
button {
class: "ctrl-btn ctrl-btn--danger",
disabled: !*in_call.read(),
disabled: !in_call,
onclick: move |_| {
in_call.set(false);
audio_muted.set(false);
let has_peer_connection = peer_connection.read().is_some();
if has_peer_connection {
if let Some(pc) = peer_connection.read().as_ref() {
pc.close();
log::info!("Initiator PeerConnection closed");
}
peer_connection.set(None);
}
reset_signal.set(false);
leave_call();
log::info!("Call ended");
},
"Leave"
@ -198,7 +78,13 @@ pub fn CallControls(
}
div { class: "call-controls__right",
span { class: "connection-hint",
if *connected.read() { "Connected to signaling" } else { "Waiting for signaling" }
if !connected {
"Waiting for signaling"
} else if in_call {
"In active call"
} else {
"Connected to signaling"
}
}
}
}

View File

@ -1,394 +1,69 @@
use crate::config::Config;
use crate::models::SignalingMessage;
use crate::utils::MediaManager;
use crate::services::signaling::use_signaling;
use dioxus::prelude::*;
use futures::StreamExt;
use std::rc::Rc;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::spawn_local;
use web_sys::MediaStream;
use web_sys::{BinaryType, MessageEvent, RtcPeerConnection, WebSocket as BrowserWebSocket};
use wasm_bindgen_futures::{spawn_local, JsFuture};
#[component]
pub fn ConnectionPanel(
mut peer_id: Signal<String>,
mut remote_id: Signal<String>,
mut connected: Signal<bool>,
mut websocket: Signal<Option<BrowserWebSocket>>,
peer_connection: Signal<Option<RtcPeerConnection>>, // **RESPONDER CONNECTION**
initiator_connection: Signal<Option<RtcPeerConnection>>, // Initiator PC (wird für eingehende Answers verwendet)
local_media: Signal<Option<MediaStream>>,
) -> Element {
let ws_status = use_signal(|| "Nicht verbunden".to_string());
// Buffer for an incoming Answer SDP if the initiator PC isn't ready yet
let pending_answer = use_signal(|| None::<String>);
let cfg_signal: Signal<Config> = use_context();
pub fn ConnectionPanel() -> Element {
let service = use_signaling();
let state = service.state.clone();
let actions = service.actions.clone();
// **COROUTINE** für Offer-Handling (Responder empfängt Offers)
let offer_handler = use_coroutine(move |mut rx| async move {
while let Some(msg) = rx.next().await {
let SignalingMessage {
from,
to,
msg_type,
data,
} = msg;
let connected = *state.connected.read();
let ws_status = state.ws_status.read().clone();
let peer_id_value = state.peer_id.read().clone();
let peer_id_for_copy = peer_id_value.clone();
let remote_id_value = state.remote_id.read().clone();
let mic_ready = *state.mic_granted.read();
let in_call = *state.in_call.read();
// **KORREKT:** In der Coroutine-Loop
if msg_type == "offer" {
log::info!("📞 WebRTC-Offer von {} als Responder verarbeiten", from);
// **WICHTIG:** Clone für später aufbewahren
let from_clone = from.clone();
// **RESPONDER:** PeerConnection erstellen
let pc = if peer_connection.read().is_none() {
match MediaManager::create_peer_connection() {
Ok(new_pc) => {
// Attach onicecandidate handler to send candidates via websocket
let ws_clone = websocket.clone();
let from_for_ice = from_clone.clone();
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
// ev.candidate may be null/undefined or an object
if let Ok(candidate_val) =
js_sys::Reflect::get(&ev, &JsValue::from_str("candidate"))
{
if candidate_val.is_null() || candidate_val.is_undefined() {
return;
}
if let Some(ws) = ws_clone.read().as_ref() {
// Try to stringify the candidate object directly
if let Ok(json_js) = js_sys::JSON::stringify(&candidate_val)
{
if let Some(json) = json_js.as_string() {
let msg = crate::models::SignalingMessage {
from: peer_id.read().clone(),
to: from_for_ice.clone(),
msg_type: "candidate".to_string(),
data: json,
};
if let Ok(text) = serde_json::to_string(&msg) {
let _ = ws.send_with_str(&text);
}
}
}
}
}
})
as Box<dyn FnMut(JsValue)>);
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
on_ice.forget();
// ontrack -> play remote audio
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
// ev.streams is an array of MediaStream
if let Ok(streams_val) =
js_sys::Reflect::get(&ev, &JsValue::from_str("streams"))
{
if streams_val.is_undefined() || streams_val.is_null() {
return;
}
let streams_array = js_sys::Array::from(&streams_val);
let first = streams_array.get(0);
let stream_js = first.clone();
if let Ok(stream) = stream_js.dyn_into::<web_sys::MediaStream>()
{
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Ok(audio_el) =
document.create_element("audio")
{
if let Ok(audio) = audio_el
.dyn_into::<web_sys::HtmlAudioElement>(
) {
audio.set_autoplay(true);
audio.set_src_object(Some(&stream));
if let Some(body) = document.body() {
let _ = body.append_child(&audio).ok();
}
}
}
}
}
}
}
})
as Box<dyn FnMut(JsValue)>);
new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
on_track.forget();
peer_connection.set(Some(new_pc.clone()));
let from_for_log = from_clone.clone();
log::info!("✅ Responder PeerConnection für {} erstellt", from_for_log);
new_pc
}
Err(e) => {
log::error!("❌ Responder PeerConnection-Fehler: {}", e);
continue;
}
}
} else {
peer_connection.read().as_ref().unwrap().clone()
};
// Offer verarbeiten und Answer erstellen
match MediaManager::handle_offer(&pc, &data).await {
Ok(answer_sdp) => {
log::info!("✅ Responder Answer erstellt, sende zurück...");
if let Some(socket) = websocket.read().as_ref() {
let answer_msg = SignalingMessage {
from: to, // ← to wird moved
to: from, // ← from wird hier moved (Original)
msg_type: "answer".to_string(),
data: answer_sdp,
};
if let Ok(json) = serde_json::to_string(&answer_msg) {
let _ = socket.send_with_str(&json);
log::info!("📤 Responder Answer gesendet an {}", from_clone);
// ✅ Clone verwenden
}
}
}
Err(e) => log::error!("❌ Responder Answer-Fehler: {}", e),
}
}
}
});
// Peer-ID generieren
use_effect(move || {
use js_sys::{Date, Math};
let timestamp = Date::now() as u64;
let random = (Math::random() * 900.0 + 100.0) as u32;
let id = format!("peer-{}-{}", timestamp, random);
peer_id.set(id.clone());
log::info!("🆔 Peer-ID generiert: {}", id);
});
// Einfacher Status-String für das Mikrofon
let mic_status = if local_media.read().is_some() {
"Granted"
} else {
"Not granted"
};
// WebSocket verbinden
let connect_logic: Rc<dyn Fn()> = {
let ws_status_signal = ws_status.clone();
let connected_signal = connected.clone();
let websocket_signal = websocket.clone();
let offer_handler = offer_handler.clone();
let pending_answer_handle = pending_answer.clone();
let cfg_signal_handle = cfg_signal.clone();
let initiator_connection_signal = initiator_connection.clone();
let peer_connection_signal = peer_connection.clone();
Rc::new(move || {
let mut ws_status = ws_status_signal.clone();
let connected = connected_signal.clone();
let mut websocket = websocket_signal.clone();
let cfg_signal = cfg_signal_handle.clone();
let initiator_connection = initiator_connection_signal.clone();
let peer_connection = peer_connection_signal.clone();
let pending_answer = pending_answer_handle.clone();
if *connected.read() || websocket.read().is_some() {
return;
}
ws_status.set("Verbinde...".to_string());
let endpoint = cfg_signal.read().server.signaling_url.trim().to_string();
let target = if endpoint.is_empty() {
crate::constants::DEFAULT_SIGNALING_URL.to_string()
} else {
endpoint
};
log::info!("🔌 Verbinde WebSocket zu {}", target);
match BrowserWebSocket::new(&target) {
Ok(socket) => {
socket.set_binary_type(BinaryType::Arraybuffer);
// onopen Handler
let mut ws_status_clone = ws_status.clone();
let mut connected_clone = connected.clone();
let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| {
log::info!("✅ WebSocket verbunden!");
ws_status_clone.set("Verbunden".to_string());
connected_clone.set(true);
})
as Box<dyn FnMut(web_sys::Event)>);
// onclose Handler
let mut ws_status_clone2 = ws_status.clone();
let mut connected_clone2 = connected.clone();
let onclose = Closure::wrap(Box::new(move |_: web_sys::CloseEvent| {
log::warn!("❌ WebSocket getrennt");
ws_status_clone2.set("Getrennt".to_string());
connected_clone2.set(false);
})
as Box<dyn FnMut(web_sys::CloseEvent)>);
// **MESSAGE ROUTER** - Leitet Messages an die richtigen Handler weiter
let offer_tx = offer_handler.clone();
let pending_answer_signal = pending_answer.clone();
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
if let Some(text) = e.data().as_string() {
log::info!("📨 WebSocket Nachricht: {}", text);
if let Ok(msg) = serde_json::from_str::<SignalingMessage>(&text) {
match msg.msg_type.as_str() {
"offer" => {
log::info!("🔀 Leite Offer an Responder-Handler weiter");
offer_tx.send(msg);
}
"answer" => {
log::info!("🔀 Answer empfangen - leite an Initiator-PeerConnection weiter");
let data_clone = msg.data.clone();
if let Some(pc) = initiator_connection.read().as_ref() {
// Versuche die Answer als Remote Description zu setzen
let pc_clone = pc.clone();
spawn_local(async move {
match crate::utils::MediaManager::handle_answer(&pc_clone, &data_clone).await {
Ok(_) => log::info!("✅ Answer erfolgreich gesetzt auf Initiator-PC"),
Err(e) => log::error!("❌ Fehler beim Setzen der Answer auf Initiator-PC: {}", e),
}
});
} else {
// Buffer the answer until an initiator PC exists
log::warn!("⚠️ Keine Initiator-PeerConnection vorhanden - buffer Answer");
let mut pending_answer_slot =
pending_answer_signal.clone();
pending_answer_slot.set(Some(data_clone));
}
}
"candidate" => {
log::info!("🔀 ICE-Kandidat empfangen: leite weiter");
// Determine whether this candidate is for initiator or responder
let data_clone = msg.data.clone();
// Try initiator first
if let Some(pc) = initiator_connection.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match crate::utils::MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Initiator-PC hinzugefügt"),
Err(e) => log::error!("❌ Kandidat konnte nicht hinzugefügt werden: {}", e),
}
});
} else if let Some(pc) = peer_connection.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match crate::utils::MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Responder-PC hinzugefügt"),
Err(e) => log::error!("❌ Kandidat konnte nicht hinzugefügt werden: {}", e),
}
});
} else {
log::warn!("⚠️ Kein PeerConnection verfügbar, um Kandidaten hinzuzufügen");
}
}
"text" => {
log::info!("💬 Textnachricht: {}", msg.data);
if let Some(window) = web_sys::window() {
let _ = window.alert_with_message(&format!(
"Nachricht von {}:\n{}",
msg.from, msg.data
));
}
}
_ => {
log::info!("❓ Unbekannte Nachricht: {}", msg.msg_type);
}
}
}
}
})
as Box<dyn FnMut(MessageEvent)>);
socket.set_onopen(Some(onopen.as_ref().unchecked_ref()));
socket.set_onclose(Some(onclose.as_ref().unchecked_ref()));
socket.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onopen.forget();
onclose.forget();
onmessage.forget();
websocket.set(Some(socket));
}
Err(e) => {
log::error!("❌ WebSocket Fehler: {:?}", e);
ws_status.set("Verbindungsfehler".to_string());
}
};
})
};
{
let connect_logic = connect_logic.clone();
use_effect(move || {
connect_logic();
});
}
// Wenn eine gepufferte Answer vorhanden ist und später eine Initiator-PC gesetzt wird,
// verarbeite die gepufferte Answer.
{
let mut pending_sig = pending_answer.clone();
let init_conn = initiator_connection.clone();
use_effect(move || {
// Clone out the buffered answer quickly to avoid holding the read-borrow
let maybe = pending_sig.read().clone();
if let Some(answer_sdp) = maybe {
if let Some(pc) = init_conn.read().as_ref() {
let pc_clone = pc.clone();
let answer_clone = answer_sdp.clone();
spawn_local(async move {
match crate::utils::MediaManager::handle_answer(&pc_clone, &answer_clone)
.await
{
Ok(_) => log::info!(
"✅ Gepufferte Answer erfolgreich gesetzt auf Initiator-PC"
),
Err(e) => {
log::error!("❌ Fehler beim Setzen der gepufferten Answer: {}", e)
}
}
});
// Clear buffer
pending_sig.set(None);
}
}
});
}
let reconnect = actions.connect.clone();
let set_remote_id_action = actions.set_remote_id.clone();
rsx! {
div { class: "connection-panel",
section { class: "connection-card",
header { class: "connection-card__header",
h3 { "Connection" }
span { class: if *connected.read() { "pill pill--success" } else { "pill pill--danger" }, "{ws_status.read()}" }
span {
class: if connected { "pill pill--success" } else { "pill pill--danger" },
"{ws_status}"
}
}
ul { class: "connection-status-list",
li {
span { class: "label", "WebSocket" }
span {
class: if *connected.read() { "value value--success" } else { "value value--danger" },
if *connected.read() { "Connected" } else { "Disconnected" }
class: if connected { "value value--success" } else { "value value--danger" },
if connected { "Connected" } else { "Disconnected" }
}
}
li {
span { class: "label", "Microphone" }
span {
class: if local_media.read().is_some() { "value value--success" } else { "value value--danger" },
"{mic_status}"
class: if mic_ready { "value value--success" } else { "value value--danger" },
if mic_ready { "Ready" } else { "Not requested" }
}
}
li {
span { class: "label", "Call" }
span {
class: if in_call { "value value--success" } else { "value" },
if in_call { "In call" } else { "Idle" }
}
}
}
div { class: "connection-card__actions",
button {
class: "ctrl-btn ctrl-btn--secondary",
disabled: connected,
onclick: move |_| {
log::info!("Manual reconnect requested");
(reconnect)();
},
"Reconnect"
}
}
}
@ -402,13 +77,39 @@ pub fn ConnectionPanel(
input {
class: "input input--readonly",
r#type: "text",
value: "{peer_id.read()}",
value: "{peer_id_value}",
readonly: true
}
button {
class: "icon-btn",
onclick: move |_| {
log::info!("📋 Peer-ID kopiert: {}", peer_id.read());
let copy_target = peer_id_for_copy.clone();
spawn_local(async move {
if let Some(window) = web_sys::window() {
let navigator = window.navigator();
match js_sys::Reflect::get(&navigator, &JsValue::from_str("clipboard")) {
Ok(handle) if !handle.is_undefined() && !handle.is_null() => {
match handle.dyn_into::<web_sys::Clipboard>() {
Ok(clipboard) => {
let promise = clipboard.write_text(&copy_target);
if let Err(err) = JsFuture::from(promise).await {
log::warn!("Clipboard write failed: {:?}", err);
} else {
log::info!("Peer ID copied to clipboard");
}
}
Err(err) => log::warn!("Clipboard handle cast failed: {:?}", err),
}
}
Ok(_) => {
log::warn!("Clipboard API undefined on navigator");
}
Err(err) => log::warn!("Clipboard lookup failed: {:?}", err),
}
} else {
log::warn!("Clipboard copy skipped: no window available");
}
});
},
"📋"
}
@ -420,9 +121,9 @@ pub fn ConnectionPanel(
class: "input",
r#type: "text",
placeholder: "Paste peer ID",
value: "{remote_id.read()}",
value: "{remote_id_value}",
oninput: move |event| {
remote_id.set(event.value());
(set_remote_id_action.clone())(event.value());
}
}
}

View File

@ -1,15 +1,32 @@
use crate::services::signaling::use_signaling;
use dioxus::prelude::*;
#[component]
pub fn StatusDisplay(connected: Signal<bool>) -> Element {
pub fn StatusDisplay() -> Element {
let service = use_signaling();
let state = service.state.clone();
let connected = *state.connected.read();
let ws_status = state.ws_status.read().clone();
let in_call = *state.in_call.read();
let hint_text = format!(
"{} · {}",
ws_status,
if in_call {
"Active call"
} else {
"No active call"
}
);
rsx! {
div { class: "status-widget",
span { class: "status-widget__label", "Signaling" }
span {
class: if *connected.read() { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" },
if *connected.read() { "Online" } else { "Offline" }
class: if connected { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" },
if connected { "Online" } else { "Offline" }
}
span { class: "status-widget__hint", "TURN integration pending" }
span { class: "status-widget__hint", "{hint_text}" }
}
}
}

View File

@ -1,6 +1,8 @@
#![allow(non_snake_case)]
use crate::models::Participant;
use crate::services::signaling::use_signaling;
use dioxus::prelude::*;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
use super::{CallControls, ConnectionPanel, StatusDisplay};
@ -9,13 +11,6 @@ pub struct VoiceChannelProps {
pub channel_name: String,
pub channel_topic: String,
pub participants: Signal<Vec<Participant>>,
pub peer_id: Signal<String>,
pub remote_id: Signal<String>,
pub connected: Signal<bool>,
pub websocket: Signal<Option<BrowserWebSocket>>,
pub responder_connection: Signal<Option<RtcPeerConnection>>,
pub initiator_connection: Signal<Option<RtcPeerConnection>>,
pub local_media: Signal<Option<MediaStream>>,
}
#[component]
@ -25,13 +20,6 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
ChannelSidebar {
channel_name: props.channel_name.clone(),
channel_topic: props.channel_topic.clone(),
peer_id: props.peer_id.clone(),
remote_id: props.remote_id.clone(),
connected: props.connected.clone(),
websocket: props.websocket.clone(),
responder_connection: props.responder_connection.clone(),
initiator_connection: props.initiator_connection.clone(),
local_media: props.local_media.clone(),
}
div { class: "channel-main",
ChannelHeader {
@ -40,14 +28,7 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
}
ParticipantsGrid { participants: props.participants.clone() }
}
ControlDock {
peer_id: props.peer_id.clone(),
remote_id: props.remote_id.clone(),
connected: props.connected.clone(),
websocket: props.websocket.clone(),
initiator_connection: props.initiator_connection.clone(),
local_media: props.local_media.clone(),
}
ControlDock {}
}
}
}
@ -56,13 +37,6 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
pub struct ChannelSidebarProps {
pub channel_name: String,
pub channel_topic: String,
pub peer_id: Signal<String>,
pub remote_id: Signal<String>,
pub connected: Signal<bool>,
pub websocket: Signal<Option<BrowserWebSocket>>,
pub responder_connection: Signal<Option<RtcPeerConnection>>,
pub initiator_connection: Signal<Option<RtcPeerConnection>>,
pub local_media: Signal<Option<MediaStream>>,
}
#[component]
@ -74,18 +48,10 @@ fn ChannelSidebar(props: ChannelSidebarProps) -> Element {
p { "{props.channel_topic}" }
}
div { class: "channel-sidebar__body",
ConnectionPanel {
peer_id: props.peer_id.clone(),
remote_id: props.remote_id.clone(),
connected: props.connected.clone(),
websocket: props.websocket.clone(),
peer_connection: props.responder_connection.clone(),
initiator_connection: props.initiator_connection.clone(),
local_media: props.local_media.clone(),
}
ConnectionPanel {}
}
div { class: "channel-sidebar__footer",
StatusDisplay { connected: props.connected.clone() }
StatusDisplay {}
}
}
}
@ -99,6 +65,28 @@ pub struct ChannelHeaderProps {
#[component]
fn ChannelHeader(props: ChannelHeaderProps) -> Element {
let service = use_signaling();
let state = service.state.clone();
let connected = *state.connected.read();
let in_call = *state.in_call.read();
let call_label = if in_call { "In call" } else { "Call idle" };
let call_class = if in_call {
"channel-pill"
} else {
"channel-pill secondary"
};
let signaling_label = if connected {
"Signaling online"
} else {
"Signaling offline"
};
let signaling_class = if connected {
"channel-pill secondary"
} else {
"channel-pill secondary"
};
rsx! {
header { class: "channel-header",
div { class: "channel-header__text",
@ -106,8 +94,8 @@ fn ChannelHeader(props: ChannelHeaderProps) -> Element {
p { "{props.topic}" }
}
div { class: "channel-header__actions",
button { class: "channel-pill", "Voice" }
button { class: "channel-pill secondary", "Active" }
button { class: "{call_class}", "{call_label}" }
button { class: "{signaling_class}", "{signaling_label}" }
}
}
}
@ -181,28 +169,11 @@ fn initials(name: &str) -> String {
}
}
#[derive(Props, Clone, PartialEq)]
pub struct ControlDockProps {
pub peer_id: Signal<String>,
pub remote_id: Signal<String>,
pub connected: Signal<bool>,
pub websocket: Signal<Option<BrowserWebSocket>>,
pub initiator_connection: Signal<Option<RtcPeerConnection>>,
pub local_media: Signal<Option<MediaStream>>,
}
#[component]
fn ControlDock(props: ControlDockProps) -> Element {
fn ControlDock() -> Element {
rsx! {
footer { class: "control-dock",
CallControls {
peer_id: props.peer_id.clone(),
remote_id: props.remote_id.clone(),
connected: props.connected.clone(),
websocket: props.websocket.clone(),
peer_connection: props.initiator_connection.clone(),
local_media: props.local_media.clone(),
}
CallControls {}
}
}
}

View File

@ -3,6 +3,7 @@ pub mod components;
pub mod config;
pub mod constants;
pub mod models;
pub mod services;
pub mod utils;
// Re-export commonly used items if needed in the future

View File

@ -5,7 +5,7 @@ use dioxus::prelude::*;
use log::Level;
use niom_webrtc::components::VoiceChannelLayout;
use niom_webrtc::models::Participant;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
use niom_webrtc::services::signaling::SignalingProvider;
// config functions used via fully-qualified paths below
const FAVICON: Asset = asset!("/assets/favicon.ico");
@ -59,14 +59,6 @@ fn ConfigProvider() -> Element {
pub fn Content() -> Element {
// Config is provided by ConfigProvider via provide_context; components can use use_context to read it.
let peer_id = use_signal(|| "peer-loading...".to_string());
let remote_id = use_signal(|| String::new());
let connected = use_signal(|| false);
let websocket = use_signal(|| None::<BrowserWebSocket>);
let initiator_connection = use_signal(|| None::<RtcPeerConnection>);
let responder_connection = use_signal(|| None::<RtcPeerConnection>);
let local_media = use_signal(|| None::<MediaStream>);
let participants = use_signal(|| {
vec![
Participant::new("self", "Ghost", "#5865F2", true, false, true),
@ -79,17 +71,12 @@ pub fn Content() -> Element {
});
rsx! {
VoiceChannelLayout {
channel_name: "Project Alpha / Voice Lounge".to_string(),
channel_topic: "Team sync & architecture deep dive".to_string(),
participants,
peer_id,
remote_id,
connected,
websocket,
responder_connection,
initiator_connection,
local_media,
SignalingProvider {
VoiceChannelLayout {
channel_name: "Project Alpha / Voice Lounge".to_string(),
channel_topic: "Team sync & architecture deep dive".to_string(),
participants,
}
}
}
}

1
src/services/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod signaling;

677
src/services/signaling.rs Normal file
View File

@ -0,0 +1,677 @@
use std::rc::Rc;
use crate::{
config::Config, constants::DEFAULT_SIGNALING_URL, models::SignalingMessage, utils::MediaManager,
};
use dioxus::prelude::*;
use futures::StreamExt;
use wasm_bindgen::{prelude::Closure, JsCast, JsValue};
use wasm_bindgen_futures::spawn_local;
use web_sys::{
BinaryType, MediaStream, MessageEvent, RtcPeerConnection, WebSocket as BrowserWebSocket,
};
#[derive(Clone)]
pub struct SignalingState {
pub peer_id: Signal<String>,
pub remote_id: Signal<String>,
pub ws_status: Signal<String>,
pub connected: Signal<bool>,
pub websocket: Signal<Option<BrowserWebSocket>>,
pub initiator_connection: Signal<Option<RtcPeerConnection>>,
pub responder_connection: Signal<Option<RtcPeerConnection>>,
pub local_media: Signal<Option<MediaStream>>,
pub pending_answer: Signal<Option<String>>,
pub mic_granted: Signal<bool>,
pub in_call: Signal<bool>,
}
#[derive(Clone)]
pub struct SignalingActions {
pub connect: Rc<dyn Fn()>,
pub set_remote_id: Rc<dyn Fn(String)>,
pub request_microphone: Rc<dyn Fn()>,
pub start_call: Rc<dyn Fn()>,
pub leave_call: Rc<dyn Fn()>,
}
#[derive(Clone)]
pub struct SignalingService {
pub state: SignalingState,
pub actions: SignalingActions,
}
pub fn use_signaling() -> SignalingService {
use_context::<SignalingService>()
}
#[derive(Props, Clone, PartialEq)]
pub struct SignalingProviderProps {
pub children: Element,
}
#[component]
pub fn SignalingProvider(props: SignalingProviderProps) -> Element {
let peer_id = use_signal(|| String::new());
let remote_id = use_signal(|| String::new());
let ws_status = use_signal(|| "Nicht verbunden".to_string());
let connected = use_signal(|| false);
let websocket = use_signal(|| None::<BrowserWebSocket>);
let initiator_connection = use_signal(|| None::<RtcPeerConnection>);
let responder_connection = use_signal(|| None::<RtcPeerConnection>);
let local_media = use_signal(|| None::<MediaStream>);
let pending_answer = use_signal(|| None::<String>);
let mic_granted = use_signal(|| false);
let in_call = use_signal(|| false);
let cfg_signal: Signal<Config> = use_context();
let offer_handler = {
let websocket = websocket.clone();
let peer_connection = responder_connection.clone();
let peer_id = peer_id.clone();
let pending_answer_signal = pending_answer.clone();
let initiator_connection = initiator_connection.clone();
use_coroutine(move |mut rx| async move {
while let Some(msg) = rx.next().await {
let SignalingMessage {
from,
to,
msg_type,
data,
} = msg;
if msg_type == "offer" {
log::info!("📞 WebRTC-Offer von {} als Responder verarbeiten", from);
let from_clone = from.clone();
let from_for_log = from.clone();
let pc = if peer_connection.read().is_none() {
match MediaManager::create_peer_connection() {
Ok(new_pc) => {
let ws_clone = websocket.clone();
let peer_id_clone = peer_id.clone();
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
if let Ok(candidate_val) =
js_sys::Reflect::get(&ev, &JsValue::from_str("candidate"))
{
if candidate_val.is_null() || candidate_val.is_undefined() {
return;
}
if let Some(ws) = ws_clone.read().as_ref() {
if let Ok(json_js) =
js_sys::JSON::stringify(&candidate_val)
{
if let Some(json) = json_js.as_string() {
let msg = SignalingMessage {
from: peer_id_clone.read().clone(),
to: from_clone.clone(),
msg_type: "candidate".to_string(),
data: json,
};
if let Ok(text) = serde_json::to_string(&msg) {
let _ = ws.send_with_str(&text);
}
}
}
}
}
})
as Box<dyn FnMut(JsValue)>);
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
on_ice.forget();
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
if let Ok(streams_val) =
js_sys::Reflect::get(&ev, &JsValue::from_str("streams"))
{
if streams_val.is_undefined() || streams_val.is_null() {
return;
}
let streams_array = js_sys::Array::from(&streams_val);
let first = streams_array.get(0);
let stream_js = first.clone();
if let Ok(stream) =
stream_js.dyn_into::<web_sys::MediaStream>()
{
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Ok(audio_el) =
document.create_element("audio")
{
if let Ok(audio) = audio_el
.dyn_into::<web_sys::HtmlAudioElement>(
) {
audio.set_autoplay(true);
audio.set_src_object(Some(&stream));
if let Some(body) = document.body() {
let _ =
body.append_child(&audio).ok();
}
}
}
}
}
}
}
})
as Box<dyn FnMut(JsValue)>);
new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
on_track.forget();
let mut responder_handle = peer_connection.clone();
responder_handle.set(Some(new_pc.clone()));
log::info!(
"✅ Responder PeerConnection für {} erstellt",
from_for_log
);
new_pc
}
Err(e) => {
log::error!("❌ Responder PeerConnection-Fehler: {}", e);
continue;
}
}
} else {
peer_connection.read().as_ref().unwrap().clone()
};
match MediaManager::handle_offer(&pc, &data).await {
Ok(answer_sdp) => {
log::info!("✅ Responder Answer erstellt, sende zurück...");
if let Some(socket) = websocket.read().as_ref() {
let answer_msg = SignalingMessage {
from: to,
to: from,
msg_type: "answer".to_string(),
data: answer_sdp,
};
if let Ok(json) = serde_json::to_string(&answer_msg) {
let _ = socket.send_with_str(&json);
log::info!("📤 Responder Answer gesendet an {}", from_for_log);
}
}
}
Err(e) => log::error!("❌ Responder Answer-Fehler: {}", e),
}
} else if msg_type == "answer" {
log::info!("🔀 Answer empfangen - leite an Initiator-PeerConnection weiter");
let data_clone = data.clone();
if let Some(pc) = initiator_connection.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match MediaManager::handle_answer(&pc_clone, &data_clone).await {
Ok(_) => {
log::info!("✅ Answer erfolgreich gesetzt auf Initiator-PC")
}
Err(e) => log::error!(
"❌ Fehler beim Setzen der Answer auf Initiator-PC: {}",
e
),
}
});
} else {
log::warn!("⚠️ Keine Initiator-PeerConnection vorhanden - buffer Answer");
let mut pending_handle = pending_answer_signal.clone();
pending_handle.set(Some(data_clone));
}
} else if msg_type == "candidate" {
let data_clone = data.clone();
if let Some(pc) = initiator_connection.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Initiator-PC hinzugefügt"),
Err(e) => log::error!(
"❌ Kandidat konnte nicht hinzugefügt werden: {}",
e
),
}
});
} else if let Some(pc) = peer_connection.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Responder-PC hinzugefügt"),
Err(e) => log::error!(
"❌ Kandidat konnte nicht hinzugefügt werden: {}",
e
),
}
});
} else {
log::warn!("⚠️ Kein PeerConnection verfügbar, um Kandidaten hinzuzufügen");
}
} else {
log::info!("❓ Unbekannte Nachricht: {}", msg_type);
}
}
})
};
let connect_logic: Rc<dyn Fn()> = {
let ws_status_signal = ws_status.clone();
let connected_signal = connected.clone();
let websocket_signal = websocket.clone();
let offer_handler = offer_handler.clone();
let cfg_signal_handle = cfg_signal.clone();
let initiator_connection_signal = initiator_connection.clone();
let responder_connection_signal = responder_connection.clone();
Rc::new(move || {
let mut ws_status_handle = ws_status_signal.clone();
let connected_handle = connected_signal.clone();
let mut websocket_handle = websocket_signal.clone();
let initiator_connection = initiator_connection_signal.clone();
let responder_connection = responder_connection_signal.clone();
let cfg_signal = cfg_signal_handle.clone();
if *connected_handle.read() || websocket_handle.read().is_some() {
return;
}
ws_status_handle.set("Verbinde...".to_string());
let endpoint = cfg_signal.read().server.signaling_url.trim().to_string();
let target = if endpoint.is_empty() {
DEFAULT_SIGNALING_URL.to_string()
} else {
endpoint
};
log::info!("🔌 Verbinde WebSocket zu {}", target);
match BrowserWebSocket::new(&target) {
Ok(socket) => {
socket.set_binary_type(BinaryType::Arraybuffer);
let mut ws_status_clone = ws_status_handle.clone();
let mut connected_clone = connected_handle.clone();
let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| {
log::info!("✅ WebSocket verbunden!");
ws_status_clone.set("Verbunden".to_string());
connected_clone.set(true);
})
as Box<dyn FnMut(web_sys::Event)>);
let mut ws_status_clone2 = ws_status_handle.clone();
let mut connected_clone2 = connected_handle.clone();
let onclose = Closure::wrap(Box::new(move |_: web_sys::CloseEvent| {
log::warn!("❌ WebSocket getrennt");
ws_status_clone2.set("Getrennt".to_string());
connected_clone2.set(false);
})
as Box<dyn FnMut(web_sys::CloseEvent)>);
let offer_tx = offer_handler.clone();
let initiator_for_router = initiator_connection.clone();
let responder_for_router = responder_connection.clone();
let pending_answer_signal = pending_answer.clone();
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
if let Some(text) = e.data().as_string() {
log::info!("📨 WebSocket Nachricht: {}", text);
if let Ok(msg) = serde_json::from_str::<SignalingMessage>(&text) {
match msg.msg_type.as_str() {
"offer" => offer_tx.send(msg),
"answer" => {
let data_clone = msg.data.clone();
if let Some(pc) = initiator_for_router.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match MediaManager::handle_answer(&pc_clone, &data_clone).await {
Ok(_) => log::info!("✅ Answer erfolgreich gesetzt auf Initiator-PC"),
Err(e) => log::error!("❌ Fehler beim Setzen der Answer auf Initiator-PC: {}", e),
}
});
} else {
log::warn!("⚠️ Keine Initiator-PeerConnection vorhanden - buffer Answer");
let mut pending_handle = pending_answer_signal.clone();
pending_handle.set(Some(data_clone));
}
}
"candidate" => {
let data_clone = msg.data.clone();
if let Some(pc) = initiator_for_router.read().as_ref() {
let pc_clone = pc.clone();
spawn_local(async move {
match MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Initiator-PC hinzugefügt"),
Err(e) => log::error!("❌ Kandidat konnte nicht hinzugefügt werden: {}", e),
}
});
} else if let Some(pc) =
responder_for_router.read().as_ref()
{
let pc_clone = pc.clone();
spawn_local(async move {
match MediaManager::add_ice_candidate(&pc_clone, &data_clone) {
Ok(_) => log::info!("✅ Kandidat zur Responder-PC hinzugefügt"),
Err(e) => log::error!("❌ Kandidat konnte nicht hinzugefügt werden: {}", e),
}
});
} else {
log::warn!("⚠️ Kein PeerConnection verfügbar, um Kandidaten hinzuzufügen");
}
}
"text" => {
if let Some(window) = web_sys::window() {
let _ = window.alert_with_message(&format!(
"Nachricht von {}:\n{}",
msg.from, msg.data
));
}
}
_ => log::info!("❓ Unbekannte Nachricht: {}", msg.msg_type),
}
}
}
})
as Box<dyn FnMut(MessageEvent)>);
socket.set_onopen(Some(onopen.as_ref().unchecked_ref()));
socket.set_onclose(Some(onclose.as_ref().unchecked_ref()));
socket.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onopen.forget();
onclose.forget();
onmessage.forget();
websocket_handle.set(Some(socket));
}
Err(e) => {
log::error!("❌ WebSocket Fehler: {:?}", e);
ws_status_handle.set("Verbindungsfehler".to_string());
}
}
})
};
use_effect({
let peer_id = peer_id.clone();
move || {
use js_sys::{Date, Math};
let timestamp = Date::now() as u64;
let random = (Math::random() * 900.0 + 100.0) as u32;
let id = format!("peer-{}-{}", timestamp, random);
let mut peer_id_handle = peer_id.clone();
peer_id_handle.set(id.clone());
log::info!("🆔 Peer-ID generiert: {}", id);
}
});
use_effect({
let connect_logic = connect_logic.clone();
move || {
connect_logic();
}
});
{
let pending = pending_answer.clone();
let initiator_connection = initiator_connection.clone();
use_effect(move || {
if let Some(answer_sdp) = pending.read().clone() {
if let Some(pc) = initiator_connection.read().as_ref() {
let pc_clone = pc.clone();
let answer_clone = answer_sdp.clone();
let mut pending_signal = pending.clone();
spawn_local(async move {
match MediaManager::handle_answer(&pc_clone, &answer_clone).await {
Ok(_) => log::info!(
"✅ Gepufferte Answer erfolgreich gesetzt auf Initiator-PC"
),
Err(e) => {
log::error!("❌ Fehler beim Setzen der gepufferten Answer: {}", e)
}
}
pending_signal.set(None);
});
}
}
});
}
let set_remote_id = {
let remote_id = remote_id.clone();
Rc::new(move |value: String| {
let mut handle = remote_id.clone();
let sanitized = value.trim().to_string();
handle.set(sanitized);
})
};
let request_microphone = {
let mic_granted = mic_granted.clone();
let local_media = local_media.clone();
let initiator_connection = initiator_connection.clone();
Rc::new(move || {
let mut mic_granted_signal = mic_granted.clone();
let mut local_media_signal = local_media.clone();
let initiator_connection_signal = initiator_connection.clone();
spawn(async move {
let mut manager = MediaManager::new();
match manager.request_microphone_access().await {
Ok(stream) => {
log::info!("Microphone granted");
mic_granted_signal.set(true);
local_media_signal.set(Some(stream.clone()));
if let Some(pc) = initiator_connection_signal.read().as_ref() {
if let Err(e) = MediaManager::add_stream_to_pc(pc, &stream) {
log::warn!("Failed to attach local tracks: {}", e);
}
}
}
Err(e) => log::error!("Microphone request failed: {}", e),
}
});
})
};
let start_call = {
let initiator_connection = initiator_connection.clone();
let websocket = websocket.clone();
let peer_id = peer_id.clone();
let remote_id = remote_id.clone();
let local_media = local_media.clone();
let mic_granted = mic_granted.clone();
let in_call = in_call.clone();
Rc::new(move || {
let initiator_signal = initiator_connection.clone();
let websocket_signal = websocket.clone();
let peer_id_signal = peer_id.clone();
let remote_id_signal = remote_id.clone();
let local_media_signal = local_media.clone();
let mic_granted_signal = mic_granted.clone();
let in_call_signal = in_call.clone();
spawn(async move {
if *in_call_signal.read() {
log::info!("Call already active; ignoring start request");
return;
}
if !*mic_granted_signal.read() {
log::warn!("Mic not granted, cannot start call");
return;
}
let pc = if initiator_signal.read().is_none() {
match MediaManager::create_peer_connection() {
Ok(new_pc) => {
let ws_clone = websocket_signal.clone();
let to_clone = remote_id_signal.read().clone();
let from_clone = peer_id_signal.read().clone();
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
if let Ok(candidate_val) =
js_sys::Reflect::get(&ev, &JsValue::from_str("candidate"))
{
if candidate_val.is_null() || candidate_val.is_undefined() {
return;
}
if let Some(ws) = ws_clone.read().as_ref() {
if let Ok(json_js) = js_sys::JSON::stringify(&candidate_val)
{
if let Some(json) = json_js.as_string() {
let msg = SignalingMessage {
from: from_clone.clone(),
to: to_clone.clone(),
msg_type: "candidate".to_string(),
data: json,
};
if let Ok(text) = serde_json::to_string(&msg) {
let _ = ws.send_with_str(&text);
}
}
}
}
}
})
as Box<dyn FnMut(JsValue)>);
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
on_ice.forget();
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
if let Ok(streams_val) =
js_sys::Reflect::get(&ev, &JsValue::from_str("streams"))
{
if streams_val.is_undefined() || streams_val.is_null() {
return;
}
let streams_array = js_sys::Array::from(&streams_val);
let first = streams_array.get(0);
if let Ok(stream) =
first.clone().dyn_into::<web_sys::MediaStream>()
{
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Ok(audio_el) =
document.create_element("audio")
{
if let Ok(audio) = audio_el
.dyn_into::<web_sys::HtmlAudioElement>(
) {
audio.set_autoplay(true);
audio.set_src_object(Some(&stream));
if let Some(body) = document.body() {
let _ = body.append_child(&audio).ok();
}
}
}
}
}
}
}
})
as Box<dyn FnMut(JsValue)>);
new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
on_track.forget();
let mut initiator_writer = initiator_signal.clone();
initiator_writer.set(Some(new_pc.clone()));
log::info!("Initiator PeerConnection ready");
new_pc
}
Err(e) => {
log::error!("Failed to create initiator peer connection: {}", e);
return;
}
}
} else {
initiator_signal.read().as_ref().unwrap().clone()
};
if let Some(local) = local_media_signal.read().as_ref() {
if let Err(e) = MediaManager::add_stream_to_pc(&pc, local) {
log::warn!("Failed to attach local tracks before offer: {}", e);
}
}
match MediaManager::create_offer(&pc).await {
Ok(offer_sdp) => {
if let Some(socket) = websocket_signal.read().as_ref() {
let msg = SignalingMessage {
from: peer_id_signal.read().clone(),
to: remote_id_signal.read().clone(),
msg_type: "offer".to_string(),
data: offer_sdp,
};
if let Ok(json) = serde_json::to_string(&msg) {
let _ = socket.send_with_str(&json);
log::info!(
"Offer dispatched to {}",
remote_id_signal.read().as_str()
);
let mut in_call_writer = in_call_signal.clone();
in_call_writer.set(true);
}
}
}
Err(e) => log::error!("Offer creation failed: {}", e),
}
});
})
};
let leave_call = {
let initiator_connection = initiator_connection.clone();
let in_call = in_call.clone();
let mic_granted = mic_granted.clone();
let local_media = local_media.clone();
Rc::new(move || {
let mut initiator_signal = initiator_connection.clone();
let mut in_call_signal = in_call.clone();
let mut local_media_signal = local_media.clone();
let mut mic_granted_signal = mic_granted.clone();
if *in_call_signal.read() {
if let Some(pc) = initiator_signal.read().as_ref() {
pc.close();
log::info!("Initiator PeerConnection closed");
}
initiator_signal.set(None);
}
let current_stream = local_media_signal.read().as_ref().cloned();
if let Some(stream) = current_stream {
let tracks = stream.get_tracks();
for i in 0..tracks.length() {
if let Ok(track) = tracks.get(i).dyn_into::<web_sys::MediaStreamTrack>() {
track.stop();
}
}
local_media_signal.set(None);
}
mic_granted_signal.set(false);
in_call_signal.set(false);
})
};
let service = SignalingService {
state: SignalingState {
peer_id: peer_id.clone(),
remote_id: remote_id.clone(),
ws_status: ws_status.clone(),
connected: connected.clone(),
websocket: websocket.clone(),
initiator_connection: initiator_connection.clone(),
responder_connection: responder_connection.clone(),
local_media: local_media.clone(),
pending_answer: pending_answer.clone(),
mic_granted: mic_granted.clone(),
in_call: in_call.clone(),
},
actions: SignalingActions {
connect: connect_logic.clone(),
set_remote_id,
request_microphone,
start_call,
leave_call,
},
};
use_context_provider(|| service.clone());
props.children
}