From a75b995cb0fb4db8102bb49c9cfcb7f77aaed5c9 Mon Sep 17 00:00:00 2001 From: ghost Date: Sun, 2 Nov 2025 16:58:50 +0100 Subject: [PATCH] feat: centralize signaling service and update ui --- Cargo.toml | 1 + assets/appsettings.json | 6 + src/components/call_controls.rs | 216 +++------ src/components/connection_panel.rs | 441 +++---------------- src/components/status_display.rs | 25 +- src/components/voice_channel.rs | 93 ++-- src/lib.rs | 1 + src/main.rs | 27 +- src/services/mod.rs | 1 + src/services/signaling.rs | 677 +++++++++++++++++++++++++++++ 10 files changed, 868 insertions(+), 620 deletions(-) create mode 100644 assets/appsettings.json create mode 100644 src/services/mod.rs create mode 100644 src/services/signaling.rs diff --git a/Cargo.toml b/Cargo.toml index a325797..26b1833 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ web-sys = { version = "0.3.77", features = [ "BinaryType", "ErrorEvent", "Navigator", + "Clipboard", "MediaDevices", "MediaStream", "MediaStreamConstraints", diff --git a/assets/appsettings.json b/assets/appsettings.json new file mode 100644 index 0000000..b278eed --- /dev/null +++ b/assets/appsettings.json @@ -0,0 +1,6 @@ +{ + "server": { + "stun_server": "stun:stun.l.google.com:19302", + "signaling_url": "ws://localhost:1900/ws" + } +} diff --git a/src/components/call_controls.rs b/src/components/call_controls.rs index 8a06ce5..0f02ee5 100644 --- a/src/components/call_controls.rs +++ b/src/components/call_controls.rs @@ -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, - remote_id: Signal, - connected: Signal, - websocket: Signal>, - peer_connection: Signal>, // **INITIATOR CONNECTION** - local_media: Signal>, -) -> 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); - 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::() { - 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::() { - audio.set_autoplay(true); - audio.set_src_object(Some(&stream)); - if let Some(body) = document.body() { - let _ = body.append_child(&audio).ok(); - } - } - } - } - } - } - } - }) as Box); - 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" + } } } } diff --git a/src/components/connection_panel.rs b/src/components/connection_panel.rs index 0534c13..e863b27 100644 --- a/src/components/connection_panel.rs +++ b/src/components/connection_panel.rs @@ -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, - mut remote_id: Signal, - mut connected: Signal, - mut websocket: Signal>, - peer_connection: Signal>, // **RESPONDER CONNECTION** - initiator_connection: Signal>, // Initiator PC (wird für eingehende Answers verwendet) - local_media: Signal>, -) -> 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::); - let cfg_signal: Signal = 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); - 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::() - { - 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::( - ) { - audio.set_autoplay(true); - audio.set_src_object(Some(&stream)); - if let Some(body) = document.body() { - let _ = body.append_child(&audio).ok(); - } - } - } - } - } - } - } - }) - as Box); - 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 = { - 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); - - // 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); - - // **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::(&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); - - 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::() { + Ok(clipboard) => { + let promise = clipboard.write_text(©_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()); } } } diff --git a/src/components/status_display.rs b/src/components/status_display.rs index 7ef1c3d..dae55c4 100644 --- a/src/components/status_display.rs +++ b/src/components/status_display.rs @@ -1,15 +1,32 @@ +use crate::services::signaling::use_signaling; use dioxus::prelude::*; #[component] -pub fn StatusDisplay(connected: Signal) -> 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}" } } } } diff --git a/src/components/voice_channel.rs b/src/components/voice_channel.rs index ce31802..3e63680 100644 --- a/src/components/voice_channel.rs +++ b/src/components/voice_channel.rs @@ -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>, - pub peer_id: Signal, - pub remote_id: Signal, - pub connected: Signal, - pub websocket: Signal>, - pub responder_connection: Signal>, - pub initiator_connection: Signal>, - pub local_media: Signal>, } #[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, - pub remote_id: Signal, - pub connected: Signal, - pub websocket: Signal>, - pub responder_connection: Signal>, - pub initiator_connection: Signal>, - pub local_media: Signal>, } #[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, - pub remote_id: Signal, - pub connected: Signal, - pub websocket: Signal>, - pub initiator_connection: Signal>, - pub local_media: Signal>, -} - #[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 {} } } } diff --git a/src/lib.rs b/src/lib.rs index 7d5a141..42a5285 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 diff --git a/src/main.rs b/src/main.rs index 73a8025..f7e8f34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::); - let initiator_connection = use_signal(|| None::); - let responder_connection = use_signal(|| None::); - let local_media = use_signal(|| None::); - 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, + } } } } diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..fd7b70e --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1 @@ +pub mod signaling; diff --git a/src/services/signaling.rs b/src/services/signaling.rs new file mode 100644 index 0000000..f9dda15 --- /dev/null +++ b/src/services/signaling.rs @@ -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, + pub remote_id: Signal, + pub ws_status: Signal, + pub connected: Signal, + pub websocket: Signal>, + pub initiator_connection: Signal>, + pub responder_connection: Signal>, + pub local_media: Signal>, + pub pending_answer: Signal>, + pub mic_granted: Signal, + pub in_call: Signal, +} + +#[derive(Clone)] +pub struct SignalingActions { + pub connect: Rc, + pub set_remote_id: Rc, + pub request_microphone: Rc, + pub start_call: Rc, + pub leave_call: Rc, +} + +#[derive(Clone)] +pub struct SignalingService { + pub state: SignalingState, + pub actions: SignalingActions, +} + +pub fn use_signaling() -> SignalingService { + use_context::() +} + +#[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::); + let initiator_connection = use_signal(|| None::); + let responder_connection = use_signal(|| None::); + let local_media = use_signal(|| None::); + let pending_answer = use_signal(|| None::); + let mic_granted = use_signal(|| false); + let in_call = use_signal(|| false); + + let cfg_signal: Signal = 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); + 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::() + { + 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::( + ) { + audio.set_autoplay(true); + audio.set_src_object(Some(&stream)); + if let Some(body) = document.body() { + let _ = + body.append_child(&audio).ok(); + } + } + } + } + } + } + } + }) + as Box); + 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 = { + 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); + + 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); + + 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::(&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); + + 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); + 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::() + { + 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::( + ) { + audio.set_autoplay(true); + audio.set_src_object(Some(&stream)); + if let Some(body) = document.body() { + let _ = body.append_child(&audio).ok(); + } + } + } + } + } + } + } + }) + as Box); + 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::() { + 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 +}