diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 91fc2e8c..632315d6 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -2486,7 +2486,10 @@ impl ClientEventLoop { } network::NetworkEvent::Listening(addr) => { let mut shared = self.shared.borrow_mut(); - // On receiving a listening event, do initial subscriptions. + // Reset subscription flag on reconnect so on_connected re-runs. + if addr == "reconnecting" { + shared.connected_subscribed = false; + } if !shared.connected_subscribed { on_connected(&shared.state, &self.cmd_tx); shared.connected_subscribed = true; diff --git a/crates/client/src/network.rs b/crates/client/src/network.rs index 8daaee52..e2349f0a 100644 --- a/crates/client/src/network.rs +++ b/crates/client/src/network.rs @@ -342,110 +342,141 @@ async fn run_network_wasm( use futures::StreamExt; use willow_network::{NetworkEvent as NetEvt, NetworkNode}; - let (node, mut events) = NetworkNode::start(identity, config).await?; - let mut file_mgr = crate::files::FileManager::new(); - let local_peer_id = node.peer_id().to_string(); + let mut backoff_ms: u32 = 1_000; + const MAX_BACKOFF_MS: u32 = 30_000; loop { - futures::select! { - event = events.next() => { - let Some(event) = event else { break }; - match event { - NetEvt::Message { topic, data, source } => { - // Try parsing as a profile broadcast. - if let Ok((profile, willow_transport::MessageType::Identity)) = - willow_transport::unpack_envelope::(&data) - { - let _ = event_tx.unbounded_send(NetworkEvent::ProfileReceived { - peer_id: profile.peer_id.to_string(), - display_name: profile.display_name, - }); - } - // Try wire format. - else if let Some((wire_msg, signer)) = - crate::ops::unpack_wire(&data) - { - let from = signer.to_string(); - match wire_msg { - crate::ops::WireMessage::Event(event) => { - let _ = event_tx.unbounded_send(NetworkEvent::EventReceived { - event, - from, - }); - } - crate::ops::WireMessage::SyncRequest { state_hash, topic } => { - let _ = event_tx.unbounded_send(NetworkEvent::SyncRequested { - state_hash, - from, - topic, - }); - } - crate::ops::WireMessage::SyncBatch { events } => { - let _ = event_tx.unbounded_send(NetworkEvent::SyncBatchReceived { - events, - from, - }); - } - crate::ops::WireMessage::TypingIndicator { channel } => { - let _ = event_tx.unbounded_send(NetworkEvent::TypingReceived { - peer_id: from, - channel, - }); - } - crate::ops::WireMessage::VoiceJoin { channel_id, peer_id } => { - let _ = event_tx.unbounded_send(NetworkEvent::VoiceJoinReceived { - channel_id, - peer_id, - }); - } - crate::ops::WireMessage::VoiceLeave { channel_id, peer_id } => { - let _ = event_tx.unbounded_send(NetworkEvent::VoiceLeaveReceived { - channel_id, - peer_id, - }); - } - crate::ops::WireMessage::VoiceSignal { channel_id, target_peer, signal } => { - if target_peer == local_peer_id { - let _ = event_tx.unbounded_send(NetworkEvent::VoiceSignalReceived { + tracing::info!("connecting to network..."); + + let node_result = NetworkNode::start(identity.clone(), config.clone()).await; + let (node, mut events) = match node_result { + Ok(pair) => { + // Successful connection — reset backoff. + backoff_ms = 1_000; + pair + } + Err(e) => { + tracing::warn!("failed to start network node: {e}"); + gloo_timers::future::TimeoutFuture::new(backoff_ms).await; + backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS); + continue; + } + }; + + let mut file_mgr = crate::files::FileManager::new(); + let local_peer_id = node.peer_id().to_string(); + + // Run the event loop until the connection drops. + loop { + futures::select! { + event = events.next() => { + let Some(event) = event else { break }; + match event { + NetEvt::Message { topic, data, source } => { + // Try parsing as a profile broadcast. + if let Ok((profile, willow_transport::MessageType::Identity)) = + willow_transport::unpack_envelope::(&data) + { + let _ = event_tx.unbounded_send(NetworkEvent::ProfileReceived { + peer_id: profile.peer_id.to_string(), + display_name: profile.display_name, + }); + } + // Try wire format. + else if let Some((wire_msg, signer)) = + crate::ops::unpack_wire(&data) + { + let from = signer.to_string(); + match wire_msg { + crate::ops::WireMessage::Event(event) => { + let _ = event_tx.unbounded_send(NetworkEvent::EventReceived { + event, + from, + }); + } + crate::ops::WireMessage::SyncRequest { state_hash, topic } => { + let _ = event_tx.unbounded_send(NetworkEvent::SyncRequested { + state_hash, + from, + topic, + }); + } + crate::ops::WireMessage::SyncBatch { events } => { + let _ = event_tx.unbounded_send(NetworkEvent::SyncBatchReceived { + events, + from, + }); + } + crate::ops::WireMessage::TypingIndicator { channel } => { + let _ = event_tx.unbounded_send(NetworkEvent::TypingReceived { + peer_id: from, + channel, + }); + } + crate::ops::WireMessage::VoiceJoin { channel_id, peer_id } => { + let _ = event_tx.unbounded_send(NetworkEvent::VoiceJoinReceived { channel_id, - from_peer: from, - signal, + peer_id, }); } + crate::ops::WireMessage::VoiceLeave { channel_id, peer_id } => { + let _ = event_tx.unbounded_send(NetworkEvent::VoiceLeaveReceived { + channel_id, + peer_id, + }); + } + crate::ops::WireMessage::VoiceSignal { channel_id, target_peer, signal } => { + if target_peer == local_peer_id { + let _ = event_tx.unbounded_send(NetworkEvent::VoiceSignalReceived { + channel_id, + from_peer: from, + signal, + }); + } + } } + } else { + let _ = event_tx.unbounded_send(NetworkEvent::MessageReceived { + topic, + data, + source: source.map(|p| p.to_string()), + }); } - } else { - let _ = event_tx.unbounded_send(NetworkEvent::MessageReceived { - topic, - data, - source: source.map(|p| p.to_string()), - }); } + NetEvt::PeerConnected(peer) => { + // Reset backoff on successful peer connection. + backoff_ms = 1_000; + let _ = event_tx.unbounded_send(NetworkEvent::PeerConnected(peer.to_string())); + } + NetEvt::PeerDisconnected(peer) => { + let _ = event_tx.unbounded_send(NetworkEvent::PeerDisconnected(peer.to_string())); + } + NetEvt::Listening(addr) => { + let _ = event_tx.unbounded_send(NetworkEvent::Listening(addr.to_string())); + } + _ => {} } - NetEvt::PeerConnected(peer) => { - let _ = event_tx.unbounded_send(NetworkEvent::PeerConnected(peer.to_string())); - } - NetEvt::PeerDisconnected(peer) => { - let _ = event_tx.unbounded_send(NetworkEvent::PeerDisconnected(peer.to_string())); - } - NetEvt::Listening(addr) => { - let _ = event_tx.unbounded_send(NetworkEvent::Listening(addr.to_string())); - } - _ => {} } - } - cmd = cmd_rx.next() => { - if let Some(cmd) = cmd { - handle_network_command(&cmd, &node, &mut file_mgr)?; + cmd = cmd_rx.next() => { + if let Some(cmd) = cmd { + let _ = handle_network_command(&cmd, &node, &mut file_mgr); + } } - } - complete => break, + complete => break, + } } - } - Ok(()) + // Connection lost — notify UI and wait before retrying. + tracing::warn!( + backoff_ms, + "network connection lost, reconnecting in {backoff_ms}ms" + ); + let _ = event_tx.unbounded_send(NetworkEvent::Listening("reconnecting".to_string())); + gloo_timers::future::TimeoutFuture::new(backoff_ms).await; + backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS); + } } // ───── Shared command handler ─────────────────────────────────────────────── diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 8a19dac6..180de61e 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -25,6 +25,10 @@ web-sys = { version = "0.3", features = [ "RtcRtpSender", "MediaStream", "MediaStreamTrack", "MediaStreamConstraints", "MediaDevices", "HtmlMediaElement", "ScrollIntoViewOptions", "ScrollBehavior", "ScrollLogicalPosition", + "DisplayMediaStreamConstraints", + "AudioContext", "BaseAudioContext", "AudioNode", + "AnalyserNode", "MediaStreamAudioSourceNode", + "RtcSignalingState", "RtcRtpTransceiver", ] } js-sys = "0.3" wasm-bindgen-futures = "0.4" diff --git a/crates/web/src/app.rs b/crates/web/src/app.rs index 3fc8b3ae..7f9b77d7 100644 --- a/crates/web/src/app.rs +++ b/crates/web/src/app.rs @@ -6,8 +6,8 @@ use send_wrapper::SendWrapper; use willow_client::{ClientConfig, ClientEvent, ClientHandle, DisplayMessage, VoiceSignalPayload}; use crate::components::{ - AddServerPanel, ChannelHeader, ChatInput, CommandPalette, FileShareButton, MemberList, - MessageList, PinnedPanel, ServerList, SettingsPanel, Sidebar, WelcomeScreen, + AddServerPanel, CallPage, ChannelHeader, ChatInput, CommandPalette, FileShareButton, + MemberList, MessageList, PinnedPanel, ServerList, SettingsPanel, Sidebar, WelcomeScreen, }; use crate::event_processing::{extract_roles, process_event_batch, refresh_all_signals}; use crate::handlers; @@ -72,20 +72,38 @@ pub fn App() -> impl IntoView { provide_context(write); // Create the VoiceManager. + let local_peer_id = handle.peer_id(); let voice_signal_handle = handle.clone(); let voice_channel_for_signal = app_state.voice.voice_channel; - let voice_manager: VoiceManagerHandle = SendWrapper::new(Rc::new(RefCell::new( - VoiceManager::new(move |target_peer: &str, signal_type: &str, payload: &str| { - let ch_id = voice_channel_for_signal.get_untracked().unwrap_or_default(); - let signal = match signal_type { - "offer" => VoiceSignalPayload::Offer(payload.to_string()), - "answer" => VoiceSignalPayload::Answer(payload.to_string()), - "ice" => VoiceSignalPayload::IceCandidate(payload.to_string()), - _ => return, - }; - voice_signal_handle.send_voice_signal(&ch_id, target_peer, signal); - }), - ))); + let set_remote_streams = write.voice.set_remote_video_streams; + let set_speaking = write.voice.set_speaking_peers; + let voice_manager: VoiceManagerHandle = + SendWrapper::new(Rc::new(RefCell::new(VoiceManager::new( + local_peer_id, + move |target_peer: &str, signal_type: &str, payload: &str| { + let ch_id = voice_channel_for_signal.get_untracked().unwrap_or_default(); + let signal = match signal_type { + "offer" => VoiceSignalPayload::Offer(payload.to_string()), + "answer" => VoiceSignalPayload::Answer(payload.to_string()), + "ice" => VoiceSignalPayload::IceCandidate(payload.to_string()), + _ => return, + }; + voice_signal_handle.send_voice_signal(&ch_id, target_peer, signal); + }, + move |peer_id: &str, stream: Option| { + let pid = peer_id.to_string(); + set_remote_streams.update(move |map| { + if let Some(s) = stream { + map.insert(pid, send_wrapper::SendWrapper::new(s)); + } else { + map.remove(&pid); + } + }); + }, + move |peers: std::collections::HashSet| { + set_speaking.set(peers); + }, + )))); provide_context(voice_manager.clone()); @@ -108,18 +126,13 @@ pub fn App() -> impl IntoView { move |ev: web_sys::KeyboardEvent| { if (ev.ctrl_key() || ev.meta_key()) && ev.key() == "k" { ev.prevent_default(); - write_for_palette - .ui - .set_show_palette - .update(|v| *v = !*v); + write_for_palette.ui.set_show_palette.update(|v| *v = !*v); } }, ); if let Some(window) = web_sys::window() { - let _ = window.add_event_listener_with_callback( - "keydown", - closure.as_ref().unchecked_ref(), - ); + let _ = window + .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()); } closure.forget(); } @@ -230,6 +243,19 @@ pub fn App() -> impl IntoView { write.voice.set_voice_channel_name.set(String::new()); write.voice.set_voice_muted.set(false); write.voice.set_voice_deafened.set(false); + write.voice.set_video_source.set(None); + write.voice.set_local_video_stream.set(None); + write.voice.set_remote_video_streams.update(|m| m.clear()); + write + .voice + .set_speaking_peers + .set(std::collections::HashSet::new()); + write.voice.set_voice_participants_map.update(|m| m.clear()); + write.ui.set_show_call_page.set(false); + write + .ui + .set_call_layout + .set(crate::state::CallLayout::default()); }; // Welcome screen callback that refreshes all signals. @@ -259,11 +285,12 @@ pub fn App() -> impl IntoView { let display_name = app_state.server.display_name; let peer_count = app_state.network.peer_count; let peer_id = app_state.network.peer_id; - let roles = app_state.server.roles; + let _roles = app_state.server.roles; let replying_to = app_state.chat.replying_to; let editing = app_state.chat.editing; let channel_views = app_state.chat.channel_views; let show_palette = app_state.ui.show_palette; + let show_call_page = app_state.ui.show_call_page; // Pre-clone handle for use inside the view closure. let handle_for_voice_join = handle.clone(); @@ -348,6 +375,30 @@ pub fn App() -> impl IntoView { move |channel_name: String| { write.ui.set_show_sidebar.set(false); + // If in a different voice channel, disconnect from the old one first. + let current_vc = app_state.voice.voice_channel.get_untracked(); + if current_vc.is_some() && current_vc.as_deref() != Some(&channel_name) { + vc_handle.leave_voice(); + vm.borrow_mut().close_all(); + write.voice.set_voice_channel.set(None); + write.voice.set_voice_channel_name.set(String::new()); + write.voice.set_voice_muted.set(false); + write.voice.set_voice_deafened.set(false); + write.voice.set_video_source.set(None); + write.voice.set_local_video_stream.set(None); + write.voice.set_remote_video_streams.update(|m| m.clear()); + write.voice.set_speaking_peers.set(std::collections::HashSet::new()); + write.voice.set_voice_participants_map.update(|m| m.clear()); + } + + // If already in this voice channel, just navigate to the call page. + if app_state.voice.voice_channel.get_untracked() == Some(channel_name.clone()) { + write.ui.set_show_call_page.set(true); + write.ui.set_show_settings.set(false); + write.ui.set_show_add_server.set(false); + return; + } + // Request mic permission SYNCHRONOUSLY in the click handler // to preserve the user gesture chain (required on mobile). let window = web_sys::window().unwrap(); @@ -364,9 +415,12 @@ pub fn App() -> impl IntoView { return; }; - // Show controls immediately (optimistic). + // Show controls and call page immediately (optimistic). write.voice.set_voice_channel.set(Some(channel_name.clone())); write.voice.set_voice_channel_name.set(channel_name.clone()); + write.ui.set_show_call_page.set(true); + write.ui.set_show_settings.set(false); + write.ui.set_show_add_server.set(false); // Handle the promise result asynchronously. let vc = vc_handle.clone(); @@ -377,11 +431,31 @@ pub fn App() -> impl IntoView { let stream: web_sys::MediaStream = stream.unchecked_into(); vm2.borrow_mut().set_local_stream(stream); vc.join_voice(&ch_name); + + // Seed participants from client state after joining. + // This ensures that on reconnect we pick up peers + // who are already in the channel (their VoiceJoined + // event was received before we joined). + let parts = vc.voice_participants(&ch_name); + write.voice.set_voice_participants_map.update(|m| { + let list = m.entry(ch_name.clone()).or_default(); + for p in parts { + if !list.contains(&p) { + list.push(p); + } + } + // Also add the local user. + let my_id = vc.peer_id(); + if !list.contains(&my_id) { + list.push(my_id); + } + }); }); let on_error = wasm_bindgen::closure::Closure::once(move |_err: wasm_bindgen::JsValue| { tracing::error!("Microphone access denied"); write.voice.set_voice_channel.set(None); write.voice.set_voice_channel_name.set(String::new()); + write.ui.set_show_call_page.set(false); }); let _ = promise.then2(&on_success, &on_error); on_success.forget(); @@ -392,9 +466,9 @@ pub fn App() -> impl IntoView { voice_channel_name=app_state.voice.voice_channel_name voice_muted=app_state.voice.voice_muted voice_deafened=app_state.voice.voice_deafened - on_voice_mute=Callback::new(on_mute) - on_voice_deafen=Callback::new(on_deafen) - on_voice_disconnect=Callback::new(on_disconnect) + on_voice_mute=Callback::new(on_mute.clone()) + on_voice_deafen=Callback::new(on_deafen.clone()) + on_voice_disconnect=Callback::new(on_disconnect.clone()) on_channel_created={ let ch_handle = handle_cc.clone(); move |_| { @@ -424,9 +498,23 @@ pub fn App() -> impl IntoView { }.into_any() } else if show_settings.get() { let tab = app_state.ui.settings_tab.get_untracked(); - view! { }.into_any() + view! { }.into_any() + } else if show_call_page.get() { + let on_mute_cp = on_mute.clone(); + let on_deafen_cp = on_deafen.clone(); + let on_disconnect_cp = on_disconnect.clone(); + view! { + + }.into_any() } else { let send2 = send.clone(); let edit_send2 = edit_send.clone(); diff --git a/crates/web/src/components/call_page.rs b/crates/web/src/components/call_page.rs new file mode 100644 index 00000000..1ab6596f --- /dev/null +++ b/crates/web/src/components/call_page.rs @@ -0,0 +1,512 @@ +//! # Call Page Component +//! +//! Full-screen call view that replaces the chat area when the user is in a +//! voice channel. Contains a top bar (channel name, participant count, timer), +//! a participant grid with [`ParticipantTile`]s, and a frosted-glass control +//! strip for mute, deafen, camera, screen share, and disconnect. + +use leptos::prelude::*; +use send_wrapper::SendWrapper; + +use crate::app::{VoiceManagerHandle, WebClientHandle}; +use crate::components::ParticipantTile; +use crate::icons; +use crate::state::{AppState, AppWriteSignals, CallLayout, VideoSource}; + +/// Render a participant tile, optionally passing a video stream. +/// +/// Because Leptos `#[prop(optional)]` expects the inner `T` when passing a +/// value (not `Option`), we branch on whether a stream exists. +#[allow(clippy::too_many_arguments)] +fn render_tile( + peer_id: String, + display_name: String, + video_stream: Option>, + is_speaking: bool, + is_muted: bool, + is_focused: bool, + is_local_camera: bool, + on_click: Callback, +) -> impl IntoView { + if let Some(stream) = video_stream { + view! { + + } + .into_any() + } else { + view! { + + } + .into_any() + } +} + +/// Format seconds as `MM:SS` or `HH:MM:SS` for the call duration timer. +fn format_duration(seconds: u32) -> String { + let h = seconds / 3600; + let m = (seconds % 3600) / 60; + let s = seconds % 60; + if h > 0 { + format!("{h:02}:{m:02}:{s:02}") + } else { + format!("{m:02}:{s:02}") + } +} + +/// The call page, shown in place of the chat view when `show_call_page` is +/// true. Reads voice state from context and renders participant tiles with +/// grid or focus layout. +#[component] +pub fn CallPage( + /// Called when the user clicks the disconnect button. + on_disconnect: Callback<()>, + /// Called when the user clicks the mute button. + on_mute: Callback<()>, + /// Called when the user clicks the deafen button. + on_deafen: Callback<()>, +) -> impl IntoView { + let app_state = use_context::().unwrap(); + let write = use_context::().unwrap(); + let handle = use_context::().unwrap(); + let vm = use_context::().unwrap(); + + // Local video stream — stored globally in VoiceState so it survives remounts. + let local_video_stream = app_state.voice.local_video_stream; + + // Duration timer — increments every second. Clean up on unmount so + // timers do not stack across call-page remounts. + let (duration, set_duration) = signal(0u32); + let timer_handle = set_interval_with_handle( + move || set_duration.update(|d| *d += 1), + std::time::Duration::from_millis(1000), + ) + .expect("set_interval failed"); + on_cleanup(move || timer_handle.clear()); + + // Layout state. + let layout = app_state.ui.call_layout; + + // Camera button click handler. + let vm_camera = vm.clone(); + let on_camera_click = move |_| { + let current_source = app_state.voice.video_source.get_untracked(); + + if current_source == Some(VideoSource::Camera) { + // Toggle off — stop camera. + vm_camera.borrow_mut().stop_video_share(); + write.voice.set_video_source.set(None); + write.voice.set_local_video_stream.set(None); + return; + } + + // Stop any existing share first. + if current_source.is_some() { + vm_camera.borrow_mut().stop_video_share(); + write.voice.set_video_source.set(None); + write.voice.set_local_video_stream.set(None); + } + + // MUST call getUserMedia synchronously in click handler for gesture. + let window = web_sys::window().unwrap(); + let navigator = window.navigator(); + let Ok(media_devices) = navigator.media_devices() else { + tracing::error!("No media devices available"); + return; + }; + let constraints = web_sys::MediaStreamConstraints::new(); + constraints.set_video(&true.into()); + constraints.set_audio(&false.into()); + let Ok(promise) = media_devices.get_user_media_with_constraints(&constraints) else { + tracing::error!("getUserMedia failed"); + return; + }; + + let vm2 = vm_camera.clone(); + let write2 = write; + let on_success = + wasm_bindgen::closure::Closure::once(move |stream: wasm_bindgen::JsValue| { + use wasm_bindgen::JsCast; + let stream: web_sys::MediaStream = stream.unchecked_into(); + let stream_for_signal = SendWrapper::new(stream.clone()); + vm2.borrow_mut().start_camera(stream); + write2.voice.set_video_source.set(Some(VideoSource::Camera)); + write2 + .voice + .set_local_video_stream + .set(Some(stream_for_signal)); + }); + let on_error = wasm_bindgen::closure::Closure::once(move |_err: wasm_bindgen::JsValue| { + tracing::error!("Camera access denied"); + }); + let _ = promise.then2(&on_success, &on_error); + on_success.forget(); + on_error.forget(); + }; + + // Screen share button click handler. + let vm_screen = vm.clone(); + let on_screen_click = move |_| { + let current_source = app_state.voice.video_source.get_untracked(); + + if current_source == Some(VideoSource::Screen) { + // Toggle off — stop screen share. + vm_screen.borrow_mut().stop_video_share(); + write.voice.set_video_source.set(None); + write.voice.set_local_video_stream.set(None); + return; + } + + // Stop any existing share first. + if current_source.is_some() { + vm_screen.borrow_mut().stop_video_share(); + write.voice.set_video_source.set(None); + write.voice.set_local_video_stream.set(None); + } + + // MUST call getDisplayMedia synchronously in click handler for gesture. + let window = web_sys::window().unwrap(); + let navigator = window.navigator(); + let Ok(media_devices) = navigator.media_devices() else { + tracing::error!("No media devices available"); + return; + }; + let Ok(promise) = media_devices.get_display_media() else { + tracing::error!("getDisplayMedia failed"); + return; + }; + + let vm2 = vm_screen.clone(); + let write2 = write; + let on_success = + wasm_bindgen::closure::Closure::once(move |stream: wasm_bindgen::JsValue| { + use wasm_bindgen::JsCast; + let stream: web_sys::MediaStream = stream.unchecked_into(); + let stream_for_signal = SendWrapper::new(stream.clone()); + vm2.borrow_mut().start_screen_share(stream.clone()); + write2.voice.set_video_source.set(Some(VideoSource::Screen)); + write2 + .voice + .set_local_video_stream + .set(Some(stream_for_signal)); + + // Listen for the browser's "Stop sharing" chrome button. + let tracks = stream.get_video_tracks(); + let track_val = tracks.get(0); + if !track_val.is_undefined() { + let track: web_sys::MediaStreamTrack = track_val.unchecked_into(); + let vm_ended = vm2.clone(); + let on_ended = wasm_bindgen::closure::Closure::once(move || { + vm_ended.borrow_mut().stop_video_share(); + write2.voice.set_local_video_stream.set(None); + write2.voice.set_video_source.set(None); + }); + track.set_onended(Some(on_ended.as_ref().unchecked_ref())); + on_ended.forget(); + } + }); + let on_error = wasm_bindgen::closure::Closure::once(move |_err: wasm_bindgen::JsValue| { + tracing::error!("Screen share denied or cancelled"); + }); + let _ = promise.then2(&on_success, &on_error); + on_success.forget(); + on_error.forget(); + }; + + // Disconnect handler — also clear call page. + let on_disconnect_click = move |_| { + write.voice.set_video_source.set(None); + write.voice.set_local_video_stream.set(None); + write.voice.set_remote_video_streams.update(|m| m.clear()); + write.ui.set_show_call_page.set(false); + write.ui.set_call_layout.set(CallLayout::default()); + on_disconnect.run(()); + }; + + let on_mute_click = move |_| on_mute.run(()); + let on_deafen_click = move |_| on_deafen.run(()); + + view! { +
+ // Top bar +
+
+ + {move || app_state.voice.voice_channel_name.get()} +
+
+ + {move || { + let ch = app_state.voice.voice_channel.get().unwrap_or_default(); + let map = app_state.voice.voice_participants_map.get(); + let count = map.get(&ch).map(|v| v.len()).unwrap_or(0); // local already in map + format!("{count} participant{}", if count != 1 { "s" } else { "" }) + }} + + {move || format_duration(duration.get())} + +
+
+ + // Participant grid + {move || { + let ch = app_state.voice.voice_channel.get().unwrap_or_default(); + let participants_map = app_state.voice.voice_participants_map.get(); + let local_peer_id = handle.peer_id(); + let local_name = handle.display_name(); + let remote_participants: Vec = participants_map + .get(&ch) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|p| p != &local_peer_id) + .collect(); + let speaking = app_state.voice.speaking_peers.get(); + let remote_streams = app_state.voice.remote_video_streams.get(); + let muted = app_state.voice.voice_muted.get(); + let video_source = app_state.voice.video_source.get(); + let current_layout = layout.get(); + + // Total participant count including self. + let total = remote_participants.len() + 1; + + let grid_class = match ¤t_layout { + CallLayout::Focus(_) => "call-grid focus".to_string(), + CallLayout::Grid if total == 1 => "call-grid single-participant".to_string(), + CallLayout::Grid if total == 2 => "call-grid two-participants".to_string(), + _ => "call-grid".to_string(), + }; + + // Build the list of all participants (local first, then remote). + let mut tiles: Vec = Vec::new(); + + // In focus layout, render the focused tile first, then thumbnails. + if let CallLayout::Focus(ref focused_id) = current_layout { + let focused_pid = focused_id.clone(); + + // Render the focused tile. + let (f_name, f_stream, f_is_muted, f_is_speaking, _f_is_local, f_is_local_cam) = + if focused_pid == local_peer_id { + let local_spk = speaking.contains(&local_peer_id); + (local_name.clone(), local_video_stream.get(), muted, local_spk, true, video_source == Some(VideoSource::Camera)) + } else { + let name = handle.peer_display_name(&focused_pid); + let stream = remote_streams.get(&focused_pid).cloned(); + let is_spk = speaking.contains(&focused_pid); + (name, stream, false, is_spk, false, false) + }; + + let fpid = focused_pid.clone(); + tiles.push(render_tile( + focused_pid, + f_name, + f_stream, + f_is_speaking, + f_is_muted, + true, + f_is_local_cam, + Callback::new(move |_pid: String| { + write.ui.set_call_layout.set(CallLayout::Grid); + }), + ).into_any()); + + // Thumbnail strip. + let mut thumb_views: Vec = Vec::new(); + + // Local in thumbnails if not focused. + if fpid != local_peer_id { + let local_spk_thumb = speaking.contains(&local_peer_id); + thumb_views.push(render_tile( + local_peer_id.clone(), + local_name.clone(), + local_video_stream.get(), + local_spk_thumb, + muted, + false, + video_source == Some(VideoSource::Camera), + Callback::new(move |pid: String| { + write.ui.set_call_layout.set(CallLayout::Focus(pid)); + }), + ).into_any()); + } + + // Remote peers in thumbnails (except focused). + for pid in &remote_participants { + if *pid == fpid { + continue; + } + let name = handle.peer_display_name(pid); + let stream = remote_streams.get(pid).cloned(); + let is_spk = speaking.contains(pid); + thumb_views.push(render_tile( + pid.clone(), + name, + stream, + is_spk, + false, + false, + false, + Callback::new(move |pid: String| { + write.ui.set_call_layout.set(CallLayout::Focus(pid)); + }), + ).into_any()); + } + + if !thumb_views.is_empty() { + tiles.push(view! { +
+ {thumb_views} +
+ }.into_any()); + } + } else { + // Grid layout — local user tile. + let local_spk_grid = speaking.contains(&local_peer_id); + tiles.push(render_tile( + local_peer_id.clone(), + local_name.clone(), + local_video_stream.get(), + local_spk_grid, + muted, + false, + video_source == Some(VideoSource::Camera), + Callback::new(move |pid: String| { + write.ui.set_call_layout.set(CallLayout::Focus(pid)); + }), + ).into_any()); + + // Remote participant tiles. + for pid in &remote_participants { + let name = handle.peer_display_name(pid); + let stream = remote_streams.get(pid).cloned(); + let is_spk = speaking.contains(pid); + tiles.push(render_tile( + pid.clone(), + name, + stream, + is_spk, + false, + false, + false, + Callback::new(move |pid: String| { + write.ui.set_call_layout.set(CallLayout::Focus(pid)); + }), + ).into_any()); + } + } + + view! { +
+ {tiles} +
+ } + }} + + // Control strip +
+
+ + + +
+ + + + +
+ + +
+
+
+ } +} diff --git a/crates/web/src/components/message.rs b/crates/web/src/components/message.rs index 578d8f70..c7c34b19 100644 --- a/crates/web/src/components/message.rs +++ b/crates/web/src/components/message.rs @@ -323,7 +323,11 @@ pub fn MessageView( let elapsed = js_sys::Date::now() - st_time_for_end.get(); let distance = st_last_for_end.get() - st_start_for_end.get(); // Dismiss if dragged past 80px OR fast swipe (>200px/s downward). - let velocity = if elapsed > 0.0 { distance / elapsed * 1000.0 } else { 0.0 }; + let velocity = if elapsed > 0.0 { + distance / elapsed * 1000.0 + } else { + 0.0 + }; if drag > 80.0 || velocity > 200.0 { set_show_sheet.set(false); } diff --git a/crates/web/src/components/mod.rs b/crates/web/src/components/mod.rs index 6a0cceef..3d0150cd 100644 --- a/crates/web/src/components/mod.rs +++ b/crates/web/src/components/mod.rs @@ -1,4 +1,5 @@ mod add_server; +mod call_page; mod chat; mod command_palette; mod confirm_dialog; @@ -7,6 +8,7 @@ mod file_share; mod input; mod member_list; mod message; +mod participant_tile; mod pinned; mod roles; mod server_list; @@ -16,6 +18,7 @@ mod voice; mod welcome; pub use add_server::*; +pub use call_page::*; pub use chat::*; pub use command_palette::*; pub use confirm_dialog::*; @@ -24,6 +27,7 @@ pub use file_share::*; pub use input::*; pub use member_list::*; pub use message::*; +pub use participant_tile::*; pub use pinned::*; pub use roles::*; pub use server_list::*; diff --git a/crates/web/src/components/participant_tile.rs b/crates/web/src/components/participant_tile.rs new file mode 100644 index 00000000..177aadd5 --- /dev/null +++ b/crates/web/src/components/participant_tile.rs @@ -0,0 +1,158 @@ +//! # Participant Tile Component +//! +//! Renders a single call participant as a tile showing either a video stream +//! or a peer-ID-derived gradient avatar. Includes a display name overlay, +//! speaking glow, and muted badge. + +use crate::icons; +use leptos::prelude::*; +use send_wrapper::SendWrapper; + +/// Derive a unique gradient from a peer ID for the avatar background. +/// +/// Splits the peer ID bytes in half, hashes each half, and picks two hue +/// values separated by 40-100 degrees for a visually distinct gradient. +fn peer_gradient(peer_id: &str) -> String { + let hash1 = peer_id + .bytes() + .take(peer_id.len() / 2) + .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32)); + let hash2 = peer_id + .bytes() + .skip(peer_id.len() / 2) + .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32)); + let hue1 = hash1 % 360; + let hue2 = (hue1 + 40 + hash2 % 60) % 360; + format!("linear-gradient(135deg, hsl({hue1}, 45%, 35%), hsl({hue2}, 45%, 30%))") +} + +/// A single participant tile in the call grid. +/// +/// Shows either a `