Compare commits

..

No commits in common. "a75b995cb0fb4db8102bb49c9cfcb7f77aaed5c9" and "573abae5e3bfb96ef1c88242cd27dd4715dcac20" have entirely different histories.

18 changed files with 1470 additions and 2460 deletions

2323
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,8 @@ edition = "2021"
[dependencies] [dependencies]
# Dioxus Framework # Dioxus Framework
dioxus = { version = "0.7", features = ["web"] } dioxus = { version = "0.6.0", features = ["web"] }
dioxus-logger = "0.7" dioxus-logger = "0.6.2"
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
# WebAssembly and Browser APIs # WebAssembly and Browser APIs
@ -20,7 +20,6 @@ web-sys = { version = "0.3.77", features = [
"BinaryType", "BinaryType",
"ErrorEvent", "ErrorEvent",
"Navigator", "Navigator",
"Clipboard",
"MediaDevices", "MediaDevices",
"MediaStream", "MediaStream",
"MediaStreamConstraints", "MediaStreamConstraints",

View File

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

View File

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

View File

@ -13,10 +13,7 @@ Diese Dokumentationssammlung beschreibt das MVP-Modul "Voice Channel" im Projekt
- Saubere Trennung von Initiator/Responder-Logik. - Saubere Trennung von Initiator/Responder-Logik.
- Testbarkeit im Browser (WASM) und auf CLI-Ebene. - Testbarkeit im Browser (WASM) und auf CLI-Ebene.
## Offene ToDos (Stand 02.11.2025) ## Offene ToDos (Stand 30.10.2025)
1. WebRTC-/Signaling-Logik aus Komponenten in dedizierte Hooks/Services auslagern (z.B. `use_signaling`, `use_peer_connection`) und globalen State für Teilnehmer & Sessions einführen. - UI auf Discord-Optik bringen (Layouts, States, Device-Auswahl).
- Ziel: UI-Komponenten konsumieren nur noch lesende Signale & Events, Logik wird separat testbar. - Signaling-Protokoll für Räume/Teilnehmer verfeinern.
2. TURN-Infrastruktur produktionsreif aufsetzen (Zertifikate, Auth, Monitoring) und E2E-Tests (Peer↔Peer via TURN) ergänzen. - WebRTC-Lifecycle vereinfachen (Hooks/State-Store).
3. UI modularisieren: Geräte-Setup, Fehlerbanner, Status-Badges, Vorbereitung auf Video-/Screen-Sharing-Tiles.
4. Signaling-Server erweitern (Raum-/Teilnehmermodell, AuthZ, robustes Error-Handling) und Schnittstellen dokumentieren.
5. CI-Pipeline mit `fmt`/`clippy`/Tests, Smoke-Tests (Web + CLI) und Playwright-Szenarien für Browserflows anlegen.

View File

@ -1,76 +1,196 @@
use crate::services::signaling::use_signaling; use crate::models::SignalingMessage;
use crate::utils::MediaManager;
use dioxus::prelude::*; 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] #[component]
pub fn CallControls() -> Element { pub fn CallControls(
let service = use_signaling(); peer_id: Signal<String>,
let state = service.state.clone(); remote_id: Signal<String>,
let actions = service.actions.clone(); connected: Signal<bool>,
websocket: Signal<Option<BrowserWebSocket>>,
let peer_id_value = state.peer_id.read().clone(); peer_connection: Signal<Option<RtcPeerConnection>>, // **INITIATOR CONNECTION**
let remote_id_value = state.remote_id.read().clone(); local_media: Signal<Option<MediaStream>>,
let remote_id_for_label = remote_id_value.clone(); ) -> Element {
let connected = *state.connected.read(); let mic_granted = use_signal(|| false);
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 audio_muted = use_signal(|| false);
let mut mute_signal = audio_muted.clone(); let mut in_call = use_signal(|| false);
let mut reset_signal = audio_muted.clone();
let muted = *audio_muted.read();
rsx! { rsx! {
div { class: "call-controls", div { class: "call-controls",
div { class: "call-controls__left", div { class: "call-controls__left",
span { class: "self-pill", "Your ID: {peer_id_value}" } span { class: "self-pill", "Your ID: {peer_id.read()}" }
if !remote_id_for_label.is_empty() { if !remote_id.read().is_empty() {
span { class: "self-pill self-pill--target", "Target: {remote_id_for_label}" } span { class: "self-pill self-pill--target", "Target: {remote_id.read()}" }
} }
} }
div { class: "call-controls__center", div { class: "call-controls__center",
button { button {
class: if mic_ready { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" }, class: if *mic_granted.read() { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" },
disabled: mic_ready, disabled: *mic_granted.read(),
onclick: move |_| { onclick: move |_| {
log::info!("Requesting microphone permission"); log::info!("Requesting microphone permission");
request_microphone(); 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);
}
}
});
}, },
if mic_ready { "Mic ready" } else { "Enable mic" } if *mic_granted.read() { "Mic ready" } else { "Enable mic" }
} }
button { button {
class: "ctrl-btn ctrl-btn--primary", class: "ctrl-btn ctrl-btn--primary",
disabled: !mic_ready || !connected || !has_target || in_call, disabled: !*mic_granted.read() || !*connected.read() || remote_id.read().is_empty(),
onclick: move |_| { onclick: move |_| {
if in_call { 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; return;
} }
log::info!("Launching WebRTC call as initiator"); }
audio_muted.set(false); } else {
start_call(); 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),
}
});
}, },
if in_call { "In call" } else { "Start call" } "Start call"
} }
button { button {
class: if muted { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" }, class: if *audio_muted.read() { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" },
disabled: !in_call, disabled: !*in_call.read(),
onclick: move |_| { onclick: move |_| {
let current = *mute_signal.read(); let current_muted = *audio_muted.read();
mute_signal.set(!current); audio_muted.set(!current_muted);
log::info!("Audio {}", if current { "unmuted" } else { "muted" }); log::info!("Audio {}", if !current_muted { "muted" } else { "unmuted" });
}, },
if muted { "Unmute" } else { "Mute" } if *audio_muted.read() { "Unmute" } else { "Mute" }
} }
button { button {
class: "ctrl-btn ctrl-btn--danger", class: "ctrl-btn ctrl-btn--danger",
disabled: !in_call, disabled: !*in_call.read(),
onclick: move |_| { onclick: move |_| {
reset_signal.set(false); in_call.set(false);
leave_call(); 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);
}
log::info!("Call ended"); log::info!("Call ended");
}, },
"Leave" "Leave"
@ -78,13 +198,7 @@ pub fn CallControls() -> Element {
} }
div { class: "call-controls__right", div { class: "call-controls__right",
span { class: "connection-hint", span { class: "connection-hint",
if !connected { if *connected.read() { "Connected to signaling" } else { "Waiting for signaling" }
"Waiting for signaling"
} else if in_call {
"In active call"
} else {
"Connected to signaling"
}
} }
} }
} }

View File

@ -1,69 +1,348 @@
use crate::services::signaling::use_signaling; use crate::models::SignalingMessage;
use crate::utils::MediaManager;
use dioxus::prelude::*; use dioxus::prelude::*;
use futures::StreamExt;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue; use wasm_bindgen::JsValue;
use wasm_bindgen_futures::{spawn_local, JsFuture}; use wasm_bindgen_futures::spawn_local;
use web_sys::MediaStream;
use web_sys::{BinaryType, MessageEvent, RtcPeerConnection, WebSocket as BrowserWebSocket};
#[component] #[component]
pub fn ConnectionPanel() -> Element { pub fn ConnectionPanel(
let service = use_signaling(); mut peer_id: Signal<String>,
let state = service.state.clone(); mut remote_id: Signal<String>,
let actions = service.actions.clone(); 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>);
let connected = *state.connected.read(); // **COROUTINE** für Offer-Handling (Responder empfängt Offers)
let ws_status = state.ws_status.read().clone(); let offer_handler = use_coroutine(move |mut rx| async move {
let peer_id_value = state.peer_id.read().clone(); while let Some(msg) = rx.next().await {
let peer_id_for_copy = peer_id_value.clone(); let SignalingMessage {
let remote_id_value = state.remote_id.read().clone(); from,
let mic_ready = *state.mic_granted.read(); to,
let in_call = *state.in_call.read(); msg_type,
data,
} = msg;
let reconnect = actions.connect.clone(); // **KORREKT:** In der Coroutine-Loop
let set_remote_id_action = actions.set_remote_id.clone(); 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_websocket = move |_| {
log::info!("🔌 Verbinde WebSocket...");
ws_status.set("Verbinde...".to_string());
match BrowserWebSocket::new("ws://localhost:3478/ws") {
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 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");
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);
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());
}
}
};
// 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! { rsx! {
div { class: "connection-panel", div { class: "connection-panel",
section { class: "connection-card", section { class: "connection-card",
header { class: "connection-card__header", header { class: "connection-card__header",
h3 { "Connection" } h3 { "Connection" }
span { span { class: if *connected.read() { "pill pill--success" } else { "pill pill--danger" }, "{ws_status.read()}" }
class: if connected { "pill pill--success" } else { "pill pill--danger" },
"{ws_status}"
}
} }
ul { class: "connection-status-list", ul { class: "connection-status-list",
li { li {
span { class: "label", "WebSocket" } span { class: "label", "WebSocket" }
span { span {
class: if connected { "value value--success" } else { "value value--danger" }, class: if *connected.read() { "value value--success" } else { "value value--danger" },
if connected { "Connected" } else { "Disconnected" } if *connected.read() { "Connected" } else { "Disconnected" }
} }
} }
li { li {
span { class: "label", "Microphone" } span { class: "label", "Microphone" }
span { span {
class: if mic_ready { "value value--success" } else { "value value--danger" }, class: if local_media.read().is_some() { "value value--success" } else { "value value--danger" },
if mic_ready { "Ready" } else { "Not requested" } "{mic_status}"
} }
} }
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"
}
} }
} }
@ -77,39 +356,13 @@ pub fn ConnectionPanel() -> Element {
input { input {
class: "input input--readonly", class: "input input--readonly",
r#type: "text", r#type: "text",
value: "{peer_id_value}", value: "{peer_id.read()}",
readonly: true readonly: true
} }
button { button {
class: "icon-btn", class: "icon-btn",
onclick: move |_| { onclick: move |_| {
let copy_target = peer_id_for_copy.clone(); log::info!("📋 Peer-ID kopiert: {}", peer_id.read());
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");
}
});
}, },
"📋" "📋"
} }
@ -121,13 +374,29 @@ pub fn ConnectionPanel() -> Element {
class: "input", class: "input",
r#type: "text", r#type: "text",
placeholder: "Paste peer ID", placeholder: "Paste peer ID",
value: "{remote_id_value}", value: "{remote_id.read()}",
oninput: move |event| { oninput: move |event| {
(set_remote_id_action.clone())(event.value()); remote_id.set(event.value());
} }
} }
} }
} }
section { class: "connection-card",
header { class: "connection-card__header",
h3 { "Networking" }
}
button {
class: if *connected.read() { "btn btn--connected" } else { "btn" },
disabled: *connected.read(),
onclick: connect_websocket,
if *connected.read() {
"Connected"
} else {
"Connect"
}
}
}
} }
} }
} }

View File

@ -1,32 +1,15 @@
use crate::services::signaling::use_signaling;
use dioxus::prelude::*; use dioxus::prelude::*;
#[component] #[component]
pub fn StatusDisplay() -> Element { pub fn StatusDisplay(connected: Signal<bool>) -> 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! { rsx! {
div { class: "status-widget", div { class: "status-widget",
span { class: "status-widget__label", "Signaling" } span { class: "status-widget__label", "Signaling" }
span { span {
class: if connected { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" }, class: if *connected.read() { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" },
if connected { "Online" } else { "Offline" } if *connected.read() { "Online" } else { "Offline" }
} }
span { class: "status-widget__hint", "{hint_text}" } span { class: "status-widget__hint", "TURN integration pending" }
} }
} }
} }

View File

@ -1,8 +1,6 @@
#![allow(non_snake_case)]
use crate::models::Participant; use crate::models::Participant;
use crate::services::signaling::use_signaling;
use dioxus::prelude::*; use dioxus::prelude::*;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
use super::{CallControls, ConnectionPanel, StatusDisplay}; use super::{CallControls, ConnectionPanel, StatusDisplay};
@ -11,6 +9,13 @@ pub struct VoiceChannelProps {
pub channel_name: String, pub channel_name: String,
pub channel_topic: String, pub channel_topic: String,
pub participants: Signal<Vec<Participant>>, 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] #[component]
@ -20,6 +25,13 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
ChannelSidebar { ChannelSidebar {
channel_name: props.channel_name.clone(), channel_name: props.channel_name.clone(),
channel_topic: props.channel_topic.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", div { class: "channel-main",
ChannelHeader { ChannelHeader {
@ -28,7 +40,14 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
} }
ParticipantsGrid { participants: props.participants.clone() } ParticipantsGrid { participants: props.participants.clone() }
} }
ControlDock {} 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(),
}
} }
} }
} }
@ -37,6 +56,13 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
pub struct ChannelSidebarProps { pub struct ChannelSidebarProps {
pub channel_name: String, pub channel_name: String,
pub channel_topic: 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] #[component]
@ -48,10 +74,18 @@ fn ChannelSidebar(props: ChannelSidebarProps) -> Element {
p { "{props.channel_topic}" } p { "{props.channel_topic}" }
} }
div { class: "channel-sidebar__body", div { class: "channel-sidebar__body",
ConnectionPanel {} 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(),
}
} }
div { class: "channel-sidebar__footer", div { class: "channel-sidebar__footer",
StatusDisplay {} StatusDisplay { connected: props.connected.clone() }
} }
} }
} }
@ -65,28 +99,6 @@ pub struct ChannelHeaderProps {
#[component] #[component]
fn ChannelHeader(props: ChannelHeaderProps) -> Element { 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! { rsx! {
header { class: "channel-header", header { class: "channel-header",
div { class: "channel-header__text", div { class: "channel-header__text",
@ -94,8 +106,8 @@ fn ChannelHeader(props: ChannelHeaderProps) -> Element {
p { "{props.topic}" } p { "{props.topic}" }
} }
div { class: "channel-header__actions", div { class: "channel-header__actions",
button { class: "{call_class}", "{call_label}" } button { class: "channel-pill", "Voice" }
button { class: "{signaling_class}", "{signaling_label}" } button { class: "channel-pill secondary", "Active" }
} }
} }
} }
@ -169,11 +181,28 @@ 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] #[component]
fn ControlDock() -> Element { fn ControlDock(props: ControlDockProps) -> Element {
rsx! { rsx! {
footer { class: "control-dock", footer { class: "control-dock",
CallControls {} 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(),
}
} }
} }
} }

View File

@ -3,18 +3,7 @@ use std::path::Path;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct ServerOptions { pub struct ServerOptions {
#[serde(default = "default_stun_server")]
pub stun_server: String, pub stun_server: String,
#[serde(default = "default_signaling_url")]
pub signaling_url: String,
}
fn default_stun_server() -> String {
crate::constants::DEFAULT_STUN_SERVER.to_string()
}
fn default_signaling_url() -> String {
crate::constants::DEFAULT_SIGNALING_URL.to_string()
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@ -43,9 +32,7 @@ pub async fn load_config_from_server() -> Result<Config, Box<dyn std::error::Err
} }
// Fallback to fetching appsettings.json from the hosting origin // Fallback to fetching appsettings.json from the hosting origin
let resp = gloo_net::http::Request::get("appsettings.json") let resp = gloo_net::http::Request::get("appsettings.json").send().await?;
.send()
.await?;
let text = resp.text().await?; let text = resp.text().await?;
let cfg: Config = serde_json::from_str(&text)?; let cfg: Config = serde_json::from_str(&text)?;
Ok(cfg) Ok(cfg)
@ -54,6 +41,7 @@ pub async fn load_config_from_server() -> Result<Config, Box<dyn std::error::Err
// Try to read a JSON config injected into index.html inside a script tag // Try to read a JSON config injected into index.html inside a script tag
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
async fn load_config_from_html() -> Result<Option<Config>, Box<dyn std::error::Error>> { async fn load_config_from_html() -> Result<Option<Config>, Box<dyn std::error::Error>> {
use wasm_bindgen::JsCast;
use web_sys::window; use web_sys::window;
let win = window().ok_or("no window")?; let win = window().ok_or("no window")?;
@ -70,9 +58,10 @@ async fn load_config_from_html() -> Result<Option<Config>, Box<dyn std::error::E
// Synchronous HTML fast-path for WASM: read script#app-config synchronously if present. // Synchronous HTML fast-path for WASM: read script#app-config synchronously if present.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn load_config_from_html_sync() -> Option<Config> { pub fn load_config_from_html_sync() -> Option<Config> {
use wasm_bindgen::JsCast;
use web_sys::window; use web_sys::window;
let win = window()?; let win = window().ok()?;
let doc = win.document()?; let doc = win.document()?;
let elem = doc.get_element_by_id("app-config")?; let elem = doc.get_element_by_id("app-config")?;
if let Some(text) = elem.text_content() { if let Some(text) = elem.text_content() {
@ -102,12 +91,7 @@ pub fn load_config_sync_or_default() -> Config {
} }
// Fallback default // Fallback default
Config { Config { server: ServerOptions { stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string() } }
server: ServerOptions {
stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string(),
signaling_url: crate::constants::DEFAULT_SIGNALING_URL.to_string(),
},
}
} }
// Native loader convenience wrapper (blocking-friendly) // Native loader convenience wrapper (blocking-friendly)
@ -125,10 +109,5 @@ pub async fn load_config_or_default() -> Config {
} }
// Fallback default // Fallback default
Config { Config { server: ServerOptions { stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string() } }
server: ServerOptions {
stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string(),
signaling_url: crate::constants::DEFAULT_SIGNALING_URL.to_string(),
},
}
} }

View File

@ -1,5 +1,4 @@
// Central constants for niom-webrtc // Central constants for niom-webrtc
pub const DEFAULT_STUN_SERVER: &str = "stun:stun.l.google.com:19302"; pub const DEFAULT_STUN_SERVER: &str = "stun:stun.l.google.com:19302";
pub const DEFAULT_SIGNALING_URL: &str = "ws://localhost:3478/ws";
pub const ASSET_FAVICON: &str = "/assets/favicon.ico"; pub const ASSET_FAVICON: &str = "/assets/favicon.ico";
pub const ASSET_MAIN_CSS: &str = "/assets/main.css"; pub const ASSET_MAIN_CSS: &str = "/assets/main.css";

View File

@ -1,10 +1,9 @@
// Library root for niom-webrtc so integration tests and other crates can depend on the modules. // Library root for niom-webrtc so integration tests and other crates can depend on the modules.
pub mod components; pub mod components;
pub mod models;
pub mod utils;
pub mod config; pub mod config;
pub mod constants; pub mod constants;
pub mod models;
pub mod services;
pub mod utils;
// Re-export commonly used items if needed in the future // Re-export commonly used items if needed in the future
// pub use config::*; // pub use config::*;

View File

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

View File

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

View File

@ -1,677 +0,0 @@
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
}

View File

@ -1,12 +1,13 @@
use crate::models::MediaState;
use js_sys::Reflect;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::{ use web_sys::{
MediaStream, MediaStreamConstraints, RtcConfiguration, RtcIceServer, RtcPeerConnection, MediaStream, MediaStreamConstraints,
RtcSdpType, RtcSessionDescriptionInit, RtcPeerConnection, RtcConfiguration, RtcIceServer,
RtcSessionDescriptionInit, RtcSdpType,
}; };
use js_sys::Reflect;
use crate::models::MediaState;
pub struct MediaManager { pub struct MediaManager {
pub state: MediaState, pub state: MediaState,
@ -58,10 +59,7 @@ impl MediaManager {
.map_err(|e| format!("set_local_description failed: {:?}", e))?; .map_err(|e| format!("set_local_description failed: {:?}", e))?;
log::info!("✅ Offer SDP length: {}", sdp.len()); log::info!("✅ Offer SDP length: {}", sdp.len());
log::debug!( log::debug!("📋 SDP-Preview: {}...", &sdp[..std::cmp::min(150, sdp.len())]);
"📋 SDP-Preview: {}...",
&sdp[..std::cmp::min(150, sdp.len())]
);
Ok(sdp) Ok(sdp)
} }
@ -131,18 +129,17 @@ impl MediaManager {
return Err("WebRTC not supported".into()); return Err("WebRTC not supported".into());
} }
self.state = MediaState::Requesting; self.state = MediaState::Requesting;
let navigator = web_sys::window().ok_or("No window")?.navigator(); let navigator = web_sys::window()
.ok_or("No window")?
.navigator();
let devices = navigator let devices = navigator
.media_devices() .media_devices()
.map_err(|_| "MediaDevices not available")?; .map_err(|_| "MediaDevices not available")?;
let constraints = MediaStreamConstraints::new(); let constraints = MediaStreamConstraints::new();
constraints.set_audio(&JsValue::from(true)); constraints.set_audio(&JsValue::from(true));
constraints.set_video(&JsValue::from(false)); constraints.set_video(&JsValue::from(false));
let js_stream = JsFuture::from( let js_stream = JsFuture::from(devices.get_user_media_with_constraints(&constraints)
devices .map_err(|e| format!("getUserMedia error: {:?}", e))?)
.get_user_media_with_constraints(&constraints)
.map_err(|e| format!("getUserMedia error: {:?}", e))?,
)
.await .await
.map_err(|e| format!("getUserMedia promise rejected: {:?}", e))?; .map_err(|e| format!("getUserMedia promise rejected: {:?}", e))?;
let stream: MediaStream = js_stream let stream: MediaStream = js_stream
@ -159,8 +156,9 @@ impl MediaManager {
// 1. JsValue holen // 1. JsValue holen
let js_val = tracks.get(i); let js_val = tracks.get(i);
// 2. In MediaStreamTrack casten // 2. In MediaStreamTrack casten
let track: web_sys::MediaStreamTrack = let track: web_sys::MediaStreamTrack = js_val
js_val.dyn_into().expect("Expected MediaStreamTrack"); .dyn_into()
.expect("Expected MediaStreamTrack");
// 3. Stoppen // 3. Stoppen
track.stop(); track.stop();
log::info!("\u{1F6D1} Track gestoppt: {}", track.label()); log::info!("\u{1F6D1} Track gestoppt: {}", track.label());
@ -186,16 +184,9 @@ impl MediaManager {
// RtcRtpSender zurück; wir ignorieren den Rückgabewert hier. // RtcRtpSender zurück; wir ignorieren den Rückgabewert hier.
// `addTrack` ist in manchen web-sys-Versionen nicht direkt verfügbar. // `addTrack` ist in manchen web-sys-Versionen nicht direkt verfügbar.
// Wir rufen die JS-Funktion dynamisch auf: pc.addTrack(track, stream) // 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")) let add_fn = js_sys::Reflect::get(pc.as_ref(), &JsValue::from_str("addTrack")).map_err(|_| "Failed to get addTrack function".to_string())?;
.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: js_sys::Function = add_fn let _ = func.call2(pc.as_ref(), &JsValue::from(track.clone()), &JsValue::from(stream.clone()));
.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()); log::info!("\u{2705} Track hinzugefügt: {}", track.label());
} }
Ok(()) Ok(())
@ -229,9 +220,7 @@ impl MediaManager {
// Call pc.addIceCandidate(obj) dynamically // Call pc.addIceCandidate(obj) dynamically
let add_fn = js_sys::Reflect::get(pc.as_ref(), &JsValue::from_str("addIceCandidate")) let add_fn = js_sys::Reflect::get(pc.as_ref(), &JsValue::from_str("addIceCandidate"))
.map_err(|_| "Failed to get addIceCandidate function".to_string())?; .map_err(|_| "Failed to get addIceCandidate function".to_string())?;
let func: js_sys::Function = add_fn let func: js_sys::Function = add_fn.dyn_into().map_err(|_| "addIceCandidate is not a function".to_string())?;
.dyn_into()
.map_err(|_| "addIceCandidate is not a function".to_string())?;
let _ = func.call1(pc.as_ref(), &obj); let _ = func.call1(pc.as_ref(), &obj);
Ok(()) Ok(())
} }

View File

@ -6,14 +6,7 @@ fn sync_loader_returns_default_when_no_file() {
// Ensure there's no appsettings.json in CWD for this test // Ensure there's no appsettings.json in CWD for this test
let _ = fs::remove_file("appsettings.json"); let _ = fs::remove_file("appsettings.json");
let cfg = load_config_sync_or_default(); let cfg = load_config_sync_or_default();
assert_eq!( assert_eq!(cfg.server.stun_server, niom_webrtc::constants::DEFAULT_STUN_SERVER.to_string());
cfg.server.stun_server,
niom_webrtc::constants::DEFAULT_STUN_SERVER.to_string()
);
assert_eq!(
cfg.server.signaling_url,
niom_webrtc::constants::DEFAULT_SIGNALING_URL.to_string()
);
} }
// This test ensures the function compiles and returns a Config; on native it will use the file-path, // This test ensures the function compiles and returns a Config; on native it will use the file-path,
@ -23,5 +16,4 @@ fn sync_loader_api_callable() {
let cfg = load_config_sync_or_default(); let cfg = load_config_sync_or_default();
// At minimum we have a non-empty stun_server // At minimum we have a non-empty stun_server
assert!(!cfg.server.stun_server.is_empty()); assert!(!cfg.server.stun_server.is_empty());
assert!(!cfg.server.signaling_url.is_empty());
} }

View File

@ -1,5 +1,5 @@
use niom_webrtc::constants::DEFAULT_STUN_SERVER;
use niom_webrtc::config::Config; use niom_webrtc::config::Config;
use niom_webrtc::constants::{DEFAULT_SIGNALING_URL, DEFAULT_STUN_SERVER};
#[test] #[test]
fn default_stun_server_present() { fn default_stun_server_present() {
@ -10,16 +10,8 @@ fn default_stun_server_present() {
fn config_from_file_roundtrip() { fn config_from_file_roundtrip() {
// Create a temporary JSON in /tmp and read it via Config::from_file // Create a temporary JSON in /tmp and read it via Config::from_file
let tmp = tempfile::NamedTempFile::new().expect("tempfile"); let tmp = tempfile::NamedTempFile::new().expect("tempfile");
let cfg_json = r#"{ "server": { "stun_server": "stun:example.org:3478", "signaling_url": "ws://example.org/ws" } }"#; let cfg_json = r#"{ "server": { "stun_server": "stun:example.org:3478" } }"#;
std::fs::write(tmp.path(), cfg_json).expect("write"); std::fs::write(tmp.path(), cfg_json).expect("write");
let cfg = Config::from_file(tmp.path()).expect("load"); let cfg = Config::from_file(tmp.path()).expect("load");
assert_eq!(cfg.server.stun_server, "stun:example.org:3478"); assert_eq!(cfg.server.stun_server, "stun:example.org:3478");
assert_eq!(cfg.server.signaling_url, "ws://example.org/ws");
// Missing fields fall back to defaults
let cfg_json_missing = r#"{ "server": { "stun_server": "stun:another.org:9999" } }"#;
std::fs::write(tmp.path(), cfg_json_missing).expect("write missing");
let cfg_missing = Config::from_file(tmp.path()).expect("load missing");
assert_eq!(cfg_missing.server.stun_server, "stun:another.org:9999");
assert_eq!(cfg_missing.server.signaling_url, DEFAULT_SIGNALING_URL);
} }