diff --git a/Cargo.toml b/Cargo.toml index 73d8bfa..e58763b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ web-sys = { version = "0.3.77", features = [ "MediaTrackSettings", "MediaTrackConstraints", "AudioContext", + "HtmlAudioElement", + "HtmlMediaElement", "RtcPeerConnection", "RtcConfiguration", "RtcIceServer", diff --git a/src/components/call_controls.rs b/src/components/call_controls.rs index 07f1077..edf2182 100644 --- a/src/components/call_controls.rs +++ b/src/components/call_controls.rs @@ -1,8 +1,10 @@ use dioxus::prelude::*; -use web_sys::{WebSocket as BrowserWebSocket, RtcPeerConnection}; +use web_sys::{WebSocket as BrowserWebSocket, RtcPeerConnection, MediaStream}; use crate::models::SignalingMessage; use crate::utils::MediaManager; -use futures::StreamExt; +use wasm_bindgen::prelude::Closure; +use wasm_bindgen::JsValue; +use wasm_bindgen::JsCast; #[component] pub fn CallControls( @@ -11,47 +13,12 @@ pub fn CallControls( connected: Signal, websocket: Signal>, peer_connection: Signal>, // **INITIATOR CONNECTION** + local_media: Signal>, ) -> Element { - let mut mic_granted = use_signal(|| false); + let mic_granted = use_signal(|| false); let mut audio_muted = use_signal(|| false); let mut in_call = use_signal(|| false); - // **COROUTINE** für Answer-Handling (Initiator empfängt Answers) - let answer_handler = 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 == "answer" { - log::info!("📞 WebRTC-Answer von {} als Initiator verarbeiten", from); - - if let Some(pc) = peer_connection.read().as_ref() { - // **DEBUG:** State checken vor Answer - let signaling_state = pc.signaling_state(); - log::info!("🔍 Initiator PeerConnection State vor Answer: {:?}", signaling_state); - - // **NUR** Answer verarbeiten wenn im korrekten State (have-local-offer) - match signaling_state { - web_sys::RtcSignalingState::HaveLocalOffer => { - log::info!("✅ Korrekter State für Answer - Initiator verarbeitet"); - match MediaManager::handle_answer(&pc, &data).await { - Ok(_) => { - log::info!("🎉 WebRTC-Handshake als Initiator abgeschlossen!"); - in_call.set(true); - } - Err(e) => log::error!("❌ Initiator Answer-Verarbeitung: {}", e), - } - } - _ => { - log::warn!("⚠️ Answer ignoriert - Initiator PC im falschen State: {:?}", signaling_state); - } - } - } else { - log::error!("❌ Keine Initiator PeerConnection für Answer"); - } - } - } - }); - rsx! { div { class: "call-controls", h2 { "Anruf-Steuerung" } @@ -62,8 +29,29 @@ pub fn CallControls( class: "mic-permission-btn primary", disabled: *mic_granted.read(), onclick: move |_| { - mic_granted.set(true); - log::info!("🎤 Mikrofon-Berechtigung simuliert"); + log::info!("🎤 Fordere Mikrofon-Berechtigung an..."); + 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!("✅ Mikrofonzugang erteilt"); + mm_state.set(true); + // Speichere den Stream global, damit andere Komponenten ihn nutzen können + 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!("Fehler beim Hinzufügen der lokalen Tracks: {}", e); + } + } + } + Err(e) => { + log::error!("❌ Mikrofonzugriff fehlgeschlagen: {}", e); + } + } + }); }, if *mic_granted.read() { "✅ Berechtigung erteilt" @@ -85,13 +73,64 @@ pub fn CallControls( let ws_signal = websocket.clone(); let from_id = peer_id.read().clone(); let to_id = remote_id.read().clone(); - let handler_tx = answer_handler.clone(); spawn(async move { // **INITIATOR:** PeerConnection erstellen let pc = if pc_signal.read().is_none() { match MediaManager::create_peer_connection() { Ok(new_pc) => { + // Attach onicecandidate handler to send candidates via websocket + 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(); + + // ontrack -> play remote audio + 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(); + pc_signal.set(Some(new_pc.clone())); log::info!("✅ Initiator PeerConnection erstellt"); new_pc @@ -105,6 +144,18 @@ pub fn CallControls( pc_signal.read().as_ref().unwrap().clone() }; + // Falls wir bereits Zugriff auf Mikrofon haben, versuchen wir die + // lokalen Tracks erneut hinzuzufügen (no-op, falls schon vorhanden). + // Hinweis: In dieser einfachen Struktur halten wir den MediaStream + // nicht global, daher ist dies ein best-effort. + + // Falls ein lokaler MediaStream vorhanden ist, stelle sicher, dass seine Tracks angehängt sind + if let Some(local) = local_media.read().as_ref() { + if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(&pc, local) { + log::warn!("Fehler beim Hinzufügen der lokalen Tracks vor Offer: {}", e); + } + } + // **INITIATOR:** Offer erstellen und senden match MediaManager::create_offer(&pc).await { Ok(offer_sdp) => { @@ -121,10 +172,8 @@ pub fn CallControls( log::info!("📤 WebRTC-Offer als Initiator gesendet an {}", to_id); // **SETUP:** Answer-Handler für eingehende Answers - if let Some(socket_clone) = ws_signal.read().as_ref() { - // Note: Answer wird über connection_panel's onmessage empfangen - // und an diese Coroutine weitergeleitet - } + // Note: Answer wird über connection_panel's onmessage empfangen + // und an diese Coroutine weitergeleitet } } } diff --git a/src/components/connection_panel.rs b/src/components/connection_panel.rs index 43a8873..6cbe5ac 100644 --- a/src/components/connection_panel.rs +++ b/src/components/connection_panel.rs @@ -1,7 +1,10 @@ use dioxus::prelude::*; use web_sys::{WebSocket as BrowserWebSocket, RtcPeerConnection, BinaryType, MessageEvent}; -use wasm_bindgen::prelude::{Closure, JsValue}; +use web_sys::MediaStream; +use wasm_bindgen::prelude::Closure; +use wasm_bindgen::JsValue; use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; use crate::models::SignalingMessage; use crate::utils::MediaManager; use futures::StreamExt; @@ -13,8 +16,12 @@ pub fn ConnectionPanel( 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 mut ws_status = use_signal(|| "Nicht verbunden".to_string()); + // Buffer for an incoming Answer SDP if the initiator PC isn't ready yet + let mut pending_answer = use_signal(|| None::); // **COROUTINE** für Offer-Handling (Responder empfängt Offers) let offer_handler = use_coroutine(move |mut rx| async move { @@ -32,8 +39,65 @@ pub fn ConnectionPanel( 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())); - log::info!("✅ Responder PeerConnection für {} erstellt", from); + let from_for_log = from_clone.clone(); + log::info!("✅ Responder PeerConnection für {} erstellt", from_for_log); new_pc } Err(e) => { @@ -80,6 +144,9 @@ pub fn ConnectionPanel( log::info!("🆔 Peer-ID generiert: {}", id); }); + // Einfacher Status-String für das Mikrofon + let mic_status = if local_media.read().is_some() { "✅ Erteilt" } else { "✖️ Nicht erteilt" }; + // WebSocket verbinden let connect_websocket = move |_| { log::info!("🔌 Verbinde WebSocket..."); @@ -120,13 +187,47 @@ pub fn ConnectionPanel( offer_tx.send(msg); } "answer" => { - log::info!("🔀 Answer empfangen - müsste an Initiator weitergeleitet werden"); - // **PROBLEM:** Hier müssen wir eine Referenz zum Call-Controls Answer-Handler haben - // **LÖSUNG:** Globaler Message-Bus oder direkte Referenz - - // **WORKAROUND:** Temporär loggen - log::info!("📞 WebRTC-Answer für Initiator empfangen (noch nicht weitergeleitet)"); - // TODO: An call_controls answer_handler weiterleiten + 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"); + pending_answer.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); @@ -161,6 +262,31 @@ pub fn ConnectionPanel( } }; + // 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); + } + } + }); + } + rsx! { div { class: "connection-panel", h2 { "Verbindung" } @@ -172,6 +298,14 @@ pub fn ConnectionPanel( "{ws_status.read()}" } } + + div { class: "status-item", + span { class: "status-label", "Mikrofon:" } + span { + class: if local_media.read().is_some() { "status-value connected" } else { "status-value disconnected" }, + "{mic_status}" + } + } div { class: "input-group", label { "Ihre Peer-ID:" } diff --git a/src/main.rs b/src/main.rs index 1319788..efdd60f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use dioxus::prelude::*; use log::Level; use console_log::init_with_level; use components::{ConnectionPanel, CallControls, StatusDisplay}; -use web_sys::{RtcPeerConnection, WebSocket as BrowserWebSocket}; +use web_sys::{RtcPeerConnection, WebSocket as BrowserWebSocket, MediaStream}; const FAVICON: Asset = asset!("/assets/favicon.ico"); const MAIN_CSS: Asset = asset!("/assets/main.css"); @@ -36,6 +36,8 @@ pub fn Content() -> Element { let mut websocket = use_signal(|| None::); let initiator_connection = use_signal(|| None::); let responder_connection = use_signal(|| None::); + // globaler Signal für den lokal freigegebenen MediaStream (Mikrofon) + let local_media = use_signal(|| None::); rsx! { div { class: "app-container", @@ -49,14 +51,17 @@ pub fn Content() -> Element { remote_id, connected, websocket, - peer_connection: responder_connection + peer_connection: responder_connection, + initiator_connection: initiator_connection, + local_media: local_media } CallControls { peer_id, remote_id, connected, websocket, - peer_connection: initiator_connection + peer_connection: initiator_connection, + local_media: local_media } StatusDisplay { connected, diff --git a/src/utils/media_manager.rs b/src/utils/media_manager.rs index b54c265..072931a 100644 --- a/src/utils/media_manager.rs +++ b/src/utils/media_manager.rs @@ -167,10 +167,67 @@ impl MediaManager { .expect("Expected MediaStreamTrack"); // 3. Stoppen track.stop(); - log::info!("🛑 Track gestoppt: {}", track.label()); + log::info!("\u{1F6D1} Track gestoppt: {}", track.label()); } self.state = MediaState::Uninitialized; - log::info!("🛑 MediaStream gestoppt."); + log::info!("\u{1F6D1} MediaStream gestoppt."); } } + + /// Fügt alle Tracks eines `MediaStream` zur angegebenen `RtcPeerConnection` hinzu. + /// + /// In WebRTC sollten Tracks einzeln mit `addTrack` hinzugefügt werden. Diese + /// Hilfsfunktion iteriert über alle Tracks des Streams und fügt sie hinzu. + pub fn add_stream_to_pc(pc: &RtcPeerConnection, stream: &MediaStream) -> Result<(), String> { + let tracks = stream.get_tracks(); + for i in 0..tracks.length() { + let js_val = tracks.get(i); + let track: web_sys::MediaStreamTrack = js_val + .dyn_into() + .map_err(|_| "Failed to cast to MediaStreamTrack")?; + + // add_track nimmt (track, stream) in JS. In web-sys gibt add_track einen + // RtcRtpSender zurück; wir ignorieren den Rückgabewert hier. + // `addTrack` ist in manchen web-sys-Versionen nicht direkt verfügbar. + // Wir rufen die JS-Funktion dynamisch auf: pc.addTrack(track, stream) + let add_fn = js_sys::Reflect::get(pc.as_ref(), &JsValue::from_str("addTrack")).map_err(|_| "Failed to get addTrack function".to_string())?; + let func: js_sys::Function = add_fn.dyn_into().map_err(|_| "addTrack is not a function".to_string())?; + let _ = func.call2(pc.as_ref(), &JsValue::from(track.clone()), &JsValue::from(stream.clone())); + log::info!("\u{2705} Track hinzugefügt: {}", track.label()); + } + Ok(()) + } + + /// Fügt einen empfangenen ICE-Candidate zur PeerConnection hinzu. + /// `candidate_json` ist ein JSON-String mit Feldern: candidate, sdpMid, sdpMLineIndex + pub fn add_ice_candidate(pc: &RtcPeerConnection, candidate_json: &str) -> Result<(), String> { + // Parse the JSON into a JsValue + // Ignore empty candidate payloads (some browsers send a final empty candidate) + if candidate_json.trim().is_empty() { + log::info!("🔇 Ignoring empty ICE candidate payload"); + return Ok(()); + } + + let js_val = js_sys::JSON::parse(candidate_json) + .map_err(|e| format!("Failed to parse candidate JSON: {:?}", e))?; + + // Prepare a plain JS object: { candidate, sdpMid, sdpMLineIndex } + let obj = js_sys::Object::new(); + if let Ok(candidate) = js_sys::Reflect::get(&js_val, &JsValue::from_str("candidate")) { + let _ = js_sys::Reflect::set(&obj, &JsValue::from_str("candidate"), &candidate); + } + if let Ok(sdp_mid) = js_sys::Reflect::get(&js_val, &JsValue::from_str("sdpMid")) { + let _ = js_sys::Reflect::set(&obj, &JsValue::from_str("sdpMid"), &sdp_mid); + } + if let Ok(idx) = js_sys::Reflect::get(&js_val, &JsValue::from_str("sdpMLineIndex")) { + let _ = js_sys::Reflect::set(&obj, &JsValue::from_str("sdpMLineIndex"), &idx); + } + + // Call pc.addIceCandidate(obj) dynamically + let add_fn = js_sys::Reflect::get(pc.as_ref(), &JsValue::from_str("addIceCandidate")) + .map_err(|_| "Failed to get addIceCandidate function".to_string())?; + let func: js_sys::Function = add_fn.dyn_into().map_err(|_| "addIceCandidate is not a function".to_string())?; + let _ = func.call1(pc.as_ref(), &obj); + Ok(()) + } }