Completed ICE. Audio is transmitted to other client.

This commit is contained in:
ghost 2025-09-25 16:15:19 +02:00
parent cb0d2765d3
commit 676a7fae24
5 changed files with 307 additions and 60 deletions

View File

@ -27,6 +27,8 @@ web-sys = { version = "0.3.77", features = [
"MediaTrackSettings",
"MediaTrackConstraints",
"AudioContext",
"HtmlAudioElement",
"HtmlMediaElement",
"RtcPeerConnection",
"RtcConfiguration",
"RtcIceServer",

View File

@ -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<bool>,
websocket: Signal<Option<BrowserWebSocket>>,
peer_connection: Signal<Option<RtcPeerConnection>>, // **INITIATOR CONNECTION**
local_media: Signal<Option<MediaStream>>,
) -> 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<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| {
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();
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
}
}
}

View File

@ -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<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 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::<String>);
// **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<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()));
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:" }

View File

@ -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::<BrowserWebSocket>);
let initiator_connection = use_signal(|| None::<RtcPeerConnection>);
let responder_connection = use_signal(|| None::<RtcPeerConnection>);
// globaler Signal für den lokal freigegebenen MediaStream (Mikrofon)
let local_media = use_signal(|| None::<MediaStream>);
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,

View File

@ -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(())
}
}