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; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; 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 { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Inter', 'Segoe UI', sans-serif;
background-color: #f5f5f5; background: radial-gradient(circle at top left, rgba(88, 101, 242, 0.18), transparent 35%),
color: #333; radial-gradient(circle at bottom right, rgba(235, 69, 158, 0.12), transparent 30%),
line-height: 1.6; var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: stretch;
} }
.app-container { .voice-channel {
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 {
display: grid; display: grid;
gap: 20px; grid-template-columns: 320px 1fr;
grid-template-columns: 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 { .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; display: flex;
align-items: flex-end; flex-direction: column;
gap: 8px; gap: 16px;
} }
.input-group label { .connection-card {
display: block; background: var(--bg-tertiary);
margin-bottom: 5px; border: 1px solid var(--border);
font-weight: 500; border-radius: var(--radius-md);
color: #374151; padding: 18px;
box-shadow: var(--shadow-soft);
} }
.input-group input { .connection-card__header {
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 {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 12px;
padding: 8px 0;
border-bottom: 1px solid #f3f4f6;
} }
.status-label { .connection-card__header h3 {
font-weight: 500; font-size: 15px;
color: #374151; font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
} }
.status-value { .pill {
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;
font-size: 12px; font-size: 12px;
background-color: #f3f4f6; text-transform: uppercase;
padding: 2px 6px; letter-spacing: 0.12em;
border-radius: 4px; padding: 4px 10px;
color: #374151; border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
} }
/* Mic Test */ .pill--success {
.mic-test-section { color: var(--accent-secondary);
margin-bottom: 20px;
padding: 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
background-color: #f9fafb;
} }
.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; margin-bottom: 12px;
color: #374151;
font-size: 16px;
} }
.mic-test-btn { .field label {
background-color: #059669; 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; 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) { .btn:hover {
background-color: #047857; transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(88, 101, 242, 0.35);
} }
.mic-test-btn:disabled { .btn:disabled {
background-color: #6b7280; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
transform: none;
box-shadow: none;
} }
.mic-stop-btn { .btn--connected {
background-color: #ef4444; 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; color: white;
position: relative;
} }
.mic-stop-btn:hover { .participant-initials {
background-color: #dc2626; z-index: 1;
} }
/* Warning Message */ .participant-badge {
.warning-message { position: absolute;
padding: 12px; bottom: -6px;
background-color: #fef3c7; right: -6px;
border: 1px solid #f59e0b; background: var(--bg-tertiary);
border-radius: 6px; border: 2px solid var(--bg-secondary);
color: #92400e; border-radius: 50%;
font-weight: 500; width: 26px;
margin-top: 12px; height: 26px;
display: grid;
place-items: center;
font-size: 14px;
} }
/* Call Button mit zusätzlicher Disabled-State */ .participant-meta {
.call-btn:disabled { display: flex;
opacity: 0.5; 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; 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 ## Zweck
- Steuert Anrufstart als Initiator, Mikrofonberechtigungen und Mute/Ende-Interaktionen. - Steuert Anrufstart als Initiator, Mikrofonberechtigungen und Mute/Ende-Interaktionen.
- Wird innerhalb des `ControlDock` (sticky Bottom-Bar) dargestellt.
## Kernfunktionen ## Kernfunktionen
- `request_microphone_access()`: nutzt `MediaManager` zum Einholen des MediaStreams und speichert ihn in `local_media` Signal. - `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). - Visuelle Feedback-Elemente (Button-States im Discord-Stil).
- Device-Auswahl (Audio Output/Input) vor dem Start. - Device-Auswahl (Audio Output/Input) vor dem Start.
- Error-Toasts (z. B. wenn Offer scheitert). - 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 ## Zweck
- Stellt Verbindungsstatus dar (WebSocket, Mikrofon) und nimmt Remote-Peer-ID entgegen. - 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. - 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 ## Wichtige Signals
- `peer_id`, `remote_id`, `connected`, `websocket` - `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). - Einfache Übersicht über Systemzustand (Placeholder für spätere KPIs wie Mitglieder, Ping, aktive Sprecher).
## Aktueller Stand ## 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. - TODO: WebRTC-Status placeholder.
## Ausbauideen ## 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. - [`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). - [`config/config_management.md`](config/config_management.md) Konfigurationen und Defaults (STUN/TURN, Appsettings).
- [`components/`](components/) UI-Komponenten (Discord-Voice-Channel UI) inkl. Zustandsfluss. - [`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. - [`utils/media_manager.md`](utils/media_manager.md) Medien- und Peer-Connection-Helfer.
## Aktueller Fokus ## 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::models::SignalingMessage;
use crate::utils::MediaManager; use crate::utils::MediaManager;
use dioxus::prelude::*;
use wasm_bindgen::prelude::Closure; use wasm_bindgen::prelude::Closure;
use wasm_bindgen::JsValue;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
#[component] #[component]
pub fn CallControls( pub fn CallControls(
@ -21,15 +21,18 @@ pub fn CallControls(
rsx! { rsx! {
div { class: "call-controls", div { class: "call-controls",
h2 { "Anruf-Steuerung" } div { class: "call-controls__left",
span { class: "self-pill", "Your ID: {peer_id.read()}" }
div { class: "mic-permission-section", if !remote_id.read().is_empty() {
h3 { "Mikrofon" } span { class: "self-pill self-pill--target", "Target: {remote_id.read()}" }
}
}
div { class: "call-controls__center",
button { 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(), disabled: *mic_granted.read(),
onclick: move |_| { onclick: move |_| {
log::info!("🎤 Fordere Mikrofon-Berechtigung an..."); log::info!("Requesting microphone permission");
let mut mm_state = mic_granted.clone(); let mut mm_state = mic_granted.clone();
let pc_signal = peer_connection.clone(); let pc_signal = peer_connection.clone();
let mut local_media_signal = local_media.clone(); let mut local_media_signal = local_media.clone();
@ -37,49 +40,39 @@ pub fn CallControls(
let mut manager = crate::utils::MediaManager::new(); let mut manager = crate::utils::MediaManager::new();
match manager.request_microphone_access().await { match manager.request_microphone_access().await {
Ok(stream) => { Ok(stream) => {
log::info!("✅ Mikrofonzugang erteilt"); log::info!("Microphone granted");
mm_state.set(true); mm_state.set(true);
// Speichere den Stream global, damit andere Komponenten ihn nutzen können
local_media_signal.set(Some(stream.clone())); local_media_signal.set(Some(stream.clone()));
if let Some(pc) = pc_signal.read().as_ref() { if let Some(pc) = pc_signal.read().as_ref() {
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(pc, &stream) { 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) => { Err(e) => {
log::error!("❌ Mikrofonzugriff fehlgeschlagen: {}", e); log::error!("Microphone request failed: {}", e);
} }
} }
}); });
}, },
if *mic_granted.read() { if *mic_granted.read() { "Mic ready" } else { "Enable mic" }
"✅ Berechtigung erteilt"
} else {
"🎤 Berechtigung erteilen"
} }
}
}
div { class: "control-buttons",
// **INITIATOR** WebRTC-Anruf starten
button { button {
class: "call-btn primary", class: "ctrl-btn ctrl-btn--primary",
disabled: !*mic_granted.read() || !*connected.read() || remote_id.read().is_empty(), disabled: !*mic_granted.read() || !*connected.read() || remote_id.read().is_empty(),
onclick: move |_| { onclick: move |_| {
log::info!("📞 Starte WebRTC-Anruf als Initiator..."); log::info!("Launching WebRTC call as initiator");
let mut pc_signal = peer_connection.clone(); let mut pc_signal = peer_connection.clone();
let ws_signal = websocket.clone(); let ws_signal = websocket.clone();
let from_id = peer_id.read().clone(); let from_id = peer_id.read().clone();
let to_id = remote_id.read().clone(); let to_id = remote_id.read().clone();
let mut in_call_flag = in_call.clone();
spawn(async move { spawn(async move {
// **INITIATOR:** PeerConnection erstellen
let pc = if pc_signal.read().is_none() { let pc = if pc_signal.read().is_none() {
match MediaManager::create_peer_connection() { match MediaManager::create_peer_connection() {
Ok(new_pc) => { Ok(new_pc) => {
// Attach onicecandidate handler to send candidates via websocket
let ws_clone = ws_signal.clone(); let ws_clone = ws_signal.clone();
let to_clone = to_id.clone(); let to_clone = to_id.clone();
let from_clone = from_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())); new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
on_ice.forget(); on_ice.forget();
// ontrack -> play remote audio
let on_track = Closure::wrap(Box::new(move |ev: JsValue| { 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 let Ok(streams_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("streams")) {
if streams_val.is_undefined() || streams_val.is_null() { return; } if streams_val.is_undefined() || streams_val.is_null() { return; }
let streams_array = js_sys::Array::from(&streams_val); let streams_array = js_sys::Array::from(&streams_val);
let first = streams_array.get(0); let first = streams_array.get(0);
let stream_js = first.clone(); if let Ok(stream) = first.clone().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(window) = web_sys::window() {
if let Some(document) = window.document() { if let Some(document) = window.document() {
if let Ok(audio_el) = document.create_element("audio") { if let Ok(audio_el) = document.create_element("audio") {
@ -132,11 +123,11 @@ pub fn CallControls(
on_track.forget(); on_track.forget();
pc_signal.set(Some(new_pc.clone())); pc_signal.set(Some(new_pc.clone()));
log::info!("✅ Initiator PeerConnection erstellt"); log::info!("Initiator PeerConnection ready");
new_pc new_pc
} }
Err(e) => { Err(e) => {
log::error!("❌ Initiator PeerConnection-Erstellung fehlgeschlagen: {}", e); log::error!("Failed to create initiator peer connection: {}", e);
return; return;
} }
} }
@ -144,19 +135,12 @@ pub fn CallControls(
pc_signal.read().as_ref().unwrap().clone() 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 Some(local) = local_media.read().as_ref() {
if let Err(e) = crate::utils::MediaManager::add_stream_to_pc(&pc, local) { 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 { match MediaManager::create_offer(&pc).await {
Ok(offer_sdp) => { Ok(offer_sdp) => {
if let Some(socket) = ws_signal.read().as_ref() { 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) { if let Ok(json) = serde_json::to_string(&msg) {
let _ = socket.send_with_str(&json); let _ = socket.send_with_str(&json);
log::info!("📤 WebRTC-Offer als Initiator gesendet an {}", to_id); log::info!("Offer dispatched to {}", to_id);
in_call_flag.set(true);
// **SETUP:** Answer-Handler für eingehende Answers
// Note: Answer wird über connection_panel's onmessage empfangen
// und an diese Coroutine weitergeleitet
} }
} }
} }
Err(e) => log::error!("❌ Initiator Offer-Erstellung fehlgeschlagen: {}", e), Err(e) => log::error!("Offer creation failed: {}", e),
} }
}); });
}, },
"📞 WebRTC-Anruf starten" "Start call"
} }
button { 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(), disabled: !*in_call.read(),
onclick: move |_| { onclick: move |_| {
let current_muted = *audio_muted.read(); let current_muted = *audio_muted.read();
audio_muted.set(!current_muted); 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() { if *audio_muted.read() { "Unmute" } else { "Mute" }
"🔇 Stumm"
} else {
"🔊 Audio An"
} }
}
button { button {
class: "end-btn danger", class: "ctrl-btn ctrl-btn--danger",
disabled: !*in_call.read(), disabled: !*in_call.read(),
onclick: move |_| { onclick: move |_| {
in_call.set(false); in_call.set(false);
audio_muted.set(false); audio_muted.set(false);
// **SCHRITT 1:** Prüfen ob PeerConnection existiert (Immutable Borrow)
let has_peer_connection = peer_connection.read().is_some(); let has_peer_connection = peer_connection.read().is_some();
// **SCHRITT 2:** Falls vorhanden, schließen und entfernen (Separate Borrows)
if has_peer_connection { if has_peer_connection {
// Schritt 2a: PeerConnection holen und schließen
if let Some(pc) = peer_connection.read().as_ref() { if let Some(pc) = peer_connection.read().as_ref() {
pc.close(); // ← Immutable borrow endet nach dieser Zeile pc.close();
log::info!("📵 Initiator PeerConnection geschlossen"); log::info!("Initiator PeerConnection closed");
} }
// Schritt 2b: Danach Signal leeren (Neuer mutable borrow) peer_connection.set(None);
peer_connection.set(None); // ✅ Kein aktiver immutable borrow mehr!
} }
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::models::SignalingMessage;
use crate::utils::MediaManager; use crate::utils::MediaManager;
use dioxus::prelude::*;
use futures::StreamExt; 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] #[component]
pub fn ConnectionPanel( pub fn ConnectionPanel(
@ -26,7 +26,12 @@ pub fn ConnectionPanel(
// **COROUTINE** für Offer-Handling (Responder empfängt Offers) // **COROUTINE** für Offer-Handling (Responder empfängt Offers)
let offer_handler = use_coroutine(move |mut rx| async move { let offer_handler = use_coroutine(move |mut rx| async move {
while let Some(msg) = rx.next().await { 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 // **KORREKT:** In der Coroutine-Loop
if msg_type == "offer" { if msg_type == "offer" {
@ -44,13 +49,16 @@ pub fn ConnectionPanel(
let from_for_ice = from_clone.clone(); let from_for_ice = from_clone.clone();
let on_ice = Closure::wrap(Box::new(move |ev: JsValue| { let on_ice = Closure::wrap(Box::new(move |ev: JsValue| {
// ev.candidate may be null/undefined or an object // 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() { if candidate_val.is_null() || candidate_val.is_undefined() {
return; return;
} }
if let Some(ws) = ws_clone.read().as_ref() { if let Some(ws) = ws_clone.read().as_ref() {
// Try to stringify the candidate object directly // 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() { if let Some(json) = json_js.as_string() {
let msg = crate::models::SignalingMessage { let msg = crate::models::SignalingMessage {
from: peer_id.read().clone(), from: peer_id.read().clone(),
@ -58,28 +66,40 @@ pub fn ConnectionPanel(
msg_type: "candidate".to_string(), msg_type: "candidate".to_string(),
data: json, 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())); new_pc.set_onicecandidate(Some(on_ice.as_ref().unchecked_ref()));
on_ice.forget(); on_ice.forget();
// ontrack -> play remote audio // ontrack -> play remote audio
let on_track = Closure::wrap(Box::new(move |ev: JsValue| { let on_track = Closure::wrap(Box::new(move |ev: JsValue| {
// ev.streams is an array of MediaStream // ev.streams is an array of MediaStream
if let Ok(streams_val) = js_sys::Reflect::get(&ev, &JsValue::from_str("streams")) { if let Ok(streams_val) =
if streams_val.is_undefined() || streams_val.is_null() { return; } 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 streams_array = js_sys::Array::from(&streams_val);
let first = streams_array.get(0); let first = streams_array.get(0);
let stream_js = first.clone(); 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(window) = web_sys::window() {
if let Some(document) = window.document() { if let Some(document) = window.document() {
if let Ok(audio_el) = document.create_element("audio") { if let Ok(audio_el) =
if let Ok(audio) = audio_el.dyn_into::<web_sys::HtmlAudioElement>() { document.create_element("audio")
{
if let Ok(audio) = audio_el
.dyn_into::<web_sys::HtmlAudioElement>(
) {
audio.set_autoplay(true); audio.set_autoplay(true);
audio.set_src_object(Some(&stream)); audio.set_src_object(Some(&stream));
if let Some(body) = document.body() { 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())); new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref()));
on_track.forget(); on_track.forget();
@ -124,7 +145,8 @@ pub fn ConnectionPanel(
if let Ok(json) = serde_json::to_string(&answer_msg) { if let Ok(json) = serde_json::to_string(&answer_msg) {
let _ = socket.send_with_str(&json); 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 // 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 // WebSocket verbinden
let connect_websocket = move |_| { let connect_websocket = move |_| {
@ -172,7 +198,8 @@ pub fn ConnectionPanel(
log::warn!("❌ WebSocket getrennt"); log::warn!("❌ WebSocket getrennt");
ws_status_clone2.set("Getrennt".to_string()); ws_status_clone2.set("Getrennt".to_string());
connected_clone2.set(false); 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 // **MESSAGE ROUTER** - Leitet Messages an die richtigen Handler weiter
let offer_tx = offer_handler.clone(); let offer_tx = offer_handler.clone();
@ -233,7 +260,8 @@ pub fn ConnectionPanel(
log::info!("💬 Textnachricht: {}", msg.data); log::info!("💬 Textnachricht: {}", msg.data);
if let Some(window) = web_sys::window() { if let Some(window) = web_sys::window() {
let _ = window.alert_with_message(&format!( 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 pc_clone = pc.clone();
let answer_clone = answer_sdp.clone(); let answer_clone = answer_sdp.clone();
spawn_local(async move { spawn_local(async move {
match crate::utils::MediaManager::handle_answer(&pc_clone, &answer_clone).await { match crate::utils::MediaManager::handle_answer(&pc_clone, &answer_clone)
Ok(_) => log::info!("✅ Gepufferte Answer erfolgreich gesetzt auf Initiator-PC"), .await
Err(e) => log::error!("❌ Fehler beim Setzen der gepufferten Answer: {}", e), {
Ok(_) => log::info!(
"✅ Gepufferte Answer erfolgreich gesetzt auf Initiator-PC"
),
Err(e) => {
log::error!("❌ Fehler beim Setzen der gepufferten Answer: {}", e)
}
} }
}); });
// Clear buffer // Clear buffer
@ -289,61 +323,78 @@ pub fn ConnectionPanel(
rsx! { rsx! {
div { class: "connection-panel", div { class: "connection-panel",
h2 { "Verbindung" } section { class: "connection-card",
header { class: "connection-card__header",
div { class: "status-item", h3 { "Connection" }
span { class: "status-label", "WebSocket:" } 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 { span {
class: if *connected.read() { "status-value connected" } else { "status-value disconnected" }, class: if *connected.read() { "value value--success" } else { "value value--danger" },
"{ws_status.read()}" if *connected.read() { "Connected" } else { "Disconnected" }
} }
} }
li {
div { class: "status-item", span { class: "label", "Microphone" }
span { class: "status-label", "Mikrofon:" }
span { 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}" "{mic_status}"
} }
} }
}
}
div { class: "input-group", section { class: "connection-card",
label { "Ihre Peer-ID:" } header { class: "connection-card__header",
h3 { "Session" }
}
div { class: "field",
label { "Your ID" }
div { class: "field__row",
input { input {
class: "readonly-input", class: "input input--readonly",
r#type: "text", r#type: "text",
value: "{peer_id.read()}", value: "{peer_id.read()}",
readonly: true readonly: true
} }
button { button {
class: "copy-btn", class: "icon-btn",
onclick: move |_| { onclick: move |_| {
log::info!("📋 Peer-ID kopiert: {}", peer_id.read()); log::info!("📋 Peer-ID kopiert: {}", peer_id.read());
}, },
"📋" "📋"
} }
} }
}
div { class: "input-group", div { class: "field",
label { "Remote Peer-ID:" } label { "Target ID" }
input { input {
class: "input",
r#type: "text", r#type: "text",
placeholder: "ID des anderen Teilnehmers", placeholder: "Paste peer ID",
value: "{remote_id.read()}", value: "{remote_id.read()}",
oninput: move |event| { oninput: move |event| {
remote_id.set(event.value()); remote_id.set(event.value());
} }
} }
} }
}
section { class: "connection-card",
header { class: "connection-card__header",
h3 { "Networking" }
}
button { button {
class: if *connected.read() { "connect-btn connected" } else { "connect-btn" }, class: if *connected.read() { "btn btn--connected" } else { "btn" },
disabled: *connected.read(), disabled: *connected.read(),
onclick: connect_websocket, onclick: connect_websocket,
if *connected.read() { if *connected.read() {
"✅ Verbunden" "Connected"
} else { } else {
"🔌 Verbinden" "Connect"
}
} }
} }
} }

View File

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

View File

@ -1,30 +1,15 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[component] #[component]
pub fn StatusDisplay( pub fn StatusDisplay(connected: Signal<bool>) -> Element {
connected: Signal<bool>,
) -> Element {
rsx! { rsx! {
div { class: "status-display", div { class: "status-widget",
h2 { "Status" } span { class: "status-widget__label", "Signaling" }
div { class: "status-item",
span { class: "status-label", "System:" }
span { class: "status-value connected", "✅ Stabil" }
}
div { class: "status-item",
span { class: "status-label", "WebSocket:" }
span { span {
class: if *connected.read() { "status-value connected" } else { "status-value disconnected" }, class: if *connected.read() { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" },
if *connected.read() { "✅ Verbunden" } else { "❌ Getrennt" } if *connected.read() { "Online" } else { "Offline" }
}
}
div { class: "status-item",
span { class: "status-label", "WebRTC:" }
span { class: "status-value", "⚙️ Bereit für Implementation" }
} }
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)] #![allow(non_snake_case)]
use console_log::init_with_level;
use dioxus::prelude::*; use dioxus::prelude::*;
use log::Level; use log::Level;
use console_log::init_with_level; use niom_webrtc::components::VoiceChannelLayout;
use niom_webrtc::components::{ConnectionPanel, CallControls, StatusDisplay}; use niom_webrtc::models::Participant;
use web_sys::{RtcPeerConnection, WebSocket as BrowserWebSocket, MediaStream}; use web_sys::{MediaStream, RtcPeerConnection, WebSocket as BrowserWebSocket};
// config functions used via fully-qualified paths below // config functions used via fully-qualified paths below
const FAVICON: Asset = asset!("/assets/favicon.ico"); const FAVICON: Asset = asset!("/assets/favicon.ico");
@ -64,37 +65,31 @@ pub fn Content() -> Element {
let websocket = use_signal(|| None::<BrowserWebSocket>); let websocket = use_signal(|| None::<BrowserWebSocket>);
let initiator_connection = use_signal(|| None::<RtcPeerConnection>); let initiator_connection = use_signal(|| None::<RtcPeerConnection>);
let responder_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 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! { rsx! {
div { class: "app-container", VoiceChannelLayout {
header { channel_name: "Project Alpha / Voice Lounge".to_string(),
h1 { "Voice Chat MVP" } channel_topic: "Team sync & architecture deep dive".to_string(),
p { "Einfache WebRTC-Demo ohne Signal-Chaos" } participants,
}
main { class: "main-content",
ConnectionPanel {
peer_id, peer_id,
remote_id, remote_id,
connected, connected,
websocket, websocket,
peer_connection: responder_connection, responder_connection,
initiator_connection: initiator_connection, initiator_connection,
local_media: local_media local_media,
}
CallControls {
peer_id,
remote_id,
connected,
websocket,
peer_connection: initiator_connection,
local_media: local_media
}
StatusDisplay {
connected,
}
}
} }
} }
} }

View File

@ -1,5 +1,7 @@
mod media_state; mod media_state;
mod participant;
mod signaling_message; mod signaling_message;
pub use media_state::MediaState; pub use media_state::MediaState;
pub use participant::Participant;
pub use signaling_message::SignalingMessage; 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,
}
}
}