From 78e9c9ff94b448ab25ede3e8339902fbe98a09ab Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Fri, 2 Oct 2026 17:26:00 +0200 Subject: [PATCH] Calls: screen share, mic/cam toggles, status badges, pin + fullscreen, call timer Screen sharing swaps the outgoing video track on every peer's sender with replaceTrack (same m-line, no renegotiation); the camera returns when the share stops, including via the browser's own "Stop sharing" bar. Mic and camera toggles flip track.enabled. A new Status signal kind broadcasts mic/cam/screen state so tiles can show badges; newcomers get it directed when their Join arrives. Tiles carry a name label, click to pin as a stage (a peer's screen share pins itself), double-click for fullscreen. The call bar has an elapsed timer and a participant count. Controls are icon-only inline SVGs on currentColor: plain with a strike when off, a soft glow and ticking LED when live. Red is reserved for leave. Also fixed along the way: the peer map was a non-reactive StoredValue, so the tile list never re-rendered when someone joined; ICE candidates that arrive before the remote description are now buffered instead of rejected; a peer whose connection fails or closes is dropped. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L3sD29ozDvqA7jozeZXZoB --- Cargo.toml | 2 + README.md | 26 ++++ src/app.rs | 378 +++++++++++++++++++++++++++++++++++++++-------- src/call.rs | 27 ++++ src/webrtc.rs | 389 ++++++++++++++++++++++++++++++++++++++++++------- style/main.css | 150 +++++++++++++++++-- 6 files changed, 847 insertions(+), 125 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index eaffbed..ab685fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,8 @@ web-sys = { version = "0.3", features = [ "RtcIceCandidateInit", "RtcPeerConnectionIceEvent", "RtcRtpSender", + "RtcPeerConnectionState", + "DisplayMediaStreamConstraints", "RtcTrackEvent", "RtcRtpTransceiver", "RtcOfferOptions", diff --git a/README.md b/README.md index 39703d7..b4abeb2 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ src/ app.rs Leptos UI (login gate + chat console) auth.rs shared User type + current_user server fn chat.rs shared ChatMessage/rooms + send_message server fn (publishes to NATS) + call.rs call signaling types + send_signal server fn (call.room.*, never archived) + webrtc.rs browser-only mesh WebRTC: peers, mic/cam toggles, screen share server/ oidc.rs Kanidm OIDC login/callback/logout handlers sse.rs NATS → browser SSE bridge (one subscription per client) @@ -117,6 +119,30 @@ src/ style/main.css the console theme ``` +## Calls + +The `lobby` room has a mesh WebRTC call (every pair of browsers connects +directly; fine for a handful of people, not more). Signaling rides NATS on +`call.room.` via `/call-sse/` and the `send_signal` server fn, so +it is never archived. Only a public STUN server is configured: calls across +symmetric NATs will not connect without a TURN relay, which this app does not +run. + +In a call: + +- **MIC / CAM** toggle your mic and camera (the track stays attached and sends + silence/black, so toggling is instant and needs no renegotiation). +- **SCR ▶ share** shares a screen, window, or tab. It swaps the outgoing video + track on every peer with `replaceTrack`; your camera comes back when you + stop, or when the browser's own "Stop sharing" bar is used. +- Tiles show the participant's name and MIC ✕ / CAM ✕ / SCR badges, kept in + sync by a `Status` signal on the same subject. +- **Click** a tile to pin it as the stage (a shared screen pins itself); + **double-click** for fullscreen. +- A peer whose tab closes without leaving is dropped once their connection + fails, and ICE candidates that arrive before the offer are buffered rather + than lost. + ## Notes & production hardening - Sessions are in-memory (`tower-sessions` `MemoryStore`): restart logs diff --git a/src/app.rs b/src/app.rs index 23783e4..fba2ba7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -422,7 +422,6 @@ fn CallPanel(room: Memo, me: String) -> impl IntoView { let call_state = StoredValue::new_local(CallState::new(room.get_untracked(), me.clone())); let in_call = call_state.get_value().in_call; - let local_video_ref = NodeRef::::new(); let es_handle = StoredValue::new_local(None::); Effect::new(move |_| { @@ -448,54 +447,22 @@ fn CallPanel(room: Memo, me: String) -> impl IntoView { } }); - // Mirror the local MediaStream into the preview