From 573abae5e3bfb96ef1c88242cd27dd4715dcac20 Mon Sep 17 00:00:00 2001 From: ghost Date: Fri, 31 Oct 2025 18:33:03 +0100 Subject: [PATCH] Revamp voice channel layout --- assets/main.css | 763 +++++++++++++++--------- docs/components/call_controls.md | 2 + docs/components/connection_panel.md | 1 + docs/components/status_display.md | 2 +- docs/components/voice_channel_layout.md | 38 ++ docs/index.md | 1 + src/components/call_controls.rs | 126 ++-- src/components/connection_panel.rs | 267 +++++---- src/components/mod.rs | 6 +- src/components/status_display.rs | 29 +- src/components/voice_channel.rs | 208 +++++++ src/main.rs | 57 +- src/models/mod.rs | 2 + src/models/participant.rs | 31 + 14 files changed, 1020 insertions(+), 513 deletions(-) create mode 100644 docs/components/voice_channel_layout.md create mode 100644 src/components/voice_channel.rs create mode 100644 src/models/participant.rs diff --git a/assets/main.css b/assets/main.css index ce7a0fa..07af422 100644 --- a/assets/main.css +++ b/assets/main.css @@ -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; + } } diff --git a/docs/components/call_controls.md b/docs/components/call_controls.md index a4c1859..6a034d5 100644 --- a/docs/components/call_controls.md +++ b/docs/components/call_controls.md @@ -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). diff --git a/docs/components/connection_panel.md b/docs/components/connection_panel.md index a3ffc97..3efbe0b 100644 --- a/docs/components/connection_panel.md +++ b/docs/components/connection_panel.md @@ -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` diff --git a/docs/components/status_display.md b/docs/components/status_display.md index a2b9a60..4aeab11 100644 --- a/docs/components/status_display.md +++ b/docs/components/status_display.md @@ -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 diff --git a/docs/components/voice_channel_layout.md b/docs/components/voice_channel_layout.md new file mode 100644 index 0000000..42beea2 --- /dev/null +++ b/docs/components/voice_channel_layout.md @@ -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>, + peer_id: Signal, + remote_id: Signal, + connected: Signal, + websocket: Signal>, + responder_connection: Signal>, + initiator_connection: Signal>, + local_media: Signal>, +} +``` + +## 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. diff --git a/docs/index.md b/docs/index.md index 3dbb8bb..9d68f9b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/src/components/call_controls.rs b/src/components/call_controls.rs index edf2182..8a06ce5 100644 --- a/src/components/call_controls.rs +++ b/src/components/call_controls.rs @@ -1,15 +1,15 @@ -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( peer_id: Signal, - remote_id: Signal, + remote_id: Signal, connected: Signal, websocket: Signal>, peer_connection: Signal>, // **INITIATOR CONNECTION** @@ -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::() { + if let Ok(stream) = first.clone().dyn_into::() { if let Some(window) = web_sys::window() { if let Some(document) = window.document() { if let Ok(audio_el) = document.create_element("audio") { @@ -132,31 +123,24 @@ 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; } } } else { 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() { @@ -166,71 +150,57 @@ pub fn CallControls( msg_type: "offer".to_string(), data: offer_sdp, }; - + if let Ok(json) = serde_json::to_string(&msg) { let _ = socket.send_with_str(&json); - log::info!("📤 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 - } } } diff --git a/src/components/connection_panel.rs b/src/components/connection_panel.rs index 6cbe5ac..b959197 100644 --- a/src/components/connection_panel.rs +++ b/src/components/connection_panel.rs @@ -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( @@ -22,19 +22,24 @@ pub fn ConnectionPanel( let mut ws_status = use_signal(|| "Nicht verbunden".to_string()); // Buffer for an incoming Answer SDP if the initiator PC isn't ready yet let mut pending_answer = use_signal(|| None::); - + // **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" { log::info!("📞 WebRTC-Offer von {} als Responder verarbeiten", from); - + // **WICHTIG:** Clone für später aufbewahren let from_clone = from.clone(); - + // **RESPONDER:** PeerConnection erstellen let pc = if peer_connection.read().is_none() { match MediaManager::create_peer_connection() { @@ -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); + }) + as Box); 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::() { + if let Ok(stream) = stream_js.dyn_into::() + { 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::() { + if let Ok(audio_el) = + document.create_element("audio") + { + if let Ok(audio) = audio_el + .dyn_into::( + ) { 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); + }) + as Box); new_pc.set_ontrack(Some(on_track.as_ref().unchecked_ref())); on_track.forget(); @@ -108,23 +129,24 @@ pub fn ConnectionPanel( } else { peer_connection.read().as_ref().unwrap().clone() }; - + // Offer verarbeiten und Answer erstellen match MediaManager::handle_offer(&pc, &data).await { Ok(answer_sdp) => { log::info!("✅ Responder Answer erstellt, sende zurück..."); - + if let Some(socket) = websocket.read().as_ref() { let answer_msg = SignalingMessage { - from: to, // ← to wird moved - to: from, // ← from wird hier moved (Original) + from: to, // ← to wird moved + to: from, // ← from wird hier moved (Original) msg_type: "answer".to_string(), data: answer_sdp, }; - + if let Ok(json) = serde_json::to_string(&answer_msg) { let _ = socket.send_with_str(&json); - log::info!("📤 Responder Answer gesendet an {}", from_clone); // ✅ Clone verwenden + log::info!("📤 Responder Answer gesendet an {}", from_clone); + // ✅ Clone verwenden } } } @@ -133,7 +155,7 @@ pub fn ConnectionPanel( } } }); - + // Peer-ID generieren use_effect(move || { use js_sys::{Date, Math}; @@ -145,17 +167,21 @@ 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 |_| { log::info!("🔌 Verbinde WebSocket..."); ws_status.set("Verbinde...".to_string()); - + match BrowserWebSocket::new("ws://localhost:3478/ws") { Ok(socket) => { socket.set_binary_type(BinaryType::Arraybuffer); - + // onopen Handler let mut ws_status_clone = ws_status.clone(); let mut connected_clone = connected.clone(); @@ -164,22 +190,23 @@ pub fn ConnectionPanel( ws_status_clone.set("Verbunden".to_string()); connected_clone.set(true); }) as Box); - - // onclose Handler + + // onclose Handler let mut ws_status_clone2 = ws_status.clone(); let mut connected_clone2 = connected.clone(); let onclose = Closure::wrap(Box::new(move |_: web_sys::CloseEvent| { log::warn!("❌ WebSocket getrennt"); ws_status_clone2.set("Getrennt".to_string()); connected_clone2.set(false); - }) as Box); - + }) + as Box); + // **MESSAGE ROUTER** - Leitet Messages an die richtigen Handler weiter let offer_tx = offer_handler.clone(); let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| { if let Some(text) = e.data().as_string() { log::info!("📨 WebSocket Nachricht: {}", text); - + if let Ok(msg) = serde_json::from_str::(&text) { match msg.msg_type.as_str() { "offer" => { @@ -187,22 +214,22 @@ pub fn ConnectionPanel( offer_tx.send(msg); } "answer" => { - log::info!("🔀 Answer empfangen - leite an Initiator-PeerConnection weiter"); - let data_clone = msg.data.clone(); - if let Some(pc) = initiator_connection.read().as_ref() { - // Versuche die Answer als Remote Description zu setzen - let pc_clone = pc.clone(); - spawn_local(async move { - match crate::utils::MediaManager::handle_answer(&pc_clone, &data_clone).await { + log::info!("🔀 Answer empfangen - leite an Initiator-PeerConnection weiter"); + let data_clone = msg.data.clone(); + if let Some(pc) = initiator_connection.read().as_ref() { + // Versuche die Answer als Remote Description zu setzen + let pc_clone = pc.clone(); + spawn_local(async move { + match crate::utils::MediaManager::handle_answer(&pc_clone, &data_clone).await { Ok(_) => log::info!("✅ Answer erfolgreich gesetzt auf Initiator-PC"), Err(e) => log::error!("❌ Fehler beim Setzen der Answer auf Initiator-PC: {}", e), } - }); - } else { - // Buffer the answer until an initiator PC exists - log::warn!("⚠️ Keine Initiator-PeerConnection vorhanden - buffer Answer"); - pending_answer.set(Some(data_clone)); - } + }); + } else { + // Buffer the answer until an initiator PC exists + log::warn!("⚠️ Keine Initiator-PeerConnection vorhanden - buffer Answer"); + pending_answer.set(Some(data_clone)); + } } "candidate" => { log::info!("🔀 ICE-Kandidat empfangen: leite weiter"); @@ -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 )); } } @@ -244,15 +272,15 @@ pub fn ConnectionPanel( } } }) as Box); - + socket.set_onopen(Some(onopen.as_ref().unchecked_ref())); socket.set_onclose(Some(onclose.as_ref().unchecked_ref())); socket.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); - + onopen.forget(); onclose.forget(); onmessage.forget(); - + websocket.set(Some(socket)); } Err(e) => { @@ -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:" } - span { - class: if *connected.read() { "status-value connected" } else { "status-value disconnected" }, - "{ws_status.read()}" + 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()}" } } - } - - div { class: "status-item", - span { class: "status-label", "Mikrofon:" } - span { - class: if local_media.read().is_some() { "status-value connected" } else { "status-value disconnected" }, - "{mic_status}" - } - } - - div { class: "input-group", - label { "Ihre Peer-ID:" } - input { - class: "readonly-input", - r#type: "text", - value: "{peer_id.read()}", - readonly: true - } - button { - class: "copy-btn", - onclick: move |_| { - log::info!("📋 Peer-ID kopiert: {}", peer_id.read()); - }, - "📋" - } - } - - div { class: "input-group", - label { "Remote Peer-ID:" } - input { - r#type: "text", - placeholder: "ID des anderen Teilnehmers", - value: "{remote_id.read()}", - oninput: move |event| { - remote_id.set(event.value()); + ul { class: "connection-status-list", + li { + span { class: "label", "WebSocket" } + span { + class: if *connected.read() { "value value--success" } else { "value value--danger" }, + if *connected.read() { "Connected" } else { "Disconnected" } + } + } + li { + span { class: "label", "Microphone" } + span { + class: if local_media.read().is_some() { "value value--success" } else { "value value--danger" }, + "{mic_status}" + } } } } - - button { - class: if *connected.read() { "connect-btn connected" } else { "connect-btn" }, - disabled: *connected.read(), - onclick: connect_websocket, - if *connected.read() { - "✅ Verbunden" - } else { - "🔌 Verbinden" + + section { class: "connection-card", + header { class: "connection-card__header", + h3 { "Session" } + } + div { class: "field", + label { "Your ID" } + div { class: "field__row", + input { + class: "input input--readonly", + r#type: "text", + value: "{peer_id.read()}", + readonly: true + } + button { + class: "icon-btn", + onclick: move |_| { + log::info!("📋 Peer-ID kopiert: {}", peer_id.read()); + }, + "📋" + } + } + } + div { class: "field", + label { "Target ID" } + input { + class: "input", + r#type: "text", + 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() { "btn btn--connected" } else { "btn" }, + disabled: *connected.read(), + onclick: connect_websocket, + if *connected.read() { + "Connected" + } else { + "Connect" + } } } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 8e75f1b..429ea0d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -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; diff --git a/src/components/status_display.rs b/src/components/status_display.rs index d77cd45..7ef1c3d 100644 --- a/src/components/status_display.rs +++ b/src/components/status_display.rs @@ -1,30 +1,15 @@ use dioxus::prelude::*; #[component] -pub fn StatusDisplay( - connected: Signal, -) -> Element { +pub fn StatusDisplay(connected: Signal) -> 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:" } - 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" } + div { class: "status-widget", + span { class: "status-widget__label", "Signaling" } + span { + class: if *connected.read() { "status-widget__value status-widget__value--online" } else { "status-widget__value status-widget__value--offline" }, + if *connected.read() { "Online" } else { "Offline" } } + span { class: "status-widget__hint", "TURN integration pending" } } } } diff --git a/src/components/voice_channel.rs b/src/components/voice_channel.rs new file mode 100644 index 0000000..ce31802 --- /dev/null +++ b/src/components/voice_channel.rs @@ -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>, + pub peer_id: Signal, + pub remote_id: Signal, + pub connected: Signal, + pub websocket: Signal>, + pub responder_connection: Signal>, + pub initiator_connection: Signal>, + pub local_media: Signal>, +} + +#[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, + pub remote_id: Signal, + pub connected: Signal, + pub websocket: Signal>, + pub responder_connection: Signal>, + pub initiator_connection: Signal>, + pub local_media: Signal>, +} + +#[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>, +} + +#[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, + pub remote_id: Signal, + pub connected: Signal, + pub websocket: Signal>, + pub initiator_connection: Signal>, + pub local_media: Signal>, +} + +#[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(), + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 6a70b81..73a8025 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::); let initiator_connection = use_signal(|| None::); let responder_connection = use_signal(|| None::); - // globaler Signal für den lokal freigegebenen MediaStream (Mikrofon) let local_media = use_signal(|| None::); + 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 { - 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, - } - } + VoiceChannelLayout { + channel_name: "Project Alpha / Voice Lounge".to_string(), + channel_topic: "Team sync & architecture deep dive".to_string(), + participants, + peer_id, + remote_id, + connected, + websocket, + responder_connection, + initiator_connection, + local_media, } } } diff --git a/src/models/mod.rs b/src/models/mod.rs index ab25f37..d9e0505 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -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; diff --git a/src/models/participant.rs b/src/models/participant.rs new file mode 100644 index 0000000..98785fa --- /dev/null +++ b/src/models/participant.rs @@ -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, + display_name: impl Into, + avatar_color: impl Into, + 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, + } + } +}