Revamp voice channel layout

This commit is contained in:
ghost 2025-10-31 18:33:03 +01:00
parent 5f0e6a2b79
commit 573abae5e3
14 changed files with 1020 additions and 513 deletions

View File

@ -1,322 +1,543 @@
/* Basis-Styling */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #1e1f22;
--bg-secondary: #2b2d31;
--bg-tertiary: #313338;
--bg-overlay: rgba(0, 0, 0, 0.25);
--border: rgba(255, 255, 255, 0.07);
--accent: #5865f2;
--accent-secondary: #43b581;
--danger: #f23f42;
--text-primary: #f2f3f5;
--text-secondary: #b5bac1;
--text-muted: #8b929e;
--tile-muted-bg: rgba(255, 255, 255, 0.04);
--tile-speaking: rgba(67, 181, 129, 0.35);
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.2);
--radius-lg: 18px;
--radius-md: 12px;
--radius-sm: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
font-family: 'Inter', 'Segoe UI', sans-serif;
background: radial-gradient(circle at top left, rgba(88, 101, 242, 0.18), transparent 35%),
radial-gradient(circle at bottom right, rgba(235, 69, 158, 0.12), transparent 30%),
var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: stretch;
}
.app-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
/* Header */
header {
text-align: center;
margin-bottom: 40px;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
header h1 {
color: #2563eb;
margin-bottom: 10px;
}
header p {
color: #666;
}
/* Main Content */
.main-content {
.voice-channel {
display: grid;
gap: 20px;
grid-template-columns: 1fr;
grid-template-columns: 320px 1fr;
grid-template-rows: 1fr auto;
grid-template-areas:
"sidebar main"
"sidebar controls";
width: 100%;
min-height: 100vh;
backdrop-filter: blur(16px);
}
.channel-sidebar {
grid-area: sidebar;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 32px 24px;
display: flex;
flex-direction: column;
gap: 28px;
}
.channel-sidebar__head h2 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.channel-sidebar__head p {
margin-top: 4px;
color: var(--text-muted);
font-size: 14px;
}
.channel-sidebar__body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding-right: 4px;
}
.channel-sidebar__footer {
padding-top: 12px;
border-top: 1px solid var(--border);
}
/* Connection Panel */
.connection-panel {
background: white;
padding: 24px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.connection-panel h2 {
margin-bottom: 20px;
color: #1f2937;
}
/* Input Group Layout */
.input-group {
margin-bottom: 16px;
display: flex;
align-items: flex-end;
gap: 8px;
flex-direction: column;
gap: 16px;
}
.input-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #374151;
.connection-card {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 18px;
box-shadow: var(--shadow-soft);
}
.input-group input {
width: 100%;
padding: 10px 12px;
border: 2px solid #e5e7eb;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
flex: 1;
}
.input-group input:focus {
outline: none;
border-color: #2563eb;
}
/* Buttons */
button {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:disabled:hover {
background-color: inherit;
}
.connect-btn {
background-color: #2563eb;
color: white;
width: 100%;
}
.connect-btn:hover {
background-color: #1d4ed8;
}
/* Call Controls */
.call-controls {
background: white;
padding: 24px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.call-controls h2 {
margin-bottom: 20px;
color: #1f2937;
}
.control-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.control-buttons button {
flex: 1;
min-width: 120px;
}
.primary {
background-color: #10b981;
color: white;
}
.primary:hover {
background-color: #059669;
}
.danger {
background-color: #ef4444;
color: white;
}
.danger:hover {
background-color: #dc2626;
}
.mute-btn {
background-color: #6b7280;
color: white;
}
.mute-btn:hover {
background-color: #4b5563;
}
/* Status Display */
.status-display {
background: white;
padding: 24px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.status-display h2 {
margin-bottom: 20px;
color: #1f2937;
}
.status-item {
.connection-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding: 8px 0;
border-bottom: 1px solid #f3f4f6;
}
.status-label {
font-weight: 500;
color: #374151;
.connection-card__header h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.status-value {
font-weight: 500;
}
.status-value.disconnected {
color: #ef4444;
}
.status-value.connected {
color: #10b981;
}
.status-value.requesting {
color: #f59e0b;
}
/* Responsive Design */
@media (min-width: 768px) {
.main-content {
grid-template-columns: 1fr 1fr;
}
.status-display {
grid-column: 1 / -1;
}
}
/* Readonly Input */
.readonly-input {
background-color: #f9fafb !important;
cursor: not-allowed;
color: #6b7280;
}
/* Copy Button */
.copy-btn {
margin-left: 8px;
padding: 8px 12px;
background-color: #6b7280;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.copy-btn:hover {
background-color: #4b5563;
}
/* Muted Button State */
.muted {
background-color: #ef4444 !important;
}
.muted:hover {
background-color: #dc2626 !important;
}
/* Peer ID Display */
.peer-id {
font-family: 'Courier New', monospace;
.pill {
font-size: 12px;
background-color: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.12em;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
}
/* Mic Test */
.mic-test-section {
margin-bottom: 20px;
padding: 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
background-color: #f9fafb;
.pill--success {
color: var(--accent-secondary);
}
.mic-test-section h3 {
.pill--danger {
color: var(--danger);
}
.connection-status-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.connection-status-list .label {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.connection-status-list .value {
font-weight: 600;
color: var(--text-secondary);
}
.connection-status-list .value--success {
color: var(--accent-secondary);
}
.connection-status-list .value--danger {
color: var(--danger);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
color: #374151;
font-size: 16px;
}
.mic-test-btn {
background-color: #059669;
.field label {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.field__row {
display: flex;
gap: 8px;
}
.input {
background: rgba(0, 0, 0, 0.35);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
padding: 10px 12px;
font-size: 14px;
color: var(--text-primary);
width: 100%;
transition: border-color 0.2s ease;
}
.input:focus {
outline: none;
border-color: var(--accent);
}
.input--readonly {
color: var(--text-secondary);
}
.icon-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0 12px;
font-size: 18px;
line-height: 32px;
color: var(--text-secondary);
transition: background 0.2s ease, color 0.2s ease;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.18);
color: var(--text-primary);
}
.btn {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 12px 16px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
background: var(--accent);
color: white;
margin-right: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.2s ease;
}
.mic-test-btn:hover:not(:disabled) {
background-color: #047857;
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(88, 101, 242, 0.35);
}
.mic-test-btn:disabled {
background-color: #6b7280;
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.mic-stop-btn {
background-color: #ef4444;
.btn--connected {
background: rgba(67, 181, 129, 0.18);
color: var(--accent-secondary);
border-color: rgba(67, 181, 129, 0.4);
box-shadow: none;
}
.channel-main {
grid-area: main;
display: flex;
flex-direction: column;
gap: 24px;
padding: 40px 56px;
overflow-y: auto;
}
.channel-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.channel-header__text h1 {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
}
.channel-header__text p {
margin-top: 6px;
color: var(--text-secondary);
font-size: 15px;
}
.channel-header__actions {
display: flex;
gap: 10px;
}
.channel-pill {
padding: 8px 16px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.channel-pill.secondary {
background: rgba(88, 101, 242, 0.2);
border-color: rgba(88, 101, 242, 0.4);
color: var(--accent);
}
.participants-grid {
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.participant-tile {
position: relative;
background: var(--tile-muted-bg);
border: 1px solid transparent;
border-radius: var(--radius-lg);
padding: 20px 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.participant-tile:hover {
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.25);
}
.participant-tile.is-speaking {
border-color: rgba(67, 181, 129, 0.65);
background: var(--tile-speaking);
}
.participant-tile.is-muted .participant-avatar {
opacity: 0.6;
}
.participant-tile.is-self {
border-color: rgba(88, 101, 242, 0.6);
}
.participant-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 24px;
font-weight: 700;
color: white;
position: relative;
}
.mic-stop-btn:hover {
background-color: #dc2626;
.participant-initials {
z-index: 1;
}
/* Warning Message */
.warning-message {
padding: 12px;
background-color: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 6px;
color: #92400e;
font-weight: 500;
margin-top: 12px;
.participant-badge {
position: absolute;
bottom: -6px;
right: -6px;
background: var(--bg-tertiary);
border: 2px solid var(--bg-secondary);
border-radius: 50%;
width: 26px;
height: 26px;
display: grid;
place-items: center;
font-size: 14px;
}
/* Call Button mit zusätzlicher Disabled-State */
.call-btn:disabled {
opacity: 0.5;
.participant-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.participant-name {
font-weight: 600;
color: var(--text-primary);
}
.participant-tag {
font-size: 12px;
color: var(--accent);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.control-dock {
grid-area: controls;
background: linear-gradient(180deg, rgba(49, 51, 56, 0.85), rgba(35, 36, 40, 0.95));
border-top: 1px solid var(--border);
padding: 18px 32px;
display: flex;
align-items: center;
box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.25);
}
.call-controls {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
gap: 20px;
}
.call-controls__left,
.call-controls__right {
display: flex;
align-items: center;
gap: 12px;
}
.call-controls__center {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.self-pill {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 14px;
font-size: 13px;
color: var(--text-secondary);
}
.self-pill--target {
color: var(--accent);
border-color: rgba(88, 101, 242, 0.45);
}
.ctrl-btn {
min-width: 120px;
padding: 12px 18px;
border-radius: var(--radius-sm);
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
font-weight: 600;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
}
.ctrl-btn:hover {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
}
.ctrl-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background-color: #9ca3af !important;
transform: none;
box-shadow: none;
}
.ctrl-btn--primary {
background: var(--accent);
border-color: rgba(88, 101, 242, 0.45);
}
.ctrl-btn--secondary {
background: rgba(88, 101, 242, 0.15);
color: var(--accent);
}
.ctrl-btn--muted {
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
}
.ctrl-btn--danger {
background: var(--danger);
border-color: rgba(242, 63, 66, 0.45);
}
.connection-hint {
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.status-widget {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.status-widget__label {
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.status-widget__value {
font-weight: 700;
}
.status-widget__value--online {
color: var(--accent-secondary);
}
.status-widget__value--offline {
color: var(--danger);
}
.status-widget__hint {
color: var(--text-muted);
}
@media (max-width: 1100px) {
.voice-channel {
grid-template-columns: 1fr;
grid-template-areas:
"main"
"controls";
}
.channel-sidebar {
display: none;
}
.channel-main {
padding: 32px 24px;
}
}
@media (max-width: 720px) {
.channel-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.call-controls {
flex-direction: column;
align-items: flex-start;
}
.call-controls__center {
justify-content: flex-start;
}
}

View File

@ -2,6 +2,7 @@
## Zweck
- Steuert Anrufstart als Initiator, Mikrofonberechtigungen und Mute/Ende-Interaktionen.
- Wird innerhalb des `ControlDock` (sticky Bottom-Bar) dargestellt.
## Kernfunktionen
- `request_microphone_access()`: nutzt `MediaManager` zum Einholen des MediaStreams und speichert ihn in `local_media` Signal.
@ -23,3 +24,4 @@
- Visuelle Feedback-Elemente (Button-States im Discord-Stil).
- Device-Auswahl (Audio Output/Input) vor dem Start.
- Error-Toasts (z. B. wenn Offer scheitert).
- Aktive Call-State-Anzeige im ControlDock (z. B. Dauer, zweiter Channel).

View File

@ -3,6 +3,7 @@
## Zweck
- Stellt Verbindungsstatus dar (WebSocket, Mikrofon) und nimmt Remote-Peer-ID entgegen.
- Hält Signals für Peer-Verbindung als **Responder** und verwaltet Offer/Answer-Eingang.
- Wird in der Sidebar des `VoiceChannelLayout` als Karten-Stack (`connection-card`) dargestellt.
## Wichtige Signals
- `peer_id`, `remote_id`, `connected`, `websocket`

View File

@ -4,7 +4,7 @@
- Einfache Übersicht über Systemzustand (Placeholder für spätere KPIs wie Mitglieder, Ping, aktive Sprecher).
## Aktueller Stand
- Zeigt statisch "System Stabil" und `connected` Status (WebSocket).
- Zeigt Füllstand im neuen `status-widget` (Signaling Online/Offline + Hint).
- TODO: WebRTC-Status placeholder.
## Ausbauideen

View File

@ -0,0 +1,38 @@
# VoiceChannelLayout Component
## Overview
`VoiceChannelLayout` orchestrates the Discord-inspired voice channel module:
- **ChannelSidebar** wraps connection management (`ConnectionPanel`) and `StatusDisplay` inside a sidebar with channel metadata.
- **ChannelHeader** displays channel title/topic and view state pills.
- **ParticipantsGrid** renders a responsive grid of `Participant` tiles. Visual states:
- `is_self` highlights the current user in accent color.
- `is_speaking` glows in the accent-green palette.
- `is_muted` dims the avatar and shows a mute badge.
- **ControlDock** sticky bottom bar that embeds `CallControls` with the reworked button set.
## Props
```
VoiceChannelProps {
channel_name: String,
channel_topic: String,
participants: Signal<Vec<Participant>>,
peer_id: Signal<String>,
remote_id: Signal<String>,
connected: Signal<bool>,
websocket: Signal<Option<WebSocket>>,
responder_connection: Signal<Option<RtcPeerConnection>>,
initiator_connection: Signal<Option<RtcPeerConnection>>,
local_media: Signal<Option<MediaStream>>,
}
```
## Layout Notes
- Top-level grid maps to Discord style: sidebar (320px) + stage + control dock.
- Mobile breakpoint hides sidebar; channel content becomes full width.
- Partial data (mock participants) lives in `main.rs` until real presence sync exists.
## Next Steps
1. Replace mock participant Signal with data from signaling server (rooms/memberships).
2. Animate speaking indicator using audio levels from `MediaStreamTrack`.
3. Add quick actions (invite, copy link) to channel header action pills.

View File

@ -5,6 +5,7 @@ Diese Dokumentationssammlung beschreibt das MVP-Modul "Voice Channel" im Projekt
- [`architecture/signaling_flow.md`](architecture/signaling_flow.md) High-Level-Ablauf von Signaling, WebRTC und TURN.
- [`config/config_management.md`](config/config_management.md) Konfigurationen und Defaults (STUN/TURN, Appsettings).
- [`components/`](components/) UI-Komponenten (Discord-Voice-Channel UI) inkl. Zustandsfluss.
- [`voice_channel_layout.md`](components/voice_channel_layout.md)
- [`utils/media_manager.md`](utils/media_manager.md) Medien- und Peer-Connection-Helfer.
## Aktueller Fokus

View File

@ -1,10 +1,10 @@
use dioxus::prelude::*;
use web_sys::{WebSocket as BrowserWebSocket, RtcPeerConnection, MediaStream};
use crate::models::SignalingMessage;
use crate::utils::MediaManager;
use dioxus::prelude::*;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsValue;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
#[component]
pub fn CallControls(
@ -21,15 +21,18 @@ pub fn CallControls(
rsx! {
div { class: "call-controls",
h2 { "Anruf-Steuerung" }
div { class: "mic-permission-section",
h3 { "Mikrofon" }
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()}" }
}
}
div { class: "call-controls__center",
button {
class: "mic-permission-btn primary",
class: if *mic_granted.read() { "ctrl-btn ctrl-btn--secondary" } else { "ctrl-btn ctrl-btn--primary" },
disabled: *mic_granted.read(),
onclick: move |_| {
log::info!("🎤 Fordere Mikrofon-Berechtigung an...");
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();
@ -37,49 +40,39 @@ pub fn CallControls(
let mut manager = crate::utils::MediaManager::new();
match manager.request_microphone_access().await {
Ok(stream) => {
log::info!("✅ Mikrofonzugang erteilt");
log::info!("Microphone granted");
mm_state.set(true);
// Speichere den Stream global, damit andere Komponenten ihn nutzen können
local_media_signal.set(Some(stream.clone()));
if let Some(pc) = pc_signal.read().as_ref() {
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(pc, &stream) {
log::warn!("Fehler beim Hinzufügen der lokalen Tracks: {}", e);
log::warn!("Failed to attach local tracks: {}", e);
}
}
}
Err(e) => {
log::error!("❌ Mikrofonzugriff fehlgeschlagen: {}", e);
log::error!("Microphone request failed: {}", e);
}
}
});
},
if *mic_granted.read() {
"✅ Berechtigung erteilt"
} else {
"🎤 Berechtigung erteilen"
if *mic_granted.read() { "Mic ready" } else { "Enable mic" }
}
}
}
div { class: "control-buttons",
// **INITIATOR** WebRTC-Anruf starten
button {
class: "call-btn primary",
class: "ctrl-btn ctrl-btn--primary",
disabled: !*mic_granted.read() || !*connected.read() || remote_id.read().is_empty(),
onclick: move |_| {
log::info!("📞 Starte WebRTC-Anruf als Initiator...");
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 {
// **INITIATOR:** PeerConnection erstellen
let pc = if pc_signal.read().is_none() {
match MediaManager::create_peer_connection() {
Ok(new_pc) => {
// Attach onicecandidate handler to send candidates via websocket
let ws_clone = ws_signal.clone();
let to_clone = to_id.clone();
let from_clone = from_id.clone();
@ -104,14 +97,12 @@ pub fn CallControls(
new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
on_ice.forget();
// ontrack -> play remote audio
let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
if let Ok(streams_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("streams")) {
if streams_val.is_undefined() || streams_val.is_null() { return; }
let streams_array = js_sys::Array::from(&streams_val);
let first = streams_array.get(0);
let stream_js = first.clone();
if let Ok(stream) = stream_js.dyn_into::<web_sys::MediaStream>() {
if let 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") {
@ -132,11 +123,11 @@ pub fn CallControls(
on_track.forget();
pc_signal.set(Some(new_pc.clone()));
log::info!("✅ Initiator PeerConnection erstellt");
log::info!("Initiator PeerConnection ready");
new_pc
}
Err(e) => {
log::error!("❌ Initiator PeerConnection-Erstellung fehlgeschlagen: {}", e);
log::error!("Failed to create initiator peer connection: {}", e);
return;
}
}
@ -144,19 +135,12 @@ pub fn CallControls(
pc_signal.read().as_ref().unwrap().clone()
};
// Falls wir bereits Zugriff auf Mikrofon haben, versuchen wir die
// lokalen Tracks erneut hinzuzufügen (no-op, falls schon vorhanden).
// Hinweis: In dieser einfachen Struktur halten wir den MediaStream
// nicht global, daher ist dies ein best-effort.
// Falls ein lokaler MediaStream vorhanden ist, stelle sicher, dass seine Tracks angehängt sind
if let Some(local) = local_media.read().as_ref() {
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(&pc, local) {
log::warn!("Fehler beim Hinzufügen der lokalen Tracks vor Offer: {}", e);
log::warn!("Failed to attach local tracks before offer: {}", e);
}
}
// **INITIATOR:** Offer erstellen und senden
match MediaManager::create_offer(&pc).await {
Ok(offer_sdp) => {
if let Some(socket) = ws_signal.read().as_ref() {
@ -169,68 +153,54 @@ pub fn CallControls(
if let Ok(json) = serde_json::to_string(&msg) {
let _ = socket.send_with_str(&json);
log::info!("📤 WebRTC-Offer als Initiator gesendet an {}", to_id);
// **SETUP:** Answer-Handler für eingehende Answers
// Note: Answer wird über connection_panel's onmessage empfangen
// und an diese Coroutine weitergeleitet
log::info!("Offer dispatched to {}", to_id);
in_call_flag.set(true);
}
}
}
Err(e) => log::error!("❌ Initiator Offer-Erstellung fehlgeschlagen: {}", e),
Err(e) => log::error!("Offer creation failed: {}", e),
}
});
},
"📞 WebRTC-Anruf starten"
"Start call"
}
button {
class: if *audio_muted.read() { "mute-btn muted" } else { "mute-btn" },
class: if *audio_muted.read() { "ctrl-btn ctrl-btn--muted" } else { "ctrl-btn" },
disabled: !*in_call.read(),
onclick: move |_| {
let current_muted = *audio_muted.read();
audio_muted.set(!current_muted);
log::info!("🔊 Audio: {}", if !current_muted { "Stumm" } else { "An" });
log::info!("Audio {}", if !current_muted { "muted" } else { "unmuted" });
},
if *audio_muted.read() {
"🔇 Stumm"
} else {
"🔊 Audio An"
if *audio_muted.read() { "Unmute" } else { "Mute" }
}
}
button {
class: "end-btn danger",
class: "ctrl-btn ctrl-btn--danger",
disabled: !*in_call.read(),
onclick: move |_| {
in_call.set(false);
audio_muted.set(false);
// **SCHRITT 1:** Prüfen ob PeerConnection existiert (Immutable Borrow)
let has_peer_connection = peer_connection.read().is_some();
// **SCHRITT 2:** Falls vorhanden, schließen und entfernen (Separate Borrows)
if has_peer_connection {
// Schritt 2a: PeerConnection holen und schließen
if let Some(pc) = peer_connection.read().as_ref() {
pc.close(); // ← Immutable borrow endet nach dieser Zeile
log::info!("📵 Initiator PeerConnection geschlossen");
pc.close();
log::info!("Initiator PeerConnection closed");
}
// Schritt 2b: Danach Signal leeren (Neuer mutable borrow)
peer_connection.set(None); // ✅ Kein aktiver immutable borrow mehr!
peer_connection.set(None);
}
log::info!("📵 Anruf beendet");
log::info!("Call ended");
},
"📵 Anruf beenden"
"Leave"
}
}
div { class: "call-controls__right",
span { class: "connection-hint",
if *connected.read() { "Connected to signaling" } else { "Waiting for signaling" }
}
}
}
// **HIDDEN:** Answer-Handler für diese Komponente
script {
// JavaScript Bridge für Answer-Weiterleitung an Coroutine
// wird über connection_panel's WebSocket-Handler geleitet
}
}
}

View File

@ -1,13 +1,13 @@
use dioxus::prelude::*;
use web_sys::{WebSocket as BrowserWebSocket, RtcPeerConnection, BinaryType, MessageEvent};
use web_sys::MediaStream;
use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsValue;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::models::SignalingMessage;
use crate::utils::MediaManager;
use 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};
#[component]
pub fn ConnectionPanel(
@ -26,7 +26,12 @@ pub fn ConnectionPanel(
// **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 SignalingMessage {
from,
to,
msg_type,
data,
} = msg;
// **KORREKT:** In der Coroutine-Loop
if msg_type == "offer" {
@ -44,13 +49,16 @@ pub fn ConnectionPanel(
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 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 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(),
@ -58,28 +66,40 @@ pub fn ConnectionPanel(
msg_type: "candidate".to_string(),
data: json,
};
if let Ok(text) = serde_json::to_string(&msg) { let _ = ws.send_with_str(&text); }
if let Ok(text) = serde_json::to_string(&msg) {
let _ = ws.send_with_str(&text);
}
}
}
}
}) as Box<dyn FnMut(JsValue)>);
}
})
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; }
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 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>() {
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() {
@ -91,7 +111,8 @@ pub fn ConnectionPanel(
}
}
}
}) as Box<dyn FnMut(JsValue)>);
})
as Box<dyn FnMut(JsValue)>);
new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
on_track.forget();
@ -124,7 +145,8 @@ pub fn ConnectionPanel(
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
log::info!("📤 Responder Answer gesendet an {}", from_clone);
// ✅ Clone verwenden
}
}
}
@ -145,7 +167,11 @@ pub fn ConnectionPanel(
});
// Einfacher Status-String für das Mikrofon
let mic_status = if local_media.read().is_some() { "✅ Erteilt" } else { "✖️ Nicht erteilt" };
let mic_status = if local_media.read().is_some() {
"Granted"
} else {
"Not granted"
};
// WebSocket verbinden
let connect_websocket = move |_| {
@ -172,7 +198,8 @@ pub fn ConnectionPanel(
log::warn!("❌ WebSocket getrennt");
ws_status_clone2.set("Getrennt".to_string());
connected_clone2.set(false);
}) as Box<dyn FnMut(web_sys::CloseEvent)>);
})
as Box<dyn FnMut(web_sys::CloseEvent)>);
// **MESSAGE ROUTER** - Leitet Messages an die richtigen Handler weiter
let offer_tx = offer_handler.clone();
@ -233,7 +260,8 @@ pub fn ConnectionPanel(
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
"Nachricht von {}:\n{}",
msg.from, msg.data
));
}
}
@ -275,9 +303,15 @@ pub fn ConnectionPanel(
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),
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
@ -289,61 +323,78 @@ pub fn ConnectionPanel(
rsx! {
div { class: "connection-panel",
h2 { "Verbindung" }
div { class: "status-item",
span { class: "status-label", "WebSocket:" }
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()}" }
}
ul { class: "connection-status-list",
li {
span { class: "label", "WebSocket" }
span {
class: if *connected.read() { "status-value connected" } else { "status-value disconnected" },
"{ws_status.read()}"
class: if *connected.read() { "value value--success" } else { "value value--danger" },
if *connected.read() { "Connected" } else { "Disconnected" }
}
}
div { class: "status-item",
span { class: "status-label", "Mikrofon:" }
li {
span { class: "label", "Microphone" }
span {
class: if local_media.read().is_some() { "status-value connected" } else { "status-value disconnected" },
class: if local_media.read().is_some() { "value value--success" } else { "value value--danger" },
"{mic_status}"
}
}
}
}
div { class: "input-group",
label { "Ihre Peer-ID:" }
section { class: "connection-card",
header { class: "connection-card__header",
h3 { "Session" }
}
div { class: "field",
label { "Your ID" }
div { class: "field__row",
input {
class: "readonly-input",
class: "input input--readonly",
r#type: "text",
value: "{peer_id.read()}",
readonly: true
}
button {
class: "copy-btn",
class: "icon-btn",
onclick: move |_| {
log::info!("📋 Peer-ID kopiert: {}", peer_id.read());
},
"📋"
}
}
div { class: "input-group",
label { "Remote Peer-ID:" }
}
div { class: "field",
label { "Target ID" }
input {
class: "input",
r#type: "text",
placeholder: "ID des anderen Teilnehmers",
placeholder: "Paste peer ID",
value: "{remote_id.read()}",
oninput: move |event| {
remote_id.set(event.value());
}
}
}
}
section { class: "connection-card",
header { class: "connection-card__header",
h3 { "Networking" }
}
button {
class: if *connected.read() { "connect-btn connected" } else { "connect-btn" },
class: if *connected.read() { "btn btn--connected" } else { "btn" },
disabled: *connected.read(),
onclick: connect_websocket,
if *connected.read() {
"✅ Verbunden"
"Connected"
} else {
"🔌 Verbinden"
"Connect"
}
}
}
}

View File

@ -1,7 +1,9 @@
mod connection_panel;
mod call_controls;
mod connection_panel;
mod status_display;
mod voice_channel;
pub use connection_panel::ConnectionPanel;
pub use call_controls::CallControls;
pub use connection_panel::ConnectionPanel;
pub use status_display::StatusDisplay;
pub use voice_channel::VoiceChannelLayout;

View File

@ -1,30 +1,15 @@
use dioxus::prelude::*;
#[component]
pub fn StatusDisplay(
connected: Signal<bool>,
) -> Element {
pub fn StatusDisplay(connected: Signal<bool>) -> Element {
rsx! {
div { class: "status-display",
h2 { "Status" }
div { class: "status-item",
span { class: "status-label", "System:" }
span { class: "status-value connected", "✅ Stabil" }
}
div { class: "status-item",
span { class: "status-label", "WebSocket:" }
div { class: "status-widget",
span { class: "status-widget__label", "Signaling" }
span {
class: if *connected.read() { "status-value connected" } else { "status-value disconnected" },
if *connected.read() { "✅ Verbunden" } else { "❌ Getrennt" }
}
}
div { class: "status-item",
span { class: "status-label", "WebRTC:" }
span { class: "status-value", "⚙️ Bereit für Implementation" }
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", "TURN integration pending" }
}
}
}

View File

@ -0,0 +1,208 @@
use crate::models::Participant;
use dioxus::prelude::*;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
use super::{CallControls, ConnectionPanel, StatusDisplay};
#[derive(Props, Clone, PartialEq)]
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]
pub fn VoiceChannelLayout(props: VoiceChannelProps) -> Element {
rsx! {
div { class: "voice-channel",
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 {
name: props.channel_name.clone(),
topic: props.channel_topic.clone()
}
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(),
}
}
}
}
#[derive(Props, Clone, PartialEq)]
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]
fn ChannelSidebar(props: ChannelSidebarProps) -> Element {
rsx! {
aside { class: "channel-sidebar",
div { class: "channel-sidebar__head",
h2 { "{props.channel_name}" }
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(),
}
}
div { class: "channel-sidebar__footer",
StatusDisplay { connected: props.connected.clone() }
}
}
}
}
#[derive(Props, Clone, PartialEq)]
pub struct ChannelHeaderProps {
pub name: String,
pub topic: String,
}
#[component]
fn ChannelHeader(props: ChannelHeaderProps) -> Element {
rsx! {
header { class: "channel-header",
div { class: "channel-header__text",
h1 { "{props.name}" }
p { "{props.topic}" }
}
div { class: "channel-header__actions",
button { class: "channel-pill", "Voice" }
button { class: "channel-pill secondary", "Active" }
}
}
}
}
#[derive(Props, Clone, PartialEq)]
pub struct ParticipantsGridProps {
pub participants: Signal<Vec<Participant>>,
}
#[component]
fn ParticipantsGrid(props: ParticipantsGridProps) -> Element {
let participants = props.participants.read();
rsx! {
section { class: "participants-grid",
for participant in participants.iter() {
ParticipantTile { participant: participant.clone() }
}
}
}
}
#[derive(Props, Clone, PartialEq)]
pub struct ParticipantTileProps {
pub participant: Participant,
}
#[component]
fn ParticipantTile(props: ParticipantTileProps) -> Element {
let mut classes = vec!["participant-tile".to_string()];
if props.participant.is_self {
classes.push("is-self".to_string());
}
if props.participant.is_speaking {
classes.push("is-speaking".to_string());
}
if props.participant.is_muted {
classes.push("is-muted".to_string());
}
rsx! {
div { class: classes.join(" "),
div { class: "participant-avatar",
style: "background: {props.participant.avatar_color};",
span { class: "participant-initials", "{initials(&props.participant.display_name)}" }
if props.participant.is_muted {
span { class: "participant-badge badge-mute", "🔇" }
}
}
div { class: "participant-meta",
span { class: "participant-name", "{props.participant.display_name}" }
if props.participant.is_self {
span { class: "participant-tag", "(You)" }
}
}
}
}
}
fn initials(name: &str) -> String {
let mut letters = name
.split_whitespace()
.filter_map(|part| part.chars().next())
.map(|c| c.to_ascii_uppercase());
match (letters.next(), letters.next()) {
(Some(first), Some(second)) => format!("{}{}", first, second),
(Some(first), None) => first.to_string(),
_ => "?".to_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 {
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(),
}
}
}
}

View File

@ -1,10 +1,11 @@
#![allow(non_snake_case)]
use console_log::init_with_level;
use dioxus::prelude::*;
use log::Level;
use console_log::init_with_level;
use niom_webrtc::components::{ConnectionPanel, CallControls, StatusDisplay};
use web_sys::{RtcPeerConnection, WebSocket as BrowserWebSocket, MediaStream};
use niom_webrtc::components::VoiceChannelLayout;
use niom_webrtc::models::Participant;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
// config functions used via fully-qualified paths below
const FAVICON: Asset = asset!("/assets/favicon.ico");
@ -64,37 +65,31 @@ pub fn Content() -> Element {
let websocket = use_signal(|| None::<BrowserWebSocket>);
let initiator_connection = use_signal(|| None::<RtcPeerConnection>);
let responder_connection = use_signal(|| None::<RtcPeerConnection>);
// globaler Signal für den lokal freigegebenen MediaStream (Mikrofon)
let local_media = use_signal(|| None::<MediaStream>);
let participants = use_signal(|| {
vec![
Participant::new("self", "Ghost", "#5865F2", true, false, true),
Participant::new("mod-1", "Nia Moderator", "#43B581", false, false, true),
Participant::new("listener-1", "Amber", "#FAA61A", false, true, false),
Participant::new("listener-2", "Basil", "#EB459E", false, false, false),
Participant::new("listener-3", "Colt", "#5865F2", false, true, false),
Participant::new("listener-4", "Delta", "#99AAB5", false, false, false),
]
});
rsx! {
div { class: "app-container",
header {
h1 { "Voice Chat MVP" }
p { "Einfache WebRTC-Demo ohne Signal-Chaos" }
}
main { class: "main-content",
ConnectionPanel {
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,
peer_connection: responder_connection,
initiator_connection: initiator_connection,
local_media: local_media
}
CallControls {
peer_id,
remote_id,
connected,
websocket,
peer_connection: initiator_connection,
local_media: local_media
}
StatusDisplay {
connected,
}
}
responder_connection,
initiator_connection,
local_media,
}
}
}

View File

@ -1,5 +1,7 @@
mod media_state;
mod participant;
mod signaling_message;
pub use media_state::MediaState;
pub use participant::Participant;
pub use signaling_message::SignalingMessage;

31
src/models/participant.rs Normal file
View File

@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Participant {
pub id: String,
pub display_name: String,
pub avatar_color: String,
pub is_self: bool,
pub is_muted: bool,
pub is_speaking: bool,
}
impl Participant {
pub fn new(
id: impl Into<String>,
display_name: impl Into<String>,
avatar_color: impl Into<String>,
is_self: bool,
is_muted: bool,
is_speaking: bool,
) -> Self {
Self {
id: id.into(),
display_name: display_name.into(),
avatar_color: avatar_color.into(),
is_self,
is_muted,
is_speaking,
}
}
}