Compare commits
3 Commits
573abae5e3
...
a75b995cb0
| Author | SHA1 | Date | |
|---|---|---|---|
| a75b995cb0 | |||
| 328d5d1003 | |||
| a917b4142b |
2333
Cargo.lock
generated
2333
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -6,8 +6,8 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Dioxus Framework
|
||||
dioxus = { version = "0.6.0", features = ["web"] }
|
||||
dioxus-logger = "0.6.2"
|
||||
dioxus = { version = "0.7", features = ["web"] }
|
||||
dioxus-logger = "0.7"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
|
||||
# WebAssembly and Browser APIs
|
||||
@ -20,6 +20,7 @@ web-sys = { version = "0.3.77", features = [
|
||||
"BinaryType",
|
||||
"ErrorEvent",
|
||||
"Navigator",
|
||||
"Clipboard",
|
||||
"MediaDevices",
|
||||
"MediaStream",
|
||||
"MediaStreamConstraints",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"server": {
|
||||
"stun_server": "stun:stun.l.google.com:19302"
|
||||
"stun_server": "stun:stun.l.google.com:19302",
|
||||
"signaling_url": "ws://localhost:3478/ws"
|
||||
}
|
||||
}
|
||||
|
||||
6
assets/appsettings.json
Normal file
6
assets/appsettings.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"server": {
|
||||
"stun_server": "stun:stun.l.google.com:19302",
|
||||
"signaling_url": "ws://localhost:1900/ws"
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,10 @@ 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 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).
|
||||
## 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.
|
||||
|
||||
@ -1,196 +1,76 @@
|
||||
use crate::models::SignalingMessage;
|
||||
use crate::utils::MediaManager;
|
||||
use crate::services::signaling::use_signaling;
|
||||
use dioxus::prelude::*;
|
||||
use wasm_bindgen::prelude::Closure;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::JsValue;
|
||||
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
|
||||
|
||||
#[component]
|
||||
pub fn CallControls(
|
||||
peer_id: Signal<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);
|
||||
pub fn CallControls() -> Element {
|
||||
let service = use_signaling();
|
||||
let state = service.state.clone();
|
||||
let actions = service.actions.clone();
|
||||
|
||||
let peer_id_value = state.peer_id.read().clone();
|
||||
let remote_id_value = state.remote_id.read().clone();
|
||||
let remote_id_for_label = remote_id_value.clone();
|
||||
let connected = *state.connected.read();
|
||||
let mic_ready = *state.mic_granted.read();
|
||||
let in_call = *state.in_call.read();
|
||||
let has_target = !remote_id_value.is_empty();
|
||||
|
||||
let request_microphone = actions.request_microphone.clone();
|
||||
let start_call = actions.start_call.clone();
|
||||
let leave_call = actions.leave_call.clone();
|
||||
|
||||
let mut audio_muted = use_signal(|| false);
|
||||
let mut in_call = use_signal(|| false);
|
||||
let mut mute_signal = audio_muted.clone();
|
||||
let mut reset_signal = audio_muted.clone();
|
||||
let muted = *audio_muted.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "call-controls",
|
||||
div { class: "call-controls__left",
|
||||
span { class: "self-pill", "Your ID: {peer_id.read()}" }
|
||||
if !remote_id.read().is_empty() {
|
||||
span { class: "self-pill self-pill--target", "Target: {remote_id.read()}" }
|
||||
span { class: "self-pill", "Your ID: {peer_id_value}" }
|
||||
if !remote_id_for_label.is_empty() {
|
||||
span { class: "self-pill self-pill--target", "Target: {remote_id_for_label}" }
|
||||
}
|
||||
}
|
||||
div { class: "call-controls__center",
|
||||
button {
|
||||
class: if *mic_granted.read() { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" },
|
||||
disabled: *mic_granted.read(),
|
||||
class: if mic_ready { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" },
|
||||
disabled: mic_ready,
|
||||
onclick: move |_| {
|
||||
log::info!("Requesting microphone permission");
|
||||
let mut mm_state = mic_granted.clone();
|
||||
let pc_signal = peer_connection.clone();
|
||||
let mut local_media_signal = local_media.clone();
|
||||
spawn(async move {
|
||||
let mut manager = crate::utils::MediaManager::new();
|
||||
match manager.request_microphone_access().await {
|
||||
Ok(stream) => {
|
||||
log::info!("Microphone granted");
|
||||
mm_state.set(true);
|
||||
local_media_signal.set(Some(stream.clone()));
|
||||
if let Some(pc) = pc_signal.read().as_ref() {
|
||||
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(pc, &stream) {
|
||||
log::warn!("Failed to attach local tracks: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Microphone request failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
request_microphone();
|
||||
},
|
||||
if *mic_granted.read() { "Mic ready" } else { "Enable mic" }
|
||||
if mic_ready { "Mic ready" } else { "Enable mic" }
|
||||
}
|
||||
button {
|
||||
class: "ctrl-btn ctrl-btn--primary",
|
||||
disabled: !*mic_granted.read() || !*connected.read() || remote_id.read().is_empty(),
|
||||
disabled: !mic_ready || !connected || !has_target || in_call,
|
||||
onclick: move |_| {
|
||||
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);
|
||||
if in_call {
|
||||
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),
|
||||
}
|
||||
});
|
||||
log::info!("Launching WebRTC call as initiator");
|
||||
audio_muted.set(false);
|
||||
start_call();
|
||||
},
|
||||
"Start call"
|
||||
if in_call { "In call" } else { "Start call" }
|
||||
}
|
||||
button {
|
||||
class: if *audio_muted.read() { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" },
|
||||
disabled: !*in_call.read(),
|
||||
class: if muted { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" },
|
||||
disabled: !in_call,
|
||||
onclick: move |_| {
|
||||
let current_muted = *audio_muted.read();
|
||||
audio_muted.set(!current_muted);
|
||||
log::info!("Audio {}", if !current_muted { "muted" } else { "unmuted" });
|
||||
let current = *mute_signal.read();
|
||||
mute_signal.set(!current);
|
||||
log::info!("Audio {}", if current { "unmuted" } else { "muted" });
|
||||
},
|
||||
if *audio_muted.read() { "Unmute" } else { "Mute" }
|
||||
if muted { "Unmute" } else { "Mute" }
|
||||
}
|
||||
button {
|
||||
class: "ctrl-btn ctrl-btn--danger",
|
||||
disabled: !*in_call.read(),
|
||||
disabled: !in_call,
|
||||
onclick: move |_| {
|
||||
in_call.set(false);
|
||||
audio_muted.set(false);
|
||||
|
||||
let has_peer_connection = peer_connection.read().is_some();
|
||||
|
||||
if has_peer_connection {
|
||||
if let Some(pc) = peer_connection.read().as_ref() {
|
||||
pc.close();
|
||||
log::info!("Initiator PeerConnection closed");
|
||||
}
|
||||
peer_connection.set(None);
|
||||
}
|
||||
|
||||
reset_signal.set(false);
|
||||
leave_call();
|
||||
log::info!("Call ended");
|
||||
},
|
||||
"Leave"
|
||||
@ -198,7 +78,13 @@ pub fn CallControls(
|
||||
}
|
||||
div { class: "call-controls__right",
|
||||
span { class: "connection-hint",
|
||||
if *connected.read() { "Connected to signaling" } else { "Waiting for signaling" }
|
||||
if !connected {
|
||||
"Waiting for signaling"
|
||||
} else if in_call {
|
||||
"In active call"
|
||||
} else {
|
||||
"Connected to signaling"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,348 +1,69 @@
|
||||
use crate::models::SignalingMessage;
|
||||
use crate::utils::MediaManager;
|
||||
use crate::services::signaling::use_signaling;
|
||||
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;
|
||||
use web_sys::MediaStream;
|
||||
use web_sys::{BinaryType, MessageEvent, RtcPeerConnection, WebSocket as BrowserWebSocket};
|
||||
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||
|
||||
#[component]
|
||||
pub fn ConnectionPanel(
|
||||
mut peer_id: Signal<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>);
|
||||
pub fn ConnectionPanel() -> Element {
|
||||
let service = use_signaling();
|
||||
let state = service.state.clone();
|
||||
let actions = service.actions.clone();
|
||||
|
||||
// **COROUTINE** für Offer-Handling (Responder empfängt Offers)
|
||||
let offer_handler = use_coroutine(move |mut rx| async move {
|
||||
while let Some(msg) = rx.next().await {
|
||||
let SignalingMessage {
|
||||
from,
|
||||
to,
|
||||
msg_type,
|
||||
data,
|
||||
} = msg;
|
||||
let connected = *state.connected.read();
|
||||
let ws_status = state.ws_status.read().clone();
|
||||
let peer_id_value = state.peer_id.read().clone();
|
||||
let peer_id_for_copy = peer_id_value.clone();
|
||||
let remote_id_value = state.remote_id.read().clone();
|
||||
let mic_ready = *state.mic_granted.read();
|
||||
let in_call = *state.in_call.read();
|
||||
|
||||
// **KORREKT:** In der Coroutine-Loop
|
||||
if msg_type == "offer" {
|
||||
log::info!("📞 WebRTC-Offer von {} als Responder verarbeiten", from);
|
||||
|
||||
// **WICHTIG:** Clone für später aufbewahren
|
||||
let from_clone = from.clone();
|
||||
|
||||
// **RESPONDER:** PeerConnection erstellen
|
||||
let pc = if peer_connection.read().is_none() {
|
||||
match MediaManager::create_peer_connection() {
|
||||
Ok(new_pc) => {
|
||||
// Attach onicecandidate handler to send candidates via websocket
|
||||
let ws_clone = websocket.clone();
|
||||
let from_for_ice = from_clone.clone();
|
||||
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
|
||||
// ev.candidate may be null/undefined or an object
|
||||
if let Ok(candidate_val) =
|
||||
js_sys::Reflect::get(&ev, &JsValue::from_str("candidate"))
|
||||
{
|
||||
if candidate_val.is_null() || candidate_val.is_undefined() {
|
||||
return;
|
||||
}
|
||||
if let Some(ws) = ws_clone.read().as_ref() {
|
||||
// Try to stringify the candidate object directly
|
||||
if let Ok(json_js) = js_sys::JSON::stringify(&candidate_val)
|
||||
{
|
||||
if let Some(json) = json_js.as_string() {
|
||||
let msg = crate::models::SignalingMessage {
|
||||
from: peer_id.read().clone(),
|
||||
to: from_for_ice.clone(),
|
||||
msg_type: "candidate".to_string(),
|
||||
data: json,
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string(&msg) {
|
||||
let _ = ws.send_with_str(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
as Box<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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let reconnect = actions.connect.clone();
|
||||
let set_remote_id_action = actions.set_remote_id.clone();
|
||||
|
||||
rsx! {
|
||||
div { class: "connection-panel",
|
||||
section { class: "connection-card",
|
||||
header { class: "connection-card__header",
|
||||
h3 { "Connection" }
|
||||
span { class: if *connected.read() { "pill pill--success" } else { "pill pill--danger" }, "{ws_status.read()}" }
|
||||
span {
|
||||
class: if connected { "pill pill--success" } else { "pill pill--danger" },
|
||||
"{ws_status}"
|
||||
}
|
||||
}
|
||||
ul { class: "connection-status-list",
|
||||
li {
|
||||
span { class: "label", "WebSocket" }
|
||||
span {
|
||||
class: if *connected.read() { "value value--success" } else { "value value--danger" },
|
||||
if *connected.read() { "Connected" } else { "Disconnected" }
|
||||
class: if connected { "value value--success" } else { "value value--danger" },
|
||||
if connected { "Connected" } else { "Disconnected" }
|
||||
}
|
||||
}
|
||||
li {
|
||||
span { class: "label", "Microphone" }
|
||||
span {
|
||||
class: if local_media.read().is_some() { "value value--success" } else { "value value--danger" },
|
||||
"{mic_status}"
|
||||
class: if mic_ready { "value value--success" } else { "value value--danger" },
|
||||
if mic_ready { "Ready" } else { "Not requested" }
|
||||
}
|
||||
}
|
||||
li {
|
||||
span { class: "label", "Call" }
|
||||
span {
|
||||
class: if in_call { "value value--success" } else { "value" },
|
||||
if in_call { "In call" } else { "Idle" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "connection-card__actions",
|
||||
button {
|
||||
class: "ctrl-btn ctrl-btn--secondary",
|
||||
disabled: connected,
|
||||
onclick: move |_| {
|
||||
log::info!("Manual reconnect requested");
|
||||
(reconnect)();
|
||||
},
|
||||
"Reconnect"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -356,13 +77,39 @@ pub fn ConnectionPanel(
|
||||
input {
|
||||
class: "input input--readonly",
|
||||
r#type: "text",
|
||||
value: "{peer_id.read()}",
|
||||
value: "{peer_id_value}",
|
||||
readonly: true
|
||||
}
|
||||
button {
|
||||
class: "icon-btn",
|
||||
onclick: move |_| {
|
||||
log::info!("📋 Peer-ID kopiert: {}", peer_id.read());
|
||||
let copy_target = peer_id_for_copy.clone();
|
||||
spawn_local(async move {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let navigator = window.navigator();
|
||||
match js_sys::Reflect::get(&navigator, &JsValue::from_str("clipboard")) {
|
||||
Ok(handle) if !handle.is_undefined() && !handle.is_null() => {
|
||||
match handle.dyn_into::<web_sys::Clipboard>() {
|
||||
Ok(clipboard) => {
|
||||
let promise = clipboard.write_text(©_target);
|
||||
if let Err(err) = JsFuture::from(promise).await {
|
||||
log::warn!("Clipboard write failed: {:?}", err);
|
||||
} else {
|
||||
log::info!("Peer ID copied to clipboard");
|
||||
}
|
||||
}
|
||||
Err(err) => log::warn!("Clipboard handle cast failed: {:?}", err),
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
log::warn!("Clipboard API undefined on navigator");
|
||||
}
|
||||
Err(err) => log::warn!("Clipboard lookup failed: {:?}", err),
|
||||
}
|
||||
} else {
|
||||
log::warn!("Clipboard copy skipped: no window available");
|
||||
}
|
||||
});
|
||||
},
|
||||
"📋"
|
||||
}
|
||||
@ -374,29 +121,13 @@ pub fn ConnectionPanel(
|
||||
class: "input",
|
||||
r#type: "text",
|
||||
placeholder: "Paste peer ID",
|
||||
value: "{remote_id.read()}",
|
||||
value: "{remote_id_value}",
|
||||
oninput: move |event| {
|
||||
remote_id.set(event.value());
|
||||
(set_remote_id_action.clone())(event.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,32 @@
|
||||
use crate::services::signaling::use_signaling;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn StatusDisplay(connected: Signal<bool>) -> Element {
|
||||
pub fn StatusDisplay() -> Element {
|
||||
let service = use_signaling();
|
||||
let state = service.state.clone();
|
||||
|
||||
let connected = *state.connected.read();
|
||||
let ws_status = state.ws_status.read().clone();
|
||||
let in_call = *state.in_call.read();
|
||||
let hint_text = format!(
|
||||
"{} · {}",
|
||||
ws_status,
|
||||
if in_call {
|
||||
"Active call"
|
||||
} else {
|
||||
"No active call"
|
||||
}
|
||||
);
|
||||
|
||||
rsx! {
|
||||
div { class: "status-widget",
|
||||
span { class: "status-widget__label", "Signaling" }
|
||||
span {
|
||||
class: if *connected.read() { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" },
|
||||
if *connected.read() { "Online" } else { "Offline" }
|
||||
class: if connected { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" },
|
||||
if connected { "Online" } else { "Offline" }
|
||||
}
|
||||
span { class: "status-widget__hint", "TURN integration pending" }
|
||||
span { class: "status-widget__hint", "{hint_text}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::models::Participant;
|
||||
use crate::services::signaling::use_signaling;
|
||||
use dioxus::prelude::*;
|
||||
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
|
||||
|
||||
use super::{CallControls, ConnectionPanel, StatusDisplay};
|
||||
|
||||
@ -9,13 +11,6 @@ pub struct VoiceChannelProps {
|
||||
pub channel_name: String,
|
||||
pub channel_topic: String,
|
||||
pub participants: Signal<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]
|
||||
@ -25,13 +20,6 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
|
||||
ChannelSidebar {
|
||||
channel_name: props.channel_name.clone(),
|
||||
channel_topic: props.channel_topic.clone(),
|
||||
peer_id: props.peer_id.clone(),
|
||||
remote_id: props.remote_id.clone(),
|
||||
connected: props.connected.clone(),
|
||||
websocket: props.websocket.clone(),
|
||||
responder_connection: props.responder_connection.clone(),
|
||||
initiator_connection: props.initiator_connection.clone(),
|
||||
local_media: props.local_media.clone(),
|
||||
}
|
||||
div { class: "channel-main",
|
||||
ChannelHeader {
|
||||
@ -40,14 +28,7 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
|
||||
}
|
||||
ParticipantsGrid { participants: props.participants.clone() }
|
||||
}
|
||||
ControlDock {
|
||||
peer_id: props.peer_id.clone(),
|
||||
remote_id: props.remote_id.clone(),
|
||||
connected: props.connected.clone(),
|
||||
websocket: props.websocket.clone(),
|
||||
initiator_connection: props.initiator_connection.clone(),
|
||||
local_media: props.local_media.clone(),
|
||||
}
|
||||
ControlDock {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -56,13 +37,6 @@ pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
|
||||
pub struct ChannelSidebarProps {
|
||||
pub channel_name: String,
|
||||
pub channel_topic: String,
|
||||
pub peer_id: Signal<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]
|
||||
@ -74,18 +48,10 @@ fn ChannelSidebar(props: ChannelSidebarProps) -> Element {
|
||||
p { "{props.channel_topic}" }
|
||||
}
|
||||
div { class: "channel-sidebar__body",
|
||||
ConnectionPanel {
|
||||
peer_id: props.peer_id.clone(),
|
||||
remote_id: props.remote_id.clone(),
|
||||
connected: props.connected.clone(),
|
||||
websocket: props.websocket.clone(),
|
||||
peer_connection: props.responder_connection.clone(),
|
||||
initiator_connection: props.initiator_connection.clone(),
|
||||
local_media: props.local_media.clone(),
|
||||
}
|
||||
ConnectionPanel {}
|
||||
}
|
||||
div { class: "channel-sidebar__footer",
|
||||
StatusDisplay { connected: props.connected.clone() }
|
||||
StatusDisplay {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -99,6 +65,28 @@ pub struct ChannelHeaderProps {
|
||||
|
||||
#[component]
|
||||
fn ChannelHeader(props: ChannelHeaderProps) -> Element {
|
||||
let service = use_signaling();
|
||||
let state = service.state.clone();
|
||||
let connected = *state.connected.read();
|
||||
let in_call = *state.in_call.read();
|
||||
|
||||
let call_label = if in_call { "In call" } else { "Call idle" };
|
||||
let call_class = if in_call {
|
||||
"channel-pill"
|
||||
} else {
|
||||
"channel-pill secondary"
|
||||
};
|
||||
let signaling_label = if connected {
|
||||
"Signaling online"
|
||||
} else {
|
||||
"Signaling offline"
|
||||
};
|
||||
let signaling_class = if connected {
|
||||
"channel-pill secondary"
|
||||
} else {
|
||||
"channel-pill secondary"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
header { class: "channel-header",
|
||||
div { class: "channel-header__text",
|
||||
@ -106,8 +94,8 @@ fn ChannelHeader(props: ChannelHeaderProps) -> Element {
|
||||
p { "{props.topic}" }
|
||||
}
|
||||
div { class: "channel-header__actions",
|
||||
button { class: "channel-pill", "Voice" }
|
||||
button { class: "channel-pill secondary", "Active" }
|
||||
button { class: "{call_class}", "{call_label}" }
|
||||
button { class: "{signaling_class}", "{signaling_label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -181,28 +169,11 @@ fn initials(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Props, Clone, PartialEq)]
|
||||
pub struct ControlDockProps {
|
||||
pub peer_id: Signal<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(props: ControlDockProps) -> Element {
|
||||
fn ControlDock() -> Element {
|
||||
rsx! {
|
||||
footer { class: "control-dock",
|
||||
CallControls {
|
||||
peer_id: props.peer_id.clone(),
|
||||
remote_id: props.remote_id.clone(),
|
||||
connected: props.connected.clone(),
|
||||
websocket: props.websocket.clone(),
|
||||
peer_connection: props.initiator_connection.clone(),
|
||||
local_media: props.local_media.clone(),
|
||||
}
|
||||
CallControls {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,18 @@ 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)]
|
||||
@ -32,7 +43,9 @@ 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)
|
||||
@ -41,7 +54,6 @@ 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")?;
|
||||
@ -58,10 +70,9 @@ 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().ok()?;
|
||||
let win = window()?;
|
||||
let doc = win.document()?;
|
||||
let elem = doc.get_element_by_id("app-config")?;
|
||||
if let Some(text) = elem.text_content() {
|
||||
@ -91,7 +102,12 @@ pub fn load_config_sync_or_default() -> Config {
|
||||
}
|
||||
|
||||
// Fallback default
|
||||
Config { server: ServerOptions { stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string() } }
|
||||
Config {
|
||||
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)
|
||||
@ -109,5 +125,10 @@ pub async fn load_config_or_default() -> Config {
|
||||
}
|
||||
|
||||
// Fallback default
|
||||
Config { server: ServerOptions { stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string() } }
|
||||
Config {
|
||||
server: ServerOptions {
|
||||
stun_server: crate::constants::DEFAULT_STUN_SERVER.to_string(),
|
||||
signaling_url: crate::constants::DEFAULT_SIGNALING_URL.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// 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";
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
// 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::*;
|
||||
|
||||
19
src/main.rs
19
src/main.rs
@ -5,7 +5,7 @@ use dioxus::prelude::*;
|
||||
use log::Level;
|
||||
use niom_webrtc::components::VoiceChannelLayout;
|
||||
use niom_webrtc::models::Participant;
|
||||
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
|
||||
use niom_webrtc::services::signaling::SignalingProvider;
|
||||
// config functions used via fully-qualified paths below
|
||||
|
||||
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||
@ -59,14 +59,6 @@ fn ConfigProvider() -> Element {
|
||||
pub fn Content() -> Element {
|
||||
// Config is provided by ConfigProvider via provide_context; components can use use_context to read it.
|
||||
|
||||
let peer_id = use_signal(|| "peer-loading...".to_string());
|
||||
let remote_id = use_signal(|| String::new());
|
||||
let connected = use_signal(|| false);
|
||||
let websocket = use_signal(|| None::<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),
|
||||
@ -79,17 +71,12 @@ 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,
|
||||
peer_id,
|
||||
remote_id,
|
||||
connected,
|
||||
websocket,
|
||||
responder_connection,
|
||||
initiator_connection,
|
||||
local_media,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
src/services/mod.rs
Normal file
1
src/services/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod signaling;
|
||||
677
src/services/signaling.rs
Normal file
677
src/services/signaling.rs
Normal file
@ -0,0 +1,677 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
config::Config, constants::DEFAULT_SIGNALING_URL, models::SignalingMessage, utils::MediaManager,
|
||||
};
|
||||
use dioxus::prelude::*;
|
||||
use futures::StreamExt;
|
||||
use wasm_bindgen::{prelude::Closure, JsCast, JsValue};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{
|
||||
BinaryType, MediaStream, MessageEvent, RtcPeerConnection, WebSocket as BrowserWebSocket,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SignalingState {
|
||||
pub peer_id: Signal<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
|
||||
}
|
||||
@ -1,13 +1,12 @@
|
||||
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,
|
||||
RtcPeerConnection, RtcConfiguration, RtcIceServer,
|
||||
RtcSessionDescriptionInit, RtcSdpType,
|
||||
MediaStream, MediaStreamConstraints, RtcConfiguration, RtcIceServer, RtcPeerConnection,
|
||||
RtcSdpType, RtcSessionDescriptionInit,
|
||||
};
|
||||
use js_sys::Reflect;
|
||||
use crate::models::MediaState;
|
||||
|
||||
pub struct MediaManager {
|
||||
pub state: MediaState,
|
||||
@ -59,7 +58,10 @@ 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)
|
||||
}
|
||||
@ -129,17 +131,18 @@ 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))?)
|
||||
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
|
||||
@ -156,9 +159,8 @@ 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());
|
||||
@ -184,9 +186,16 @@ 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(())
|
||||
@ -220,7 +229,9 @@ 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(())
|
||||
}
|
||||
|
||||
@ -6,7 +6,14 @@ 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.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,
|
||||
@ -16,4 +23,5 @@ 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());
|
||||
}
|
||||
|
||||
@ -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,8 +10,16 @@ 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" } }"#;
|
||||
let cfg_json = r#"{ "server": { "stun_server": "stun:example.org:3478", "signaling_url": "ws://example.org/ws" } }"#;
|
||||
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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user