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]
# Dioxus Framework
dioxus = { version = "0.7", features = ["web"] }
dioxus-logger = "0.7"
dioxus = { version = "0.6.0", features = ["web"] }
dioxus-logger = "0.6.2"
console_error_panic_hook = "0.1.7"
# WebAssembly and Browser APIs
@ -20,7 +20,6 @@ web-sys = { version = "0.3.77", features = [
"BinaryType",
"ErrorEvent",
"Navigator",
"Clipboard",
"MediaDevices",
"MediaStream",
"MediaStreamConstraints",

View File

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

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.
- Testbarkeit im Browser (WASM) und auf CLI-Ebene.
## Offene ToDos (Stand 02.11.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.
- Ziel: UI-Komponenten konsumieren nur noch lesende Signale & Events, Logik wird separat testbar.
2. TURN-Infrastruktur produktionsreif aufsetzen (Zertifikate, Auth, Monitoring) und E2E-Tests (Peer↔Peer via TURN) ergänzen.
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.
## Offene ToDos (Stand 30.10.2025)
- UI auf Discord-Optik bringen (Layouts, States, Device-Auswahl).
- Signaling-Protokoll für Räume/Teilnehmer verfeinern.
- WebRTC-Lifecycle vereinfachen (Hooks/State-Store).

View File

@ -1,76 +1,196 @@
use crate::services::signaling::use_signaling;
use crate::models::SignalingMessage;
use crate::utils::MediaManager;
use dioxus::prelude::*;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
#[component]
pub fn CallControls() -> Element {
let service = use_signaling();
let state = service.state.clone();
let actions = service.actions.clone();
let peer_id_value = state.peer_id.read().clone();
let remote_id_value = state.remote_id.read().clone();
let remote_id_for_label = remote_id_value.clone();
let connected = *state.connected.read();
let mic_ready = *state.mic_granted.read();
let in_call = *state.in_call.read();
let has_target = !remote_id_value.is_empty();
let request_microphone = actions.request_microphone.clone();
let start_call = actions.start_call.clone();
let leave_call = actions.leave_call.clone();
pub fn CallControls(
peer_id: Signal<String>,
remote_id: Signal<String>,
connected: Signal<bool>,
websocket: Signal<Option<BrowserWebSocket>>,
peer_connection: Signal<Option<RtcPeerConnection>>, // **INITIATOR CONNECTION**
local_media: Signal<Option<MediaStream>>,
) -> Element {
let mic_granted = use_signal(|| false);
let mut audio_muted = use_signal(|| false);
let mut mute_signal = audio_muted.clone();
let mut reset_signal = audio_muted.clone();
let muted = *audio_muted.read();
let mut in_call = use_signal(|| false);
rsx! {
div { class: "call-controls",
div { class: "call-controls__left",
span { class: "self-pill", "Your ID: {peer_id_value}" }
if !remote_id_for_label.is_empty() {
span { class: "self-pill self-pill--target", "Target: {remote_id_for_label}" }
span { class: "self-pill", "Your ID: {peer_id.read()}" }
if !remote_id.read().is_empty() {
span { class: "self-pill self-pill--target", "Target: {remote_id.read()}" }
}
}
div { class: "call-controls__center",
button {
class: if mic_ready { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" },
disabled: mic_ready,
class: if *mic_granted.read() { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" },
disabled: *mic_granted.read(),
onclick: move |_| {
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 {
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 |_| {
if in_call {
return;
}
log::info!("Launching WebRTC call as initiator");
audio_muted.set(false);
start_call();
let mut pc_signal = peer_connection.clone();
let ws_signal = websocket.clone();
let from_id = peer_id.read().clone();
let to_id = remote_id.read().clone();
let mut in_call_flag = in_call.clone();
spawn(async move {
let pc = if pc_signal.read().is_none() {
match MediaManager::create_peer_connection() {
Ok(new_pc) => {
let ws_clone = ws_signal.clone();
let to_clone = to_id.clone();
let from_clone = from_id.clone();
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
if let Ok(candidate_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("candidate")) {
if candidate_val.is_null() || candidate_val.is_undefined() { return; }
if let Some(ws) = ws_clone.read().as_ref() {
if let Ok(json_js) = js_sys::JSON::stringify(&candidate_val) {
if let Some(json) = json_js.as_string() {
let msg = SignalingMessage {
from: from_clone.clone(),
to: to_clone.clone(),
msg_type: "candidate".to_string(),
data: json,
};
if let Ok(text) = serde_json::to_string(&msg) { let _ = ws.send_with_str(&text); }
}
}
}
}
}) as Box<dyn FnMut(JsValue)>);
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
on_ice.forget();
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
if let Ok(streams_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("streams")) {
if streams_val.is_undefined() || streams_val.is_null() { return; }
let streams_array = js_sys::Array::from(&streams_val);
let first = streams_array.get(0);
if let Ok(stream) = first.clone().dyn_into::<web_sys::MediaStream>() {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Ok(audio_el) = document.create_element("audio") {
if let Ok(audio) = audio_el.dyn_into::<web_sys::HtmlAudioElement>() {
audio.set_autoplay(true);
audio.set_src_object(Some(&stream));
if let Some(body) = document.body() {
let _ = body.append_child(&audio).ok();
}
}
}
}
}
}
}
}) as Box<dyn FnMut(JsValue)>);
new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
on_track.forget();
pc_signal.set(Some(new_pc.clone()));
log::info!("Initiator PeerConnection ready");
new_pc
}
Err(e) => {
log::error!("Failed to create initiator peer connection: {}", e);
return;
}
}
} else {
pc_signal.read().as_ref().unwrap().clone()
};
if let Some(local) = local_media.read().as_ref() {
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(&pc, local) {
log::warn!("Failed to attach local tracks before offer: {}", e);
}
}
match MediaManager::create_offer(&pc).await {
Ok(offer_sdp) => {
if let Some(socket) = ws_signal.read().as_ref() {
let msg = SignalingMessage {
from: from_id.clone(),
to: to_id.clone(),
msg_type: "offer".to_string(),
data: offer_sdp,
};
if let Ok(json) = serde_json::to_string(&msg) {
let _ = socket.send_with_str(&json);
log::info!("Offer dispatched to {}", to_id);
in_call_flag.set(true);
}
}
}
Err(e) => log::error!("Offer creation failed: {}", e),
}
});
},
if in_call { "In call" } else { "Start call" }
"Start call"
}
button {
class: if muted { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" },
disabled: !in_call,
class: if *audio_muted.read() { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" },
disabled: !*in_call.read(),
onclick: move |_| {
let current = *mute_signal.read();
mute_signal.set(!current);
log::info!("Audio {}", if current { "unmuted" } else { "muted" });
let current_muted = *audio_muted.read();
audio_muted.set(!current_muted);
log::info!("Audio {}", if !current_muted { "muted" } else { "unmuted" });
},
if muted { "Unmute" } else { "Mute" }
if *audio_muted.read() { "Unmute" } else { "Mute" }
}
button {
class: "ctrl-btn ctrl-btn--danger",
disabled: !in_call,
disabled: !*in_call.read(),
onclick: move |_| {
reset_signal.set(false);
leave_call();
in_call.set(false);
audio_muted.set(false);
let has_peer_connection = peer_connection.read().is_some();
if has_peer_connection {
if let Some(pc) = peer_connection.read().as_ref() {
pc.close();
log::info!("Initiator PeerConnection closed");
}
peer_connection.set(None);
}
log::info!("Call ended");
},
"Leave"
@ -78,13 +198,7 @@ pub fn CallControls() -> Element {
}
div { class: "call-controls__right",
span { class: "connection-hint",
if !connected {
"Waiting for signaling"
} else if in_call {
"In active call"
} else {
"Connected to signaling"
}
if *connected.read() { "Connected to signaling" } else { "Waiting for 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 futures::StreamExt;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsCast;
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]
pub fn ConnectionPanel() -> Element {
let service = use_signaling();
let state = service.state.clone();
let actions = service.actions.clone();
pub fn ConnectionPanel(
mut peer_id: Signal<String>,
mut remote_id: Signal<String>,
mut connected: Signal<bool>,
mut websocket: Signal<Option<BrowserWebSocket>>,
peer_connection: Signal<Option<RtcPeerConnection>>, // **RESPONDER CONNECTION**
initiator_connection: Signal<Option<RtcPeerConnection>>, // Initiator PC (wird für eingehende Answers verwendet)
local_media: Signal<Option<MediaStream>>,
) -> Element {
let 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();
let ws_status = state.ws_status.read().clone();
let peer_id_value = state.peer_id.read().clone();
let peer_id_for_copy = peer_id_value.clone();
let remote_id_value = state.remote_id.read().clone();
let mic_ready = *state.mic_granted.read();
let in_call = *state.in_call.read();
// **COROUTINE** für Offer-Handling (Responder empfängt Offers)
let offer_handler = use_coroutine(move |mut rx| async move {
while let Some(msg) = rx.next().await {
let SignalingMessage {
from,
to,
msg_type,
data,
} = msg;
let reconnect = actions.connect.clone();
let set_remote_id_action = actions.set_remote_id.clone();
// **KORREKT:** In der Coroutine-Loop
if msg_type == "offer" {
log::info!("📞 WebRTC-Offer von {} als Responder verarbeiten", from);
// **WICHTIG:** Clone für später aufbewahren
let from_clone = from.clone();
// **RESPONDER:** PeerConnection erstellen
let pc = if peer_connection.read().is_none() {
match MediaManager::create_peer_connection() {
Ok(new_pc) => {
// Attach onicecandidate handler to send candidates via websocket
let ws_clone = websocket.clone();
let from_for_ice = from_clone.clone();
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
// ev.candidate may be null/undefined or an object
if let Ok(candidate_val) =
js_sys::Reflect::get(&ev, &JsValue::from_str("candidate"))
{
if candidate_val.is_null() || candidate_val.is_undefined() {
return;
}
if let Some(ws) = ws_clone.read().as_ref() {
// Try to stringify the candidate object directly
if let Ok(json_js) = js_sys::JSON::stringify(&candidate_val)
{
if let Some(json) = json_js.as_string() {
let msg = crate::models::SignalingMessage {
from: peer_id.read().clone(),
to: from_for_ice.clone(),
msg_type: "candidate".to_string(),
data: json,
};
if let Ok(text) = serde_json::to_string(&msg) {
let _ = ws.send_with_str(&text);
}
}
}
}
}
})
as Box<dyn FnMut(JsValue)>);
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
on_ice.forget();
// ontrack -> play remote audio
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
// ev.streams is an array of MediaStream
if let Ok(streams_val) =
js_sys::Reflect::get(&ev, &JsValue::from_str("streams"))
{
if streams_val.is_undefined() || streams_val.is_null() {
return;
}
let streams_array = js_sys::Array::from(&streams_val);
let first = streams_array.get(0);
let stream_js = first.clone();
if let Ok(stream) = stream_js.dyn_into::<web_sys::MediaStream>()
{
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Ok(audio_el) =
document.create_element("audio")
{
if let Ok(audio) = audio_el
.dyn_into::<web_sys::HtmlAudioElement>(
) {
audio.set_autoplay(true);
audio.set_src_object(Some(&stream));
if let Some(body) = document.body() {
let _ = body.append_child(&audio).ok();
}
}
}
}
}
}
}
})
as Box<dyn FnMut(JsValue)>);
new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
on_track.forget();
peer_connection.set(Some(new_pc.clone()));
let from_for_log = from_clone.clone();
log::info!("✅ Responder PeerConnection für {} erstellt", from_for_log);
new_pc
}
Err(e) => {
log::error!("❌ Responder PeerConnection-Fehler: {}", e);
continue;
}
}
} else {
peer_connection.read().as_ref().unwrap().clone()
};
// Offer verarbeiten und Answer erstellen
match MediaManager::handle_offer(&pc, &data).await {
Ok(answer_sdp) => {
log::info!("✅ Responder Answer erstellt, sende zurück...");
if let Some(socket) = websocket.read().as_ref() {
let answer_msg = SignalingMessage {
from: to, // ← to wird moved
to: from, // ← from wird hier moved (Original)
msg_type: "answer".to_string(),
data: answer_sdp,
};
if let Ok(json) = serde_json::to_string(&answer_msg) {
let _ = socket.send_with_str(&json);
log::info!("📤 Responder Answer gesendet an {}", from_clone);
// ✅ Clone verwenden
}
}
}
Err(e) => log::error!("❌ Responder Answer-Fehler: {}", e),
}
}
}
});
// Peer-ID generieren
use_effect(move || {
use js_sys::{Date, Math};
let timestamp = Date::now() as u64;
let random = (Math::random() * 900.0 + 100.0) as u32;
let id = format!("peer-{}-{}", timestamp, random);
peer_id.set(id.clone());
log::info!("🆔 Peer-ID generiert: {}", id);
});
// Einfacher Status-String für das Mikrofon
let mic_status = if local_media.read().is_some() {
"Granted"
} else {
"Not granted"
};
// WebSocket verbinden
let connect_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! {
div { class: "connection-panel",
section { class: "connection-card",
header { class: "connection-card__header",
h3 { "Connection" }
span {
class: if connected { "pill pill--success" } else { "pill pill--danger" },
"{ws_status}"
}
span { class: if *connected.read() { "pill pill--success" } else { "pill pill--danger" }, "{ws_status.read()}" }
}
ul { class: "connection-status-list",
li {
span { class: "label", "WebSocket" }
span {
class: if connected { "value value--success" } else { "value value--danger" },
if connected { "Connected" } else { "Disconnected" }
class: if *connected.read() { "value value--success" } else { "value value--danger" },
if *connected.read() { "Connected" } else { "Disconnected" }
}
}
li {
span { class: "label", "Microphone" }
span {
class: if mic_ready { "value value--success" } else { "value value--danger" },
if mic_ready { "Ready" } else { "Not requested" }
class: if local_media.read().is_some() { "value value--success" } else { "value value--danger" },
"{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 {
class: "input input--readonly",
r#type: "text",
value: "{peer_id_value}",
value: "{peer_id.read()}",
readonly: true
}
button {
class: "icon-btn",
onclick: move |_| {
let copy_target = peer_id_for_copy.clone();
spawn_local(async move {
if let Some(window) = web_sys::window() {
let navigator = window.navigator();
match js_sys::Reflect::get(&navigator, &JsValue::from_str("clipboard")) {
Ok(handle) if !handle.is_undefined() && !handle.is_null() => {
match handle.dyn_into::<web_sys::Clipboard>() {
Ok(clipboard) => {
let promise = clipboard.write_text(&copy_target);
if let Err(err) = JsFuture::from(promise).await {
log::warn!("Clipboard write failed: {:?}", err);
} else {
log::info!("Peer ID copied to clipboard");
}
}
Err(err) => log::warn!("Clipboard handle cast failed: {:?}", err),
}
}
Ok(_) => {
log::warn!("Clipboard API undefined on navigator");
}
Err(err) => log::warn!("Clipboard lookup failed: {:?}", err),
}
} else {
log::warn!("Clipboard copy skipped: no window available");
}
});
log::info!("📋 Peer-ID kopiert: {}", peer_id.read());
},
"📋"
}
@ -121,13 +374,29 @@ pub fn ConnectionPanel() -> Element {
class: "input",
r#type: "text",
placeholder: "Paste peer ID",
value: "{remote_id_value}",
value: "{remote_id.read()}",
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::*;
#[component]
pub fn StatusDisplay() -> Element {
let service = use_signaling();
let state = service.state.clone();
let connected = *state.connected.read();
let ws_status = state.ws_status.read().clone();
let in_call = *state.in_call.read();
let hint_text = format!(
"{} · {}",
ws_status,
if in_call {
"Active call"
} else {
"No active call"
}
);
pub fn StatusDisplay(connected: Signal<bool>) -> Element {
rsx! {
div { class: "status-widget",
span { class: "status-widget__label", "Signaling" }
span {
class: if connected { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" },
if connected { "Online" } else { "Offline" }
class: if *connected.read() { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" },
if *connected.read() { "Online" } else { "Offline" }
}
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::services::signaling::use_signaling;
use dioxus::prelude::*;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
use super::{CallControls, ConnectionPanel, StatusDisplay};
@ -11,6 +9,13 @@ pub struct VoiceChannelProps {
pub channel_name: String,
pub channel_topic: String,
pub participants: Signal<Vec<Participant>>,
pub peer_id: Signal<String>,
pub remote_id: Signal<String>,
pub connected: Signal<bool>,
pub websocket: Signal<Option<BrowserWebSocket>>,
pub responder_connection: Signal<Option<RtcPeerConnection>>,
pub initiator_connection: Signal<Option<RtcPeerConnection>>,
pub local_media: Signal<Option<MediaStream>>,
}
#[component]
@ -20,6 +25,13 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
ChannelSidebar {
channel_name: props.channel_name.clone(),
channel_topic: props.channel_topic.clone(),
peer_id: props.peer_id.clone(),
remote_id: props.remote_id.clone(),
connected: props.connected.clone(),
websocket: props.websocket.clone(),
responder_connection: props.responder_connection.clone(),
initiator_connection: props.initiator_connection.clone(),
local_media: props.local_media.clone(),
}
div { class: "channel-main",
ChannelHeader {
@ -28,7 +40,14 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
}
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 channel_name: String,
pub channel_topic: String,
pub peer_id: Signal<String>,
pub remote_id: Signal<String>,
pub connected: Signal<bool>,
pub websocket: Signal<Option<BrowserWebSocket>>,
pub responder_connection: Signal<Option<RtcPeerConnection>>,
pub initiator_connection: Signal<Option<RtcPeerConnection>>,
pub local_media: Signal<Option<MediaStream>>,
}
#[component]
@ -48,10 +74,18 @@ fn ChannelSidebar(props: ChannelSidebarProps) -> Element {
p { "{props.channel_topic}" }
}
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",
StatusDisplay {}
StatusDisplay { connected: props.connected.clone() }
}
}
}
@ -65,28 +99,6 @@ pub struct ChannelHeaderProps {
#[component]
fn ChannelHeader(props: ChannelHeaderProps) -> Element {
let service = use_signaling();
let state = service.state.clone();
let connected = *state.connected.read();
let in_call = *state.in_call.read();
let call_label = if in_call { "In call" } else { "Call idle" };
let call_class = if in_call {
"channel-pill"
} else {
"channel-pill secondary"
};
let signaling_label = if connected {
"Signaling online"
} else {
"Signaling offline"
};
let signaling_class = if connected {
"channel-pill secondary"
} else {
"channel-pill secondary"
};
rsx! {
header { class: "channel-header",
div { class: "channel-header__text",
@ -94,8 +106,8 @@ fn ChannelHeader(props: ChannelHeaderProps) -> Element {
p { "{props.topic}" }
}
div { class: "channel-header__actions",
button { class: "{call_class}", "{call_label}" }
button { class: "{signaling_class}", "{signaling_label}" }
button { class: "channel-pill", "Voice" }
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]
fn ControlDock() -> Element {
fn ControlDock(props: ControlDockProps) -> Element {
rsx! {
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)]
pub struct ServerOptions {
#[serde(default = "default_stun_server")]
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)]
@ -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
let resp = gloo_net::http::Request::get("appsettings.json")
.send()
.await?;
let resp = gloo_net::http::Request::get("appsettings.json").send().await?;
let text = resp.text().await?;
let cfg: Config = serde_json::from_str(&text)?;
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
#[cfg(target_arch = "wasm32")]
async fn load_config_from_html() -> Result<Option<Config>, Box<dyn std::error::Error>> {
use wasm_bindgen::JsCast;
use web_sys::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.
#[cfg(target_arch = "wasm32")]
pub fn load_config_from_html_sync() -> Option<Config> {
use wasm_bindgen::JsCast;
use web_sys::window;
let win = window()?;
let win = window().ok()?;
let doc = win.document()?;
let elem = doc.get_element_by_id("app-config")?;
if let Some(text) = elem.text_content() {
@ -102,12 +91,7 @@ pub fn load_config_sync_or_default() -> Config {
}
// Fallback default
Config {
server: ServerOptions {
stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string(),
signaling_url: crate::constants::DEFAULT_SIGNALING_URL.to_string(),
},
}
Config { server: ServerOptions { stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string() } }
}
// Native loader convenience wrapper (blocking-friendly)
@ -125,10 +109,5 @@ pub async fn load_config_or_default() -> Config {
}
// Fallback default
Config {
server: ServerOptions {
stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string(),
signaling_url: crate::constants::DEFAULT_SIGNALING_URL.to_string(),
},
}
Config { server: ServerOptions { stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string() } }
}

View File

@ -1,5 +1,4 @@
// Central constants for niom-webrtc
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_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.
pub mod components;
pub mod models;
pub mod utils;
pub mod config;
pub mod constants;
pub mod models;
pub mod services;
pub mod utils;
// Re-export commonly used items if needed in the future
// pub use config::*;

View File

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

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::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{
MediaStream, MediaStreamConstraints, RtcConfiguration, RtcIceServer, RtcPeerConnection,
RtcSdpType, RtcSessionDescriptionInit,
MediaStream, MediaStreamConstraints,
RtcPeerConnection, RtcConfiguration, RtcIceServer,
RtcSessionDescriptionInit, RtcSdpType,
};
use js_sys::Reflect;
use crate::models::MediaState;
pub struct MediaManager {
pub state: MediaState,
@ -20,10 +21,10 @@ impl MediaManager {
}
pub fn create_peer_connection() -> Result<RtcPeerConnection, String> {
let ice_server = RtcIceServer::new();
let urls = js_sys::Array::new();
// Use centralized default STUN server constant
urls.push(&JsValue::from_str(crate::constants::DEFAULT_STUN_SERVER));
let ice_server = RtcIceServer::new();
let urls = js_sys::Array::new();
// Use centralized default STUN server constant
urls.push(&JsValue::from_str(crate::constants::DEFAULT_STUN_SERVER));
ice_server.set_urls(&urls.into());
let config = RtcConfiguration::new();
let servers = js_sys::Array::new();
@ -49,7 +50,7 @@ impl MediaManager {
.ok_or_else(|| "SDP field was not a string".to_string())?;
// 3. Init-Objekt bauen und SDP setzen
let init = RtcSessionDescriptionInit::new(RtcSdpType::Offer);
let init = RtcSessionDescriptionInit::new(RtcSdpType::Offer);
init.set_sdp(&sdp);
// 4. Local Description setzen
@ -58,10 +59,7 @@ impl MediaManager {
.map_err(|e| format!("set_local_description failed: {:?}", e))?;
log::info!("✅ Offer SDP length: {}", sdp.len());
log::debug!(
"📋 SDP-Preview: {}...",
&sdp[..std::cmp::min(150, sdp.len())]
);
log::debug!("📋 SDP-Preview: {}...", &sdp[..std::cmp::min(150, sdp.len())]);
Ok(sdp)
}
@ -98,7 +96,7 @@ impl MediaManager {
pub async fn handle_answer(pc: &RtcPeerConnection, answer_sdp: &str) -> Result<(), String> {
log::info!("📨 Handling received answer...");
// **DEBUG:** State vor Answer-Verarbeitung
// Use the signaling_state() result for debug but avoid importing the enum type locally.
let state = pc.signaling_state();
@ -107,14 +105,14 @@ impl MediaManager {
if state != web_sys::RtcSignalingState::HaveLocalOffer {
return Err(format!("❌ Falscher State für Answer: {:?}", state));
}
let init = RtcSessionDescriptionInit::new(RtcSdpType::Answer);
let init = RtcSessionDescriptionInit::new(RtcSdpType::Answer);
init.set_sdp(answer_sdp);
JsFuture::from(pc.set_remote_description(&init))
.await
.map_err(|e| format!("set_remote_answer desc failed: {:?}", e))?;
log::info!("✅ Handled answer, WebRTC handshake complete!");
Ok(())
}
@ -131,20 +129,19 @@ impl MediaManager {
return Err("WebRTC not supported".into());
}
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
.media_devices()
.map_err(|_| "MediaDevices not available")?;
let constraints = MediaStreamConstraints::new();
constraints.set_audio(&JsValue::from(true));
constraints.set_video(&JsValue::from(false));
let js_stream = JsFuture::from(
devices
.get_user_media_with_constraints(&constraints)
.map_err(|e| format!("getUserMedia error: {:?}", e))?,
)
.await
.map_err(|e| format!("getUserMedia promise rejected: {:?}", e))?;
let js_stream = JsFuture::from(devices.get_user_media_with_constraints(&constraints)
.map_err(|e| format!("getUserMedia error: {:?}", e))?)
.await
.map_err(|e| format!("getUserMedia promise rejected: {:?}", e))?;
let stream: MediaStream = js_stream
.dyn_into()
.map_err(|_| "Failed to cast to MediaStream")?;
@ -159,8 +156,9 @@ impl MediaManager {
// 1. JsValue holen
let js_val = tracks.get(i);
// 2. In MediaStreamTrack casten
let track: web_sys::MediaStreamTrack =
js_val.dyn_into().expect("Expected MediaStreamTrack");
let track: web_sys::MediaStreamTrack = js_val
.dyn_into()
.expect("Expected MediaStreamTrack");
// 3. Stoppen
track.stop();
log::info!("\u{1F6D1} Track gestoppt: {}", track.label());
@ -186,16 +184,9 @@ impl MediaManager {
// 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()),
);
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(())
@ -229,9 +220,7 @@ impl MediaManager {
// 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: js_sys::Function = add_fn.dyn_into().map_err(|_| "addIceCandidate is not a function".to_string())?;
let _ = func.call1(pc.as_ref(), &obj);
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
let _ = fs::remove_file("appsettings.json");
let cfg = load_config_sync_or_default();
assert_eq!(
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()
);
assert_eq!(cfg.server.stun_server, niom_webrtc::constants::DEFAULT_STUN_SERVER.to_string());
}
// 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();
// At minimum we have a non-empty stun_server
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::constants::{DEFAULT_SIGNALING_URL, DEFAULT_STUN_SERVER};
#[test]
fn default_stun_server_present() {
@ -10,16 +10,8 @@ fn default_stun_server_present() {
fn config_from_file_roundtrip() {
// Create a temporary JSON in /tmp and read it via Config::from_file
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");
let cfg = Config::from_file(tmp.path()).expect("load");
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);
}