11 Commits

Author SHA1 Message Date
Builder 26a08acc36 Implement mumble UDP ping protocol for server status display
Build Mumble Web 2 / windows_build (push) Successful in 2m45s
Build Mumble Web 2 / linux_build (push) Successful in 1m20s
Build Mumble Web 2 / android_build (push) Successful in 4m26s
Adds ping_server method to PlatformInterface. The desktop implementation
sends a 12-byte UDP datagram (4 zero bytes + 8-byte request ID) and
parses the 24-byte response to extract version, current users, max
users, and bandwidth. Includes a 2-second timeout.

The ServerPingInfo component uses use_resource to asynchronously ping
each server and displays user count (e.g. "3/50") on the server card.
Web and mobile platforms return an error (UDP not available in browsers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:20:38 +00:00
Builder b20ed1ff56 Wire LoginView to persisted servers with add/edit/delete and overrides mode
Replaces the hardcoded server list with data from the settings store.
The Add Server modal now saves entries with all fields wired to signals.
An Edit Server modal pre-populates from the existing entry and includes
a delete button. The connect button on each card initiates connection
using that server's configured address, port, and username.

In overrides mode (any_server=false), displays a single non-editable
server card with an inline username input field, allowing the user to
set their identity before connecting to the preset server.

Adds CSS for the delete button, override username row, connect button
highlight, and ping info placeholder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:16:00 +00:00
Builder 765446392d Add ServerEntry model and server list persistence to platform trait
Introduces a ServerEntry struct in common with name, address, port,
username, and optional password fields. Extends PlatformInterface with
load_servers/save_servers methods, implemented across all platforms
(desktop persists to JSON config, web uses localStorage, mobile/stub
are stubs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:14:03 +00:00
restitux 2c22942fb3 add modal for adding server
Build Mumble Web 2 / linux_build (push) Successful in 1m20s
Build Mumble Web 2 / windows_build (push) Successful in 2m31s
Build Mumble Web 2 / android_build (push) Successful in 5m49s
2026-02-17 23:36:26 -07:00
restitux 75990ca9ce delete commented out code 2026-02-17 23:18:11 -07:00
restitux 9f6557bb92 change login screen ui 2026-02-17 22:54:37 -07:00
sam 9006a082b0 Refactor the imp/gui bondary to use real traits (#18)
Build Mumble Web 2 / linux_build (push) Successful in 1m24s
Build Mumble Web 2 / windows_build (push) Successful in 2m36s
Build Mumble Web 2 / android_build (push) Successful in 5m57s
Build android container / android-release-builder-container-build (push) Successful in -4s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 16s
# Summary

Introduces a trait-based platform abstraction layer that makes the boundary
between platform-specific and shared code explicit and compile-time verified.

The TLDR version of this new trait stuff works:

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

```rust
#[cfg(feature = "web")]
pub type Platform = web::WebPlatform;

#[cfg(all(feature = "desktop"))]
pub type Platform = desktop::DesktopPlatform;

#[cfg(all(feature = "mobile", not(feature = "web")))]
pub type Platform = mobile::MobilePlatform;
```

5. Add a compile time assertion that `Platform` implements `PlatformInterface`.

#  Motivation

Previously, platform code used a mix of pub use re-exports and #[cfg] blocks
that made it difficult to understand what each platform must implement. The
new trait-based approach provides:

- Clear documentation of the platform contract
- Compile-time verification that all platforms implement required
  functionality
- Ability to cargo check without feature flags (via stub platform)

# Changes

New traits in imp/mod.rs:
  - PlatformInterface - logging, permissions, network, config, storage. Overall this the trait that platforms must satify to compile.
  - AudioSystemInterface - audio system initialization and recording
  - AudioPlayerInterface - opus audio playback

 Type aliases:
  - Platform, AudioSystem, AudioPlayer resolve to the correct types based on
  feature flags

Call site updates:
  - Changed from imp::function() to Platform::function() syntax
  - Removed ImpRead/ImpWrite helper traits in favor of direct bounds

# Testing

Manual testing reveals that Web and Desktop still work, I (Liam) have not tested the mobile version beyond compilation.

Co-authored-by: Liam Warfield <liam.warfield@gmail.com>
Reviewed-on: #18
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-02-18 04:53:41 +00: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
21 changed files with 1919 additions and 504 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 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 - 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 - uses: Swatinem/rust-cache@v2
+10
View File
@@ -16,3 +16,13 @@ pub struct ServerStatus {
pub max_users: Option<u32>, pub max_users: Option<u32>,
pub bandwidth: Option<u32>, pub bandwidth: Option<u32>,
} }
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
pub struct ServerEntry {
pub name: String,
pub address: String,
pub port: u16,
pub username: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
}
+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 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 # Install dioxus-cli
RUN cargo binstall dioxus-cli@0.7.2 RUN cargo binstall dioxus-cli@0.7.3
# Install bindgen-cli # Install bindgen-cli
RUN cargo binstall 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 toolchain install stable-x86_64-pc-windows-msvc
RUN rustup default 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; ` 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 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"] 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 # Install dioxus-cli
# This is to work around a bug in the windows builder upstream. RUN cargo binstall dioxus-cli@0.7.3
# Dioxus has released 0.7.2, but it seems to be broken for now.
RUN cargo binstall dioxus-cli
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"] 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", "rfd/xdg-portal",
"etcetera", "etcetera",
] ]
mobile = [ mobile = [
"dioxus/mobile", "dioxus/mobile",
"tokio", "tokio",
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#000000" fill-rule="evenodd" d="M11.7071,4.29289 L15.4142,8 L11.7071,11.7071 C11.3166,12.0976 10.6834,12.0976 10.2929,11.7071 C9.90237,11.3166 9.90237,10.6834 10.2929,10.2929 L11.5858,9 L2,9 C1.44771,9 1,8.55228 1,8 C1,7.44772 1.44771,7 2,7 L11.5858,7 L10.2929,5.70711 C9.90237,5.31658 9.90237,4.68342 10.2929,4.29289 C10.6834,3.90237 11.3166,3.90237 11.7071,4.29289 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 601 B

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 12V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 12V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 7H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 10V18C6 19.6569 7.34315 21 9 21H15C16.6569 21 18 19.6569 18 18V10" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 862 B

+135
View File
@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M491.878,156.348C472.437,110.39,439.989,71.33,399.14,43.731C358.307,16.131,308.964-0.008,256,0
c-35.304,0-69.011,7.167-99.652,20.122C110.39,39.564,71.33,72.011,43.731,112.86C16.131,153.693-0.008,203.036,0,256
c0,35.304,7.167,69.02,20.122,99.653c19.442,45.957,51.889,85.016,92.738,112.616c40.832,27.6,90.176,43.74,143.14,43.731
c35.305,0,69.02-7.166,99.653-20.122c45.957-19.442,85.017-51.889,112.617-92.738c27.6-40.832,43.74-90.176,43.731-143.14
C512,220.697,504.842,186.98,491.878,156.348z M427.814,110.348c0.774,0.915,1.53,1.856,2.294,2.789
c-1.496-0.454-2.991-0.908-4.486-1.37C426.353,111.297,427.084,110.819,427.814,110.348z M382.832,101.182
C387.142,100.754,380.446,101.434,382.832,101.182c-1.798-0.126-3.159-0.858-4.066-2.177
C384.579,95.217,388.747,100.585,382.832,101.182z M290.917,81.127c1.613,4.142-9.956,0.277-11.216-0.336
c-0.739-0.739-1.294-1.58-1.663-2.52C278.021,79.203,290.388,79.749,290.917,81.127z M258.79,75.406
c2.823,0.958,14.022-1.572,14.383,1.722c0.673,6.049-3.99,0.058-4.956,0.058c-2.622,0,1.21,2.78,1.31,2.923
c-0.656-0.957-8.461-0.857-10.107-0.462C254.656,88.352,241.675,69.592,258.79,75.406z M271.711,87.026
c-3.108,1.142-8.443,0.168-11.754,0.168C253.808,85.58,276.718,85.194,271.711,87.026z M79.236,313.19
C79.06,315.812,79.346,311.544,79.236,313.19c0.042-0.663,1.126-5.755,2.084-6.049c1.513-0.453,4.613,6.999,4.487,8.041
C85.11,321.206,78.892,318.148,79.236,313.19z M136.15,339.169c-3.252-3.983-5.192-8.461-8.284-12.528
c-0.63-0.84-11.031-6.754-9.058-7.796c4.243-2.21,39.505,18.517,37.934,21.676c-0.152,0.303-7.889-6.797-9.847-5.52
c-1.302,0.848,2.689,3.932,2.689,5.419c2.016,1.033,7.149,8.646,2.932,8.872C147.862,349.545,138.872,342.512,136.15,339.169z
M154.894,340.546c0.21,1.37-3.646-1.185-3.873-1.277C151.777,337.884,154.718,339.412,154.894,340.546z M151.92,353.208
c-3.898-2.487,12.569-0.554,13.459-0.487c3.252,0.243,11.418,0.076,13.552,3.974C179.569,357.862,155.574,355.544,151.92,353.208
C154.356,354.77,150.122,352.066,151.92,353.208z M188.686,317.644c4.125-4.201,8.839,3.235,9.174,2.932
c-2.596,2.369-6.486,6.94-1.252,9.671c3.932,2.058-4.672,3.537-4.672,4.268c0,2.966-4.771,10.795-7.814,12.014
c0.177-0.067-11.913-3.419-12.972-3.722c-2.638-0.764-3.445-8.082-5.058-10.426C166.093,328.036,184.51,321.895,188.686,317.644z
M167.53,281.869c1.756-1.328,3.378-1.479,4.848-0.454C173.546,287.801,165.514,282.785,167.53,281.869z M195.718,306.864
c0.009-0.017,0.017-0.025,0.034-0.034c0.067-0.059,0.101-0.084,0.092-0.076c1.05-0.857,3.806-3.302,4.898-3.546
C203.716,302.529,193.676,308.502,195.718,306.864z M201.658,294.278c-0.563-3.353-0.151-3.974,0.093-6.68
c0.428-4.713,5.915-6.772,7.007-1.092c0.193,0.975-5.721,9.88-1.218,9.317c3.612-0.454,6.301-0.622,5.134,3.932
c-1.076-0.714-7.814-4.075-8.637-3.898c-0.798,0.168-3.36,8.746-6.049,7.939C199.162,304.151,201.809,295.95,201.658,294.278z
M200.734,269.073c0.084-1.328,2.042-5.125,4.176-4.688c2.461,0.513-0.731,10.141-2.487,9.032c-1.26-1.193-1.823-2.639-1.689-4.335
C200.7,269.678,200.608,271.199,200.734,269.073z M213.479,300.403c0.016-0.008,0.016-0.016,0.034-0.016
c4.797-2.42,3.452,4.218,3.965,4.452C216.831,304.546,213.101,300.613,213.479,300.403z M218.184,308.04
C217.26,307.889,219.276,308.217,218.184,308.04c1.521,0.244,2.269,3.369,2.42,4.378c0.621,4.243,0.546,2.411-2.26,3.52
c-0.765,0.294,0.084,2.747-1.672,2.865c-0.84,0.05-5.436-0.908-3.948-2.672c3.091-3.663-5.663-0.815-5.663-1.134
c0-1.227,1.386-4.075,3.184-3.697C215.504,312.384,215.58,307.62,218.184,308.04z M223.435,335.086
c-1.311-1.748-0.118-6.075,1.159-7.512c-1.016,1.142,2.706,3.058,2.882,2.26C227.081,331.632,223.661,335.388,223.435,335.086z
M230.03,343.202c-0.076-0.739-6.041-0.588-6.948-0.73C221.51,342.471,229.593,338.766,230.03,343.202z M223.704,262.839
c-3.907,0.89,0.816-2.747,1.664-2.823C226.518,259.907,223.914,262.554,223.704,262.839z M222.393,355.989
c1.529,3.941-8.503,4.672-9.906,5.008c1.076-0.261,7.284-2.588,7.898-4.26C220.494,356.426,221.612,353.964,222.393,355.989z
M200.557,343.369c-2.243,1.227,0.21,5.831-2.16,6.066c-3.503,0.352-1.638-8.755-0.866-11.208
c3.798-11.998,9.352,4.495,12.469-3.47c-0.252,0.647-10.847-2.655-9.091-3.201c0.025-0.008,0.051-0.008,0.076-0.017
c2.89-0.873,12.997-3.184,15.408-2.596c0.571,0.134-6.662,10.132-10.636,9.83c4.798,0.369,2.705,7.419,0.941,9.351
c-0.63,0.689,4.411,2.646,3.722,3.117C207.993,352.914,197.574,345,200.557,343.369z M206.497,356.938
c0.597,0.303-2.731,1.025-3.159,1.109c-1.377,0.47-2.688,0.412-3.932-0.176C198.465,357.115,205.691,356.535,206.497,356.938z
M199.297,358.946c-0.647,0.404-1.336,0.404-2.067,0C195.256,357.744,198.348,357.938,199.297,358.946z M205.548,300.05
c0,0,0,0-0.009,0c-0.025-0.025-0.051-0.042-0.076-0.058c0.017,0.008,0.034,0.025,0.058,0.041c-0.235-0.159-2.68-1.814-0.84-2.008
C207.278,297.757,205.582,300.076,205.548,300.05z M208.783,308.284c-2.798,0-3.016-4.05-3.134-5.621
C207.748,296.421,209.429,308.284,208.783,308.284z M209.975,358.132c0.74-0.387,8.208-2.403,7.704-1.613
c-0.471,0.748-8.838,4.604-9.679,2.924C208.228,358.527,208.883,358.09,209.975,358.132z M218.839,343.202c0.008,0,0.017,0,0.017,0
c-0.026,0-0.009,0-0.026,0c-1.142,0.051-1.622-0.328-1.403-1.143c2.445-1.126,3.344,1.303,1.429,1.143
C219.318,343.244,219.091,343.226,218.839,343.202z M209.707,304.738c-0.093-0.042-0.06-0.025-0.009-0.008
c-1.294-0.512-0.109-3.394,1.084-2.118C211.177,303.041,210.454,305.016,209.707,304.738z M212.244,305.528
c-0.084,0.68-0.605,3.453-1.84,3.453c-0.218-0.74-0.218-1.479,0-2.218C210.539,305.629,211.151,305.218,212.244,305.528z
M202.355,305.184L202.355,305.184c-0.58,1.168-7.78,9.838-8.839,5.184C193.542,310.486,201.884,306.142,202.355,305.184z
M194.903,356.737c-1.512,0.395-2.789,1.294-4.47,0.344C188.719,356.115,194.836,356.737,194.903,356.737z M187.451,356.325
c-0.992,0-0.446-0.656,0.268-0.739C188.442,355.502,188.35,356.325,187.451,356.325z M281.365,457.809
c-5.629,13.039-11.771-7.864-10.998-7.62C277.752,452.726,286.145,446.736,281.365,457.809z M293.849,421.691
c-1.949,5.721-3.814,11.728-7.343,16.845c-4.864,7.049-8.561,3.294-15.064,3.234c-3.723-0.034-2.849,2.866-6.916,1.278
c-2.285-0.899-5.402-1.597-7.444-2.916c2.521,1.63-4.394-9.427-3.764-5.411c-0.344-2.167-2.823-1.084-4.848-1.479
c1.093-1.386,1.042-3.537,2.151-4.915c-2.747-0.067-5.091,1.135-6.814,3.277c-7.738-9.561-14.93-10.452-27.372-7.327
c-2.882,0.723-8.604,5.361-10.208,5.361c-6.537,0-8.041-0.261-12.914,3.596c-3.344,2.646-11.703,0.84-12.678-3.596
c-0.446-2.016,3.218-4.797,3.226-6.89c0.008-1.327-3.84-2.596-4.31-3.94c-0.723-2.108,1-4.596-0.781-6.343
c-0.748-0.731-4.125-3.15-4.243-4.15c-0.151-1.319,2.874-2.731,2.874-4.386c0-2.336,0-4.663,0-6.999
c0-5.284,14.291-6.46,19.962-9.057c6.629-3.025,1.764-4.906,5.881-8.427c1.236-1.05,5.529,1.008,6.822,0
c0.739-0.571-0.218-3.36,0.781-4.478c1.412-1.562,7.796-8.108,8.284-1.874c0.286,3.697-0.067,3.402,3.99,3.402
c3.268,0-0.176-1.604,1.302-2.948c2.244-2.05,5-8.645,7.898-9.511c1.697-0.513,10.183,3.268,12.914,3.277
c-0.95,2.73-1.874,5.478-2.865,8.2c4.267,2.16,12.258,8.788,17.223,5.906c2.318-1.352,3.268-13.014,3.949-15.745
c2.881,4.453,7.83,7.755,9.519,12.09c2.579,6.604,6.864,6.83,10.729,12.233c3.604,5.016,6.906,9.864,9.906,15.199
C297.757,412.423,296.564,413.717,293.849,421.691z M237.466,240.155c1.05,1.444-3.789,8.956-4.907,6.982
c-0.772-1.37-0.982-4.73-1.554-6.428c-0.109-0.319-0.319-0.958,0-0.008C230.064,237.928,236.24,238.475,237.466,240.155z
M267.989,202.381c3.991-0.008,4.168,5.125,8.604,3.772c-0.866,0.26,2.478,3.621-2.16,4.486c-2.916,0.529-6.965,3.73-10.166,1.63
c0.428,0.286,0.218,0.151,0.008,0.016c1.95,1.311-3.47,2.412-3.671,0.89C260.604,213.202,270.182,202.372,267.989,202.381z
M268.569,177.066c0,0.092,1.604-2.21,2.252-3.176c-0.706,2.824,6.435,16.526,4.335,17.173c-1.31,0.404-3.101-2.134-3.596-1.529
c-2.209,2.697,1.74,6.83,0.479,7.973c0.319-0.286-2.958,0.723-3.772,1.193c0.017,0.152,0.042,0.236,0.067,0.236
c-0.335,0-0.302-0.093-0.067-0.236C268.023,196.458,268.712,178.62,268.569,177.066z M291.959,363.442
c-7.687,1.613-17.484-13.88-21.886-7.05c-3.151,4.89-16.854-2.201-17.409-0.083c0.782-2.992,5.84-0.933,2.579-5.294
c-2.805-3.756-6.351-3.546-10.897-4.201c-3.251-0.47-2.352-2.453-3.772-4.268c-0.874-1.117-1.832,1.958-2.689,1.639
c-0.118-0.042-4.848-6.982-4.848-7.209c5.864-7.688,7.494,2.764,10.771,4.1c6.612,2.697,6.704-4.73,13.098-1.806
c3.251,1.487,27.7,10.418,26.877,12.493c-0.428,0.496-0.949,0.874-1.571,1.118C283.382,354.77,291.144,363.618,291.959,363.442z
M239.138,239.726c0-0.009,0-0.009,0-0.017c0.907-2.848,8.679-3.907,7.301-0.756C245.725,240.592,238.088,243.12,239.138,239.726z
M261.218,221.948c0.008-0.176,3.126-11.066,4.722-8.15c1.756,3.218,0.95,19.433-2.874,21.769
c-0.991-0.084-1.352-0.597-1.076-1.538c0,2.05-13.325,5.05-13.14,5.234c-2.075-2.151,1.344-3.428-3.756-2.941
c0.075,0-11.309,1.487-7.746-0.546c3.798-2.168,7.074-2.714,11.527-2.798c3.646-0.067,3.319-5.276,5.94-5.511
c1.227-0.109,0.538,3.369,2.748,1.537C260.478,226.586,260.881,225.46,261.218,221.948z M292.53,351.729
c-2,0.488-7.343-2.218-5.478-2.453c-1.646,0.201-0.588,0.067,0.017-0.009c3.285-0.412,7.612-1.697,10.217-1.966
C299.899,347.041,293.539,351.486,292.53,351.729z M299.706,347.142c-0.513-0.538-0.908-1.134-1.16-1.806
C296.354,341.832,303.705,348.604,299.706,347.142z M376.371,386.555c-0.924-0.143-1.252-0.672-0.991-1.58
c0.984-2.823,3.059,1.033,3.386,1.37C377.968,386.412,377.169,386.488,376.371,386.555z M379.958,381.53
c-0.865,0,1.924-3.042,3.588-1.395C385.26,381.824,380.295,381.446,379.958,381.53z M397.502,128.882
c-4.588-0.865-3.108-4.159-6.713-4.462c-4.226-0.353-1.932,5.016-5.713,4c-13.897-3.73-0.63,5.469-4.882,9.612
c0.428-0.412-7.335-1.093-8.712-0.622c-2.815,0.966-6.738,1.622-9.108,3.352c-3.193,2.336-6.385,4.672-9.578,6.999
c-1.68,1.236-3.822-2.016-6.15-0.932c-2.731,1.269-8.78,0.513-10.595,1.697c-1.941,1.277-3.369,7.192-4.318,9.208
c-3.756,8.007-8.108,25.087-19.727,26.222c-0.572-4.79-5.957-24.718,3.73-25.752c7.747-0.84,18.82-12.014,16.728-19.845
c-7.771,0.311-3.847,4.739-7.183,6.235c-6.084,2.722-6.94-3.168-9.074-2.286c-6.377,2.655-6.974,2.454-8.284,8.755
c-0.487,2.336-8.847,0.774-11.107,0.689c-6.436-0.227-22.358-2.588-27.138,0.605c-7.427,4.957-14.854,9.914-22.282,14.871
c3.982,7.579,14.846,2.512,17.224,10.167c1.571,5.023-5.192,14.938-8.914,18.76c-3.664,3.772-7.335,7.545-11.006,11.318
c-3.512,3.612-3.991-0.488-7.352,1.411c-5.218,2.941-5.201,11.897-12.57,12.14c3.117,4.109,3.681,7.016,4.058,11.847
c0.378,4.89-3.243,4.487-8.007,6.192c-0.143-3.016-0.588-6.04,0-9.023c0.849-4.26-4.074-0.336-3.226-4.588
c1.47-7.385-7.906-3.201-11.846-2.134c0.302-2.134-0.412-4.444,0-6.561c-4.067,2.294-8.132,4.595-12.199,6.889
c3.688,3.185,6.284,7.276,11.838,4.587c0.571,4.075-3.126,4.537-6.814,6.562c5.142,3,5.436,9.83,6.948,14.779
c1.932,6.285-2.252,8.41-6.645,13.561c-4.672,5.478-5.655,6.982-12.746,8.931c-3.932,1.092-7.864,2.176-11.796,3.26
c-2,0.554-0.319,3.428-4.117,3.428c-0.982-6.73-8.847-1.386-11.258,1.512c-3.579,4.284,3.159,7.486,6.756,10.998
c9.653,9.436-3.874,15.123-10.931,19.962c-2.563-6.772-10.234-8.67-12.208-14.106c-2.689,9.838-3.285,10.074,4.033,17.568
c5.335,5.469,6.058,9.796,8.175,16.87c-4.31-1.966-9.377-2.907-11.073-7.142c-2.076-5.167-3.621-8.048-7.47-12.267
c-3.965-4.352-4.134-17.256-6.225-23.231c-1.555,1.168-4.201,1.118-5.747,2.294c-1.16-4.436-4.217-16.913-9.687-18.694
c-4.453-1.453-14.241,4.646-17.82,7.267c-4.721,3.47-15.476,8.226-15.636,14.09c-0.268,10.234-0.538,10.998-8.897,17.677
c-8.007-11.67-12.108-21.239-14.359-34.775c-7.662,3.755-11.872-6.578-16.509-10.821c-2.151-1.957-6.16-2.151-10.461-1.966
c-0.092-2.655-0.15-5.318-0.15-7.99c0-31.145,6.301-60.728,17.694-87.672c16.123-38.135,42.504-70.936,75.649-94.922
c2.142-0.05,4.251,0.286,6.326,1.294c4.025,1.966,9.368-4.974,15.392-2.924c-2.142-8.746,18.668-9.174,13.997,0.328
c4.537-1.336,8.486-3.184,13.208-2.487c7.746,1.126,5.578,2.403,4.529,9.368c-0.042,0.261-24.189,8.838-15.812,10.014
c8.108,1.143,15.132-4.327,23.542-3.713c9.687,0.698,14.005,4.89,24.424,5.184c-4.159-9.99,12.728-2.789,17.946-1.638
c-5.78,4.748,5.738,10.158,9.696,13.452c-0.143-7.301,14.871-5.973,21.6-5.629c3.706,0.194,1.983-6.638,6.57-5.839
c6.226,1.084,12.443,2.167,18.668,3.251c5.252,0.916,8.015-0.344,13.148,2.403c3.512,1.882,1.143,6.394,5.948,5.882
c3.42-0.362,11.595-2.806,14.048-0.362c3.655,3.638,1.597,7.814,7.596,9.805c1.352-6.663,12.871-6.612,18.66-4.924
c2.68,0.79,14.972,10.544,10.41-1.311c10.871,2.21,21.734,4.411,32.606,6.621c4.26,0.866,4.352,4.192,7.956,5.848
c2.344,1.076,8.856,0.748,12.2,1.966C413.851,126.9,403.425,130.008,397.502,128.882z M406.693,134.654
c0.176-0.588,0.362-1.176,0.546-1.764c1.429-1.412,9.62-1.546,10.814,0.428C419.136,135.108,407.441,135.293,406.693,134.654z
M437.107,168.185c4.974-4.864,9.897-9.754,15.081-14.425c-10.149,0.513-12.468,2.932-14.358-6.562
c-2.151,1.31-4.31,2.621-6.461,3.932c-2.638-3.923-8.838-9.552-2.874-13.778c1.782-1.252,6.008,0.487,7.898-0.656
c1.832-1.1,3.293-6.008,4.31-7.873c-5.176-1.84-5.31,2.723-9.334,2.63c-3.881-0.092-9.258-3.293-12.922-4.596
c3.377-6.99,11.485-5.906,17.013-6.939c2.521,3.318,4.982,6.687,7.326,10.158c6.41,9.494,12.09,19.508,17.022,29.943
C453.406,164.791,445.156,170.715,437.107,168.185z M453.18,278.096c-1.051,0.194-1.673-1.747-0.236-1.747
C454.381,276.349,454.381,277.878,453.18,278.096z M457.011,283.785c-0.47-0.58-0.227-0.278-0.008-0.017
c-2.243-2.739-1.663-6.209,2.403-4.352C461.498,280.356,458.43,285.499,457.011,283.785z"/>
<path class="st0" d="M231.005,240.701v0.008C231.055,240.86,231.089,240.945,231.005,240.701z"/>
<path class="st0" d="M287.07,349.268c-0.008,0-0.008,0.009-0.017,0.009C287.608,349.2,287.414,349.226,287.07,349.268z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.2799 6.40005L11.7399 15.94C10.7899 16.89 7.96987 17.33 7.33987 16.7C6.70987 16.07 7.13987 13.25 8.08987 12.3L17.6399 2.75002C17.8754 2.49308 18.1605 2.28654 18.4781 2.14284C18.7956 1.99914 19.139 1.92124 19.4875 1.9139C19.8359 1.90657 20.1823 1.96991 20.5056 2.10012C20.8289 2.23033 21.1225 2.42473 21.3686 2.67153C21.6147 2.91833 21.8083 3.21243 21.9376 3.53609C22.0669 3.85976 22.1294 4.20626 22.1211 4.55471C22.1128 4.90316 22.0339 5.24635 21.8894 5.5635C21.7448 5.88065 21.5375 6.16524 21.2799 6.40005V6.40005Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 4H6C4.93913 4 3.92178 4.42142 3.17163 5.17157C2.42149 5.92172 2 6.93913 2 8V18C2 19.0609 2.42149 20.0783 3.17163 20.8284C3.92178 21.5786 4.93913 22 6 22H17C19.21 22 20 20.2 20 18V13" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+384
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 { .channel {
&_details { &_details {
flex: 0 0 100%; flex: 0 0 100%;
@@ -393,3 +431,349 @@ a:visited {
} }
} }
} }
.server-list-page {
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 1rem;
}
.server-list-page h1 {
text-align: center;
}
.login_version {
font-size: 0.55em;
font-weight: 400;
color: rgba(255, 255, 255, 0.4);
vertical-align: middle;
}
.server-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 500px;
margin: 0 auto;
}
/* Rounded card */
.server-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.server-card__icon {
width: 32px;
height: 32px;
opacity: 0.65;
filter: brightness(0) invert(0.8); /* light gray */
flex-shrink: 0;
}
.server-card__info {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1; /* pushes the connect button to the far right */
min-width: 0; /* prevents text overflow from breaking flex layout */
}
.server-card__name {
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-card__address {
font-size: 0.78rem;
opacity: 0.55;
}
.server-card__action {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
line-height: 0;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.07);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
.server-card__action img {
width: 20px;
height: 20px;
filter: brightness(0) invert(0.8); /* light gray */
opacity: 0.75;
transition: opacity 0.15s;
}
.server-card__action:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.35);
transform: scale(1.08);
}
.server-card__action:hover img {
opacity: 1.0;
}
.server-card__action:active {
transform: scale(0.95);
}
/* Add server — dashed outline style to distinguish from real cards */
.add-server-btn {
width: 100%;
padding: 0.85rem;
border-radius: 12px;
border: 2px dashed rgba(255, 255, 255, 0.2);
background: transparent;
color: rgba(255, 255, 255, 0.45);
font-size: 0.9rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
width: 500px;
margin: 0 auto;
}
.add-server-btn:hover {
border-color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.7);
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.0);
z-index: 999;
animation: backdrop-fade-in 150ms ease-out forwards;
}
.modal-container {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
pointer-events: none;
}
.modal {
pointer-events: auto;
/* Make this solid or nearly solid instead of see-through */
/* Old: background: rgba(255, 255, 255, 0.05); */
background: #141414; /* or #151822, or rgb(15, 15, 20) */
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
padding: 1.25rem 1.5rem 1.4rem;
width: 500px;
max-width: 90vw;
color: #fff;
display: flex;
flex-direction: column;
gap: 0.9rem;
opacity: 0;
transform: scale(0.9);
animation: modal-pop-in 160ms ease-out forwards;
}
.modal h2 {
font-size: 1.05rem;
font-weight: 600;
text-align: left;
margin: 0;
}
/* Form layout */
.modal-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.modal-field label {
font-size: 0.8rem;
opacity: 0.7;
}
.modal-field input {
padding: 0.55rem 0.6rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.35);
color: #fff;
font-size: 0.85rem;
outline: none;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.modal-field input::placeholder {
color: rgba(255, 255, 255, 0.45);
}
.modal-field input:focus {
border-color: rgba(255, 255, 255, 0.55);
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
}
/* Actions row */
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* Secondary button (Cancel) */
.modal-btn {
padding: 0.5rem 0.9rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.85);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
.modal-btn:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.35);
transform: translateY(-1px);
}
.modal-btn:active {
transform: translateY(0) scale(0.97);
}
/* Primary button (Save) */
.modal-btn--primary {
background: rgba(67, 156, 255, 0.85);
border-color: rgba(67, 156, 255, 1);
color: #ffffff;
}
.modal-btn--primary:hover {
background: rgba(92, 174, 255, 0.95);
border-color: rgba(135, 196, 255, 1);
}
/* Delete button (danger) */
.modal-btn--danger {
background: rgba(220, 60, 60, 0.85);
border-color: rgba(220, 60, 60, 1);
color: #ffffff;
}
.modal-btn--danger:hover {
background: rgba(240, 80, 80, 0.95);
border-color: rgba(255, 120, 120, 1);
}
.modal-actions__spacer {
flex: 1;
}
/* Override mode username row */
.override-username-row {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 0.75rem 1.25rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.override-username-input {
flex: 1;
padding: 0.55rem 0.6rem;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.35);
color: #fff;
font-size: 0.85rem;
outline: none;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.override-username-input:focus {
border-color: rgba(255, 255, 255, 0.55);
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
}
.override-username-input::placeholder {
color: rgba(255, 255, 255, 0.45);
}
/* Connect action button highlight */
.server-card__action--connect:hover {
background: rgba(67, 156, 255, 0.3);
border-color: rgba(67, 156, 255, 0.6);
}
/* Ping info on server card */
.server-card__ping {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.1rem;
font-size: 0.75rem;
opacity: 0.6;
flex-shrink: 0;
min-width: 60px;
text-align: right;
}
/* Keyframes */
@keyframes backdrop-fade-in {
from { background: rgba(0, 0, 0, 0.0); }
to { background: rgba(0, 0, 0, 0.4); }
}
@keyframes modal-pop-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1.0);
}
}
+600 -163
View File
@@ -2,11 +2,11 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use mime_guess::Mime; use mime_guess::Mime;
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerEntry};
use ordermap::OrderSet; 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 ChannelId = u32;
pub type UserId = u32; pub type UserId = u32;
@@ -54,14 +54,6 @@ pub enum Command {
use Command::*; use Command::*;
use ConnectionState::*; use ConnectionState::*;
#[derive(Default)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
}
#[derive(Default)] #[derive(Default)]
pub struct UserState { pub struct UserState {
pub name: String, pub name: String,
@@ -94,8 +86,121 @@ pub struct Chat {
} }
#[derive(Default)] #[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>, 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 users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>, pub chat: Vec<Chat>,
pub session: Option<UserId>, pub session: Option<UserId>,
@@ -182,26 +287,57 @@ pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read(); let server = STATE.server.read();
let user = server.session.unwrap(); 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}"); return rsx!("missing channel {id}");
}; };
let mut open = use_signal(|| true);
let has_children = !state.users.is_empty() || !state.children.is_empty();
rsx!( rsx!(
details { div {
class: "channel_details", class: "channel_details",
open: true,
summary { div {
class: "channel_header",
// Arrow: only toggles open
if has_children {
span { span {
role: "button", class: "channel_arrow",
ondoubleclick: move |evt| { 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.stop_propagation();
evt.prevent_default(); evt.prevent_default();
net.send(EnterChannel { channel: id, user }) net.send(EnterChannel { channel: id, user })
}, },
// remove dblclick from the inner span
span {
class: "channel_title",
"{state.name}" "{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 { div {
class: "channel_children", class: "channel_children",
for id in state.users.iter() { for id in state.users.iter() {
@@ -336,7 +472,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
return rsx!(); 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 let proxy_url = config
.read_unchecked() .read_unchecked()
@@ -528,7 +664,7 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
class: "server_grid", class: "server_grid",
div { div {
class: "server_channel_box", 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() { if state.parent.is_none() {
Channel { id: *id } Channel { id: *id }
} }
@@ -550,173 +686,474 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
pub fn LoginView(config: Resource<ClientConfig>) -> Element { pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>); let mut servers = use_signal(|| Platform::load_servers());
use_resource(move || async move { let mut show_add_modal = use_signal(|| false);
let client = reqwest::Client::new(); let mut editing_index = use_signal(|| None::<usize>);
loop {
*last_status.write_unchecked() = Some(imp::get_status(&client).await);
imp::sleep(std::time::Duration::from_secs_f32(1.0)).await;
}
});
let mut address_input = use_signal(|| imp::load_server_url());
let address = use_memo(move || {
if let Some(addr) = address_input() {
addr.clone()
} else {
config()
.and_then(|c| c.proxy_url.clone())
.unwrap_or_default()
}
});
let previous_username = imp::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());
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
imp::set_default_server(&address.read());
}
net.send(Connect {
address: address.read().clone(),
username: username.read().clone(),
config: config.read().clone().unwrap_or_default(),
})
};
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"); let version = option_env!("MUMBLE_WEB2_VERSION");
rsx!(
let is_override_mode = config
.read()
.as_ref()
.is_some_and(|c| !c.any_server);
// --- Overrides mode: single preset server, username-only input ---
if is_override_mode {
let proxy_url = config
.read()
.as_ref()
.and_then(|c| c.proxy_url.clone())
.unwrap_or_default();
let previous_username = Platform::load_username();
let mut username = use_signal(|| previous_username.unwrap_or_default());
let status = &STATE.status;
let is_connecting = matches!(&*status.read(), Connecting);
return rsx!(
div { div {
class: "login", class: "server-list-page",
h1 { h1 {
"Mumble Web" "Mumble Web"
match version { match version {
Some(v) => rsx!(" " span { class: "login_version", "({v})" }), Some(v) => rsx!(div { class: "login_version", "({v})" }),
None => rsx!(), None => rsx!(),
} }
} }
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
div { div {
label { class: "server-list",
for: "address-entry", div {
"Server Address:" class: "server-card",
} img {
input { class: "server-card__icon",
id: "address-entry", src: asset!("assets/earth-14-svgrepo-com.svg"),
placeholder: "address", alt: "Server icon",
value: "{address.read()}",
autofocus: "true",
oninput: move |evt| address_input.set(Some(evt.value().clone())),
} }
div {
class: "server-card__info",
span { class: "server-card__name", "Server" }
span { class: "server-card__address", "{proxy_url}" }
} }
} }
div { div {
label { class: "override-username-row",
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 { input {
id: "username-entry", class: "override-username-input",
placeholder: "username", r#type: "text",
placeholder: "Username",
value: "{username.read()}",
oninput: move |evt| username.set(evt.value().clone()),
}
button {
class: "server-card__action server-card__action--connect",
disabled: is_connecting || username.read().is_empty(),
onclick: {
let proxy_url = proxy_url.clone();
move |_| {
let _ = Platform::set_default_username(&username.read());
net.send(Connect {
address: proxy_url.clone(),
username: username.read().clone(),
config: config.read().clone().unwrap_or_default(),
});
}
},
img {
src: asset!("assets/arrow-right-svgrepo-com.svg"),
alt: "Connect",
}
}
}
match &*STATE.status.read() {
Failed(msg) => rsx!(
div {
class: "login_error",
"Failed to connect:"
pre { "{msg}" }
}
),
_ => rsx!(),
}
}
}
);
}
// --- Normal mode: editable server list ---
rsx!(
div {
class: "server-list-page",
h1 {
"Mumble Web"
match version {
Some(v) => rsx!(div { class: "login_version", "({v})" }),
None => rsx!(),
}
}
div {
class: "server-list",
for (idx, server) in servers.read().iter().enumerate() {
{
let address = format!("{}:{}", server.address, server.port);
let connect_entry = server.clone();
rsx!(
div {
key: "{idx}",
class: "server-card",
img {
class: "server-card__icon",
src: asset!("assets/earth-14-svgrepo-com.svg"),
alt: "Server icon",
}
div {
class: "server-card__info",
span { class: "server-card__name", "{server.name}" }
span { class: "server-card__address", "{address}" }
}
ServerPingInfo {
address: server.address.clone(),
port: server.port,
}
button {
class: "server-card__action",
onclick: move |_| editing_index.set(Some(idx)),
img {
src: asset!("assets/edit-3-svgrepo-com.svg"),
alt: "Edit",
}
}
button {
class: "server-card__action server-card__action--connect",
onclick: {
let entry = connect_entry.clone();
move |_| {
let _ = Platform::set_default_username(&entry.username);
let addr = format!("{}:{}", entry.address, entry.port);
net.send(Connect {
address: addr,
username: entry.username.clone(),
config: config.read().clone().unwrap_or_default(),
});
}
},
img {
src: asset!("assets/arrow-right-svgrepo-com.svg"),
alt: "Connect",
}
}
}
)
}
}
}
match &*STATE.status.read() {
Failed(msg) => rsx!(
div {
class: "server-list",
div {
class: "login_error",
"Failed to connect:"
pre { "{msg}" }
}
}
),
_ => rsx!(),
}
button {
class: "add-server-btn",
onclick: move |_| show_add_modal.set(true),
"+ Add Server"
}
if *show_add_modal.read() {
AddServerModal {
on_save: move |entry: ServerEntry| {
servers.write().push(entry);
Platform::save_servers(&servers.read());
show_add_modal.set(false);
},
on_cancel: move |_| show_add_modal.set(false),
}
}
if let Some(idx) = *editing_index.read() {
if let Some(entry) = servers.read().get(idx).cloned() {
EditServerModal {
entry,
on_save: move |updated: ServerEntry| {
servers.write()[idx] = updated;
Platform::save_servers(&servers.read());
editing_index.set(None);
},
on_delete: move |_| {
servers.write().remove(idx);
Platform::save_servers(&servers.read());
editing_index.set(None);
},
on_cancel: move |_| editing_index.set(None),
}
}
}
}
)
}
/// Displays live ping info (user count, latency) for a server using the mumble UDP ping protocol.
#[component]
fn ServerPingInfo(address: String, port: u16) -> Element {
let ping_result = use_resource(move || {
let addr = address.clone();
async move { Platform::ping_server(&addr, port).await }
});
let read = ping_result.read();
match &*read {
Some(Ok(status)) => {
let users_text = match (status.users, status.max_users) {
(Some(u), Some(m)) => format!("{u}/{m}"),
(Some(u), None) => format!("{u} online"),
_ => String::new(),
};
rsx!(
div {
class: "server-card__ping",
if !users_text.is_empty() {
span { "{users_text}" }
}
}
)
}
Some(Err(_)) => rsx!(
div {
class: "server-card__ping",
span { "offline" }
}
),
None => rsx!(
div {
class: "server-card__ping",
span { "..." }
}
),
}
}
#[component]
fn AddServerModal(on_save: EventHandler<ServerEntry>, on_cancel: EventHandler<()>) -> Element {
let mut name = use_signal(|| String::new());
let mut address = use_signal(|| String::new());
let mut port = use_signal(|| "64738".to_string());
let mut username = use_signal(|| Platform::load_username().unwrap_or_default());
let mut password = use_signal(|| String::new());
let do_save = move |_| {
let port_num: u16 = port.read().parse().unwrap_or(64738);
on_save.call(ServerEntry {
name: name.read().clone(),
address: address.read().clone(),
port: port_num,
username: username.read().clone(),
password: if password.read().is_empty() {
None
} else {
Some(password.read().clone())
},
});
};
rsx! {
div {
class: "modal-backdrop",
onclick: move |_| on_cancel.call(()),
}
div {
class: "modal-container",
onclick: move |evt| evt.stop_propagation(),
div {
class: "modal",
h2 { "Add Server" }
div {
class: "modal-field",
label { "Name" }
input {
r#type: "text",
placeholder: "My Mumble Server",
value: "{name.read()}",
oninput: move |evt| name.set(evt.value().clone()),
}
}
div {
class: "modal-field",
label { "Address" }
input {
r#type: "text",
placeholder: "mumble.example.com",
value: "{address.read()}",
oninput: move |evt| address.set(evt.value().clone()),
}
}
div {
class: "modal-field",
label { "Port" }
input {
r#type: "number",
placeholder: "64738",
value: "{port.read()}",
oninput: move |evt| port.set(evt.value().clone()),
}
}
div {
class: "modal-field",
label { "Username" }
input {
r#type: "text",
placeholder: "Nickname",
value: "{username.read()}", value: "{username.read()}",
autofocus: "true",
oninput: move |evt| username.set(evt.value().clone()), oninput: move |evt| username.set(evt.value().clone()),
} }
} }
div { div {
match &*last_status.read() { class: "modal-field",
None => rsx!(div { label { "Password (optional)" }
class: "login_status", input {
span {"···"} r#type: "password",
}), placeholder: "Password",
Some(Ok(ServerStatus { success: false, .. })) => rsx!(div { value: "{password.read()}",
class: "login_status is_error", oninput: move |evt| password.set(evt.value().clone()),
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 { div {
{bottom} class: "modal-actions",
button {
class: "modal-btn",
onclick: move |_| on_cancel.call(()),
"Cancel"
}
button {
class: "modal-btn modal-btn--primary",
disabled: address.read().is_empty() || username.read().is_empty(),
onclick: do_save,
"Save"
}
}
}
}
}
} }
#[component]
fn EditServerModal(
entry: ServerEntry,
on_save: EventHandler<ServerEntry>,
on_delete: EventHandler<()>,
on_cancel: EventHandler<()>,
) -> Element {
let mut name = use_signal(|| entry.name.clone());
let mut address = use_signal(|| entry.address.clone());
let mut port = use_signal(|| entry.port.to_string());
let mut username = use_signal(|| entry.username.clone());
let mut password = use_signal(|| entry.password.clone().unwrap_or_default());
let do_save = move |_| {
let port_num: u16 = port.read().parse().unwrap_or(64738);
on_save.call(ServerEntry {
name: name.read().clone(),
address: address.read().clone(),
port: port_num,
username: username.read().clone(),
password: if password.read().is_empty() {
None
} else {
Some(password.read().clone())
},
});
};
rsx! {
div {
class: "modal-backdrop",
onclick: move |_| on_cancel.call(()),
}
div {
class: "modal-container",
onclick: move |evt| evt.stop_propagation(),
div {
class: "modal",
h2 { "Edit Server" }
div {
class: "modal-field",
label { "Name" }
input {
r#type: "text",
placeholder: "My Mumble Server",
value: "{name.read()}",
oninput: move |evt| name.set(evt.value().clone()),
}
}
div {
class: "modal-field",
label { "Address" }
input {
r#type: "text",
placeholder: "mumble.example.com",
value: "{address.read()}",
oninput: move |evt| address.set(evt.value().clone()),
}
}
div {
class: "modal-field",
label { "Port" }
input {
r#type: "number",
placeholder: "64738",
value: "{port.read()}",
oninput: move |evt| port.set(evt.value().clone()),
}
}
div {
class: "modal-field",
label { "Username" }
input {
r#type: "text",
placeholder: "Nickname",
value: "{username.read()}",
oninput: move |evt| username.set(evt.value().clone()),
}
}
div {
class: "modal-field",
label { "Password (optional)" }
input {
r#type: "password",
placeholder: "Password",
value: "{password.read()}",
oninput: move |evt| password.set(evt.value().clone()),
}
}
div {
class: "modal-actions",
button {
class: "modal-btn modal-btn--danger",
onclick: move |_| on_delete.call(()),
"Delete"
}
span { class: "modal-actions__spacer" }
button {
class: "modal-btn",
onclick: move |_| on_cancel.call(()),
"Cancel"
}
button {
class: "modal-btn modal-btn--primary",
disabled: address.read().is_empty() || username.read().is_empty(),
onclick: do_save,
"Save"
}
}
}
} }
} }
)
// 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}
// }
// )
} }
pub fn app() -> Element { pub fn app() -> Element {
@@ -724,13 +1161,13 @@ pub fn app() -> Element {
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx)); use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
let config = use_resource(|| async move { let config = use_resource(|| async move {
match imp::load_config().await { match Platform::load_config().await {
Ok(config) => config, Ok(config) => config,
Err(_) => ClientConfig::default(), Err(_) => ClientConfig::default(),
} }
}); });
imp::request_permissions(); Platform::request_permissions();
rsx!( 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=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 std::sync::Arc;
use tracing::{error, info}; use tracing::{error, info};
use crate::imp; use crate::imp::SpawnHandle;
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz"); static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
// TODO: make this user configurable. // TODO: make this user configurable.
@@ -32,10 +32,7 @@ enum DenoisingModelState {
Availible(Box<DfTract>), Availible(Box<DfTract>),
} }
fn with_denoising_model<O>( fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract) -> O) -> Option<O> {
spawn: &imp::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 // 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) while AudioProcessing itself might change threads whenever.
thread_local! { thread_local! {
@@ -89,7 +86,7 @@ fn with_denoising_model<O>(
pub struct AudioProcessor { pub struct AudioProcessor {
denoise: bool, denoise: bool,
spawn: imp::SpawnHandle, spawn: SpawnHandle,
buffer: Vec<f32>, buffer: Vec<f32>,
noise_floor: f32, noise_floor: f32,
/// Whether we were transmitting in the previous frame /// Whether we were transmitting in the previous frame
@@ -102,7 +99,7 @@ impl AudioProcessor {
pub fn new_plain() -> Self { pub fn new_plain() -> Self {
AudioProcessor { AudioProcessor {
denoise: false, denoise: false,
spawn: imp::SpawnHandle::current(), spawn: SpawnHandle::current(),
buffer: Vec::new(), buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR, noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false, was_transmitting: false,
@@ -113,7 +110,7 @@ impl AudioProcessor {
pub fn new_denoising() -> Self { pub fn new_denoising() -> Self {
AudioProcessor { AudioProcessor {
denoise: true, denoise: true,
spawn: imp::SpawnHandle::current(), spawn: SpawnHandle::current(),
buffer: Vec::new(), buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR, noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false, was_transmitting: false,
@@ -123,7 +120,12 @@ impl AudioProcessor {
} }
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; let mut include_raw = true;
if self.denoise { if self.denoise {
with_denoising_model(&self.spawn, |df| { 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> { pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet") bail!("status not supported on desktop yet")
} }
#[allow(unused)]
pub use tokio::spawn;
#[allow(unused)]
pub type SpawnHandle = tokio::runtime::Handle;
+145 -45
View File
@@ -1,17 +1,108 @@
use crate::app::Command;
use color_eyre::eyre::{bail, Error};
use dioxus::hooks::UnboundedReceiver;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use mumble_web2_common::ClientConfig; use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
use std::collections::HashMap; use std::collections::HashMap;
pub use tokio::runtime::Handle as SpawnHandle; use std::time::Duration;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
pub use super::connect::*; /// Desktop platform implementation using Tokio and native audio.
pub use super::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()
}
fn load_servers() -> Vec<ServerEntry> {
let config = load_config_map();
config
.get("servers")
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default()
}
fn save_servers(servers: &[ServerEntry]) {
let mut config = load_config_map();
if let Ok(json) = serde_json::to_string(servers) {
config.insert("servers".to_string(), json);
let _ = save_config_map(&config);
}
}
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
}
async fn ping_server(address: &str, port: u16) -> color_eyre::Result<ServerStatus> {
mumble_udp_ping(address, port).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 { fn get_config_path() -> std::path::PathBuf {
let strategy = choose_app_strategy(AppStrategyArgs { let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "com".to_string(), top_level_domain: "xyz".to_string(),
author: "Ohea Corp".to_string(), author: "ohea".to_string(),
app_name: "Mumble Web2".to_string(), app_name: "Mumble Web2".to_string(),
}) })
.expect("failed to choose app strategy"); .expect("failed to choose app strategy");
@@ -36,47 +127,56 @@ fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
Ok(()) Ok(())
} }
pub fn set_default_username(username: &str) -> Option<()> { /// Mumble UDP ping protocol.
let mut config = load_config_map(); ///
config.insert("username".to_string(), username.to_string()); /// Send a 12-byte packet: 4 zero bytes + 8-byte identifier.
save_config_map(&config).ok() /// Receive a 24-byte response: 4 bytes version (1 byte each: major.minor.patch + padding)
} /// + 8 bytes identifier echo + 4 bytes current_users + 4 bytes max_users + 4 bytes bandwidth.
async fn mumble_udp_ping(address: &str, port: u16) -> color_eyre::Result<ServerStatus> {
use std::net::ToSocketAddrs;
use tokio::net::UdpSocket;
pub fn set_default_server(server: &str) -> Option<()> { let dest = format!("{}:{}", address, port)
let mut config = load_config_map(); .to_socket_addrs()?
config.insert("server".to_string(), server.to_string()); .next()
save_config_map(&config).ok() .ok_or_else(|| color_eyre::eyre::eyre!("could not resolve address"))?;
}
pub fn load_username() -> Option<String> { let bind_addr = if dest.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" };
let config = load_config_map(); let socket = UdpSocket::bind(bind_addr).await?;
config.get("username").cloned() socket.connect(dest).await?;
}
pub fn load_server_url() -> Option<String> { // Build ping packet: 4 zero bytes + 8-byte request ID
let config = load_config_map(); let request_id: u64 = std::time::SystemTime::now()
config.get("server").cloned() .duration_since(std::time::UNIX_EPOCH)
} .unwrap_or_default()
.as_nanos() as u64;
pub async fn load_config() -> color_eyre::Result<ClientConfig> { let mut buf = [0u8; 12];
Ok(ClientConfig { buf[4..12].copy_from_slice(&request_id.to_be_bytes());
proxy_url: None, socket.send(&buf).await?;
cert_hash: None,
any_server: true, let mut response = [0u8; 24];
let timeout = tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut response)).await;
match timeout {
Ok(Ok(len)) if len >= 24 => {
let version_major = response[0] as u32;
let version_minor = response[1] as u32;
let version_patch = response[2] as u32;
let users = u32::from_be_bytes([response[12], response[13], response[14], response[15]]);
let max_users = u32::from_be_bytes([response[16], response[17], response[18], response[19]]);
let bandwidth = u32::from_be_bytes([response[20], response[21], response[22], response[23]]);
Ok(ServerStatus {
success: true,
version: Some((version_major, version_minor, version_patch)),
users: Some(users),
max_users: Some(max_users),
bandwidth: Some(bandwidth),
}) })
} }
Ok(Ok(_)) => bail!("ping response too short"),
pub fn init_logging() { Ok(Err(e)) => Err(e.into()),
use tracing::level_filters::LevelFilter; Err(_) => bail!("ping timed out"),
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();
} }
+63 -29
View File
@@ -1,32 +1,17 @@
use android_permissions::{PermissionManager, RECORD_AUDIO}; use crate::app::Command;
use jni::{objects::JObject, JavaVM}; use color_eyre::eyre::Error;
use mumble_web2_common::ClientConfig; use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
use std::future::Future;
use std::time::Duration;
use std::collections::HashMap; /// Mobile platform implementation using Tokio, native audio, and Android permissions.
pub use tokio::runtime::Handle as SpawnHandle; pub struct MobilePlatform;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
pub use super::connect::*; impl super::PlatformInterface for MobilePlatform {
pub use super::native_audio::*; type AudioSystem = super::native_audio::NativeAudioSystem;
pub fn set_default_username(username: &str) -> Option<()> { async fn load_config() -> color_eyre::Result<ClientConfig> {
None
}
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 { Ok(ClientConfig {
proxy_url: None, proxy_url: None,
cert_hash: None, cert_hash: None,
@@ -34,7 +19,46 @@ pub async fn load_config() -> color_eyre::Result<ClientConfig> {
}) })
} }
pub fn init_logging() { 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
}
fn load_servers() -> Vec<ServerEntry> {
Vec::new()
}
fn save_servers(_servers: &[ServerEntry]) {}
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
}
async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result<ServerStatus> {
color_eyre::eyre::bail!("ping not supported on mobile yet")
}
fn init_logging() {
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter; use tracing_subscriber::filter::EnvFilter;
@@ -49,13 +73,23 @@ pub fn init_logging() {
.init(); .init();
} }
#[cfg(feature = "mobile")] fn request_permissions() {
pub fn request_permissions() {
request_recording_permission(); request_recording_permission();
} }
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
}
#[cfg(not(target_os = "android"))]
pub fn request_recording_permission() {}
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub fn request_recording_permission() { pub fn request_recording_permission() {
use android_permissions::{PermissionManager, RECORD_AUDIO};
use jni::{objects::JObject, JavaVM};
let ctx = ndk_context::android_context(); let ctx = ndk_context::android_context();
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() }; let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() };
let activity = unsafe { JObject::from_raw(ctx.context().cast()) }; let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
+168 -19
View File
@@ -1,29 +1,178 @@
#[cfg(feature = "web")] //! Platform abstraction layer
mod web; //!
//! 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, ServerEntry, 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.) via the web proxy status endpoint.
fn get_status(
client: &reqwest::Client,
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
/// Ping a mumble server via UDP to get version, user count, etc.
fn ping_server(
address: &str,
port: u16,
) -> 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<()>;
/// Load the saved server list.
fn load_servers() -> Vec<ServerEntry>;
/// Save the server list.
fn save_servers(servers: &[ServerEntry]);
/// Async sleep for the given duration.
fn sleep(duration: Duration) -> impl Future<Output = ()>;
}
// ============================================================================
// Platform Modules
// ============================================================================
#[cfg(any(feature = "desktop", feature = "mobile"))] #[cfg(any(feature = "desktop", feature = "mobile"))]
mod connect; mod connect;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
mod desktop; mod desktop;
#[cfg(feature = "mobile")] #[cfg(feature = "mobile")]
mod mobile; mod mobile;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
mod stub;
#[cfg(feature = "web")]
mod web;
// ============================================================================
// Platform Type Alias
// ============================================================================
#[cfg(feature = "web")]
pub type Platform = web::WebPlatform;
#[cfg(all(feature = "desktop", not(feature = "web")))]
pub type Platform = desktop::DesktopPlatform;
#[cfg(all(feature = "mobile", not(feature = "web"), not(feature = "desktop")))]
pub type Platform = mobile::MobilePlatform;
#[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")] #[cfg(feature = "desktop")]
pub use desktop::*; let _ = assert_platform::<desktop::DesktopPlatform>;
#[cfg(feature = "mobile")] #[cfg(feature = "mobile")]
pub use mobile::*; let _ = assert_platform::<mobile::MobilePlatform>;
let _ = assert_platform::<stub::StubPlatform>;
#[cfg(feature = "mobile")] };
pub use mobile::request_permissions;
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn request_permissions() {}
#[cfg(all(feature = "web", not(any(feature = "desktop", feature = "mobile"))))]
pub use web::*;
#[cfg(any(feature = "desktop"))]
pub use desktop::*;
+39 -42
View File
@@ -1,19 +1,12 @@
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState}; use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{eyre, Error}; use color_eyre::eyre::{eyre, Error};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
use futures::io::{AsyncRead, AsyncWrite};
use std::mem::replace; use std::mem::replace;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {} pub struct NativeAudioSystem {
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 {
output: cpal::Device, output: cpal::Device,
input: cpal::Device, input: cpal::Device,
processors: AudioProcessorSender, processors: AudioProcessorSender,
@@ -22,6 +15,8 @@ pub struct AudioSystem {
const SAMPLE_RATE: u32 = 48_000; const SAMPLE_RATE: u32 = 48_000;
const PACKET_SAMPLES: u32 = 960; 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( fn encode_and_send(
state: TransmitState, state: TransmitState,
@@ -50,28 +45,7 @@ fn encode_and_send(
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>; type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
impl AudioSystem { impl NativeAudioSystem {
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))
}
fn choose_config( fn choose_config(
&self, &self,
configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>, configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>,
@@ -101,8 +75,32 @@ impl AudioSystem {
.cloned() .cloned()
.ok_or(eyre!("no supported stream configs")) .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 self,
mut each: impl FnMut(Vec<u8>, bool) + Send + 'static, mut each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error> { ) -> Result<(), Error> {
@@ -122,7 +120,8 @@ impl AudioSystem {
if let Some(new_processor) = processors.take() { if let Some(new_processor) = processors.take() {
current_processor = new_processor; 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); 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()?)?; let config = self.choose_config(self.output.supported_output_configs()?)?;
info!( info!(
"creating player on {:?} with {:#?}", "creating player on {:?} with {:#?}",
@@ -180,32 +179,30 @@ impl AudioSystem {
)? )?
}; };
stream.play()?; stream.play()?;
Ok(AudioPlayer { Ok(NativeAudioPlayer {
decoder, decoder,
stream, stream,
buffer, buffer,
tmp: vec![0; 2400], tmp: vec![0; MAX_DECODE_SAMPLES],
}) })
} }
} }
pub struct AudioPlayer { pub struct NativeAudioPlayer {
decoder: opus::Decoder, decoder: opus::Decoder,
stream: cpal::Stream, stream: cpal::Stream,
buffer: Buffer, buffer: Buffer,
tmp: Vec<i16>, tmp: Vec<i16>,
} }
impl AudioPlayer { impl super::AudioPlayerInterface for NativeAudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) { fn play_opus(&mut self, payload: &[u8]) {
let len = loop { let len = match self.decoder.decode(payload, &mut self.tmp, false) {
match self.decoder.decode(payload, &mut self.tmp, false) { Ok(l) => l,
Ok(l) => break l,
Err(e) => { Err(e) => {
error!("opus decode error {e:?}"); error!("opus decode error {e:?}");
return; return;
} }
}
}; };
let mut buffer = self.buffer.lock().unwrap(); let mut buffer = self.buffer.lock().unwrap();
+134
View File
@@ -0,0 +1,134 @@
/// 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, ServerEntry, 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 ping_server(
_address: &str,
_port: u16,
) -> 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 load_servers() -> Vec<ServerEntry> {
panic!("stubbed platform")
}
fn save_servers(_servers: &[ServerEntry]) {
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")
}
+146 -109
View File
@@ -3,11 +3,10 @@ use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{bail, eyre, Error}; use color_eyre::eyre::{bail, eyre, Error};
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use dioxus::prelude::*; use dioxus::prelude::*;
use futures::{AsyncRead, AsyncWrite};
use gloo_timers::future::TimeoutFuture; use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array; use js_sys::Float32Array;
use mumble_protocol::control::ClientControlCodec; use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
use reqwest::Url; use reqwest::Url;
use std::future::Future; use std::future::Future;
use std::sync::Arc; use std::sync::Arc;
@@ -29,7 +28,6 @@ use web_sys::AudioWorkletNode;
use web_sys::EncodedAudioChunk; use web_sys::EncodedAudioChunk;
use web_sys::EncodedAudioChunkInit; use web_sys::EncodedAudioChunkInit;
use web_sys::EncodedAudioChunkType; use web_sys::EncodedAudioChunkType;
use web_sys::MediaStream;
use web_sys::MediaStreamConstraints; use web_sys::MediaStreamConstraints;
use web_sys::MessageEvent; use web_sys::MessageEvent;
use web_sys::WebTransport; use web_sys::WebTransport;
@@ -39,16 +37,142 @@ use web_sys::WorkletOptions;
use web_sys::{console, window}; use web_sys::{console, window};
use web_sys::{AudioContext, AudioDataCopyToOptions}; use web_sys::{AudioContext, AudioDataCopyToOptions};
#[allow(unused)]
pub use wasm_bindgen_futures::spawn_local as spawn; pub use wasm_bindgen_futures::spawn_local as spawn;
pub trait ImpRead: AsyncRead + Unpin + 'static {} #[allow(unused)]
impl<T: AsyncRead + Unpin + 'static> ImpRead for T {} #[derive(Clone)]
pub struct SpawnHandle;
pub trait ImpWrite: AsyncWrite + Unpin + 'static {} impl SpawnHandle {
impl<T: AsyncWrite + Unpin + 'static> ImpWrite for T {} pub fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(future);
}
pub async fn sleep(d: Duration) { pub fn current() -> Self {
TimeoutFuture::new(d.as_millis() as u32).await 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
}
fn load_servers() -> Vec<ServerEntry> {
web_sys::window()
.and_then(|w| w.local_storage().ok()?)
.and_then(|s| s.get_item("servers").ok()?)
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default()
}
fn save_servers(servers: &[ServerEntry]) {
if let Ok(json) = serde_json::to_string(servers) {
if let Some(storage) = web_sys::window()
.and_then(|w| w.local_storage().ok()?)
{
let _ = storage.set_item("servers", &json);
}
}
}
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 ping_server(_address: &str, _port: u16) -> color_eyre::Result<ServerStatus> {
// UDP ping not available in browsers; use get_status via HTTP proxy instead
color_eyre::eyre::bail!("UDP ping not supported on web platform")
}
async fn sleep(duration: Duration) {
TimeoutFuture::new(duration.as_millis() as u32).await;
}
} }
trait ResultExt<T> { trait ResultExt<T> {
@@ -73,7 +197,7 @@ impl<T> ResultExt<T> for Result<T, JsError> {
} }
} }
pub struct AudioSystem { pub struct WebAudioSystem {
webctx: AudioContext, webctx: AudioContext,
processors: AudioProcessorSender, processors: AudioProcessorSender,
} }
@@ -104,8 +228,10 @@ async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
Ok(()) Ok(())
} }
impl AudioSystem { impl super::AudioSystemInterface for WebAudioSystem {
pub async fn new() -> Result<Self, Error> { type AudioPlayer = WebAudioPlayer;
async fn new() -> Result<Self, Error> {
// Create MediaStreams to playback decoded audio // Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio. // The audio context is used to reproduce audio.
let webctx = configure_audio_context(); let webctx = configure_audio_context();
@@ -113,17 +239,14 @@ impl AudioSystem {
let processors = AudioProcessorSender::default(); 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)) self.processors.store(Some(processor))
} }
pub fn start_recording( fn start_recording(&mut self, each: impl FnMut(Vec<u8>, bool) + 'static) -> Result<(), Error> {
&mut self,
each: impl FnMut(Vec<u8>, bool) + 'static,
) -> Result<(), Error> {
let audio_context_worklet = self.webctx.clone(); let audio_context_worklet = self.webctx.clone();
let processors = self.processors.clone(); let processors = self.processors.clone();
spawn(async move { spawn(async move {
@@ -135,7 +258,7 @@ impl AudioSystem {
Ok(()) 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()?; let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?;
// Connect worklet to destination // Connect worklet to destination
@@ -188,14 +311,14 @@ impl AudioSystem {
decoder_error.forget(); decoder_error.forget();
output.forget(); output.forget();
Ok(AudioPlayer(audio_decoder)) Ok(WebAudioPlayer(audio_decoder))
} }
} }
pub struct AudioPlayer(AudioDecoder); pub struct WebAudioPlayer(AudioDecoder);
impl AudioPlayer { impl super::AudioPlayerInterface for WebAudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) { fn play_opus(&mut self, payload: &[u8]) {
let js_audio_payload = Uint8Array::from(payload); let js_audio_payload = Uint8Array::from(payload);
let _ = self.0.decode( let _ = self.0.decode(
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new( &EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
@@ -418,94 +541,8 @@ pub async fn network_connect(
crate::network_loop(username, event_rx, reader, writer).await 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> { pub fn absolute_url(path: &str) -> Result<Url, Error> {
let window: web_sys::Window = web_sys::window().expect("no global `window` exists"); let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
let location = window.location(); let location = window.location();
Ok(Url::parse(&location.href().ey()?)?.join(path)?) 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 color_eyre::eyre::{bail, Error};
use dioxus::prelude::*; use dioxus::prelude::*;
use futures::select; use futures::select;
use futures::AsyncRead;
use futures::AsyncWrite;
use futures::FutureExt as _; use futures::FutureExt as _;
use futures::SinkExt as _; use futures::SinkExt as _;
use futures::StreamExt as _; use futures::StreamExt as _;
use futures_channel::mpsc::UnboundedSender; use futures_channel::mpsc::UnboundedSender;
pub use imp::spawn;
use msghtml::process_message_html; use msghtml::process_message_html;
use mumble_protocol::control::msgs; use mumble_protocol::control::msgs;
use mumble_protocol::control::ControlCodec; use mumble_protocol::control::ControlCodec;
@@ -27,7 +28,10 @@ use tracing::error;
use tracing::info; use tracing::info;
use crate::effects::AudioProcessor; use crate::effects::AudioProcessor;
use crate::imp::AudioSystem; use crate::imp::{
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
PlatformInterface as _,
};
pub mod app; pub mod app;
mod effects; mod effects;
@@ -47,7 +51,9 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
*STATE.server.write() = Default::default(); *STATE.server.write() = Default::default();
*STATE.status.write() = ConnectionState::Connecting; *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); error!("could not connect {:?}", error);
*STATE.status.write() = ConnectionState::Failed(error.to_string()); *STATE.status.write() = ConnectionState::Failed(error.to_string());
} else { } 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, username: String,
event_rx: &mut UnboundedReceiver<Command>, event_rx: &mut UnboundedReceiver<Command>,
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>, mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
@@ -105,12 +111,12 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
break; 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 send_chan = send_chan.clone();
let mut sequence_num = 0; let mut sequence_num = 0;
@@ -296,8 +302,8 @@ fn accept_command(
fn accept_packet( fn accept_packet(
msg: ControlPacket<mumble_protocol::Clientbound>, msg: ControlPacket<mumble_protocol::Clientbound>,
audio_context: &mut imp::AudioSystem, audio_context: &mut AudioSystem,
player_map: &mut HashMap<u32, imp::AudioPlayer>, player_map: &mut HashMap<u32, AudioPlayer>,
) -> Result<(), Error> { ) -> Result<(), Error> {
match msg { match msg {
ControlPacket::UDPTunnel(u) => { ControlPacket::UDPTunnel(u) => {
@@ -335,41 +341,11 @@ fn accept_packet(
} }
ControlPacket::ChannelState(u) => { ControlPacket::ChannelState(u) => {
let mut server = STATE.server.write(); let mut server = STATE.server.write();
let id = u.get_channel_id(); server.channels_state.update_from_channel_state(&u);
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();
}
} }
ControlPacket::ChannelRemove(u) => { ControlPacket::ChannelRemove(u) => {
let mut server = STATE.server.write(); let mut server = STATE.server.write();
let id = u.get_channel_id(); server.channels_state.update_from_channel_remove(&u);
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);
}
}
} }
ControlPacket::UserState(u) => { ControlPacket::UserState(u) => {
let mut server = STATE.server.write(); let mut server = STATE.server.write();
@@ -381,12 +357,13 @@ fn accept_packet(
let state = state_entry.or_default(); let state = state_entry.or_default();
// the server might now send a channel_id if the user is in channel=0 // the server might now send a channel_id if the user is in channel=0
if u.has_channel_id() || new { 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); parent.users.remove(&id);
} }
let channel_id = u.get_channel_id(); let channel_id = u.get_channel_id();
server server
.channels_state
.channels .channels
.entry(channel_id) .entry(channel_id)
.or_default() .or_default()
@@ -418,7 +395,7 @@ fn accept_packet(
let mut server = STATE.server.write(); let mut server = STATE.server.write();
let id = u.get_session(); let id = u.get_session();
if let Some(state) = server.users.remove(&id) { 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); 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() { pub fn main() {
imp::init_logging(); Platform::init_logging();
dioxus::launch(app::app); dioxus::launch(app::app);
} }