12 Commits

Author SHA1 Message Date
liamwarfield 5f3466546e Add some Trait doc comments
Build Mumble Web 2 / windows_build (push) Successful in 2m25s
Build Mumble Web 2 / linux_build (push) Successful in 1m18s
Build Mumble Web 2 / android_build (push) Successful in 5m47s
Added comments to Audio(System|Player)Interface traits.
2026-02-17 21:28:05 -07:00
liamwarfield 8170383278 Remove connection::* import. 2026-02-17 21:28:05 -07:00
sam 2e86f68a3c some more review changes 2026-02-17 21:28:05 -07:00
sam 35b2a06e64 some ideas including stub 2026-02-17 21:28:05 -07:00
sam 09985e6031 move audio stuff into trait 2026-02-17 21:28:01 -07:00
sam 056a673bc0 all platform traits implemented & dumb async runtime imports 2026-02-17 21:26:03 -07:00
sam 411d923c2a wip improved trait shit 2026-02-17 21:26:03 -07:00
liamwarfield ff14f577fe Add Platform trait.
I did some more thinking about the whole trait/boundary stuff and reallized that we don't need the GUI to handle the generic-ness of a trait object since only 1 platform will ever be used in a binary. What do you think of the following:

1. Define a platform trait
2. Each platform defines a zero-sized struct implementing the trait (ex `WebPlatform`).
3. Create an ifdef'd type alias on those structs:

```
     // gui/src/platform/mod.rs
     #[cfg(feature = "web")]
     pub type CurrentPlatform = web::WebPlatform;
     #[cfg(feature = "desktop")]
     pub type CurrentPlatform = desktop::DesktopPlatform;
     #[cfg(feature = "mobile")]
     pub type CurrentPlatform = mobile::MobilePlatform;
```

4. Add a compile time assertion that `CurrentPlatform` implements `Platform`.

Pros:

- We don't end up working around async trait objects
- We define what functions are needed for a platform
- We save a little on binary size by avoiding a fully generic solution.

Cons:

- The trait does not really do much other than being a collection of functions.
- In some ways it seems like what we're currently doing but with extra steps.
2026-02-17 21:26:03 -07:00
liamwarfield 083a11274e Fix buffer bug in native audio.
Build Mumble Web 2 / linux_build (push) Successful in 5m5s
Build Mumble Web 2 / windows_build (push) Successful in 9m48s
Build Mumble Web 2 / android_build (push) Successful in 12m5s
Build android container / android-release-builder-container-build (push) Successful in 1s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 13s
Setting this buffer to 2400 was only enough to store 50m of sound
at 48,000 samples/sec. I've also done a small refactor here to
make this buffer scale with sample rate.
2026-02-06 17:47:55 -07:00
restitux 2fcb853c30 gui: improve channel selection behavior (#21)
Build Mumble Web 2 / windows_build (push) Successful in 2m41s
Build Mumble Web 2 / linux_build (push) Successful in 1m25s
Build Mumble Web 2 / android_build (push) Successful in 5m58s
Build android container / android-release-builder-container-build (push) Successful in 9s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 13s
# Summary
This change improves the channel selection behavior to be more similar to the official client and generally more usable. It's currently mildly broken due to the details element grabbing click events from the whole row and the row text being selectable. This change also makes it more obvious that the channel title can be clicked. I'm not sure how this works on mobile, so we might need to make more changes there in the future to work better with touchscreens.

# Changes
- Channels can only be expanded or collapsed by clicking on the adjacent arrow
- Expand/collapse arrows are only displayed on channels with children or users
- Channel can only be joined by double clicking to the right of the collapse/expand arrow
- The channel title background (and the empty space to the right) display a highlight when the user hovers over them.
- All text inside the channel view is no longer selectable.

# Testing
I tested on the desktop client. I didn't test on mobile but I'll give it a shot after I merge and maybe come back with another PR to make this behavior more intuitive over there.

Reviewed-on: #21
2026-01-28 06:03:23 +00:00
restitux feaa9f2bda gui: fix channel sort order (#17) (#20)
Build Mumble Web 2 / linux_build (push) Successful in 1m28s
Build Mumble Web 2 / windows_build (push) Successful in 2m44s
Build Mumble Web 2 / android_build (push) Successful in 5m56s
Build android container / android-release-builder-container-build (push) Successful in 7s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
# Summary
Channel ordering is currently broken as described in #17.  This change makes sorting work correctly and cleans up the logic a bit.

# Changes
- creates a `ChannelsState` wrapper struct to handle this behavior
- moves the logic for handling `ChannelState` processing, including data update and parent-child tree sorting, into the impl for `ChannelsState`
- moves the logic for handling `ChannelRemove` into this impl

Parent child sorting properly applies the position values, which are arbitrary integers that are supposed to be sorted in numerical order. Lexicographical sorting is use for tiebreaking, which lines up (at least in my testing) with the official client's behavior. We may handle some lexicographical edge cases differently (spaces, symbols, etc) but 1. the Desktop client compliance is best effort and 2. users should use the position fields instead of relying on text sort order. Some compatibility is still helpful for matching temporary channel positioning, especially for servers with automated channel creation workflows.

This code is a bit complicated, as the mumble protocol makes no guarantees which order the channels will be sent. It ended up being simpler to just bulk recreate the children anytime any channel update is sent. I don't expect this to ever have performance issues, though maybe someday some server with 10,000 channels will send us a bug report 😆

# Testing
I tested this change by creating a bunch of channel with various sort orders and names. I compared the behavior with the official desktop client and our client seemed to follow along.

Reviewed-on: #20
2026-01-25 19:10:17 +00:00
restitux aa3fcf09cf meta: update dioxus cli to 0.7.3 (#19)
Build Mumble Web 2 / linux_build (push) Successful in 1m26s
Build Mumble Web 2 / windows_build (push) Successful in 2m37s
Build Mumble Web 2 / android_build (push) Successful in 5m54s
Reviewed-on: #19
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-01-25 06:25:21 +00:00
16 changed files with 835 additions and 363 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
- name: Install dioxus-cli
run: cargo binstall dioxus-cli --version 0.7.2
run: cargo binstall dioxus-cli --version 0.7.3
- uses: Swatinem/rust-cache@v2
+1 -1
View File
@@ -29,7 +29,7 @@ RUN yes | /opt/android-tools/cmdline-tools/latest/bin/sdkmanager --install "plat
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
# Install dioxus-cli
RUN cargo binstall dioxus-cli@0.7.2
RUN cargo binstall dioxus-cli@0.7.3
# Install bindgen-cli
RUN cargo binstall bindgen-cli
+3 -5
View File
@@ -44,14 +44,12 @@ RUN choco install rustup.install -y --no-progress
RUN rustup toolchain install stable-x86_64-pc-windows-msvc
RUN rustup default stable-x86_64-pc-windows-msvc
# Install carog binstall
# Install cargo binstall
RUN Set-ExecutionPolicy Unrestricted -Scope Process; `
iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
# Install dioxus-cli from git HEAD with cargo
# This is to work around a bug in the windows builder upstream.
# Dioxus has released 0.7.2, but it seems to be broken for now.
RUN cargo binstall dioxus-cli
# Install dioxus-cli
RUN cargo binstall dioxus-cli@0.7.3
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
-1
View File
@@ -146,7 +146,6 @@ desktop = [
"rfd/xdg-portal",
"etcetera",
]
mobile = [
"dioxus/mobile",
"tokio",
+38
View File
@@ -83,6 +83,44 @@ a:visited {
}
}
.channel_header {
display: flex;
flex-direction: row;
align-items: center;
}
.channel_arrow {
width: 1em;
text-align: center;
margin-right: 0.25rem;
}
.channel_arrow--placeholder {
pointer-events: none;
visibility: hidden;
}
/* The whole right side of the row is the dblclick target */
.channel_row_click {
flex: 1;
padding: 0.1rem 0.25rem 0.1rem 0.5rem;
cursor: pointer;
}
/* Hover highlight for whole row area (title + blank space) */
.channel_row_click:hover {
background-color: var(--channel-hover-bg, #222); /* pick your color */
}
/* still keep text non-selectable if desired */
.channel_details {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.channel {
&_details {
flex: 0 0 100%;
+166 -30
View File
@@ -4,9 +4,9 @@ use dioxus::prelude::*;
use mime_guess::Mime;
use mumble_web2_common::{ClientConfig, ServerStatus};
use ordermap::OrderSet;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use crate::imp;
use crate::imp::{Platform, PlatformInterface as _};
pub type ChannelId = u32;
pub type UserId = u32;
@@ -54,14 +54,6 @@ pub enum Command {
use Command::*;
use ConnectionState::*;
#[derive(Default)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
}
#[derive(Default)]
pub struct UserState {
pub name: String,
@@ -94,8 +86,121 @@ pub struct Chat {
}
#[derive(Default)]
pub struct ServerState {
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
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)]
pub struct ChannelsState {
pub channels: HashMap<ChannelId, ChannelState>,
}
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<ChannelId>, 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<ChannelId, (i32, String)> = self
.channels
.iter()
.map(|(&id, state)| (id, (state.position, state.name.clone())))
.collect();
let mut updated: HashSet<ChannelId> = 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)]
pub struct ServerState {
pub channels_state: ChannelsState,
pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>,
pub session: Option<UserId>,
@@ -182,26 +287,57 @@ pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let user = server.session.unwrap();
let Some(state) = server.channels.get(&id) else {
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!(
details {
div {
class: "channel_details",
open: true,
summary {
span {
role: "button",
ondoubleclick: move |evt| {
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 })
},
"{state.name}"
// remove dblclick from the inner span
span {
class: "channel_title",
"{state.name}"
}
// if you add icons/badges later, put them here too
}
}
if state.users.len() + state.children.len() > 0 {
if *open.read() && has_children {
div {
class: "channel_children",
for id in state.users.iter() {
@@ -336,7 +472,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
return rsx!();
};
let current_channel_name = server.channels[&channel].name.clone();
let current_channel_name = server.channels_state.channels[&channel].name.clone();
let proxy_url = config
.read_unchecked()
@@ -528,7 +664,7 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
class: "server_grid",
div {
class: "server_channel_box",
for (id, state) in server.channels.iter() {
for (id, state) in server.channels_state.channels.iter() {
if state.parent.is_none() {
Channel { id: *id }
}
@@ -554,12 +690,12 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
use_resource(move || async move {
let client = reqwest::Client::new();
loop {
*last_status.write_unchecked() = Some(imp::get_status(&client).await);
imp::sleep(std::time::Duration::from_secs_f32(1.0)).await;
*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(|| imp::load_server_url());
let mut address_input = use_signal(|| Platform::load_server_url());
let address = use_memo(move || {
if let Some(addr) = address_input() {
addr.clone()
@@ -570,14 +706,14 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
}
});
let previous_username = imp::load_username();
let previous_username = Platform::load_username();
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
let do_connect = move |_| {
//let _ = set_default_username(&username.read());
let _ = imp::set_default_username(&username.read());
let _ = Platform::set_default_username(&username.read());
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
imp::set_default_server(&address.read());
Platform::set_default_server(&address.read());
}
net.send(Connect {
address: address.read().clone(),
@@ -724,13 +860,13 @@ pub fn app() -> Element {
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
let config = use_resource(|| async move {
match imp::load_config().await {
match Platform::load_config().await {
Ok(config) => config,
Err(_) => ClientConfig::default(),
}
});
imp::request_permissions();
Platform::request_permissions();
rsx!(
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
+11 -9
View File
@@ -7,7 +7,7 @@ use std::cell::RefCell;
use std::sync::Arc;
use tracing::{error, info};
use crate::imp;
use crate::imp::SpawnHandle;
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
// TODO: make this user configurable.
@@ -32,10 +32,7 @@ enum DenoisingModelState {
Availible(Box<DfTract>),
}
fn with_denoising_model<O>(
spawn: &imp::SpawnHandle,
func: impl FnOnce(&mut DfTract) -> O,
) -> Option<O> {
fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract) -> O) -> Option<O> {
// Using a thread local is super gross, but DfTract is not Send (so it can never leave the current
// thread) while AudioProcessing itself might change threads whenever.
thread_local! {
@@ -89,7 +86,7 @@ fn with_denoising_model<O>(
pub struct AudioProcessor {
denoise: bool,
spawn: imp::SpawnHandle,
spawn: SpawnHandle,
buffer: Vec<f32>,
noise_floor: f32,
/// Whether we were transmitting in the previous frame
@@ -102,7 +99,7 @@ impl AudioProcessor {
pub fn new_plain() -> Self {
AudioProcessor {
denoise: false,
spawn: imp::SpawnHandle::current(),
spawn: SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
@@ -113,7 +110,7 @@ impl AudioProcessor {
pub fn new_denoising() -> Self {
AudioProcessor {
denoise: true,
spawn: imp::SpawnHandle::current(),
spawn: SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
@@ -123,7 +120,12 @@ impl AudioProcessor {
}
impl AudioProcessor {
pub fn process(&mut self, audio: &[f32], channels: usize, output: &mut Vec<f32>) -> TransmitState {
pub fn process(
&mut self,
audio: &[f32],
channels: usize,
output: &mut Vec<f32>,
) -> TransmitState {
let mut include_raw = true;
if self.denoise {
with_denoising_model(&self.spawn, |df| {
+5
View File
@@ -108,3 +108,8 @@ pub async fn network_connect(
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet")
}
#[allow(unused)]
pub use tokio::spawn;
#[allow(unused)]
pub type SpawnHandle = tokio::runtime::Handle;
+77 -51
View File
@@ -1,12 +1,83 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use mumble_web2_common::ClientConfig;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::collections::HashMap;
pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
use std::time::Duration;
pub use super::connect::*;
pub use super::native_audio::*;
/// Desktop platform implementation using Tokio and native audio.
pub struct DesktopPlatform;
impl super::PlatformInterface for DesktopPlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
fn load_username() -> Option<String> {
let config = load_config_map();
config.get("username").cloned()
}
fn load_server_url() -> Option<String> {
let config = load_config_map();
config.get("server").cloned()
}
fn set_default_username(username: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("username".to_string(), username.to_string());
save_config_map(&config).ok()
}
fn set_default_server(server: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("server".to_string(), server.to_string());
save_config_map(&config).ok()
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, gui_config).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
super::connect::get_status(client).await
}
fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
fn request_permissions() {
// No-op on desktop
}
}
fn get_config_path() -> std::path::PathBuf {
let strategy = choose_app_strategy(AppStrategyArgs {
@@ -35,48 +106,3 @@ fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
std::fs::write(&config_path, contents)?;
Ok(())
}
pub fn set_default_username(username: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("username".to_string(), username.to_string());
save_config_map(&config).ok()
}
pub fn set_default_server(server: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("server".to_string(), server.to_string());
save_config_map(&config).ok()
}
pub fn load_username() -> Option<String> {
let config = load_config_map();
config.get("username").cloned()
}
pub fn load_server_url() -> Option<String> {
let config = load_config_map();
config.get("server").cloned()
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
pub fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
+74 -50
View File
@@ -1,61 +1,85 @@
use android_permissions::{PermissionManager, RECORD_AUDIO};
use jni::{objects::JObject, JavaVM};
use mumble_web2_common::ClientConfig;
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::future::Future;
use std::time::Duration;
use std::collections::HashMap;
pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
pub struct MobilePlatform;
pub use super::connect::*;
pub use super::native_audio::*;
impl super::PlatformInterface for MobilePlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
pub fn set_default_username(username: &str) -> Option<()> {
None
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
fn load_username() -> Option<String> {
None
}
fn load_server_url() -> Option<String> {
None
}
fn set_default_username(_username: &str) -> Option<()> {
None
}
fn set_default_server(server: &str) -> Option<()> {
None
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, gui_config).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
super::connect::get_status(client).await
}
fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
fn request_permissions() {
request_recording_permission();
}
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
}
pub fn set_default_server(server: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> {
None
}
pub fn load_server_url() -> Option<String> {
None
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
pub fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
#[cfg(feature = "mobile")]
pub fn request_permissions() {
request_recording_permission();
}
#[cfg(not(target_os = "android"))]
pub fn request_recording_permission() {}
#[cfg(target_os = "android")]
pub fn request_recording_permission() {
use android_permissions::{PermissionManager, RECORD_AUDIO};
use jni::{objects::JObject, JavaVM};
let ctx = ndk_context::android_context();
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() };
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
+154 -17
View File
@@ -1,29 +1,166 @@
#[cfg(feature = "web")]
mod web;
//! Platform abstraction layer
//!
//! This module defines traits that each platform (web, desktop, mobile) must implement.
//! The traits make the platform boundary explicit and provide compile-time verification.
#![allow(async_fn_in_trait)]
use crate::{app::Command, effects::AudioProcessor};
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::future::Future;
use std::time::Duration;
// ============================================================================
// Trait Definitions
// ============================================================================
/// Platform-specific audio subsystem for capturing microphone input and creating playback streams.
///
/// The audio system handles Opus encoding internally - callers receive encoded frames
/// ready for network transmission.
pub trait AudioSystemInterface: Sized {
/// The player type returned by [`create_player`](Self::create_player).
type AudioPlayer: AudioPlayerInterface;
/// Initialize the audio system.
async fn new() -> Result<Self, Error>;
/// Set the processor for the microphone input, mainly noise cancellation settings.
fn set_processor(&self, processor: AudioProcessor);
/// Begin listening to microphone input, calling the `each` function with
/// encoded opus frames.
fn start_recording(
&mut self,
each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error>;
/// Begin playback of an audio stream, returning an object that can be passed opus frames.
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error>;
}
/// A handle to an active audio playback stream for a single remote user.
///
/// Each connected user gets their own `AudioPlayer` instance, which decodes
/// incoming Opus frames and outputs PCM audio to the platform's audio device.
/// The player manages its own decoder state and output buffer.
pub trait AudioPlayerInterface {
/// Decode and play an Opus-encoded audio frame.
fn play_opus(&mut self, payload: &[u8]);
}
/// This is the main trait that each platform must implement. It combines all
/// platform-specific functionality into a single interface, providing compile-time
/// verification that all platforms implement the required functionality.
pub trait PlatformInterface {
type AudioSystem: AudioSystemInterface;
/// Initialize logging for the platform.
fn init_logging();
/// Request runtime permissions (Android audio recording, etc.).
fn request_permissions();
/// Establish a connection to the Mumble server and run the network loop.
fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> impl Future<Output = Result<(), Error>>;
/// Get server status (user count, version, etc.).
fn get_status(
client: &reqwest::Client,
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
/// Load the proxy overrides (proxy URL, cert hash, etc.).
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>>;
/// Load saved username.
fn load_username() -> Option<String>;
/// Load saved server URL.
fn load_server_url() -> Option<String>;
/// Save the default username.
fn set_default_username(username: &str) -> Option<()>;
/// Save the default server URL.
fn set_default_server(server: &str) -> Option<()>;
/// Async sleep for the given duration.
fn sleep(duration: Duration) -> impl Future<Output = ()>;
}
// ============================================================================
// Platform Modules
// ============================================================================
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod connect;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
#[cfg(feature = "desktop")]
mod desktop;
#[cfg(feature = "mobile")]
mod mobile;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
mod stub;
#[cfg(feature = "web")]
mod web;
#[cfg(feature = "desktop")]
pub use desktop::*;
#[cfg(feature = "mobile")]
pub use mobile::*;
// ============================================================================
// Platform Type Alias
// ============================================================================
#[cfg(feature = "mobile")]
pub use mobile::request_permissions;
#[cfg(feature = "web")]
pub type Platform = web::WebPlatform;
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn request_permissions() {}
#[cfg(all(feature = "desktop", not(feature = "web")))]
pub type Platform = desktop::DesktopPlatform;
#[cfg(all(feature = "web", not(any(feature = "desktop", feature = "mobile"))))]
pub use web::*;
#[cfg(all(feature = "mobile", not(feature = "web"), not(feature = "desktop")))]
pub type Platform = mobile::MobilePlatform;
#[cfg(any(feature = "desktop"))]
pub use desktop::*;
#[cfg(all(
not(feature = "mobile"),
not(feature = "web"),
not(feature = "desktop")
))]
pub type Platform = stub::StubPlatform;
pub type AudioSystem = <Platform as PlatformInterface>::AudioSystem;
pub type AudioPlayer = <AudioSystem as AudioSystemInterface>::AudioPlayer;
// ========================
// Platform Async Runtime
// ========================
// Note: these can not be part of the Platform because they differ in Send requiremets
#[cfg(all(any(feature = "desktop", feature = "mobile"), not(feature = "web")))]
pub use connect::{spawn, SpawnHandle};
#[cfg(all(
not(feature = "desktop"),
not(feature = "mobile"),
not(feature = "web")
))]
pub use stub::{spawn, SpawnHandle};
#[cfg(feature = "web")]
pub use web::{spawn, SpawnHandle};
// =======================
// Compile-time Assertions
// =======================
const _: () = {
fn assert_platform<T: PlatformInterface>() {}
// Check each implementation, and prevent warnings that the implementations are unused.
#[cfg(feature = "web")]
let _ = assert_platform::<web::WebPlatform>;
#[cfg(feature = "desktop")]
let _ = assert_platform::<desktop::DesktopPlatform>;
#[cfg(feature = "mobile")]
let _ = assert_platform::<mobile::MobilePlatform>;
let _ = assert_platform::<stub::StubPlatform>;
};
+42 -45
View File
@@ -1,19 +1,12 @@
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{eyre, Error};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
use futures::io::{AsyncRead, AsyncWrite};
use std::mem::replace;
use std::sync::Arc;
use std::sync::Mutex;
use tracing::{error, info, warn};
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
pub struct AudioSystem {
pub struct NativeAudioSystem {
output: cpal::Device,
input: cpal::Device,
processors: AudioProcessorSender,
@@ -22,6 +15,8 @@ pub struct AudioSystem {
const SAMPLE_RATE: u32 = 48_000;
const PACKET_SAMPLES: u32 = 960;
// Divide by 1000 to get samples per ms, then multiply by 60ms for max Opus frame size.
const MAX_DECODE_SAMPLES: usize = SAMPLE_RATE as usize / 1000 * 60;
fn encode_and_send(
state: TransmitState,
@@ -50,28 +45,7 @@ fn encode_and_send(
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
impl AudioSystem {
pub async fn new() -> Result<Self, Error> {
// TODO
let host = cpal::default_host();
let name = host.id();
let processors = AudioProcessorSender::default();
Ok(AudioSystem {
output: host
.default_output_device()
.ok_or(eyre!("no output devices from {name:?}"))?,
input: host
.default_input_device()
.ok_or(eyre!("no input devices from {name:?}"))?,
processors,
recording_stream: None,
})
}
pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
impl NativeAudioSystem {
fn choose_config(
&self,
configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>,
@@ -101,8 +75,32 @@ impl AudioSystem {
.cloned()
.ok_or(eyre!("no supported stream configs"))
}
}
pub fn start_recording(
impl super::AudioSystemInterface for NativeAudioSystem {
type AudioPlayer = NativeAudioPlayer;
async fn new() -> Result<Self, Error> {
let host = cpal::default_host();
let name = host.id();
let processors = AudioProcessorSender::default();
Ok(NativeAudioSystem {
output: host
.default_output_device()
.ok_or(eyre!("no output devices from {name:?}"))?,
input: host
.default_input_device()
.ok_or(eyre!("no input devices from {name:?}"))?,
processors,
recording_stream: None,
})
}
fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
fn start_recording(
&mut self,
mut each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error> {
@@ -122,7 +120,8 @@ impl AudioSystem {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
}
let state = current_processor.process(frame, config.channels as usize, &mut output_buffer);
let state =
current_processor.process(frame, config.channels as usize, &mut output_buffer);
encode_and_send(state, &mut output_buffer, &mut encoder, &mut each);
};
@@ -142,7 +141,7 @@ impl AudioSystem {
}
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
fn create_player(&mut self) -> Result<NativeAudioPlayer, Error> {
let config = self.choose_config(self.output.supported_output_configs()?)?;
info!(
"creating player on {:?} with {:#?}",
@@ -180,31 +179,29 @@ impl AudioSystem {
)?
};
stream.play()?;
Ok(AudioPlayer {
Ok(NativeAudioPlayer {
decoder,
stream,
buffer,
tmp: vec![0; 2400],
tmp: vec![0; MAX_DECODE_SAMPLES],
})
}
}
pub struct AudioPlayer {
pub struct NativeAudioPlayer {
decoder: opus::Decoder,
stream: cpal::Stream,
buffer: Buffer,
tmp: Vec<i16>,
}
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
let len = loop {
match self.decoder.decode(payload, &mut self.tmp, false) {
Ok(l) => break l,
Err(e) => {
error!("opus decode error {e:?}");
return;
}
impl super::AudioPlayerInterface for NativeAudioPlayer {
fn play_opus(&mut self, payload: &[u8]) {
let len = match self.decoder.decode(payload, &mut self.tmp, false) {
Ok(l) => l,
Err(e) => {
error!("opus decode error {e:?}");
return;
}
};
+119
View File
@@ -0,0 +1,119 @@
/// Stub implementation of the platform interface, so that we can
/// `cargo check` without any --feature flags.
use crate::effects::AudioProcessor;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::future::Future;
pub struct StubPlatform;
impl super::PlatformInterface for StubPlatform {
type AudioSystem = StubAudioSystem;
fn init_logging() {
panic!("stubbed platform")
}
fn request_permissions() {
panic!("stubbed platform")
}
fn network_connect(
_address: String,
_username: String,
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
_gui_config: &ClientConfig,
) -> impl Future<Output = Result<(), Error>> {
async { panic!("stubbed platform") }
}
fn get_status(
_client: &reqwest::Client,
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
async { panic!("stubbed platform") }
}
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>> {
async { panic!("stubbed platform") }
}
fn load_username() -> Option<String> {
panic!("stubbed platform")
}
fn load_server_url() -> Option<String> {
panic!("stubbed platform")
}
fn set_default_username(_username: &str) -> Option<()> {
panic!("stubbed platform")
}
fn set_default_server(_server: &str) -> Option<()> {
panic!("stubbed platform")
}
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
async { panic!("stubbed platform") }
}
}
pub struct StubAudioSystem;
impl super::AudioSystemInterface for StubAudioSystem {
type AudioPlayer = StubAudioPlayer;
async fn new() -> Result<Self, Error> {
panic!("stubbed platform")
}
fn set_processor(&self, _processor: AudioProcessor) {
panic!("stubbed platform")
}
fn start_recording(
&mut self,
_each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error> {
panic!("stubbed platform")
}
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error> {
panic!("stubbed platform")
}
}
pub struct StubAudioPlayer;
impl super::AudioPlayerInterface for StubAudioPlayer {
fn play_opus(&mut self, _payload: &[u8]) {
panic!("stubbed platform")
}
}
#[allow(unused)]
pub struct SpawnHandle;
impl SpawnHandle {
#[allow(unused)]
pub fn spawn<F>(&self, _future: F)
where
F: Future<Output = ()> + 'static,
{
panic!("stubbed platform")
}
#[allow(unused)]
pub fn current() -> Self {
SpawnHandle
}
}
#[allow(unused)]
pub fn spawn<F>(_future: F)
where
F: Future<Output = ()> + 'static,
{
panic!("stubbed platform")
}
+122 -108
View File
@@ -3,7 +3,6 @@ use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{bail, eyre, Error};
use crossbeam::atomic::AtomicCell;
use dioxus::prelude::*;
use futures::{AsyncRead, AsyncWrite};
use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array;
use mumble_protocol::control::ClientControlCodec;
@@ -29,7 +28,6 @@ use web_sys::AudioWorkletNode;
use web_sys::EncodedAudioChunk;
use web_sys::EncodedAudioChunkInit;
use web_sys::EncodedAudioChunkType;
use web_sys::MediaStream;
use web_sys::MediaStreamConstraints;
use web_sys::MessageEvent;
use web_sys::WebTransport;
@@ -39,16 +37,119 @@ use web_sys::WorkletOptions;
use web_sys::{console, window};
use web_sys::{AudioContext, AudioDataCopyToOptions};
#[allow(unused)]
pub use wasm_bindgen_futures::spawn_local as spawn;
pub trait ImpRead: AsyncRead + Unpin + 'static {}
impl<T: AsyncRead + Unpin + 'static> ImpRead for T {}
#[allow(unused)]
#[derive(Clone)]
pub struct SpawnHandle;
pub trait ImpWrite: AsyncWrite + Unpin + 'static {}
impl<T: AsyncWrite + Unpin + 'static> ImpWrite for T {}
impl SpawnHandle {
pub fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(future);
}
pub async fn sleep(d: Duration) {
TimeoutFuture::new(d.as_millis() as u32).await
pub fn current() -> Self {
SpawnHandle
}
}
/// Web platform implementation using WebTransport and Web Audio API.
pub struct WebPlatform;
impl super::PlatformInterface for WebPlatform {
type AudioSystem = WebAudioSystem;
fn init_logging() {
// copied from tracing_web example usage
use tracing_subscriber::fmt::format::Pretty;
use tracing_subscriber::prelude::*;
use tracing_web::{performance_layer, MakeWebConsoleWriter};
let fmt_layer = tracing_subscriber::fmt::layer()
.with_ansi(false) // Only partially supported across browsers
.without_time() // std::time is not available in browsers
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
.with_filter(LevelFilter::DEBUG);
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
tracing_subscriber::registry()
.with(fmt_layer)
.with(perf_layer)
.init();
info!("logging initiated");
}
fn request_permissions() {
// No-op on web
}
async fn load_config() -> color_eyre::Result<ClientConfig> {
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
Some(url) => Url::parse(url)?,
None => absolute_url("config")?,
};
info!("loading config from {}", config_url);
let config = reqwest::get(config_url)
.await?
.json::<ClientConfig>()
.await?;
Ok(config)
}
fn load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
fn load_server_url() -> Option<String> {
None
}
fn set_default_username(username: &str) -> Option<()> {
web_sys::window()?
.local_storage()
.ok()??
.set_item("username", username)
.ok()
}
fn set_default_server(_server: &str) -> Option<()> {
None
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
network_connect(address, username, event_rx, gui_config).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(absolute_url("status")?)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
async fn sleep(duration: Duration) {
TimeoutFuture::new(duration.as_millis() as u32).await;
}
}
trait ResultExt<T> {
@@ -73,7 +174,7 @@ impl<T> ResultExt<T> for Result<T, JsError> {
}
}
pub struct AudioSystem {
pub struct WebAudioSystem {
webctx: AudioContext,
processors: AudioProcessorSender,
}
@@ -104,8 +205,10 @@ async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
Ok(())
}
impl AudioSystem {
pub async fn new() -> Result<Self, Error> {
impl super::AudioSystemInterface for WebAudioSystem {
type AudioPlayer = WebAudioPlayer;
async fn new() -> Result<Self, Error> {
// Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio.
let webctx = configure_audio_context();
@@ -113,17 +216,14 @@ impl AudioSystem {
let processors = AudioProcessorSender::default();
Ok(AudioSystem { webctx, processors })
Ok(WebAudioSystem { webctx, processors })
}
pub fn set_processor(&self, processor: AudioProcessor) {
fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
pub fn start_recording(
&mut self,
each: impl FnMut(Vec<u8>, bool) + 'static,
) -> Result<(), Error> {
fn start_recording(&mut self, each: impl FnMut(Vec<u8>, bool) + 'static) -> Result<(), Error> {
let audio_context_worklet = self.webctx.clone();
let processors = self.processors.clone();
spawn(async move {
@@ -135,7 +235,7 @@ impl AudioSystem {
Ok(())
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
fn create_player(&mut self) -> Result<WebAudioPlayer, Error> {
let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?;
// Connect worklet to destination
@@ -188,14 +288,14 @@ impl AudioSystem {
decoder_error.forget();
output.forget();
Ok(AudioPlayer(audio_decoder))
Ok(WebAudioPlayer(audio_decoder))
}
}
pub struct AudioPlayer(AudioDecoder);
pub struct WebAudioPlayer(AudioDecoder);
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
impl super::AudioPlayerInterface for WebAudioPlayer {
fn play_opus(&mut self, payload: &[u8]) {
let js_audio_payload = Uint8Array::from(payload);
let _ = self.0.decode(
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
@@ -418,94 +518,8 @@ pub async fn network_connect(
crate::network_loop(username, event_rx, reader, writer).await
}
pub fn set_default_username(username: &str) -> Option<()> {
web_sys::window()?
.local_storage()
.ok()??
.set_item("username", username)
.ok()
}
pub fn set_default_server(username: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
pub fn load_server_url() -> Option<String> {
None
}
pub fn absolute_url(path: &str) -> Result<Url, Error> {
let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
let location = window.location();
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
Some(url) => Url::parse(url)?,
None => absolute_url("config")?,
};
info!("loading config from {}", config_url);
let config = reqwest::get(config_url)
.await?
.json::<ClientConfig>()
.await?;
Ok(config)
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(absolute_url("status")?)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
pub fn init_logging() {
// copied from tracing_web example usage
use tracing_subscriber::fmt::format::Pretty;
use tracing_subscriber::prelude::*;
use tracing_web::{performance_layer, MakeWebConsoleWriter};
let fmt_layer = tracing_subscriber::fmt::layer()
.with_ansi(false) // Only partially supported across browsers
.without_time() // std::time is not available in browsers
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
.with_filter(LevelFilter::DEBUG);
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
tracing_subscriber::registry()
.with(fmt_layer)
.with(perf_layer)
.init();
info!("logging initiated");
}
pub struct SpawnHandle;
impl SpawnHandle {
pub fn current() -> Self {
SpawnHandle
}
pub fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + 'static,
{
spawn(future);
}
}
+19 -42
View File
@@ -7,11 +7,12 @@ use asynchronous_codec::FramedWrite;
use color_eyre::eyre::{bail, Error};
use dioxus::prelude::*;
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;
pub use imp::spawn;
use msghtml::process_message_html;
use mumble_protocol::control::msgs;
use mumble_protocol::control::ControlCodec;
@@ -27,7 +28,10 @@ use tracing::error;
use tracing::info;
use crate::effects::AudioProcessor;
use crate::imp::AudioSystem;
use crate::imp::{
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
PlatformInterface as _,
};
pub mod app;
mod effects;
@@ -47,7 +51,9 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
*STATE.server.write() = Default::default();
*STATE.status.write() = ConnectionState::Connecting;
if let Err(error) = imp::network_connect(address, username, &mut event_rx, &config).await {
if let Err(error) =
Platform::network_connect(address, username, &mut event_rx, &config).await
{
error!("could not connect {:?}", error);
*STATE.status.write() = ConnectionState::Failed(error.to_string());
} else {
@@ -56,7 +62,7 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
}
}
pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin + 'static>(
username: String,
event_rx: &mut UnboundedReceiver<Command>,
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
@@ -105,12 +111,12 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
break;
}
imp::sleep(Duration::from_millis(3000)).await;
Platform::sleep(Duration::from_millis(3000)).await;
}
});
}
let mut audio = imp::AudioSystem::new().await?;
let mut audio = AudioSystem::new().await?;
{
let send_chan = send_chan.clone();
let mut sequence_num = 0;
@@ -296,8 +302,8 @@ fn accept_command(
fn accept_packet(
msg: ControlPacket<mumble_protocol::Clientbound>,
audio_context: &mut imp::AudioSystem,
player_map: &mut HashMap<u32, imp::AudioPlayer>,
audio_context: &mut AudioSystem,
player_map: &mut HashMap<u32, AudioPlayer>,
) -> Result<(), Error> {
match msg {
ControlPacket::UDPTunnel(u) => {
@@ -335,41 +341,11 @@ fn accept_packet(
}
ControlPacket::ChannelState(u) => {
let mut server = STATE.server.write();
let id = u.get_channel_id();
let state = server.channels.entry(id).or_default();
let new_parent = if u.has_parent() {
if let Some(parent) = state.parent.and_then(|p| server.channels.get_mut(&p)) {
parent.children.remove(&id);
}
let parent_id = u.get_parent();
let parent = server.channels.entry(parent_id).or_default();
if u.has_position() && u.get_position() as usize <= parent.children.len() {
// TODO: what if positions are received out of order? we need to sort afterwards?
parent.children.insert_before(u.get_position() as usize, id);
} else {
parent.children.insert(id);
}
Some(parent_id)
} else {
None
};
let state = server.channels.entry(id).or_default();
state.parent = new_parent;
if u.has_name() {
state.name = u.get_name().to_string();
}
server.channels_state.update_from_channel_state(&u);
}
ControlPacket::ChannelRemove(u) => {
let mut server = STATE.server.write();
let id = u.get_channel_id();
if let Some(channel) = server.channels.remove(&id) {
if let Some(parent) = channel.parent.and_then(|p| server.channels.get_mut(&p)) {
parent.children.remove(&id);
}
}
server.channels_state.update_from_channel_remove(&u);
}
ControlPacket::UserState(u) => {
let mut server = STATE.server.write();
@@ -381,12 +357,13 @@ fn accept_packet(
let state = state_entry.or_default();
// the server might now send a channel_id if the user is in channel=0
if u.has_channel_id() || new {
if let Some(parent) = server.channels.get_mut(&state.channel) {
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
parent.users.remove(&id);
}
let channel_id = u.get_channel_id();
server
.channels_state
.channels
.entry(channel_id)
.or_default()
@@ -418,7 +395,7 @@ fn accept_packet(
let mut server = STATE.server.write();
let id = u.get_session();
if let Some(state) = server.users.remove(&id) {
if let Some(parent) = server.channels.get_mut(&state.channel) {
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
parent.users.remove(&id);
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
use mumble_web2_gui::{app, imp};
use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
pub fn main() {
imp::init_logging();
Platform::init_logging();
dioxus::launch(app::app);
}