diff --git a/.gitignore b/.gitignore index c00bc84..3e62aed 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ server_hash.txt proxy/bundle /config.toml proxy/config.toml -gui/assets/*_onnx.tar.gz +*_onnx.tar.gz diff --git a/Cargo.lock b/Cargo.lock index e273680..b94105d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1587,9 +1587,9 @@ dependencies = [ [[package]] name = "dioxus-core-types" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" +checksum = "b99d7d199aad72431b549759550002e7d72c8a257eba500dca9fbdb2122de103" [[package]] name = "dioxus-desktop" @@ -4220,14 +4220,7 @@ dependencies = [ ] [[package]] -name = "mumble-web2-common" -version = "0.1.0" -dependencies = [ - "serde", -] - -[[package]] -name = "mumble-web2-gui" +name = "mumble-web2-client" version = "0.1.0" dependencies = [ "android-permissions", @@ -4241,9 +4234,8 @@ dependencies = [ "crossbeam-queue", "dasp_ring_buffer", "deep_filter", - "dioxus", "dioxus-asset-resolver", - "dioxus-web", + "dioxus-signals", "etcetera", "futures", "futures-channel", @@ -4252,6 +4244,7 @@ dependencies = [ "jni", "js-sys", "lol_html 2.7.0", + "manganis", "markdown", "merge-io", "mime_guess", @@ -4263,7 +4256,6 @@ dependencies = [ "opus", "ordermap", "reqwest", - "rfd 0.16.0", "serde", "serde-wasm-bindgen", "serde_json", @@ -4279,6 +4271,25 @@ dependencies = [ "web-sys", ] +[[package]] +name = "mumble-web2-common" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "mumble-web2-gui" +version = "0.1.0" +dependencies = [ + "color-eyre", + "dioxus", + "dioxus-web", + "mumble-web2-client", + "mumble-web2-common", + "rfd 0.16.0", +] + [[package]] name = "mumble-web2-proxy" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2905e2b..6cbb733 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["common", "gui", "proxy"] +members = ["client", "common", "gui", "proxy"] [workspace.dependencies] serde = { version = "1.0.214", features = ["derive"] } diff --git a/client/Cargo.toml b/client/Cargo.toml new file mode 100644 index 0000000..a7e498f --- /dev/null +++ b/client/Cargo.toml @@ -0,0 +1,144 @@ +[package] +name = "mumble-web2-client" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Web Dependencies +# ================ +wasm-bindgen = { version = "^0.2.92", optional = true } +wasm-bindgen-futures = { version = "^0.4.42", optional = true } +wasm-streams = { version = "^0.4.0", optional = true } +serde-wasm-bindgen = { version = "^0.6.5", optional = true } +js-sys = { version = "=0.3.82", optional = true } +web-sys = { version = "=0.3.82", features = [ + "WebTransport", + "console", + "WebTransportOptions", + "WebTransportBidirectionalStream", + "WebTransportSendStream", + "WebTransportReceiveStream", + "Navigator", + "MediaDevices", + "AudioDecoder", + "AudioDecoderInit", + "AudioData", + "AudioEncoderConfig", + "AudioDecoderConfig", + "EncodedAudioChunk", + "EncodedAudioChunkInit", + "EncodedAudioChunkType", + "CodecState", + "AudioContext", + "AudioContextOptions", + "MediaStream", + "GainNode", + "MediaStreamAudioSourceNode", + "BaseAudioContext", + "AudioDestinationNode", + "AudioWorkletNode", + "AudioWorklet", + "AudioWorkletProcessor", + "MessagePort", + "MediaStreamConstraints", + "WorkletOptions", + "AudioEncoder", + "AudioEncoderInit", + "AudioDataInit", + "HtmlAnchorElement", + "Url", + "Blob", + "AudioDataCopyToOptions", + "AudioSampleFormat", + "Storage", +], optional = true } +gloo-timers = { version = "^0.3.0", features = ["futures"], optional = true } +tracing-web = { version = "^0.1.3", optional = true } + +# Desktop Dependecies +# =================== +tokio = { version = "^1.41.1", features = ["net", "rt"], optional = true } +tokio-rustls = { version = "^0.26.0", optional = true } +opus = { version = "0.3.0", optional = true } +cpal = { version = "0.15.3", optional = true } +dasp_ring_buffer = { version = "0.11.0", optional = true } +etcetera = { version = "0.10.0", optional = true } + +# Base Dependencies +# ================ +dioxus-signals = "0.7.2" +manganis = "0.7.2" +once_cell = "1.19.0" +asynchronous-codec = { workspace = true } +futures = "^0.3.30" +merge-io = "^0.3.0" +mumble-protocol = { workspace = true } +serde_json = "1" +tokio-util = { version = "^0.7.11", features = ["codec", "compat"] } +byteorder = "1.5" +ogg = "^0.9.1" +ordermap = "^0.5.3" +html-purifier = "^0.3.0" +markdown = "^0.3.0" +futures-channel = "^0.3.30" +mumble-web2-common = { workspace = true } +serde = { workspace = true } +tracing-subscriber = { version = "^0.3.18", features = ["ansi"] } +tracing = "^0.1.40" +color-eyre = "^0.6.3" +crossbeam-queue = "^0.3.11" +lol_html = "^2.2.0" +base64 = "^0.22" +mime_guess = "^2.0.5" +async_cell = "^0.2.3" +reqwest = { version = "^0.12.22", features = ["json"] } +dioxus-asset-resolver = "0.7.2" + +# Denoising +# ========= +deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [ + "tract", +] } +crossbeam = "0.8.4" + +# Android dependencies for requesting permissions +[target.'cfg(target_os = "android")'.dependencies] +android-permissions = "0.1.2" +jni = "0.21.1" +ndk-context = "0.1.1" + +[patch.crates-io] +tract-hir = "=0.12.4" +tract-core = "=0.12.4" +tract-onnx = "=0.12.4" +tract-pulse = "=0.12.4" + +[features] +web = [ + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "serde-wasm-bindgen", + "js-sys", + "web-sys", + "gloo-timers", + "tracing-web", + "deep_filter/wasm", +] +desktop = [ + "tokio", + "tokio-rustls", + "tracing-subscriber/env-filter", + "opus", + "cpal", + "dasp_ring_buffer", + "etcetera", +] +mobile = [ + "tokio", + "tokio-rustls", + "tracing-subscriber/env-filter", + "opus", + "cpal", + "dasp_ring_buffer", +] diff --git a/gui/assets/rust_audio_worklet.js b/client/assets/rust_audio_worklet.js similarity index 100% rename from gui/assets/rust_audio_worklet.js rename to client/assets/rust_audio_worklet.js diff --git a/gui/build.rs b/client/build.rs similarity index 100% rename from gui/build.rs rename to client/build.rs diff --git a/client/src/app.rs b/client/src/app.rs new file mode 100644 index 0000000..41f8721 --- /dev/null +++ b/client/src/app.rs @@ -0,0 +1,215 @@ +use dioxus_signals::{ReadableExt as _, Signal}; +use mime_guess::Mime; +use mumble_web2_common::ProxyOverrides; +use ordermap::OrderSet; +use std::collections::{HashMap, HashSet}; +use std::{fmt, sync::Arc}; + +pub type ChannelId = u32; +pub type UserId = u32; + +#[derive(Debug)] +pub enum ConnectionState { + Disconnected, + Connecting, + Connected, + Failed(String), +} + +#[derive(Debug, Clone)] +pub struct AudioSettings { + pub denoise: bool, +} + +#[derive(Debug)] +pub enum Command { + Connect { + address: String, + username: String, + config: ProxyOverrides, + }, + SendChat { + markdown: String, + channels: Vec, + }, + SendFile { + bytes: Vec, + name: String, + mime: Option, + channels: Vec, + }, + SetMute { + mute: bool, + }, + SetDeaf { + deaf: bool, + }, + EnterChannel { + channel: ChannelId, + user: UserId, + }, + UpdateAudioSettings(AudioSettings), + Disconnect, +} + +#[derive(Default, Debug)] +pub struct UserState { + pub name: String, + pub channel: ChannelId, + pub deaf: bool, + pub mute: bool, + pub suppress: bool, + pub self_deaf: bool, + pub self_mute: bool, +} + +#[derive(Debug)] +pub struct Chat { + pub raw: String, + pub dangerous_html: String, + pub sender: Option, +} + +#[derive(Default, Debug)] +pub struct ChannelState { + pub name: String, + pub children: OrderSet, + pub users: OrderSet, + pub parent: Option, + pub position: i32, +} + +impl ChannelState { + pub fn update_from_channel_state( + &mut self, + channel_state: &mumble_protocol::control::msgs::ChannelState, + ) { + if channel_state.has_position() { + self.position = channel_state.get_position(); + } + if channel_state.has_parent() { + self.parent = Some(channel_state.get_parent()); + } + if channel_state.has_name() { + self.name = channel_state.get_name().to_string(); + } + } +} + +#[derive(Default, Debug)] +pub struct ChannelsState { + pub channels: HashMap, +} + +impl ChannelsState { + pub fn update_from_channel_state( + &mut self, + channel_state: &mumble_protocol::control::msgs::ChannelState, + ) { + self.channels + .entry(channel_state.get_channel_id()) + .or_default() + .update_from_channel_state(channel_state); + + self.update_channel_parents(); + } + + pub fn update_from_channel_remove( + &mut self, + channel_remove: &mumble_protocol::control::msgs::ChannelRemove, + ) { + self.channels.remove(&channel_remove.get_channel_id()); + self.update_channel_parents(); + } + + pub fn update_channel_parents(&mut self) { + // Zero out existing children + for state in self.channels.values_mut() { + state.children.clear(); + } + + let mut to_sort: Vec<(ChannelId, Option, i32, String)> = Vec::new(); + for (id, state) in self.channels.iter() { + // Handle channels with no parent (the root channel) + let Some(parent_id) = state.parent else { + to_sort.push((*id, None, 0, state.name.clone())); + continue; + }; + + // If a channel has a parent that we haven't gotten a channel + // state packet for, ignore it + if !self.channels.contains_key(&parent_id) { + continue; + } + + to_sort.push((*id, Some(parent_id), state.position, state.name.clone())); + } + + let pos_name: HashMap = self + .channels + .iter() + .map(|(&id, state)| (id, (state.position, state.name.clone()))) + .collect(); + + let mut updated: HashSet = HashSet::new(); + + while updated.len() < to_sort.len() { + for &(id, ref parent_id, position, ref name) in &to_sort { + let Some(parent_id) = parent_id else { + updated.insert(id); + continue; + }; + + if updated.contains(&id) || !updated.contains(&parent_id) { + continue; + } + + // Unwrap should never fail here since we pre filter + let parent = self.channels.get_mut(&parent_id).unwrap(); + + let mut insert_index = parent.children.len(); + for (i, &child) in parent.children.iter().enumerate() { + let (p, ref n) = pos_name[&child]; + if (position == p && name < n) || p > position { + insert_index = i; + break; + } + } + + parent.children.insert_before(insert_index, id); + updated.insert(id); + } + } + } +} + +#[derive(Default, Debug)] +pub struct ServerState { + pub channels_state: ChannelsState, + pub users: HashMap, + pub chat: Vec, + pub session: Option, +} + +impl ServerState { + pub fn this_user(&self) -> Option<&UserState> { + self.users.get(&self.session?) + } +} + +pub struct State { + pub status: Signal, + pub server: Signal, + pub audio: Signal, +} + +impl fmt::Debug for State { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("State") + .field("status", &self.status.read()) + .field("server", &self.server.read()) + .finish() + } +} + +pub type SharedState = Arc; diff --git a/gui/src/effects.rs b/client/src/effects.rs similarity index 92% rename from gui/src/effects.rs rename to client/src/effects.rs index 58f3abe..89a7dfe 100644 --- a/gui/src/effects.rs +++ b/client/src/effects.rs @@ -1,8 +1,8 @@ use crossbeam::atomic::AtomicCell; use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview}; use df::tract::{DfParams, DfTract, RuntimeParams}; -use dioxus::prelude::{asset, manganis, Asset}; use dioxus_asset_resolver::read_asset_bytes; +use manganis::{asset, Asset}; use std::cell::RefCell; use std::sync::Arc; use tracing::{error, info}; @@ -44,11 +44,12 @@ fn with_denoising_model(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract) let cell = Arc::new(AtomicCell::new(None)); let cell_task = cell.clone(); *state = DenoisingModelState::Downloading(cell); + let model = DF_MODEL.to_string(); spawn.spawn(async move { - let model_bytes = match read_asset_bytes(&DF_MODEL).await { + let model_bytes = match read_asset_bytes(&model).await { Ok(b) => b, Err(e) => { - error!("could not read denoising model from \"{DF_MODEL}\": {e:?}"); + error!("could not read denoising model from \"{model}\": {e:?}"); return; } }; @@ -96,20 +97,9 @@ pub struct AudioProcessor { } impl AudioProcessor { - pub fn new_plain() -> Self { + pub fn new(denoise: bool) -> Self { AudioProcessor { - denoise: false, - spawn: SpawnHandle::current(), - buffer: Vec::new(), - noise_floor: DEFAULT_NOISE_FLOOR, - was_transmitting: false, - hold_samples: 0, - } - } - - pub fn new_denoising() -> Self { - AudioProcessor { - denoise: true, + denoise, spawn: SpawnHandle::current(), buffer: Vec::new(), noise_floor: DEFAULT_NOISE_FLOOR, diff --git a/gui/src/imp/android.rs b/client/src/imp/android.rs similarity index 100% rename from gui/src/imp/android.rs rename to client/src/imp/android.rs diff --git a/gui/src/imp/connect.rs b/client/src/imp/connect.rs similarity index 93% rename from gui/src/imp/connect.rs rename to client/src/imp/connect.rs index e4e7b96..92c268b 100644 --- a/gui/src/imp/connect.rs +++ b/client/src/imp/connect.rs @@ -1,6 +1,6 @@ use crate::app::{Command, SharedState}; use color_eyre::eyre::{bail, Error}; -use dioxus::hooks::UnboundedReceiver; +use futures_channel::mpsc::UnboundedReceiver; use mumble_protocol::control::ClientControlCodec; use std::net::ToSocketAddrs; use std::sync::Arc; @@ -103,7 +103,9 @@ pub async fn network_connect( let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec); let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec); - crate::network_loop(username, state, event_rx, reader, writer).await + let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded(); + spawn(crate::sender_loop(outgoing_recv, writer)); + crate::network_loop(username, state, event_rx, outgoing_send, reader).await } pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result { diff --git a/gui/src/imp/desktop.rs b/client/src/imp/desktop.rs similarity index 97% rename from gui/src/imp/desktop.rs rename to client/src/imp/desktop.rs index 8cff633..9a1616e 100644 --- a/gui/src/imp/desktop.rs +++ b/client/src/imp/desktop.rs @@ -1,6 +1,6 @@ use crate::app::{Command, SharedState}; use color_eyre::eyre::Error; -use dioxus::hooks::UnboundedReceiver; +use futures_channel::mpsc::UnboundedReceiver; use mumble_web2_common::{ProxyOverrides, ServerStatus}; use std::time::Duration; diff --git a/gui/src/imp/mobile.rs b/client/src/imp/mobile.rs similarity index 98% rename from gui/src/imp/mobile.rs rename to client/src/imp/mobile.rs index 0a15995..66da970 100644 --- a/gui/src/imp/mobile.rs +++ b/client/src/imp/mobile.rs @@ -1,6 +1,6 @@ use crate::app::{Command, SharedState}; use color_eyre::eyre::Error; -use dioxus::hooks::UnboundedReceiver; +use futures_channel::mpsc::UnboundedReceiver; use mumble_web2_common::{ProxyOverrides, ServerStatus}; use std::time::Duration; diff --git a/gui/src/imp/mod.rs b/client/src/imp/mod.rs similarity index 99% rename from gui/src/imp/mod.rs rename to client/src/imp/mod.rs index 7504a97..0004193 100644 --- a/gui/src/imp/mod.rs +++ b/client/src/imp/mod.rs @@ -7,7 +7,7 @@ use crate::app::{Command, SharedState}; use crate::effects::AudioProcessor; use color_eyre::eyre::Error; -use dioxus::hooks::UnboundedReceiver; +use futures_channel::mpsc::UnboundedReceiver; use mumble_web2_common::{ProxyOverrides, ServerStatus}; use std::collections::HashMap; use std::future::Future; diff --git a/gui/src/imp/native_audio.rs b/client/src/imp/native_audio.rs similarity index 99% rename from gui/src/imp/native_audio.rs rename to client/src/imp/native_audio.rs index ed3eb43..adfcfbf 100644 --- a/gui/src/imp/native_audio.rs +++ b/client/src/imp/native_audio.rs @@ -112,7 +112,7 @@ impl super::AudioSystemInterface for NativeAudioSystem { ); let mut encoder = opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?; - let mut current_processor = AudioProcessor::new_plain(); + let mut current_processor = AudioProcessor::new(false); let mut output_buffer = Vec::new(); let processors = self.processors.clone(); let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}"); diff --git a/gui/src/imp/native_config.rs b/client/src/imp/native_config.rs similarity index 100% rename from gui/src/imp/native_config.rs rename to client/src/imp/native_config.rs diff --git a/gui/src/imp/stub.rs b/client/src/imp/stub.rs similarity index 98% rename from gui/src/imp/stub.rs rename to client/src/imp/stub.rs index a73aeb3..340c9be 100644 --- a/gui/src/imp/stub.rs +++ b/client/src/imp/stub.rs @@ -2,7 +2,7 @@ /// `cargo check` without any --feature flags. use crate::{app::SharedState, effects::AudioProcessor}; use color_eyre::eyre::Error; -use dioxus::hooks::UnboundedReceiver; +use futures_channel::mpsc::UnboundedReceiver; use mumble_web2_common::{ProxyOverrides, ServerStatus}; use std::future::Future; diff --git a/gui/src/imp/web.rs b/client/src/imp/web.rs similarity index 96% rename from gui/src/imp/web.rs rename to client/src/imp/web.rs index 8ca52e3..959b8af 100644 --- a/gui/src/imp/web.rs +++ b/client/src/imp/web.rs @@ -2,9 +2,10 @@ use crate::app::{Command, SharedState}; use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState}; use color_eyre::eyre::{bail, eyre, Error}; use crossbeam::atomic::AtomicCell; -use dioxus::prelude::*; +use futures_channel::mpsc::UnboundedReceiver; use gloo_timers::future::TimeoutFuture; use js_sys::Float32Array; +use manganis::asset; use mumble_protocol::control::ClientControlCodec; use mumble_web2_common::{ProxyOverrides, ServerStatus}; use reqwest::Url; @@ -157,7 +158,7 @@ pub struct WebAudioSystem { processors: AudioProcessorSender, } -async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> { +async fn attach_worklet(audio_context: &AudioContext, worklet_url: &str) -> Result<(), Error> { // Create worklets to process mic and speaker audio // Speaker audio processing worklet only required on // browsers that don't support MediaStreamTrackGenerator @@ -170,12 +171,11 @@ async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> { ) .ey()?; - let module = asset!("assets/rust_audio_worklet.js").to_string(); - info!("loading mic worklet from {module:?}"); + info!("loading mic worklet from {worklet_url:?}"); audio_context .audio_worklet() .ey()? - .add_module_with_options(&module, &options) + .add_module_with_options(worklet_url, &options) .ey()? .into_future() .await @@ -190,7 +190,11 @@ impl super::AudioSystemInterface for WebAudioSystem { // Create MediaStreams to playback decoded audio // The audio context is used to reproduce audio. let webctx = configure_audio_context(); - attach_worklet(&webctx).await?; + attach_worklet( + &webctx, + &asset!("/assets/rust_audio_worklet.js").to_string(), + ) + .await?; let processors = AudioProcessorSender::default(); @@ -381,7 +385,7 @@ async fn run_encoder_worklet( audio_encoder.configure(&encoder_config); info!("created audio encoder"); - let mut current_processor = AudioProcessor::new_plain(); + let mut current_processor = AudioProcessor::new(false); let onmessage: Closure = Closure::new(move |event: MessageEvent| { if let Some(new_processor) = processors.take() { current_processor = new_processor; @@ -494,7 +498,9 @@ pub async fn network_connect( let writer = asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec); - crate::network_loop(username, state, event_rx, reader, writer).await + let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded(); + spawn(crate::sender_loop(outgoing_recv, writer)); + crate::network_loop(username, state, event_rx, outgoing_send, reader).await } pub fn absolute_url(path: &str) -> Result { diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..59c8226 --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,11 @@ +mod app; +mod effects; +mod imp; +mod mainloop; +mod msghtml; + +pub use app::*; +pub use imp::*; +pub use mainloop::*; +pub use mime_guess; +pub use reqwest; diff --git a/gui/src/lib.rs b/client/src/mainloop.rs similarity index 90% rename from gui/src/lib.rs rename to client/src/mainloop.rs index 024d46f..b6451e5 100644 --- a/gui/src/lib.rs +++ b/client/src/mainloop.rs @@ -1,18 +1,20 @@ -use app::Chat; -use app::Command; -use app::ConnectionState; +use crate::msghtml::process_message_html; +use crate::AudioSettings; +use crate::Chat; +use crate::Command; +use crate::ConnectionState; use asynchronous_codec::FramedRead; use asynchronous_codec::FramedWrite; use color_eyre::eyre::{bail, Error}; -use dioxus::prelude::*; +use dioxus_signals::ReadableExt as _; +use dioxus_signals::WritableExt as _; use futures::select; use futures::AsyncRead; use futures::AsyncWrite; use futures::FutureExt as _; use futures::SinkExt as _; use futures::StreamExt as _; -use futures_channel::mpsc::UnboundedSender; -use msghtml::process_message_html; +use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender}; use mumble_protocol::control::msgs; use mumble_protocol::control::ControlCodec; use mumble_protocol::control::ControlPacket; @@ -26,20 +28,14 @@ use std::time::Duration; use tracing::error; use tracing::info; -use crate::app::AudioSettings; use crate::app::SharedState; use crate::app::State; use crate::effects::AudioProcessor; use crate::imp::{ - AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform, - PlatformInterface as _, + spawn, AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, + Platform, PlatformInterface as _, }; -pub mod app; -mod effects; -pub mod imp; -mod msghtml; - pub async fn network_entrypoint(mut event_rx: UnboundedReceiver, state: SharedState) { loop { let Some(Command::Connect { @@ -65,28 +61,30 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver, state: } } -pub async fn network_loop( +pub(crate) async fn sender_loop( + mut outgoing: UnboundedReceiver>, + mut writer: FramedWrite>, +) { + while let Some(msg) = outgoing.next().await { + if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) { + info!("sending packet {:#?}", msg); + } + if let Err(e) = writer.send(msg).await { + error!("error sending packet {:?}", e); + break; + } + } +} + +pub(crate) async fn network_loop( username: String, state: SharedState, event_rx: &mut UnboundedReceiver, + mut outgoing: UnboundedSender>, mut reader: FramedRead>, - mut writer: FramedWrite>, ) -> Result<(), Error> { let audio_settings = state.audio.read().clone(); - let (mut send_chan, mut writer_recv_chan) = futures_channel::mpsc::unbounded(); - spawn(async move { - while let Some(msg) = writer_recv_chan.next().await { - if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) { - info!("sending packet {:#?}", msg); - } - if let Err(e) = writer.send(msg).await { - error!("error sending packet {:?}", e); - break; - } - } - }); - // Get version packet let version = match reader.next().await { Some(Ok(v)) => v, @@ -100,17 +98,17 @@ pub async fn network_loop break, Some(command) => { - let res = accept_command(command, &mut send_chan, &mut audio, &state); + let res = accept_command(command, &mut outgoing, &mut audio, &state); if let Err(err) = res { info!("error accepting command {:?}", err) } @@ -189,7 +185,7 @@ pub async fn network_loop (), UpdateAudioSettings(AudioSettings { denoise }) => { - if denoise { - audio.set_processor(AudioProcessor::new_denoising()); - } else { - audio.set_processor(AudioProcessor::new_plain()); - } + audio.set_processor(AudioProcessor::new(denoise)); } } diff --git a/gui/src/msghtml.rs b/client/src/msghtml.rs similarity index 100% rename from gui/src/msghtml.rs rename to client/src/msghtml.rs diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 7e29572..1b0f856 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -4,103 +4,11 @@ version = "0.1.0" edition = "2021" [dependencies] -# Web Dependencies -# ================ -dioxus-web = { version = "0.7.1", optional = true } -wasm-bindgen = { version = "^0.2.92", optional = true } -wasm-bindgen-futures = { version = "^0.4.42", optional = true } -wasm-streams = { version = "^0.4.0", optional = true } -serde-wasm-bindgen = { version = "^0.6.5", optional = true } -js-sys = { version = "^0.3.70", optional = true } -web-sys = { version = "^0.3.72", features = [ - "WebTransport", - "console", - "WebTransportOptions", - "WebTransportBidirectionalStream", - "WebTransportSendStream", - "WebTransportReceiveStream", - "Navigator", - "MediaDevices", - "AudioDecoder", - "AudioDecoderInit", - "AudioData", - "AudioEncoderConfig", - "AudioDecoderConfig", - "EncodedAudioChunk", - "EncodedAudioChunkInit", - "EncodedAudioChunkType", - "CodecState", - "AudioContext", - "AudioContextOptions", - "MediaStream", - "GainNode", - "MediaStreamAudioSourceNode", - "BaseAudioContext", - "AudioDestinationNode", - "AudioWorkletNode", - "AudioWorklet", - "AudioWorkletProcessor", - "MessagePort", - "MediaStreamConstraints", - "WorkletOptions", - "AudioEncoder", - "AudioEncoderInit", - "AudioDataInit", - "HtmlAnchorElement", - "Url", - "Blob", - "AudioDataCopyToOptions", - "AudioSampleFormat", - "Storage", -], optional = true } -gloo-timers = { version = "^0.3.0", features = ["futures"], optional = true } -tracing-web = { version = "^0.1.3", optional = true } - -# Desktop Dependecies -# =================== -tokio = { version = "^1.41.1", features = ["net", "rt"], optional = true } -tokio-rustls = { version = "^0.26.0", optional = true } -opus = { version = "0.3.0", optional = true } -cpal = { version = "0.15.3", optional = true } -dasp_ring_buffer = { version = "0.11.0", optional = true } -etcetera = { version = "0.10.0", optional = true } - -# Base Dependencies -# ================ dioxus = { version = "0.7.2" } -once_cell = "1.19.0" -asynchronous-codec = { workspace = true } -futures = "^0.3.30" -merge-io = "^0.3.0" -mumble-protocol = { workspace = true } -serde_json = "1" -tokio-util = { version = "^0.7.11", features = ["codec", "compat"] } -byteorder = "1.5" -ogg = "^0.9.1" -ordermap = "^0.5.3" -html-purifier = "^0.3.0" -markdown = "^0.3.0" -futures-channel = "^0.3.30" -mumble-web2-common = { workspace = true } -serde = { workspace = true } -tracing-subscriber = { version = "^0.3.18", features = ["ansi"] } -tracing = "^0.1.40" +dioxus-web = { version = "0.7.2", optional = true } +mumble-web2-client = { version = "0.1.0", path = "../client" } +mumble-web2-common = { version = "0.1.0", path = "../common" } color-eyre = "^0.6.3" -crossbeam-queue = "^0.3.11" -lol_html = "^2.2.0" -base64 = "^0.22" -mime_guess = "^2.0.5" -async_cell = "^0.2.3" -reqwest = { version = "^0.12.22", features = ["json"] } -dioxus-asset-resolver = "0.7.2" - - -# Denoising -# ========= -deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [ - "tract", -] } -crossbeam = "0.8.4" # Platform Integration # ==================== @@ -108,50 +16,19 @@ crossbeam = "0.8.4" [target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies] rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true } -# Android dependencies for requesting permissions -[target.'cfg(target_os = "android")'.dependencies] -android-permissions = "0.1.2" -jni = "0.21.1" -ndk-context = "0.1.1" - -[patch.crates-io] -tract-hir = "=0.12.4" -tract-core = "=0.12.4" -tract-onnx = "=0.12.4" -tract-pulse = "=0.12.4" - [features] web = [ "dioxus/web", "dioxus-web", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "serde-wasm-bindgen", - "js-sys", - "web-sys", - "gloo-timers", - "tracing-web", - "deep_filter/wasm", + "mumble-web2-client/web", "rfd", ] desktop = [ "dioxus/desktop", - "tokio", - "tokio-rustls", - "tracing-subscriber/env-filter", - "opus", - "cpal", - "dasp_ring_buffer", + "mumble-web2-client/desktop", "rfd/xdg-portal", - "etcetera", ] mobile = [ "dioxus/mobile", - "tokio", - "tokio-rustls", - "tracing-subscriber/env-filter", - "opus", - "cpal", - "dasp_ring_buffer", + "mumble-web2-client/mobile" ] diff --git a/gui/src/app.rs b/gui/src/app.rs deleted file mode 100644 index 8034f3e..0000000 --- a/gui/src/app.rs +++ /dev/null @@ -1,928 +0,0 @@ -#![allow(non_snake_case)] - -use dioxus::prelude::*; -use mime_guess::Mime; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; -use ordermap::OrderSet; -use std::collections::{HashMap, HashSet}; -use std::{fmt, sync::Arc}; - -use crate::imp::{ConfigSystem, ConfigSystemInterface as _, Platform, PlatformInterface as _}; - -pub type ChannelId = u32; -pub type UserId = u32; - -#[derive(Debug)] -pub enum ConnectionState { - Disconnected, - Connecting, - Connected, - Failed(String), -} - -#[derive(Debug, Clone)] -pub struct AudioSettings { - pub denoise: bool, -} - -#[derive(Debug)] -pub enum Command { - Connect { - address: String, - username: String, - config: ProxyOverrides, - }, - SendChat { - markdown: String, - channels: Vec, - }, - SendFile { - bytes: Vec, - name: String, - mime: Option, - channels: Vec, - }, - SetMute { - mute: bool, - }, - SetDeaf { - deaf: bool, - }, - EnterChannel { - channel: ChannelId, - user: UserId, - }, - UpdateAudioSettings(AudioSettings), - Disconnect, -} - -use Command::*; -use ConnectionState::*; - -#[derive(Default, Debug)] -pub struct UserState { - pub name: String, - pub channel: ChannelId, - pub deaf: bool, - pub mute: bool, - pub suppress: bool, - pub self_deaf: bool, - pub self_mute: bool, -} - -impl UserState { - pub fn icon(&self) -> UserIcon { - if self.deaf || self.self_deaf { - UserIcon::Deafened - } else if self.mute || self.self_mute { - UserIcon::Muted - } else if self.suppress { - UserIcon::Suppressed - } else { - UserIcon::Normal - } - } -} - -#[derive(Debug)] -pub struct Chat { - pub raw: String, - pub dangerous_html: String, - pub sender: Option, -} - -#[derive(Default, Debug)] -pub struct ChannelState { - pub name: String, - pub children: OrderSet, - pub users: OrderSet, - pub parent: Option, - pub position: i32, -} - -impl ChannelState { - pub fn update_from_channel_state( - &mut self, - channel_state: &mumble_protocol::control::msgs::ChannelState, - ) { - if channel_state.has_position() { - self.position = channel_state.get_position(); - } - if channel_state.has_parent() { - self.parent = Some(channel_state.get_parent()); - } - if channel_state.has_name() { - self.name = channel_state.get_name().to_string(); - } - } -} - -#[derive(Default, Debug)] -pub struct ChannelsState { - pub channels: HashMap, -} - -impl ChannelsState { - pub fn update_from_channel_state( - &mut self, - channel_state: &mumble_protocol::control::msgs::ChannelState, - ) { - self.channels - .entry(channel_state.get_channel_id()) - .or_default() - .update_from_channel_state(channel_state); - - self.update_channel_parents(); - } - - pub fn update_from_channel_remove( - &mut self, - channel_remove: &mumble_protocol::control::msgs::ChannelRemove, - ) { - self.channels.remove(&channel_remove.get_channel_id()); - self.update_channel_parents(); - } - - pub fn update_channel_parents(&mut self) { - // Zero out existing children - for state in self.channels.values_mut() { - state.children.clear(); - } - - let mut to_sort: Vec<(ChannelId, Option, i32, String)> = Vec::new(); - for (id, state) in self.channels.iter() { - // Handle channels with no parent (the root channel) - let Some(parent_id) = state.parent else { - to_sort.push((*id, None, 0, state.name.clone())); - continue; - }; - - // If a channel has a parent that we haven't gotten a channel - // state packet for, ignore it - if !self.channels.contains_key(&parent_id) { - continue; - } - - to_sort.push((*id, Some(parent_id), state.position, state.name.clone())); - } - - let pos_name: HashMap = self - .channels - .iter() - .map(|(&id, state)| (id, (state.position, state.name.clone()))) - .collect(); - - let mut updated: HashSet = HashSet::new(); - - while updated.len() < to_sort.len() { - for &(id, ref parent_id, position, ref name) in &to_sort { - let Some(parent_id) = parent_id else { - updated.insert(id); - continue; - }; - - if updated.contains(&id) || !updated.contains(&parent_id) { - continue; - } - - // Unwrap should never fail here since we pre filter - let parent = self.channels.get_mut(&parent_id).unwrap(); - - let mut insert_index = parent.children.len(); - for (i, &child) in parent.children.iter().enumerate() { - let (p, ref n) = pos_name[&child]; - if (position == p && name < n) || p > position { - insert_index = i; - break; - } - } - - parent.children.insert_before(insert_index, id); - updated.insert(id); - } - } - } -} - -#[derive(Default, Debug)] -pub struct ServerState { - pub channels_state: ChannelsState, - pub users: HashMap, - pub chat: Vec, - pub session: Option, -} - -impl ServerState { - pub fn this_user(&self) -> Option<&UserState> { - self.users.get(&self.session?) - } -} - -pub struct State { - pub status: Signal, - pub server: Signal, - pub audio: Signal, -} - -impl fmt::Debug for State { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("State") - .field("status", &self.status.read()) - .field("server", &self.server.read()) - .finish() - } -} - -pub type SharedState = Arc; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum UserIcon { - Normal, - Muted, - Deafened, - Suppressed, - None, -} - -impl UserIcon { - pub fn url(self) -> Option { - // speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/ - // mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/ - - use UserIcon::*; - Some(match self { - Normal => asset!("assets/mic-svgrepo-com.svg"), - Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"), - Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"), - None => return Option::None, - }) - } -} - -#[component] -pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element { - let color = match icon { - UserIcon::Normal => "var(--accent-normal)", - UserIcon::Muted => "var(--accent-muted)", - UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)", - UserIcon::None => "var(--accent-normal)", - }; - - rsx!( - div { - class: match isself { true => "userpil is_self", false => "userpil" }, - style: "background-color: {color}", - { icon.url().map(|url| rsx!(img { src: url })) } - "\u{00A0}{name}\u{00A0}" - } - ) -} - -#[component] -pub fn User(id: UserId) -> Element { - let state = use_context::(); - let server = state.server.read(); - match server.users.get(&id) { - Some(state) => rsx!(UserPill { - name: state.name.clone(), - icon: state.icon(), - isself: server.session.unwrap() == id, - }), - None => rsx!(UserPill { - name: format!("unknown user ({id})"), - icon: UserIcon::None, - isself: false, - }), - } -} - -#[component] -pub fn Channel(id: ChannelId) -> Element { - let net: Coroutine = use_coroutine_handle(); - let state = use_context::(); - let server = state.server.read(); - let user = server.session.unwrap(); - let Some(state) = server.channels_state.channels.get(&id) else { - return rsx!("missing channel {id}"); - }; - - let mut open = use_signal(|| true); - - let has_children = !state.users.is_empty() || !state.children.is_empty(); - - rsx!( - div { - class: "channel_details", - - div { - class: "channel_header", - // Arrow: only toggles open - if has_children { - span { - class: "channel_arrow", - onclick: move |evt| { - evt.stop_propagation(); - evt.prevent_default(); - let mut w = open.write(); - *w = !*w; - }, - if *open.read() { "▾" } else { "▸" } - } - } else { - span { - class: "channel_arrow channel_arrow--placeholder", - " " - } - } - - // Clickable row area (everything except the arrow) - div { - class: "channel_row_click", - ondblclick: move |evt| { - evt.stop_propagation(); - evt.prevent_default(); - net.send(EnterChannel { channel: id, user }) - }, - // remove dblclick from the inner span - span { - class: "channel_title", - "{state.name}" - } - // if you add icons/badges later, put them here too - } - } - - if *open.read() && has_children { - div { - class: "channel_children", - for id in state.users.iter() { - User { id: *id } - } - for child in state.children.iter() { - Channel { id: *child } - } - } - } - } - ) -} - -#[cfg(any(feature = "desktop", feature = "web"))] -pub fn pick_and_send_file(net: &Coroutine) { - let state = use_context::(); - let channels = if let Some(user) = state.server.read().this_user() { - vec![user.channel] - } else { - return; - }; - let dialog = rfd::AsyncFileDialog::new().pick_file(); - let sender = net.tx(); - spawn(async move { - let Some(handle) = dialog.await else { return }; - let name = handle.file_name(); - let bytes = handle.read().await; - let mime = mime_guess::from_path(&name).first(); - let _ = sender.unbounded_send(SendFile { - bytes, - name, - mime, - channels, - }); - }); -} -#[cfg(not(any(feature = "desktop", feature = "web")))] -pub fn pick_and_send_file(net: &Coroutine) {} - -#[component] -pub fn ChatView() -> Element { - let net: Coroutine = use_coroutine_handle(); - let state = use_context::(); - let server = state.server.read(); - let mut draft = use_signal(|| "".to_string()); - - let mut do_send = move || { - let state = use_context::(); - let server = state.server.read(); - if let Some(user) = server.this_user() { - net.send(SendChat { - markdown: draft.write().split_off(0), - channels: vec![user.channel], - }); - } - }; - - rsx!( - div { - class: "chat_panel", - div { - class: "chat_history", - for chat in server.chat.iter() { - div { - class: "chat_message", - if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) { - UserPill { - name: sender.name.clone(), - icon: UserIcon::None, - isself: false, - } - } - span { - dangerous_inner_html: "{chat.dangerous_html}", - } - } - } - } - div { - class: "chat_box_wrapper", - div { - class: "chat_box", - input { - placeholder: "say something", - value: "{draft.read()}", - oninput: move |evt| draft.set(evt.value().clone()), - onkeypress: move |evt: Event| { - if evt.code() == Code::Enter && evt.modifiers().is_empty() { - do_send(); - } - } - } - div { - span { - onclick: move |_| pick_and_send_file(&net), - class: "material-symbols-outlined", - style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", - "attach_file", - } - } - div { - span { - onclick: move |_| do_send(), - class: "material-symbols-outlined", - style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", - "send", - } - } - } - //button { - // onclick: move |_| do_send(), - // "Send" - //} - } - } - ) -} - -#[component] -pub fn ControlView(overrides: Resource) -> Element { - let net: Coroutine = use_coroutine_handle(); - let state = use_context::(); - let status = &state.status; - let server = state.server.read(); - let audio = state.audio.read(); - let Some(&UserState { - deaf, - self_deaf, - mute, - suppress, - self_mute, - ref name, - channel, - .. - }) = server.this_user() - else { - return rsx!(); - }; - - let current_channel_name = server.channels_state.channels[&channel].name.clone(); - - let proxy_url = overrides - .read_unchecked() - .as_ref() - .and_then(|overrides| overrides.proxy_url.clone()); - - let connecting_color = "yellow"; - let connected_color = "oklch(0.55 0.1184 141.35)"; - let disconnected_color = "gray"; - let failed_color = "red"; - - let connection_status = match &*status.read() { - Connecting => rsx! { - div { - class: "connection_status", - style: "color: {connecting_color};", - div { - span { - class: "material-symbols-outlined", - "signal_cellular_alt_2_bar" - } - span { - class: "status_text", - " Connecting" - } - } - } - }, - Connected => rsx! { - div { - class: "connection_status", - div { - style: "color: {connected_color};", - span { - class: "material-symbols-outlined", - "signal_cellular_alt" - } - span { - class: "status_text", - " Connected" - } - } - div { - class: "channel_text", - span { "{current_channel_name}" } - } - } - }, - Disconnected => rsx! { - div { - class: "connection_status", - style: "color: {disconnected_color};", - div { - span { - class: "material-symbols-outlined", - "signal_disconnected" - } - span { - class: "status_text", - " Disconnected" - } - } - } - }, - Failed(_) => rsx! { - div { - class: "connection_status", - style: "color: {failed_color};", - div { - span { - class: "material-symbols-outlined", - "error" - } - span { - class: "status_text", - " Failed" - } - } - } - }, - }; - - rsx!( - // Server control - div { - class: "button_row", - div { - {connection_status} - } - span { class: "spacer" } - button { - class: "toggle_button", - onclick: move |_| net.send(Disconnect), - span { - class: "material-symbols-outlined", - "signal_disconnected" - } - } - } - hr { style: "width: 100%;" } - // User control - div { - class: "button_row", - button { - class: "user_edit_button", - span { - class: "material-symbols-outlined", - style: "color: oklch(0.65 0.2245 28.06);", - "person_edit" - } - } - div { - class: "user_info", - div { - span { class: "user_name", "{name}" } - } - div { - span { class: "user_data", "some data" } - } - } - span { class: "spacer" } - button { - class: match audio.denoise { - true => "toggle_button is_on", - false => "toggle_button", - }, - role: "switch", - aria_checked: audio.denoise, - onclick: move |_| { - let state = use_context::(); - let mut audio = state.audio.read().clone(); - audio.denoise = !audio.denoise; - let denoise = audio.denoise; - *state.audio.write_unchecked() = audio; - net.send(UpdateAudioSettings(AudioSettings { denoise: denoise })); - let user_config = use_context::(); - user_config.config_set::("denoise", &denoise); - }, - match audio.denoise { - true => rsx!(span { class: "material-symbols-outlined", "cadence"}), - false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}), - } - } - button { - class: match mute || suppress || self_mute { - true => "toggle_button is_on", - false => "toggle_button", - }, - role: "switch", - aria_checked: mute || suppress || self_mute, - disabled: mute || suppress, - onclick: move |_| net.send(SetMute { mute: !self_mute }), - match mute || suppress || self_mute { - true => rsx!(span { class: "material-symbols-outlined", "mic_off"}), - false => rsx!(span { class: "material-symbols-outlined", "mic"}), - } - } - button { - class: match deaf || self_deaf { - true => "toggle_button in_on", - false => "toggle_button", - }, - role: "switch", - aria_checked: deaf || self_deaf, - disabled: deaf, - onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }), - match deaf || self_deaf { - true => rsx!(span { class: "material-symbols-outlined", "volume_off"}), - false => rsx!(span { class: "material-symbols-outlined", "volume_up"}), - } - } - } - ) -} - -#[component] -pub fn ServerView(overrides: Resource) -> Element { - let net: Coroutine = use_coroutine_handle(); - let state = use_context::(); - let server = state.server.read(); - let Some(&UserState { - deaf, - self_deaf, - mute, - self_mute, - .. - }) = server.this_user() - else { - return rsx!(); - }; - - rsx!( - div { - class: "server_grid", - div { - class: "server_channel_box", - for (id, state) in server.channels_state.channels.iter() { - if state.parent.is_none() { - Channel { id: *id } - } - } - } - div { - class: "server_chat_box", - ChatView {} - } - div { - class: "server_control_box", - ControlView { overrides } - } - } - ) -} - -#[component] -pub fn LoginView(overrides: Resource) -> Element { - let user_config = use_context::(); - let net: Coroutine = use_coroutine_handle(); - - let last_status = use_signal(|| None::>); - use_resource(move || async move { - let client = reqwest::Client::new(); - loop { - *last_status.write_unchecked() = Some(Platform::get_status(&client).await); - Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await; - } - }); - - let mut address_input = use_signal(|| user_config.config_get::("server_url")); - let address = use_memo(move || { - if let Some(addr) = address_input() { - addr.clone() - } else { - overrides() - .and_then(|c| c.proxy_url.clone()) - .unwrap_or_default() - } - }); - - let mut username = use_signal(|| { - user_config - .config_get::("username") - .unwrap_or(String::new()) - }); - - let do_connect = move |_| { - let _ = user_config.config_set::("username", &username.read()); - if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) { - user_config.config_set::("server_url", &address.read()); - } - net.send(Connect { - address: address.read().clone(), - username: username.read().clone(), - config: overrides.read().clone().unwrap_or_default(), - }) - }; - let state = use_context::(); - let status = &state.status; - let bottom = match &*status.read() { - Disconnected => rsx! { - button { - class: "login_bttn", - onclick: do_connect.clone(), - "Connect" - } - }, - Connecting => rsx! { - div { - class: "login_bttn", - "Connecting..." - } - }, - Failed(msg) => rsx!( - button { - class: "login_bttn", - onclick: do_connect.clone(), - "Reconnect" - } - div { - class: "login_error", - "Failed to connect:" - pre { - "{msg}" - } - } - ), - Connected => unreachable!(), - }; - let version = option_env!("MUMBLE_WEB2_VERSION"); - rsx!( - div { - class: "login", - h1 { - "Mumble Web" - match version { - Some(v) => rsx!(" " span { class: "login_version", "({v})" }), - None => rsx!(), - } - } - if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) { - div { - label { - for: "address-entry", - "Server Address:" - } - input { - id: "address-entry", - placeholder: "address", - value: "{address.read()}", - autofocus: "true", - oninput: move |evt| address_input.set(Some(evt.value().clone())), - } - } - } - div { - label { - for: "username-entry", - "Username:" - //style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", - } - input { - id: "username-entry", - placeholder: "username", - value: "{username.read()}", - autofocus: "true", - oninput: move |evt| username.set(evt.value().clone()), - } - } - div { - match &*last_status.read() { - None => rsx!(div { - class: "login_status", - span {"···"} - }), - Some(Ok(ServerStatus { success: false, .. })) => rsx!(div { - class: "login_status is_error", - span { - "Could not reach server" - } - }), - Some(Ok(status)) => rsx!(div { - class: "login_status", - if let (Some(users), Some(max_users)) = (status.users, status.max_users) { - span {"{users}/{max_users} Online"} - } else { - span {"Unknown Online"} - } - span {"-"} - if let Some((maj, min, pat)) = status.version { - span {"Version: {maj}.{min}.{pat}"} - } else { - span {"Unknown Version"} - } - }), - Some(Err(_)) => rsx!(div { - class: "login_status is_error", - span { - "Could not reach proxy server" - } - }), - } - div { - {bottom} - } - - } - } - ) - // rsx!( - // div { - // class: "{login_box}", - // h1 { - // "Mumble Web" - // } - // input { - // placeholder: "username", - // value: "{username.read()}", - // autofocus: "true", - // oninput: move |evt| username.set(evt.value().clone()), - // } - // input { - // placeholder: "server address", - // value: "{address.read()}", - // autofocus: "true", - // oninput: move |evt| address_input.set(Some(evt.value().clone())), - // } - // {bottom} - // } - // ) -} - -#[component] -pub fn app() -> Element { - static STYLE: Asset = asset!("/assets/main.scss"); - - use_effect(|| { - Platform::request_permissions(); - }); - - let user_config = use_root_context(|| ConfigSystem::new().unwrap()); - let state = use_root_context(|| { - SharedState::new(State { - status: Signal::new(Disconnected), - server: Signal::new(Default::default()), - audio: Signal::new(AudioSettings { - denoise: user_config.config_get::("denoise").unwrap_or(true), - }), - }) - }); - - let network_state = state.clone(); - use_coroutine(move |rx: UnboundedReceiver| { - super::network_entrypoint(rx, network_state.clone()) - }); - let overrides = use_resource(|| async move { - match Platform::load_proxy_overrides().await { - Ok(overrides) => overrides, - Err(_) => ProxyOverrides::default(), - } - }); - - rsx!( - document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" } - document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" } - document::Link{ rel: "stylesheet", href: STYLE } - - match *state.status.read() { - Connected => rsx!(ServerView { overrides }), - _ => rsx!(LoginView { overrides }), - } - ) -} diff --git a/gui/src/main.rs b/gui/src/main.rs index 58ce7cf..dd41eeb 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,5 +1,720 @@ +#![allow(non_snake_case)] + use dioxus::prelude::*; -use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _}; +use mumble_web2_client::{ + network_entrypoint, reqwest, AudioSettings, ChannelId, Command, ConfigSystem, + ConfigSystemInterface as _, ConnectionState, Platform, PlatformInterface as _, ServerState, + SharedState, State, UserId, UserState, +}; +use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use std::collections::{HashMap, HashSet}; +use std::{fmt, sync::Arc}; +use Command::*; +use ConnectionState::*; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum UserIcon { + Normal, + Muted, + Deafened, + Suppressed, + None, +} + +impl UserIcon { + pub fn icon(user: &UserState) -> UserIcon { + if user.deaf || user.self_deaf { + UserIcon::Deafened + } else if user.mute || user.self_mute { + UserIcon::Muted + } else if user.suppress { + UserIcon::Suppressed + } else { + UserIcon::Normal + } + } + + pub fn url(self) -> Option { + // speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/ + // mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/ + + use UserIcon::*; + Some(match self { + Normal => asset!("assets/mic-svgrepo-com.svg"), + Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"), + Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"), + None => return Option::None, + }) + } +} + +#[component] +pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element { + let color = match icon { + UserIcon::Normal => "var(--accent-normal)", + UserIcon::Muted => "var(--accent-muted)", + UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)", + UserIcon::None => "var(--accent-normal)", + }; + + rsx!( + div { + class: match isself { true => "userpil is_self", false => "userpil" }, + style: "background-color: {color}", + { icon.url().map(|url| rsx!(img { src: url })) } + "\u{00A0}{name}\u{00A0}" + } + ) +} + +#[component] +pub fn User(id: UserId) -> Element { + let state = use_context::(); + let server = state.server.read(); + match server.users.get(&id) { + Some(state) => rsx!(UserPill { + name: state.name.clone(), + icon: UserIcon::icon(state), + isself: server.session.unwrap() == id, + }), + None => rsx!(UserPill { + name: format!("unknown user ({id})"), + icon: UserIcon::None, + isself: false, + }), + } +} + +#[component] +pub fn Channel(id: ChannelId) -> Element { + let net: Coroutine = use_coroutine_handle(); + let state = use_context::(); + let server = state.server.read(); + let user = server.session.unwrap(); + let Some(state) = server.channels_state.channels.get(&id) else { + return rsx!("missing channel {id}"); + }; + + let mut open = use_signal(|| true); + + let has_children = !state.users.is_empty() || !state.children.is_empty(); + + rsx!( + div { + class: "channel_details", + + div { + class: "channel_header", + // Arrow: only toggles open + if has_children { + span { + class: "channel_arrow", + onclick: move |evt| { + evt.stop_propagation(); + evt.prevent_default(); + let mut w = open.write(); + *w = !*w; + }, + if *open.read() { "▾" } else { "▸" } + } + } else { + span { + class: "channel_arrow channel_arrow--placeholder", + " " + } + } + + // Clickable row area (everything except the arrow) + div { + class: "channel_row_click", + ondblclick: move |evt| { + evt.stop_propagation(); + evt.prevent_default(); + net.send(EnterChannel { channel: id, user }) + }, + // remove dblclick from the inner span + span { + class: "channel_title", + "{state.name}" + } + // if you add icons/badges later, put them here too + } + } + + if *open.read() && has_children { + div { + class: "channel_children", + for id in state.users.iter() { + User { id: *id } + } + for child in state.children.iter() { + Channel { id: *child } + } + } + } + } + ) +} + +#[cfg(any(feature = "desktop", feature = "web"))] +pub fn pick_and_send_file(net: &Coroutine) { + let state = use_context::(); + let channels = if let Some(user) = state.server.read().this_user() { + vec![user.channel] + } else { + return; + }; + let dialog = rfd::AsyncFileDialog::new().pick_file(); + let sender = net.tx(); + spawn(async move { + let Some(handle) = dialog.await else { return }; + let name = handle.file_name(); + let bytes = handle.read().await; + let mime = mumble_web2_client::mime_guess::from_path(&name).first(); + let _ = sender.unbounded_send(SendFile { + bytes, + name, + mime, + channels, + }); + }); +} +#[cfg(not(any(feature = "desktop", feature = "web")))] +pub fn pick_and_send_file(net: &Coroutine) {} + +#[component] +pub fn ChatView() -> Element { + let net: Coroutine = use_coroutine_handle(); + let state = use_context::(); + let server = state.server.read(); + let mut draft = use_signal(|| "".to_string()); + + let mut do_send = move || { + let state = use_context::(); + let server = state.server.read(); + if let Some(user) = server.this_user() { + net.send(SendChat { + markdown: draft.write().split_off(0), + channels: vec![user.channel], + }); + } + }; + + rsx!( + div { + class: "chat_panel", + div { + class: "chat_history", + for chat in server.chat.iter() { + div { + class: "chat_message", + if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) { + UserPill { + name: sender.name.clone(), + icon: UserIcon::None, + isself: false, + } + } + span { + dangerous_inner_html: "{chat.dangerous_html}", + } + } + } + } + div { + class: "chat_box_wrapper", + div { + class: "chat_box", + input { + placeholder: "say something", + value: "{draft.read()}", + oninput: move |evt| draft.set(evt.value().clone()), + onkeypress: move |evt: Event| { + if evt.code() == Code::Enter && evt.modifiers().is_empty() { + do_send(); + } + } + } + div { + span { + onclick: move |_| pick_and_send_file(&net), + class: "material-symbols-outlined", + style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", + "attach_file", + } + } + div { + span { + onclick: move |_| do_send(), + class: "material-symbols-outlined", + style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", + "send", + } + } + } + //button { + // onclick: move |_| do_send(), + // "Send" + //} + } + } + ) +} + +#[component] +pub fn ControlView(overrides: Resource) -> Element { + let net: Coroutine = use_coroutine_handle(); + let state = use_context::(); + let status = &state.status; + let server = state.server.read(); + let audio = state.audio.read(); + let Some(&UserState { + deaf, + self_deaf, + mute, + suppress, + self_mute, + ref name, + channel, + .. + }) = server.this_user() + else { + return rsx!(); + }; + + let current_channel_name = server.channels_state.channels[&channel].name.clone(); + + let proxy_url = overrides + .read_unchecked() + .as_ref() + .and_then(|overrides| overrides.proxy_url.clone()); + + let connecting_color = "yellow"; + let connected_color = "oklch(0.55 0.1184 141.35)"; + let disconnected_color = "gray"; + let failed_color = "red"; + + let connection_status = match &*status.read() { + Connecting => rsx! { + div { + class: "connection_status", + style: "color: {connecting_color};", + div { + span { + class: "material-symbols-outlined", + "signal_cellular_alt_2_bar" + } + span { + class: "status_text", + " Connecting" + } + } + } + }, + Connected => rsx! { + div { + class: "connection_status", + div { + style: "color: {connected_color};", + span { + class: "material-symbols-outlined", + "signal_cellular_alt" + } + span { + class: "status_text", + " Connected" + } + } + div { + class: "channel_text", + span { "{current_channel_name}" } + } + } + }, + Disconnected => rsx! { + div { + class: "connection_status", + style: "color: {disconnected_color};", + div { + span { + class: "material-symbols-outlined", + "signal_disconnected" + } + span { + class: "status_text", + " Disconnected" + } + } + } + }, + Failed(_) => rsx! { + div { + class: "connection_status", + style: "color: {failed_color};", + div { + span { + class: "material-symbols-outlined", + "error" + } + span { + class: "status_text", + " Failed" + } + } + } + }, + }; + + rsx!( + // Server control + div { + class: "button_row", + div { + {connection_status} + } + span { class: "spacer" } + button { + class: "toggle_button", + onclick: move |_| net.send(Disconnect), + span { + class: "material-symbols-outlined", + "signal_disconnected" + } + } + } + hr { style: "width: 100%;" } + // User control + div { + class: "button_row", + button { + class: "user_edit_button", + span { + class: "material-symbols-outlined", + style: "color: oklch(0.65 0.2245 28.06);", + "person_edit" + } + } + div { + class: "user_info", + div { + span { class: "user_name", "{name}" } + } + div { + span { class: "user_data", "some data" } + } + } + span { class: "spacer" } + button { + class: match audio.denoise { + true => "toggle_button is_on", + false => "toggle_button", + }, + role: "switch", + aria_checked: audio.denoise, + onclick: move |_| { + let state = use_context::(); + let mut audio = state.audio.read().clone(); + audio.denoise = !audio.denoise; + let denoise = audio.denoise; + *state.audio.write_unchecked() = audio; + net.send(UpdateAudioSettings(AudioSettings { denoise: denoise })); + let user_config = use_context::(); + user_config.config_set::("denoise", &denoise); + }, + match audio.denoise { + true => rsx!(span { class: "material-symbols-outlined", "cadence"}), + false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}), + } + } + button { + class: match mute || suppress || self_mute { + true => "toggle_button is_on", + false => "toggle_button", + }, + role: "switch", + aria_checked: mute || suppress || self_mute, + disabled: mute || suppress, + onclick: move |_| net.send(SetMute { mute: !self_mute }), + match mute || suppress || self_mute { + true => rsx!(span { class: "material-symbols-outlined", "mic_off"}), + false => rsx!(span { class: "material-symbols-outlined", "mic"}), + } + } + button { + class: match deaf || self_deaf { + true => "toggle_button in_on", + false => "toggle_button", + }, + role: "switch", + aria_checked: deaf || self_deaf, + disabled: deaf, + onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }), + match deaf || self_deaf { + true => rsx!(span { class: "material-symbols-outlined", "volume_off"}), + false => rsx!(span { class: "material-symbols-outlined", "volume_up"}), + } + } + } + ) +} + +#[component] +pub fn ServerView(overrides: Resource) -> Element { + let net: Coroutine = use_coroutine_handle(); + let state = use_context::(); + let server = state.server.read(); + let Some(&UserState { + deaf, + self_deaf, + mute, + self_mute, + .. + }) = server.this_user() + else { + return rsx!(); + }; + + rsx!( + div { + class: "server_grid", + div { + class: "server_channel_box", + for (id, state) in server.channels_state.channels.iter() { + if state.parent.is_none() { + Channel { id: *id } + } + } + } + div { + class: "server_chat_box", + ChatView {} + } + div { + class: "server_control_box", + ControlView { overrides } + } + } + ) +} + +#[component] +pub fn LoginView(overrides: Resource) -> Element { + let user_config = use_context::(); + let net: Coroutine = use_coroutine_handle(); + + let last_status = use_signal(|| None::>); + use_resource(move || async move { + let client = reqwest::Client::new(); + loop { + *last_status.write_unchecked() = Some(Platform::get_status(&client).await); + Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await; + } + }); + + let mut address_input = use_signal(|| user_config.config_get::("server_url")); + let address = use_memo(move || { + if let Some(addr) = address_input() { + addr.clone() + } else { + overrides() + .and_then(|c| c.proxy_url.clone()) + .unwrap_or_default() + } + }); + + let mut username = use_signal(|| { + user_config + .config_get::("username") + .unwrap_or(String::new()) + }); + + let do_connect = move |_| { + let _ = user_config.config_set::("username", &username.read()); + if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) { + user_config.config_set::("server_url", &address.read()); + } + net.send(Connect { + address: address.read().clone(), + username: username.read().clone(), + config: overrides.read().clone().unwrap_or_default(), + }) + }; + let state = use_context::(); + let status = &state.status; + let bottom = match &*status.read() { + Disconnected => rsx! { + button { + class: "login_bttn", + onclick: do_connect.clone(), + "Connect" + } + }, + Connecting => rsx! { + div { + class: "login_bttn", + "Connecting..." + } + }, + Failed(msg) => rsx!( + button { + class: "login_bttn", + onclick: do_connect.clone(), + "Reconnect" + } + div { + class: "login_error", + "Failed to connect:" + pre { + "{msg}" + } + } + ), + Connected => unreachable!(), + }; + let version = option_env!("MUMBLE_WEB2_VERSION"); + rsx!( + div { + class: "login", + h1 { + "Mumble Web" + match version { + Some(v) => rsx!(" " span { class: "login_version", "({v})" }), + None => rsx!(), + } + } + if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) { + div { + label { + for: "address-entry", + "Server Address:" + } + input { + id: "address-entry", + placeholder: "address", + value: "{address.read()}", + autofocus: "true", + oninput: move |evt| address_input.set(Some(evt.value().clone())), + } + } + } + div { + label { + for: "username-entry", + "Username:" + //style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", + } + input { + id: "username-entry", + placeholder: "username", + value: "{username.read()}", + autofocus: "true", + oninput: move |evt| username.set(evt.value().clone()), + } + } + div { + match &*last_status.read() { + None => rsx!(div { + class: "login_status", + span {"···"} + }), + Some(Ok(ServerStatus { success: false, .. })) => rsx!(div { + class: "login_status is_error", + span { + "Could not reach server" + } + }), + Some(Ok(status)) => rsx!(div { + class: "login_status", + if let (Some(users), Some(max_users)) = (status.users, status.max_users) { + span {"{users}/{max_users} Online"} + } else { + span {"Unknown Online"} + } + span {"-"} + if let Some((maj, min, pat)) = status.version { + span {"Version: {maj}.{min}.{pat}"} + } else { + span {"Unknown Version"} + } + }), + Some(Err(_)) => rsx!(div { + class: "login_status is_error", + span { + "Could not reach proxy server" + } + }), + } + div { + {bottom} + } + + } + } + ) + // rsx!( + // div { + // class: "{login_box}", + // h1 { + // "Mumble Web" + // } + // input { + // placeholder: "username", + // value: "{username.read()}", + // autofocus: "true", + // oninput: move |evt| username.set(evt.value().clone()), + // } + // input { + // placeholder: "server address", + // value: "{address.read()}", + // autofocus: "true", + // oninput: move |evt| address_input.set(Some(evt.value().clone())), + // } + // {bottom} + // } + // ) +} + +#[component] +pub fn app() -> Element { + static STYLE: Asset = asset!("/assets/main.scss"); + + use_effect(|| { + Platform::request_permissions(); + }); + + let user_config = use_root_context(|| ConfigSystem::new().unwrap()); + let state = use_root_context(|| { + SharedState::new(State { + status: Signal::new(Disconnected), + server: Signal::new(Default::default()), + audio: Signal::new(AudioSettings { + denoise: user_config.config_get::("denoise").unwrap_or(true), + }), + }) + }); + + let network_state = state.clone(); + use_coroutine(move |rx: UnboundedReceiver| { + network_entrypoint(rx, network_state.clone()) + }); + let overrides = use_resource(|| async move { + match Platform::load_proxy_overrides().await { + Ok(overrides) => overrides, + Err(_) => ProxyOverrides::default(), + } + }); + + rsx!( + document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" } + document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" } + document::Link{ rel: "stylesheet", href: STYLE } + + match *state.status.read() { + Connected => rsx!(ServerView { overrides }), + _ => rsx!(LoginView { overrides }), + } + ) +} pub fn main() { Platform::init_logging(); @@ -18,5 +733,5 @@ pub fn main() { .with_maximized(false), ) }) - .launch(app::app); + .launch(app); }