38 Commits

Author SHA1 Message Date
restitux dab00dce2f disable rust cache in linux ci
Build Mumble Web 2 / windows_build (push) Successful in 2m46s
Build Mumble Web 2 / linux_build (push) Successful in 4m20s
Build Mumble Web 2 / android_build (push) Successful in 5m56s
2026-01-24 22:00:11 -07:00
liamwarfield a30082eebe Fix Proxy slow disconnect.
Build android container / android-release-builder-container-build (push) Successful in 10s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 13s
Build Mumble Web 2 / linux_build (push) Successful in 1m25s
Build Mumble Web 2 / windows_build (push) Successful in 2m37s
Build Mumble Web 2 / android_build (push) Successful in 5m57s
When `select!` drops a JoinHandle, it doesn't abort the spawned task - it detaches it. From tokio docs:

A JoinHandle detaches the associated task when it is dropped, which means that there is no
longer any handle to the task, and no way to join on it.

This means the spawn task is still spinning in the background:
  1. c2s completes (client disconnected)
  2. select! drops the s2c JoinHandle
  3. The s2c task continues running in the background, detached
  4. That task holds the Mumble server connection open

The fix is to hand the async calls directly to select! instead of
calling tokio::spawn.
2026-01-19 19:17:23 -07:00
liamwarfield 7c75e64a64 Add SELinux :z labels to docker-compose volume mounts
Build Mumble Web 2 / linux_build (push) Successful in 1m29s
Build Mumble Web 2 / windows_build (push) Successful in 2m40s
Build Mumble Web 2 / android_build (push) Successful in 5m57s
Enables volume access on SELinux-enabled systems like Fedora by
applying shared labels to the mounted paths.
2026-01-19 15:08:38 -07:00
liamwarfield 65883917b0 Add a default noise floor. (#13)
Build Mumble Web 2 / linux_build (push) Successful in 1m29s
Build Mumble Web 2 / android_build (push) Has been cancelled
Build Mumble Web 2 / windows_build (push) Has been cancelled
(Turns out not) Pretty simple, if the average amplitude is under a certain value clear
out the buffer! The value I chose (.001) was an arbitrary value I got
from printf debugging. I was able to show that this worked pretty well
on the desktop session. Hopefully we'll add this to the settings page at
some point.

Once the app has been under that threshold for more than 200ms, we stop transmitting and send a terminator packet.

Reviewed-on: #13
Reviewed-by: restitux <restitux@ohea.xyz>
2026-01-19 22:07:06 +00:00
liamwarfield c8119d0efa The client now handles being suppressed correctly (#16)
Build Mumble Web 2 / linux_build (push) Successful in 1m25s
Build Mumble Web 2 / windows_build (push) Successful in 2m38s
Build Mumble Web 2 / android_build (push) Successful in 5m56s
I noticed our BRB and AFK rooms were borked and made a change to fix that. I've attached a photo showing the result.

Overview:

- Add suppress field to UserState struct
- Process suppress field from UserState protobuf messages
- Update UI to show suppressed users with blacked out styling and muted icon
- Disable mute toggle button when user is suppressed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Reviewed-on: #16
2026-01-19 19:12:57 +00:00
restitux d7b88874df android builds (#9)
Build Mumble Web 2 / linux_build (push) Successful in 1m31s
Build Mumble Web 2 / windows_build (push) Successful in 2m39s
Build Mumble Web 2 / android_build (push) Successful in 5m54s
Build android container / android-release-builder-container-build (push) Successful in 5s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 16s
This adds android builds to the CI infrastructure. These builds generate an `apk` file that you can download and install.
- Adds a new container build job that builds a container with all the required android dependencies
- Adds a new release build that builds an android apk
- Updated the imp module to split out mobile and desktop behavior
- Adds logic to request microphone permissions
- Added a custom android manifest that declares the required permissions

Reviewed-on: #9
2026-01-19 01:03:45 +00:00
samuel127849 f001a192e1 UI scaling (#15)
Build Mumble Web 2 / linux_build (push) Successful in 1m31s
Build Mumble Web 2 / windows_build (push) Successful in 2m26s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
I vibe coded a change so that I can use mumble-web2 when I only have it in a small area instead of a full screen.

Scaling for smol screens:
![image.png](/attachments/7187de3c-cfce-4a30-891d-6a1b85096021)

Full screen scaling:
![image.png](/attachments/a1edc925-fb29-4dc7-ab45-51795d0738ad)

Reviewed-on: #15
Reviewed-by: restitux <restitux@ohea.xyz>
Co-authored-by: Samuel Warfield <samuel.warfield2@gmail.com>
Co-committed-by: Samuel Warfield <samuel.warfield2@gmail.com>
2026-01-13 18:24:41 +00:00
samuel127849 37c0bce57e Fix typo
Build Mumble Web 2 / linux_build (push) Successful in 1m41s
Build Mumble Web 2 / windows_build (push) Successful in 2m24s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 13s
2026-01-10 21:01:29 -07:00
samuel127849 4abb130a77 Address @sam
Build Mumble Web 2 / linux_build (push) Successful in 1m33s
Build Mumble Web 2 / windows_build (push) Successful in 2m23s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
2026-01-10 23:26:21 +00:00
samuel127849 af35d72e4e Added persistant settings on desktop 2026-01-10 23:26:21 +00:00
samuel127849 889bdf6b80 Add configuration for rust analyzer for vscode. 2026-01-10 23:26:21 +00:00
liamwarfield 391d18a11e web: fix flash of unstyled content during load
Build Mumble Web 2 / linux_build (push) Successful in 1m28s
Build Mumble Web 2 / windows_build (push) Has been cancelled
Add background color to #main in loader styles to prevent white flash
while app CSS loads after WASM initialization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:24:15 -07:00
liamwarfield ca8a3d1b92 web: add loading screen while WASM fetches
Shows a themed spinner overlay while the large WASM bundle downloads,
improving perceived load time on slower connections.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:09:37 -07:00
restitux 5d2c2a93c7 ci: upgrade version of dioxus cli installed during linux build to 0.7.2 (#8)
Build Mumble Web 2 / linux_build (push) Successful in 1m42s
Build Mumble Web 2 / windows_build (push) Successful in 2m30s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 12s
Forgot to update this with the rest of the 0.7.2 migration

Reviewed-on: #8
2026-01-05 09:40:40 +00:00
restitux 96589e28c6 meta: update Dioxus to 0.7.2 (#6)
Build Mumble Web 2 / linux_build (push) Successful in 1m50s
Build Mumble Web 2 / windows_build (push) Successful in 4m16s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
Build with latest container: https://git.ohea.xyz/mumble/mumble-web2/actions/runs/192/jobs/1

Reviewed-on: #6
2026-01-05 01:45:40 +00:00
restitux e7e7b945c5 gui: switch to upstream version of rfd (#7)
Build Mumble Web 2 / windows_build (push) Has been cancelled
Build Mumble Web 2 / linux_build (push) Has been cancelled
The PR was merged into upstream. They haven't cut a release yet though so we still need to pull from git.

Reviewed-on: #7
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-01-05 01:45:27 +00:00
restitux cd90cb628b ci: correct windows dockerfile path
Build Mumble Web 2 / linux_build (push) Successful in 1m41s
Build Mumble Web 2 / windows_build (push) Successful in 2m31s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 17s
2025-12-05 14:24:43 -07:00
restitux 37613a65c4 web: fix audio on firefox
Build Mumble Web 2 / windows_build (push) Successful in 2m39s
Build Mumble Web 2 / linux_build (push) Successful in 4m57s
2025-12-05 01:54:12 -07:00
sam d6b482528f Load status from relative url (#5)
Build Mumble Web 2 / linux_build (push) Successful in 2m23s
Build Mumble Web 2 / windows_build (push) Successful in 2m33s
Remove public_url config option
Use proxy_url instead for example configs
Get status from relative endpoint, like /config
Show version on login page

Reviewed-on: #5
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2025-12-05 07:00:38 +00:00
sam 5df7b0e082 Fix audio on windows (#3)
Build Mumble Web 2 / linux_build (push) Successful in 1m42s
Build Mumble Web 2 / windows_build (push) Successful in 2m43s
Reviewed-on: #3

Automatically choose supported profile
Play stream once created

Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2025-12-05 05:34:07 +00:00
sam 55412f5778 Revert 0.7.2 upgrade
Build Mumble Web 2 / linux_build (push) Successful in 1m39s
Build Mumble Web 2 / windows_build (push) Successful in 2m52s
2025-12-04 22:21:37 -07:00
sam 2982a7f8d8 Finish 0.7.2 upgrade
Build Mumble Web 2 / linux_build (push) Failing after 58s
Build Mumble Web 2 / windows_build (push) Successful in 2m29s
2025-12-04 22:17:15 -07:00
sam b1970cf23f Upgrade dioxus to 0.7.2
Build Mumble Web 2 / windows_build (push) Has been cancelled
Build Mumble Web 2 / linux_build (push) Has been cancelled
2025-12-04 22:16:02 -07:00
restitux a11fb4f10e Windows Desktop Client CI (#2)
Build Mumble Web 2 / linux_build (push) Successful in 1m43s
Build Mumble Web 2 / windows_build (push) Successful in 2m38s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Failing after 11s
Adds a windows container build and a windows client build.
Outstanding issues:
- I'm not sure if `workflow_dispatch` works. Based on [this PR](https://github.com/go-gitea/gitea/pull/28163) it seems like it should, but I don't see a button. It might only work after merging into the default branch.
- The windows build container is building dioxus from git HEAD because there is an unreleased bugfix we are depending on. We should revert this to a versioned release (maybe using `binstall`) once they cut 0.7.2

Reviewed-on: #2
2025-12-05 03:58:11 +00:00
sam 40bb8e18ef upgrade to dioxus 0.7.1
Build dioxus container / BuildContainer (push) Successful in 3m10s
2025-11-27 22:39:18 -07:00
restitux 128791bccd Add CI and artifact publishing (#1)
Build dioxus container / BuildContainer (push) Successful in 2m23s
Reviewed-on: #1
Reviewed-by: Sam Sartor <cap@samsartor.com>
2025-11-28 05:24:11 +00:00
sam e8d41f7e92 make status display live 2025-11-27 19:31:02 -07:00
sam 1e28442356 consistant line color 2025-11-27 18:02:30 -07:00
sam c645722d21 increase playback buffer size on desktop 2025-11-27 17:33:25 -07:00
restitux b9e293cca4 [cicd] add docker build workflow 2025-11-09 01:19:09 -07:00
restitux 97bdb1a143 [meta] add leading slash to .gitignored config.toml to fix import errors
WHAT THE FUCK
2025-11-09 01:17:50 -07:00
sam 70634065ac fix some problems with url origins in web (denoising works there too) 2025-10-28 01:44:28 -06:00
sam 1efd32892e denoising actually works on desktop! 2025-10-28 01:20:59 -06:00
sam 1ff302816e load an actual denoising model 2025-10-28 01:10:35 -06:00
sam ebcf5ce4ce fix some dependencies 2025-10-27 22:49:49 -06:00
sam 4e30be3ebd switch to scss asset & remove sir 2025-10-27 19:04:33 -06:00
sam 987cfd57d2 audio seems fine now 2025-10-26 19:05:48 -06:00
sam fea6800bea desktop recording sorta works 2025-10-26 18:55:09 -06:00
39 changed files with 5049 additions and 3066 deletions
@@ -0,0 +1,27 @@
name: Build android container
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *"
jobs:
android-release-builder-container-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: git.ohea.xyz
username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Build Android builder image
shell: bash
run: |
docker pull "$(grep -m1 '^FROM' ./docker/android-release-builder.Dockerfile | awk '{print $2}')"
docker build -t git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest -f ./docker/android-release-builder.Dockerfile .
docker push git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
+108
View File
@@ -0,0 +1,108 @@
name: Build Mumble Web 2
on: [push]
jobs:
linux_build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: wasm32-unknown-unknown
- name: Install cargo binstall
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
- name: Install dioxus-cli
run: cargo binstall dioxus-cli --version 0.7.2
#- uses: Swatinem/rust-cache@v2
- name: Build dioxus project
run: dx build --platform web --release -p mumble-web2-gui
- name: Upload mumble-web2-gui Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-gui
path: target/dx/mumble-web2-gui/release/web/public
retention-days: 5
- name: Build proxy
run: cargo build --release -p mumble-web2-proxy
- name: Upload mumble-web2-proxy Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-proxy
path: target/release/mumble-web2-proxy
retention-days: 5
windows_build:
runs-on: windows
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Restore Rust cache
uses: actions/cache/restore@v4
with:
path: |
~/.cargo
./target
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-
- name: Pull builder container
run: docker pull git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest
- name: Bundle dioxus project
run: docker run `
--mount "type=bind,source=${PWD},target=C:\app" `
--workdir "C:\app\gui" `
git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest `
C:\Users\ContainerAdministrator\.cargo\bin\dx.exe bundle --verbose --trace -p mumble-web2-gui --release --windows
- name: Save Rust cache
if: always()
uses: actions/cache/save@v4
with:
path: |
~/.cargo
./target
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- name: Upload mumble-web2-gui Windows Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-gui-windows
path: gui/dist
retention-days: 5
android_build:
runs-on: ubuntu-latest
container:
image: git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: Swatinem/rust-cache@v2
- name: Build dioxus project (x86_64-linux-android)
run: dx build --platform android --target x86_64-linux-android --release -p mumble-web2-gui
- name: Build dioxus project (aarch64-linux-android)
run: dx build --platform android --target aarch64-linux-android --release -p mumble-web2-gui
- name: Upload mumble-web2-gui Android Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-android
path: target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk
retention-days: 5
@@ -0,0 +1,27 @@
name: Build Mumble Web 2 release builder containers
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *"
jobs:
windows-release-builder-container-build:
runs-on: windows
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: git.ohea.xyz
username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Build Windows image
shell: bash
run: |
docker pull "$(grep -m1 '^FROM' ./docker/windows-release-builder.Dockerfile | awk '{print $2}')"
docker build -t git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest -f ./docker/windows-release-builder.Dockerfile .
docker push git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest
+2 -1
View File
@@ -4,5 +4,6 @@ server_hash.txt
.aider* .aider*
**.pem **.pem
proxy/bundle proxy/bundle
config.toml /config.toml
proxy/config.toml proxy/config.toml
gui/assets/*_onnx.tar.gz
+4
View File
@@ -0,0 +1,4 @@
{
"rust-analyzer.cargo.features": ["desktop","web"],
"rust-analyzer.cargo.noDefaultFeatures": false
}
Generated
+3091 -2122
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -2,13 +2,12 @@
## Running Desktop ## Running Desktop
1. `cargo install dioxus-cli --version 0.6.3` 1. `cargo install dioxus-cli --version 0.7.1`
2. `dx run -p mumble-web2-gui --platform desktop --release` 2. `dx run -p mumble-web2-gui --platform desktop --release`
## Running Web (development) ## Running Web (development)
1. `cargo install dioxus-cli --version 0.6.3` 1. `cargo install dioxus-cli --version 0.7.1`
3. `dx serve -p mumble-web2-gui --platform web` 3. `dx serve -p mumble-web2-gui --platform web`
2. `cd docker && docker compose up` 2. `cd docker && docker compose up`
4. connect to `https://localhost:64444` 4. connect to `https://localhost:64444`
@@ -16,7 +15,7 @@
## Running Web (with `proxy` only) ## Running Web (with `proxy` only)
1. `cargo install dioxus-cli --version 0.6.3` 1. `cargo install dioxus-cli --version 0.7.1`
2. `dx build -p mumble-web2-gui --platform web --release` 2. `dx build -p mumble-web2-gui --platform web --release`
3. `cp config.toml.example config.toml` 3. `cp config.toml.example config.toml`
4. `cargo run -p mumble-web2-proxy` in the background 4. `cargo run -p mumble-web2-proxy` in the background
+1 -1
View File
@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ClientConfig { pub struct ClientConfig {
pub proxy_url: Option<String>, pub proxy_url: Option<String>,
pub status_url: Option<String>,
pub cert_hash: Option<Vec<u8>>, pub cert_hash: Option<Vec<u8>>,
pub any_server: bool,
} }
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
+1 -1
View File
@@ -1,4 +1,4 @@
public_url = "https://127.0.0.1:4433" proxy_url = "https://127.0.0.1:4433/proxy"
https_listen_address = "127.0.0.1:4433" https_listen_address = "127.0.0.1:4433"
http_listen_address = "127.0.0.1:8080" http_listen_address = "127.0.0.1:8080"
mumble_server_url = "[SERVER_URL_HERE]" mumble_server_url = "[SERVER_URL_HERE]"
+43
View File
@@ -0,0 +1,43 @@
FROM rust:trixie
ARG ANDROID_CLI_TOOLS_VERSION=13114758
# Install android rust toolchains
RUN rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
# Install debian dependencies
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
nodejs \
ca-certificates \
curl \
unzip \
default-jdk
# Install android commandline tools (required to install the sdk)
RUN cd /tmp && \
curl -o commandlinetools-linux.zip "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CLI_TOOLS_VERSION}_latest.zip" && \
unzip commandlinetools-linux.zip && \
mkdir -p /opt/android-tools/cmdline-tools && \
cp -r cmdline-tools /opt/android-tools/cmdline-tools/latest
# Install required android tools
RUN yes | /opt/android-tools/cmdline-tools/latest/bin/sdkmanager --install "platform-tools" "platforms;android-36.1" "build-tools;36.1.0" "ndk;29.0.14206865" "cmake;3.31.6"
# Install cargo binstall
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
# Install dioxus-cli
RUN cargo binstall dioxus-cli@0.7.2
# Install bindgen-cli
RUN cargo binstall bindgen-cli
# Set required env vars
ENV ANDROID_HOME="/opt/android-tools/"
ENV NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865"
ENV PATH="$PATH:$ANDROID_HOME/platform-tools"
ENV PATH="$PATH:/opt/android-tools/cmake/3.31.6/bin/"
ENV LLVM_CONFIG_PATH="/opt/android-tools/ndk/29.0.14206865/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-config"
-22
View File
@@ -1,22 +0,0 @@
FROM rust:1-bookworm AS base
# Install cargo-binstall for faster CLI installation
#RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
RUN apt-get update && apt-get install -y screen
# Install dioxus-cli version 0.6.3 specifically
RUN cargo install dioxus-cli --version 0.6.3
# Set working directory
WORKDIR /app
# Add wasm32 target for web development
RUN rustup target add wasm32-unknown-unknown
# Set environment variables
ENV PATH="/root/.cargo/bin:$PATH"
# Default command (can be overridden in docker-compose)
CMD ["dx", "--help"]
+4 -6
View File
@@ -5,7 +5,7 @@ services:
- "64444:64444/tcp" - "64444:64444/tcp"
- "64444:64444/udp" - "64444:64444/udp"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile - ./Caddyfile:/etc/caddy/Caddyfile:z
#- caddy_data:/data #- caddy_data:/data
#- caddy_config:/config #- caddy_config:/config
depends_on: depends_on:
@@ -35,12 +35,12 @@ services:
image: rust:latest image: rust:latest
working_dir: /app working_dir: /app
volumes: volumes:
- ..:/app - ..:/app:z
- ./proxy-config.toml:/app/config.toml - ./proxy-config.toml:/app/config.toml:z
ports: ports:
- "4433:4433/tcp" - "4433:4433/tcp"
- "4433:4433/udp" - "4433:4433/udp"
command: ["cargo", "run", "-p", "mumble-web2-proxy"] command: ["cargo", "run", "-p", "mumble-web2-proxy", "--locked"]
network_mode: host network_mode: host
mumble-server: mumble-server:
@@ -48,8 +48,6 @@ services:
ports: ports:
- "64738:64738/tcp" - "64738:64738/tcp"
- "64738:64738/udp" - "64738:64738/udp"
environment:
- MUMBLE_CONFIG_WELCOMETEXT=Welcome to the Mumble server
network_mode: host network_mode: host
#volumes: #volumes:
# caddy_data: # caddy_data:
+1 -1
View File
@@ -1,4 +1,4 @@
public_url = "https://127.0.0.1:4433" proxy_url = "https://127.0.0.1:4433/proxy"
https_listen_address = "127.0.0.1:4433" https_listen_address = "127.0.0.1:4433"
http_listen_address = "127.0.0.1:4400" http_listen_address = "127.0.0.1:4400"
mumble_server_url = "127.0.0.1:64738" mumble_server_url = "127.0.0.1:64738"
+57
View File
@@ -0,0 +1,57 @@
# escape=`
# Use a Windows Server Core 2025 image that matches our build host.
# If the version doesn't match the build host we cannot run
# this container. I'm not sure with what specificity it has to
# match, so let's pin this and then upgrade it as we upgrade
# the host.
FROM mcr.microsoft.com/windows/servercore:10.0.26100.7171
ENV CMAKE_VERSION=3.31.10
ENV CMAKE_ARCH=windows-x86_64
# Restore the default Windows shell for correct batch processing.
SHELL ["cmd", "/S", "/C"]
RUN curl -SL --output vs_buildtools.exe https://aka.ms/vs/17/release/vs_buildtools.exe `
&& ( start /w vs_buildtools.exe --quiet --wait --norestart --nocache `
--installPath "%ProgramFiles(x86)%\Microsoft Visual Studio\2022\BuildTools" `
--add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
--add Microsoft.VisualStudio.Component.Windows10SDK.19041 `
--add Microsoft.VisualStudio.Workload.NativeDesktop `
|| IF "%ERRORLEVEL%"=="3010" EXIT 0 )
SHELL ["powershell", "-NoLogo", "-ExecutionPolicy", "Bypass", "-Command"]
RUN $ErrorActionPreference = 'Stop'; `
$url = \"https://github.com/Kitware/CMake/releases/download/v$env:CMAKE_VERSION/cmake-$env:CMAKE_VERSION-$env:CMAKE_ARCH.zip\"; `
$out = 'C:\\cmake.zip'; `
(New-Object System.Net.WebClient).DownloadFile($url, $out); `
Expand-Archive -Path $out -DestinationPath 'C:\\'; `
Remove-Item $out; `
$cmakeDir = \"C:\\cmake-$env:CMAKE_VERSION-$env:CMAKE_ARCH\\bin\"; `
[Environment]::SetEnvironmentVariable('PATH', $cmakeDir + ';' + $env:PATH, 'Machine')
# Install Chocolatey
RUN Set-ExecutionPolicy Bypass -Scope Process; `
[System.Net.ServicePointManager]::SecurityProtocol = `
[System.Net.SecurityProtocolType]::Tls12; `
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
RUN choco install git -y --no-progress
RUN choco install rustup.install -y --no-progress
RUN rustup toolchain install stable-x86_64-pc-windows-msvc
RUN rustup default stable-x86_64-pc-windows-msvc
# Install carog binstall
RUN Set-ExecutionPolicy Unrestricted -Scope Process; `
iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
# Install dioxus-cli from git HEAD with cargo
# This is to work around a bug in the windows builder upstream.
# Dioxus has released 0.7.2, but it seems to be broken for now.
RUN cargo binstall dioxus-cli
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
+70 -39
View File
@@ -6,13 +6,13 @@ edition = "2021"
[dependencies] [dependencies]
# Web Dependencies # Web Dependencies
# ================ # ================
dioxus-web = { version = "0.6.3", optional = true } dioxus-web = { version = "0.7.1", optional = true }
wasm-bindgen = { version = "0.2.92", optional = true } wasm-bindgen = { version = "^0.2.92", optional = true }
wasm-bindgen-futures = { version = "0.4.42", optional = true } wasm-bindgen-futures = { version = "^0.4.42", optional = true }
wasm-streams = { version = "0.4.0", optional = true } wasm-streams = { version = "^0.4.0", optional = true }
serde-wasm-bindgen = { version = "0.6.5", optional = true } serde-wasm-bindgen = { version = "^0.6.5", optional = true }
js-sys = { version = "0.3.70", optional = true } js-sys = { version = "^0.3.70", optional = true }
web-sys = { version = "0.3.72", features = [ web-sys = { version = "^0.3.72", features = [
"WebTransport", "WebTransport",
"console", "console",
"WebTransportOptions", "WebTransportOptions",
@@ -30,8 +30,6 @@ web-sys = { version = "0.3.72", features = [
"EncodedAudioChunkInit", "EncodedAudioChunkInit",
"EncodedAudioChunkType", "EncodedAudioChunkType",
"CodecState", "CodecState",
"MediaStreamTrackGenerator",
"MediaStreamTrackGeneratorInit",
"AudioContext", "AudioContext",
"AudioContextOptions", "AudioContextOptions",
"MediaStream", "MediaStream",
@@ -42,6 +40,7 @@ web-sys = { version = "0.3.72", features = [
"AudioWorkletNode", "AudioWorkletNode",
"AudioWorklet", "AudioWorklet",
"AudioWorkletProcessor", "AudioWorkletProcessor",
"MessagePort",
"MediaStreamConstraints", "MediaStreamConstraints",
"WorkletOptions", "WorkletOptions",
"AudioEncoder", "AudioEncoder",
@@ -54,55 +53,73 @@ web-sys = { version = "0.3.72", features = [
"AudioSampleFormat", "AudioSampleFormat",
"Storage", "Storage",
], optional = true } ], optional = true }
gloo-timers = { version = "0.3.0", features = ["futures"], optional = true } gloo-timers = { version = "^0.3.0", features = ["futures"], optional = true }
tracing-web = { version = "0.1.3", optional = true } tracing-web = { version = "^0.1.3", optional = true }
# Desktop Dependecies # Desktop Dependecies
# =================== # ===================
dioxus-desktop = { version = "0.6.3", optional = true } tokio = { version = "^1.41.1", features = ["net", "rt"], optional = true }
tokio = { version = "1.41.1", features = ["net", "rt"], optional = true } tokio-rustls = { version = "^0.26.0", optional = true }
tokio-rustls = { version = "0.26.0", optional = true }
opus = { version = "0.3.0", optional = true } opus = { version = "0.3.0", optional = true }
cpal = { version = "0.15.3", optional = true } cpal = { version = "0.15.3", optional = true }
dasp_ring_buffer = { version = "0.11.0", optional = true } dasp_ring_buffer = { version = "0.11.0", optional = true }
etcetera = { version = "0.10.0", optional = true }
# Base Dependencies # Base Dependencies
# ================ # ================
dioxus = { version = "0.6.3" } dioxus = { version = "0.7.2" }
once_cell = "1.19.0" once_cell = "1.19.0"
asynchronous-codec = { workspace = true } asynchronous-codec = { workspace = true }
futures = "0.3.30" futures = "^0.3.30"
merge-io = "0.3.0" merge-io = "^0.3.0"
mumble-protocol = { workspace = true } mumble-protocol = { workspace = true }
serde_json = "1.0.117" serde_json = "1"
tokio-util = { version = "0.7.11", features = ["codec", "compat"] } tokio-util = { version = "^0.7.11", features = ["codec", "compat"] }
byteorder = "1.5.0" byteorder = "1.5"
ogg = "0.9.1" ogg = "^0.9.1"
ordermap = "0.5.3" ordermap = "^0.5.3"
html-purifier = "0.3.0" html-purifier = "^0.3.0"
markdown = "0.3.0" markdown = "^0.3.0"
futures-channel = "0.3.30" futures-channel = "^0.3.30"
sir = { git = "https://gitlab.com/samsartor/sir", features = [
"dioxus",
] } # dioxus 0.6
mumble-web2-common = { workspace = true } mumble-web2-common = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
tracing-subscriber = { version = "0.3.18", features = ["ansi"] } tracing-subscriber = { version = "^0.3.18", features = ["ansi"] }
tracing = "0.1.40" tracing = "^0.1.40"
color-eyre = "0.6.3" color-eyre = "^0.6.3"
crossbeam-queue = "0.3.11" crossbeam-queue = "^0.3.11"
lol_html = "2.2.0" lol_html = "^2.2.0"
rfd = "0.15.2" base64 = "^0.22"
base64 = "0.22" mime_guess = "^2.0.5"
mime_guess = "2.0.5" async_cell = "^0.2.3"
async_cell = "0.2.3" reqwest = { version = "^0.12.22", features = ["json"] }
reqwest = { version = "0.12.22", features = ["json"] } dioxus-asset-resolver = "0.7.2"
# Denoising # Denoising
# ========= # =========
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31" } deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
"tract",
] }
crossbeam = "0.8.4" crossbeam = "0.8.4"
# Platform Integration
# ====================
# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true }
# Android dependencies for requesting permissions
[target.'cfg(target_os = "android")'.dependencies]
android-permissions = "0.1.2"
jni = "0.21.1"
ndk-context = "0.1.1"
[patch.crates-io]
tract-hir = "=0.12.4"
tract-core = "=0.12.4"
tract-onnx = "=0.12.4"
tract-pulse = "=0.12.4"
[features] [features]
web = [ web = [
"dioxus/web", "dioxus/web",
@@ -115,6 +132,8 @@ web = [
"web-sys", "web-sys",
"gloo-timers", "gloo-timers",
"tracing-web", "tracing-web",
"deep_filter/wasm",
"rfd",
] ]
desktop = [ desktop = [
"dioxus/desktop", "dioxus/desktop",
@@ -124,4 +143,16 @@ desktop = [
"opus", "opus",
"cpal", "cpal",
"dasp_ring_buffer", "dasp_ring_buffer",
"rfd/xdg-portal",
"etcetera",
]
mobile = [
"dioxus/mobile",
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
] ]
+14 -1
View File
@@ -8,6 +8,8 @@ out_dir = "dist"
# resource (public) file folder # resource (public) file folder
asset_dir = "public" asset_dir = "public"
android_manifest = "build/AndroidManifest.xml"
[web.app] [web.app]
# HTML title tag content # HTML title tag content
title = "Mumble Web 2" title = "Mumble Web 2"
@@ -23,7 +25,7 @@ watch_path = ["src", "assets"]
# CSS style file # CSS style file
style = [] style = []
# Javascript code file # Javascript code file
script = [] script = ["loader.js"]
[web.resource.dev] [web.resource.dev]
# serve: [dev-server] only # serve: [dev-server] only
@@ -31,3 +33,14 @@ script = []
style = [] style = []
# Javascript code file # Javascript code file
script = [] script = []
[bundle]
identifier = "xyz.ohea.mumble_web_2"
publisher = "OheaCorp"
icon = [
"icons/32x32.png",
"icons/256x256.png",
"assets/favicon.ico",
"icons/icon.icns",
"icons/icon.ico",
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

+395
View File
@@ -0,0 +1,395 @@
:root {
--txt-color: oklch(0.9 0 99);
--bg-color: oklch(0.15 0.01 338.64);
--light-bg-color: oklch(0.25 0.01 338.64);
--login-bg-color: #5d7680;
--primary-btn-color: #7bad9f;
--accent-normal: #7bad9f;
--accent-muted: #ff746c;
--accent-deafened: #464459;
--line-width: 2px;
--line-color: oklch(0.7 0 0.99);
}
body {
margin: 0;
}
#main {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-around;
background-color: var(--bg-color);
overflow: auto;
color: var(--txt-color);
font-family: Nunito;
font-size: 15pt;
font-weight: 600;
}
hr {
color: var(--line-color);
background-color: var(--line-color);
height: var(--line-width);
border: none;
}
button {
font-weight: bold;
font-size: medium;
border: none;
border-radius: 4px;
color: var(--txt-color);
background-color: var(--primary-btn-color);
cursor: pointer;
}
input {
border: none;
border-radius: 4px;
background-color: white;
color: black;
}
input:focus,
input:focus-visible {
border: none;
outline: solid var(--line-width) var(--accent-normal);
outline-offset: -3px;
}
a:link {
color: var(--accent-normal);
}
a:visited {
color: var(--accent-muted);
}
.userpil {
border-radius: 100px;
padding: 4px 8px;
width: fit-content;
img {
height: 1em;
vertical-align: text-bottom;
}
&.is_self {
font-weight: bolder;
}
}
.channel {
&_details {
flex: 0 0 100%;
summary {
cursor: pointer;
}
summary:focus-visible {
outline: none;
}
}
&_children {
border-left: solid var(--line-color) var(--line-width);
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-left: 5px;
padding-left: 11px;
padding-top: 4px;
}
}
.chat {
&_panel {
display: flex;
flex-direction: column;
}
&_history {
overflow-y: auto;
flex: 1 0 0;
}
&_message {
display: flex;
flex-direction: row;
margin: 16px;
gap: 8px;
align-items: center;
}
&_box_wrapper {
padding: 16px;
border-top: solid var(--line-color) var(--line-width);
}
&_box {
display: flex;
flex-direction: row;
gap: 16px;
background-color: var(--light-bg-color);
padding-top: 16px;
padding-bottom: 16px;
padding-left: 8px;
padding-right: 16px;
border-radius: 8px;
input {
color: white;
background-color: var(--light-bg-color);
font-size: larger;
flex-grow: 1;
border: none;
}
input:focus {
outline: none;
}
}
}
.user_edit_button {
background-color: oklch(0.53 0.1431 264.18);
border-radius: 50%;
aspect-ratio: 1 / 1;
flex-shrink: 0;
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
}
}
.button_row {
display: flex;
gap: clamp(4px, 1vw, 10px);
align-items: center;
flex-wrap: nowrap;
min-height: 0;
.spacer {
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
}
.connection_status {
display: flex;
flex-direction: column;
min-width: 0;
flex-shrink: 1;
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle;
}
}
.user_info {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
flex-shrink: 1;
.user_name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user_data {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.toggle_button {
padding: clamp(4px, 0.5vw, 8px);
aspect-ratio: 1 / 1;
flex-shrink: 0;
background-color: unset;
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
border-radius: clamp(4px, 0.8vw, 10px);
color: rgb(255 255 255 / 50%);
transition: all 0.5s ease-in-out;
&.is_on {
background-color: oklch(0.5 0.1381 21.71 / 20.12%);
color: oklch(0.53 0.1505 21.71 / 89.38%);
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle;
}
}
.server {
&_grid {
display: grid;
height: 100%;
background-color: var(--bg-color);
grid-template-rows: 1fr auto;
grid-template-columns: 1fr 2fr;
grid-template-areas:
"tree chat"
"control chat";
@media screen and (max-width: 720px) {
grid-template-rows: auto 1fr 1fr;
grid-template-columns: 1fr;
grid-template-areas:
"tree"
"control"
"chat";
}
}
&_channel_box {
padding: 16px;
overflow: auto;
grid-area: tree;
}
&_chat_box {
display: flex;
flex-direction: row;
grid-area: chat;
border-left: solid var(--line-color) var(--line-width);
@media screen and (max-width: 720px) {
border-left: unset;
border-top: solid var(--line-color) var(--line-width);
}
}
&_control_box {
padding: clamp(6px, 0.8vw, 12px);
margin: clamp(6px, 0.8vw, 12px);
background-color: var(--light-bg-color);
border-radius: clamp(6px, 1vw, 10px);
overflow: hidden;
grid-area: control;
display: flex;
gap: clamp(4px, 0.8vw, 8px);
flex-direction: column;
// Dynamic font sizing for control elements
--control-icon-size: clamp(16px, 2.5vw, 30px);
--control-text-size: clamp(12px, 2vw, 25px);
--control-small-text-size: clamp(10px, 1.5vw, 20px);
--user-icon-size: clamp(24px, 4vw, 45px);
--toggle-icon-size: clamp(18px, 3vw, 35px);
.connection_status {
.material-symbols-outlined {
font-size: var(--control-icon-size);
}
.status_text {
font-size: var(--control-text-size);
}
.channel_text {
font-size: var(--control-small-text-size);
}
}
.user_edit_button {
.material-symbols-outlined {
font-size: var(--user-icon-size);
}
}
.user_info {
.user_name {
font-size: var(--control-text-size);
}
.user_data {
font-size: var(--control-small-text-size);
}
}
.toggle_button {
.material-symbols-outlined {
font-size: var(--toggle-icon-size);
}
}
hr {
margin: 0;
}
}
}
.login {
max-width: 50vw;
align-self: center;
padding: 32px;
border-radius: 16px;
background-color: var(--login-bg-color);
display: flex;
flex-direction: column;
gap: 16px;
input,
button {
padding: 8px;
}
h1 {
margin: 0;
color: #b3c6b4;
}
&_version {
color: var(--txt-color);
font-weight: normal;
}
&_bttn {
font-weight: bold;
font-size: large;
}
&_error {
background-color: white;
border-radius: 4px;
overflow: auto;
padding: 4px;
color: red;
pre {
color: black;
}
}
&_status {
&.is_error {
color: red;
}
}
}
@@ -1,7 +1,7 @@
const SAMPLE_RATE = 48000; const SAMPLE_RATE = 48000;
const PACKET_SAMPLES = 960; const PACKET_SAMPLES = 960;
class RustWorklet extends AudioWorkletProcessor { class RustMicWorklet extends AudioWorkletProcessor {
constructor(options) { constructor(options) {
super(); super();
this.module = options.processorOptions; this.module = options.processorOptions;
@@ -31,7 +31,7 @@ class RustWorklet extends AudioWorkletProcessor {
} }
this.buffer_offset -= PACKET_SAMPLES; this.buffer_offset -= PACKET_SAMPLES;
this.timestamp = null; this.timestamp = null;
} }
process(inputs) { process(inputs) {
//console.log(inputs); //console.log(inputs);
@@ -60,4 +60,44 @@ class RustWorklet extends AudioWorkletProcessor {
} }
}; };
registerProcessor("rust_mic_worklet", RustWorklet);
class RustSpeakerWorklet extends AudioWorkletProcessor {
constructor() {
super();
this.queue = [];
this.readIndex = 0;
this.port.onmessage = (event) => {
this.queue.push(event.data)
};
}
process(inputs, outputs) {
if (this.queue.length) {
console.log(this.queue[0].samples.length, outputs[0][0].length);
}
const output = outputs[0];
for (let i = 0; i < output[0].length; i++) {
if (!this.queue.length) {
return true;
}
const current = this.queue[0];
for (let ch = 0; ch < output.length; ch++) {
output[ch][i] = current.samples[this.readIndex];
}
this.readIndex++;
if (this.readIndex >= current.samples.length) {
this.queue.shift();
this.readIndex = 0;
}
}
return true;
}
};
registerProcessor("rust_mic_worklet", RustMicWorklet);
registerProcessor("rust_speaker_worklet", RustSpeakerWorklet);
+86
View File
@@ -0,0 +1,86 @@
use std::env;
use std::path::Path;
use std::process::Command;
fn version_env() -> Option<()> {
if env::var("MUMBLE_WEB2_VERSION").is_ok() {
return Some(());
}
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
let git_hash = String::from_utf8(output.stdout).ok()?;
let git_hash = git_hash.trim(); // drop trailing newline
let status = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()?;
let dirty = match status.stdout.is_empty() {
true => "",
false => "-dirty",
};
// Expose it as a compile-time env var
println!("cargo::rustc-env=MUMBLE_WEB2_VERSION=git-{git_hash}{dirty}");
// Optional: rebuild when HEAD changes
println!("cargo::rerun-if-changed=.git/HEAD");
Some(())
}
fn download_deepfilternet() {
// Define the target directory and file
let assets_dir = "assets";
let target_file = format!("{}/DeepFilterNet3_ll_onnx.tar.gz", assets_dir);
let target_path = Path::new(&target_file);
// Check if the file already exists
if target_path.exists() {
println!(
"cargo::warning=DeepFilterNet model already exists at {}",
target_file
);
return;
}
println!(
"cargo::warning=Downloading DeepFilterNet model to {}...",
target_file
);
// Download the file using curl
let url = "https://github.com/Rikorose/DeepFilterNet/raw/refs/heads/main/models/DeepFilterNet3_ll_onnx.tar.gz";
let status = Command::new("curl")
.args([
"-L", // Follow redirects
"-o",
&target_file, // Output file
url,
])
.status()
.expect("Failed to execute curl command. Make sure curl is installed.");
if !status.success() {
println!("cargo::error=Failed to download DeepFilterNet model from {url}");
return;
}
println!(
"cargo::warning=Successfully downloaded DeepFilterNet model to {}",
target_file
);
// Rerun this build script if the target file is deleted
println!("cargo::rerun-if-changed={}", target_file);
}
fn main() {
version_env();
download_deepfilternet();
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Borrowed from https://github.com/irh/audio-app/blob/main/apps/dioxus/AndroidManifest.xml
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
<uses-feature android:name="android.hardware.audio.output" android:required="false" />
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application android:hasCode="true" android:supportsRtl="true" android:icon="@mipmap/ic_launcher"
android:extractNativeLibs="true"
android:allowNativeHeapPointerTagging="false"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config">
<activity android:configChanges="orientation|screenLayout|screenSize|keyboardHidden" android:exported="true"
android:label="@string/app_name" android:name="dev.dioxus.main.MainActivity">
<meta-data android:name="android.app.lib_name" android:value="dioxusmain" />
<meta-data android:name="android.app.func_name" android:value="ANativeActivity_onCreate" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+68
View File
@@ -0,0 +1,68 @@
// Loading screen that displays while WASM loads
(function() {
// Create and inject loader styles immediately (head exists)
var style = document.createElement('style');
style.textContent =
'.wasm-loader {' +
'position: fixed;' +
'top: 0;' +
'left: 0;' +
'width: 100%;' +
'height: 100%;' +
'background-color: oklch(0.15 0.01 338.64);' +
'display: flex;' +
'align-items: center;' +
'justify-content: center;' +
'z-index: 9999;' +
'transition: opacity 0.3s ease-out;' +
'}' +
'.wasm-loader.hidden {' +
'opacity: 0;' +
'pointer-events: none;' +
'}' +
'.wasm-spinner {' +
'width: 48px;' +
'height: 48px;' +
'border: 4px solid rgba(123, 173, 159, 0.2);' +
'border-top-color: #7bad9f;' +
'border-radius: 50%;' +
'animation: wasm-spin 1s linear infinite;' +
'}' +
'@keyframes wasm-spin {' +
'to { transform: rotate(360deg); }' +
'}' +
'#main {' +
'background-color: oklch(0.15 0.01 338.64);' +
'}';
document.head.appendChild(style);
function init() {
// Create loader element
var loader = document.createElement('div');
loader.className = 'wasm-loader';
loader.innerHTML = '<div class="wasm-spinner"></div>';
document.body.appendChild(loader);
// Watch for Dioxus to mount content in #main
var observer = new MutationObserver(function(mutations, obs) {
var main = document.getElementById('main');
if (main && main.children.length > 0) {
loader.classList.add('hidden');
setTimeout(function() { loader.remove(); }, 300);
obs.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Wait for body to exist
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
})();
+164 -520
View File
@@ -1,13 +1,10 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
use base64::{display::Base64Display, prelude::BASE64_URL_SAFE};
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, ServerStatus};
use ordermap::OrderSet; use ordermap::OrderSet;
use sir::{css, global_css};
use std::collections::HashMap; use std::collections::HashMap;
use tracing::error;
use crate::imp; use crate::imp;
@@ -71,16 +68,21 @@ pub struct UserState {
pub channel: ChannelId, pub channel: ChannelId,
pub deaf: bool, pub deaf: bool,
pub mute: bool, pub mute: bool,
pub suppress: bool,
pub self_deaf: bool, pub self_deaf: bool,
pub self_mute: bool, pub self_mute: bool,
} }
impl UserState { impl UserState {
pub fn icon(&self) -> UserIcon { pub fn icon(&self) -> UserIcon {
match (self.mute || self.self_mute, self.deaf || self.self_deaf) { if self.deaf || self.self_deaf {
(false, false) => UserIcon::Normal, UserIcon::Deafened
(true, false) => UserIcon::Muted, } else if self.mute || self.self_mute {
(_, true) => UserIcon::Deafened, UserIcon::Muted
} else if self.suppress {
UserIcon::Suppressed
} else {
UserIcon::Normal
} }
} }
} }
@@ -120,6 +122,7 @@ pub enum UserIcon {
Normal, Normal,
Muted, Muted,
Deafened, Deafened,
Suppressed,
None, None,
} }
@@ -131,7 +134,7 @@ impl UserIcon {
use UserIcon::*; use UserIcon::*;
Some(match self { Some(match self {
Normal => asset!("assets/mic-svgrepo-com.svg"), Normal => asset!("assets/mic-svgrepo-com.svg"),
Muted => asset!("assets/mic-off-svgrepo-com.svg"), Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"),
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"), Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
None => return Option::None, None => return Option::None,
}) })
@@ -140,35 +143,16 @@ impl UserIcon {
#[component] #[component]
pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element { pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
let pill = css!(
"
border-radius: 100px;
padding: 4px 8px;
width: fit-content;
img {
height: 1em;
vertical-align: text-bottom;
}
"
);
let pill_self = css!(
"
font-weight: bolder;
"
);
let color = match icon { let color = match icon {
UserIcon::Normal => "var(--accent-normal)", UserIcon::Normal => "var(--accent-normal)",
UserIcon::Muted => "var(--accent-muted)", UserIcon::Muted => "var(--accent-muted)",
UserIcon::Deafened => "var(--accent-deafened)", UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)",
UserIcon::None => "var(--accent-normal)", UserIcon::None => "var(--accent-normal)",
}; };
rsx!( rsx!(
div { div {
class: match isself { true => format!("{pill} {pill_self}"), false => format!("{pill}") }, class: match isself { true => "userpil is_self", false => "userpil" },
style: "background-color: {color}", style: "background-color: {color}",
{ icon.url().map(|url| rsx!(img { src: url })) } { icon.url().map(|url| rsx!(img { src: url })) }
"\u{00A0}{name}\u{00A0}" "\u{00A0}{name}\u{00A0}"
@@ -202,34 +186,9 @@ pub fn Channel(id: ChannelId) -> Element {
return rsx!("missing channel {id}"); return rsx!("missing channel {id}");
}; };
let channel_details = css!(
"
flex: 0 0 100%;
summary {
cursor: pointer;
}
summary:focus-visible {
outline: none;
}
"
);
let channel_children = css!(
"
border-left: solid var(--line-color) var(--line-width);
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-left: 5px;
padding-left: 11px;
padding-top: 4px; "
);
rsx!( rsx!(
details { details {
class: "{channel_details}", class: "channel_details",
open: true, open: true,
summary { summary {
span { span {
@@ -244,7 +203,7 @@ pub fn Channel(id: ChannelId) -> Element {
} }
if state.users.len() + state.children.len() > 0 { if state.users.len() + state.children.len() > 0 {
div { div {
class: "{channel_children}", class: "channel_children",
for id in state.users.iter() { for id in state.users.iter() {
User { id: *id } User { id: *id }
} }
@@ -257,6 +216,7 @@ pub fn Channel(id: ChannelId) -> Element {
) )
} }
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn pick_and_send_file(net: &Coroutine<Command>) { pub fn pick_and_send_file(net: &Coroutine<Command>) {
let channels = if let Some(user) = STATE.server.read().this_user() { let channels = if let Some(user) = STATE.server.read().this_user() {
vec![user.channel] vec![user.channel]
@@ -278,6 +238,8 @@ pub fn pick_and_send_file(net: &Coroutine<Command>) {
}); });
}); });
} }
#[cfg(not(any(feature = "desktop", feature = "web")))]
pub fn pick_and_send_file(net: &Coroutine<Command>) {}
#[component] #[component]
pub fn ChatView() -> Element { pub fn ChatView() -> Element {
@@ -285,71 +247,6 @@ pub fn ChatView() -> Element {
let server = STATE.server.read(); let server = STATE.server.read();
let mut draft = use_signal(|| "".to_string()); let mut draft = use_signal(|| "".to_string());
let chat_panel = css!(
"
display: flex;
flex-direction: column;
"
);
let chat_history = css!(
"
overflow-y: auto;
flex: 1 0 0;
"
);
let chat_message = css!(
"
display: flex;
flex-direction: row;
margin: 16px;
gap: 8px;
align-items: center;
"
);
let chat_box_wrapper = css!(
"
padding: 16px;
border-top: solid var(--line-color) var(--line-width);
"
);
let chat_box = css!(
"
display: flex;
flex-direction: row;
gap: 16px;
background-color: var(--light-bg-color);
padding-top: 16px;
padding-bottom: 16px;
padding-left: 8px;
padding-right: 16px;
border-radius: 8px;
input {
color: white;
background-color: var(--light-bg-color);
font-size: larger;
flex-grow: 1;
border: none;
}
input:focus {
outline: none;
}
"
);
let mut do_send = move || { let mut do_send = move || {
if let Some(user) = STATE.server.read().this_user() { if let Some(user) = STATE.server.read().this_user() {
net.send(SendChat { net.send(SendChat {
@@ -361,12 +258,12 @@ pub fn ChatView() -> Element {
rsx!( rsx!(
div { div {
class: "{chat_panel}", class: "chat_panel",
div { div {
class: "{chat_history}", class: "chat_history",
for chat in server.chat.iter() { for chat in server.chat.iter() {
div { div {
class: "{chat_message}", class: "chat_message",
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) { if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
UserPill { UserPill {
name: sender.name.clone(), name: sender.name.clone(),
@@ -381,9 +278,9 @@ pub fn ChatView() -> Element {
} }
} }
div { div {
class: "{chat_box_wrapper}", class: "chat_box_wrapper",
div { div {
class: "{chat_box}", class: "chat_box",
input { input {
placeholder: "say something", placeholder: "say something",
value: "{draft.read()}", value: "{draft.read()}",
@@ -420,25 +317,6 @@ pub fn ChatView() -> Element {
) )
} }
// true => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "mic_off"}),
//Connecting => rsx! {
// div {
// class: "{connecting_status}",
// span {
// class: "material-symbols-outlined",
// style: "vertical-align: middle; font-size: 30px;",
// "signal_cellular_alt_2_bar"
// }
// span {
// style: "width: 5px; display: inline-block;"
// }
// span {
// style: "vertical-align: middle; font-size: 30px;",
// "Connecting"
// }
// }
//},
#[component] #[component]
pub fn ControlView(config: Resource<ClientConfig>) -> Element { pub fn ControlView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
@@ -448,6 +326,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
deaf, deaf,
self_deaf, self_deaf,
mute, mute,
suppress,
self_mute, self_mute,
ref name, ref name,
channel, channel,
@@ -459,178 +338,82 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
let current_channel_name = server.channels[&channel].name.clone(); let current_channel_name = server.channels[&channel].name.clone();
let Some(proxy_url) = config let proxy_url = config
.read_unchecked() .read_unchecked()
.as_ref() .as_ref()
.and_then(|gui_config| gui_config.proxy_url.clone()) .and_then(|gui_config| gui_config.proxy_url.clone());
else {
return rsx!();
};
let button_row = css!( let connecting_color = "yellow";
r#" let connected_color = "oklch(0.55 0.1184 141.35)";
display: flex; let disconnected_color = "gray";
gap: 10px; let failed_color = "red";
"#
);
let spacer = css!(
r#"
flex-grow: 1;
"#
);
let toggle_button = css!(
r#"
padding: 8px;
height: 100%;
aspect-ratio: 1 / 1;
background-color: unset;
border: solid rgb(255 255 255 / 0.1) 3px;
border-radius: 10px;
color: rgb(255 255 255 / 50%);
transition: all 0.5s ease-in-out;
"#
);
let toggle_button_on = css!(
r#"
padding: 8px;
height: 100%;
aspect-ratio: 1 / 1;
background-color: oklch(0.5 0.1381 21.71 / 20.12%);
border: solid rgb(255 255 255 / 0) 3px;
border-radius: 10px;
color: oklch(0.53 0.1505 21.71 / 89.38%);
transition: all 0.25s ease-in-out;
"#
);
let button_style = r#"
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle;
font-size: 35px;
"#;
let connecting_status = css!(
r#"
color: yellow;
"#
);
let connected_status = css!(
r#"
color: oklch(0.55 0.1184 141.35);
"#
);
let disconnected_status = css!(
r#"
color: gray;
"#
);
let failed_status = css!(
r#"
color: red;
"#
);
let connection_info = css!(
r#"
color: gray;
"#
);
let user_edit_button = css!(
r#"
background-color: oklch(0.53 0.1431 264.18);
border-radius: 50%;
aspect-ratio: 1 / 1;
"#
);
let connection_status = match &*status.read() { let connection_status = match &*status.read() {
Connecting => rsx! { Connecting => rsx! {
div { div {
class: "{connecting_status}", class: "connection_status",
span { style: "color: {connecting_color};",
class: "material-symbols-outlined", div {
style: "vertical-align: middle; font-size: 30px;", span {
"signal_cellular_alt_2_bar" class: "material-symbols-outlined",
} "signal_cellular_alt_2_bar"
span { }
style: "width: 5px; display: inline-block;" span {
} class: "status_text",
span { " Connecting"
style: "vertical-align: middle; font-size: 30px;", }
"Connecting"
} }
} }
}, },
Connected => rsx! { Connected => rsx! {
div { div {
class: "connection_status",
div { div {
class: "{connected_status}", style: "color: {connected_color};",
span { span {
class: "material-symbols-outlined", class: "material-symbols-outlined",
style: "vertical-align: middle; font-size: 30px;",
"signal_cellular_alt" "signal_cellular_alt"
} }
span { span {
style: "width: 5px; display: inline-block;" class: "status_text",
} " Connected"
span {
style: "vertical-align: middle; font-size: 25px;",
"Connected"
} }
} }
div { div {
class: "{connection_info}", class: "channel_text",
span { style: "width: 3px; display: inline-block;"}
span { "{current_channel_name}" } span { "{current_channel_name}" }
span { "" }
span { "{proxy_url}" }
} }
} }
}, },
Disconnected => rsx! { Disconnected => rsx! {
div { div {
class: "{disconnected_status}", class: "connection_status",
span { style: "color: {disconnected_color};",
class: "material-symbols-outlined", div {
style: "vertical-align: middle;", span {
"signal_disconnected" class: "material-symbols-outlined",
} "signal_disconnected"
span { }
style: "width: 5px; display: inline-block;" span {
} class: "status_text",
span { " Disconnected"
style: "vertical-align: middle;", }
"Disconnected"
} }
} }
}, },
Failed(_) => rsx! { Failed(_) => rsx! {
div { div {
class: "{failed_status}", class: "connection_status",
span { style: "color: {failed_color};",
class: "material-symbols-outlined", div {
style: "vertical-align: middle;", span {
"error" class: "material-symbols-outlined",
} "error"
span { }
style: "width: 5px; display: inline-block;" span {
} class: "status_text",
span { " Failed"
style: "vertical-align: middle;", }
"Failed"
} }
} }
}, },
@@ -640,17 +423,16 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
rsx!( rsx!(
// Server control // Server control
div { div {
class: "{button_row}", class: "button_row",
div { div {
{connection_status} {connection_status}
} }
span { class: "{spacer}" } span { class: "spacer" }
button { button {
class: "{toggle_button}", class: "toggle_button",
onclick: move |_| net.send(Disconnect), onclick: move |_| net.send(Disconnect),
span { span {
class: "material-symbols-outlined", class: "material-symbols-outlined",
style: "{button_style}",
"signal_disconnected" "signal_disconnected"
} }
} }
@@ -658,28 +440,29 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
hr { style: "width: 100%;" } hr { style: "width: 100%;" }
// User control // User control
div { div {
class: "{button_row}", class: "button_row",
button { button {
class: "{user_edit_button}", class: "user_edit_button",
span { span {
class: "material-symbols-outlined", class: "material-symbols-outlined",
style: "color: oklch(0.65 0.2245 28.06); font-size: 45px; font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;", style: "color: oklch(0.65 0.2245 28.06);",
"person_edit" "person_edit"
} }
} }
div { div {
class: "user_info",
div { div {
span { style: "font-size: 25px;", "{name}" } span { class: "user_name", "{name}" }
} }
div { div {
span { style: "font-size: 20px; color: gray;", "some data" } span { class: "user_data", "some data" }
} }
} }
span { class: "{spacer}" } span { class: "spacer" }
button { button {
class: match denoise() { class: match denoise() {
true => toggle_button_on, true => "toggle_button is_on",
false => toggle_button, false => "toggle_button",
}, },
role: "switch", role: "switch",
aria_checked: denoise(), aria_checked: denoise(),
@@ -689,36 +472,36 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
net.send(UpdateMicEffects { denoise: new_denoise }) net.send(UpdateMicEffects { denoise: new_denoise })
}, },
match denoise() { match denoise() {
true => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "cadence"}), true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
false => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "graphic_eq"}), false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
} }
} }
button { button {
class: match mute || self_mute { class: match mute || suppress || self_mute {
true => toggle_button_on, true => "toggle_button is_on",
false => toggle_button, false => "toggle_button",
}, },
role: "switch", role: "switch",
aria_checked: mute || self_mute, aria_checked: mute || suppress || self_mute,
disabled: mute, disabled: mute || suppress,
onclick: move |_| net.send(SetMute { mute: !self_mute }), onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || self_mute { match mute || suppress || self_mute {
true => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "mic_off"}), true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
false => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "mic"}), false => rsx!(span { class: "material-symbols-outlined", "mic"}),
} }
} }
button { button {
class: match deaf || self_deaf { class: match deaf || self_deaf {
true => toggle_button_on, true => "toggle_button in_on",
false => toggle_button, false => "toggle_button",
}, },
role: "switch", role: "switch",
aria_checked: deaf || self_deaf, aria_checked: deaf || self_deaf,
disabled: deaf, disabled: deaf,
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }), onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
match deaf || self_deaf { match deaf || self_deaf {
true => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "volume_off"}), true => rsx!(span { class: "material-symbols-outlined", "volume_off"}),
false => rsx!(span { class: "material-symbols-outlined", style: "{button_style}", "volume_up"}), false => rsx!(span { class: "material-symbols-outlined", "volume_up"}),
} }
} }
} }
@@ -740,71 +523,11 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
return rsx!(); return rsx!();
}; };
let grid = css!(
r#"
display: grid;
height: 100%;
background-color: var(--bg-color);
grid-template-rows: 1fr auto;
grid-template-columns: 1fr 2fr;
grid-template-areas:
"tree chat"
"control chat";
@media screen and (max-width: 720px) {
grid-template-rows: auto 1fr 1fr;
grid-template-columns: 1fr;
grid-template-areas:
"tree"
"control"
"chat";
}
"#
);
let channel_box = css!(
"
padding: 16px;
overflow: auto;
grid-area: tree;
"
);
let chat_box = css!(
"
display: flex;
flex-direction: row;
grid-area: chat;
border-left: solid var(--line-color) var(--line-width);
@media screen and (max-width: 720px) {
border-left:unset;
border-top: solid var(--line-color) var(--line-width);
}
"
);
let control_box = css!(
"
padding: 16px;
margin: 16px;
background-color: var(--light-bg-color);
border-radius: 10px;
overflow: hidden;
grid-area: control;
display: flex;
gap: 10px;
flex-direction: column;
"
);
rsx!( rsx!(
div { div {
class: "{grid}", class: "server_grid",
div { div {
class: "{channel_box}", class: "server_channel_box",
for (id, state) in server.channels.iter() { for (id, state) in server.channels.iter() {
if state.parent.is_none() { if state.parent.is_none() {
Channel { id: *id } Channel { id: *id }
@@ -812,50 +535,32 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
} }
} }
div { div {
class: "{chat_box}", class: "server_chat_box",
ChatView {} ChatView {}
} }
div { div {
class: "{control_box}", class: "server_control_box",
ControlView { config } ControlView { config }
} }
} }
) )
} }
async fn get_status(
client: &reqwest::Client,
status_url: &str,
) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(status_url)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
#[component] #[component]
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 last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
use_resource(move || async move { use_resource(move || async move {
let Some(config) = config.read().clone() else {
return;
};
let Some(status_url) = config.status_url else {
return;
};
let client = reqwest::Client::new(); let client = reqwest::Client::new();
loop { loop {
*last_status.write_unchecked() = Some(get_status(&client, &status_url).await); *last_status.write_unchecked() = Some(imp::get_status(&client).await);
imp::sleep(std::time::Duration::from_secs_f32(1.0)).await; imp::sleep(std::time::Duration::from_secs_f32(1.0)).await;
} }
}); });
let mut address_input = use_signal(|| None::<String>); let mut address_input = use_signal(|| imp::load_server_url());
let mut address = use_memo(move || { let address = use_memo(move || {
if let Some(addr) = address_input() { if let Some(addr) = address_input() {
addr.clone() addr.clone()
} else { } else {
@@ -868,52 +573,12 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let previous_username = imp::load_username(); let previous_username = imp::load_username();
let mut username = use_signal(|| previous_username.unwrap_or(String::new())); let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
let error = css!(
"
background-color: white;
border-radius: 4px;
overflow: auto;
padding: 4px;
color: red;
pre {
color: black;
}
"
);
let login_box = css!(
"
max-width: 50vw;
align-self: center;
padding: 32px;
border-radius: 16px;
background-color: var(--login-bg-color);
display: flex;
flex-direction: column;
gap: 16px;
input,button {
padding: 8px;
}
h1 {
margin: 0;
color: #b3c6b4;
}
"
);
let bttn = css!(
"
font-weight: bold;
font-size: large;
"
);
let do_connect = move |_| { let do_connect = move |_| {
//let _ = set_default_username(&username.read()); //let _ = set_default_username(&username.read());
let _ = imp::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 { net.send(Connect {
address: address.read().clone(), address: address.read().clone(),
username: username.read().clone(), username: username.read().clone(),
@@ -924,25 +589,25 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let bottom = match &*status.read() { let bottom = match &*status.read() {
Disconnected => rsx! { Disconnected => rsx! {
button { button {
class: "{bttn}", class: "login_bttn",
onclick: do_connect.clone(), onclick: do_connect.clone(),
"Connect" "Connect"
} }
}, },
Connecting => rsx! { Connecting => rsx! {
div { div {
class: "{bttn}", class: "login_bttn",
"Connecting..." "Connecting..."
} }
}, },
Failed(msg) => rsx!( Failed(msg) => rsx!(
button { button {
class: "{bttn}", class: "login_bttn",
onclick: do_connect.clone(), onclick: do_connect.clone(),
"Reconnect" "Reconnect"
} }
div { div {
class: "{error}", class: "login_error",
"Failed to connect:" "Failed to connect:"
pre { pre {
"{msg}" "{msg}"
@@ -951,11 +616,31 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
), ),
Connected => unreachable!(), Connected => unreachable!(),
}; };
let version = option_env!("MUMBLE_WEB2_VERSION");
rsx!( rsx!(
div { div {
class: "{login_box}", class: "login",
h1 { h1 {
"Mumble Web" "Mumble Web"
match version {
Some(v) => rsx!(" " span { class: "login_version", "({v})" }),
None => rsx!(),
}
}
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
div {
label {
for: "address-entry",
"Server Address:"
}
input {
id: "address-entry",
placeholder: "address",
value: "{address.read()}",
autofocus: "true",
oninput: move |evt| address_input.set(Some(evt.value().clone())),
}
}
} }
div { div {
label { label {
@@ -972,15 +657,37 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
} }
} }
div { div {
div { match &*last_status.read() {
span {} None => rsx!(div {
span {""} class: "login_status",
span {} span {"···"}
} }),
div { Some(Ok(ServerStatus { success: false, .. })) => rsx!(div {
span {"1/100 Online"} class: "login_status is_error",
span {""} span {
span {"Version: 1.4.255"} "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} {bottom}
@@ -1013,6 +720,8 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
} }
pub fn app() -> Element { pub fn app() -> Element {
static STYLE: Asset = asset!("/assets/main.scss");
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 imp::load_config().await {
@@ -1021,78 +730,13 @@ pub fn app() -> Element {
} }
}); });
global_css!( imp::request_permissions();
"
:root {
--txt-color: oklch(0.9 0 99);
--bg-color: oklch(0.15 0.01 338.64);
--light-bg-color: oklch(0.25 0.01 338.64);
--login-bg-color: #5d7680;
--primary-btn-color: #7bad9f;
--accent-normal: #7bad9f;
--accent-muted: #ff746c;
--accent-deafened: #464459;
--line-width: 2px;
--line-color: white;
}
body {
margin: 0;
}
#main {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-around;
background-color: var(--bg-color);
overflow: auto;
color: var(--txt-color);
font-family: Nunito;
font-size: 15pt;
font-weight: 600;
}
button {
font-weight: bold;
font-size: medium;
border: none;
border-radius: 4px;
color: var(--txt-color);
background-color: var(--primary-btn-color);
cursor: pointer;
}
input {
border: none;
border-radius: 4px;
background-color: white;
color: black;
}
input:focus,input:focus-visible {
border: none;
outline: solid var(--line-width) var(--accent-normal);
outline-offset: -3px;
}
a:link {
color: var(--accent-normal);
}
a:visited {
color: var(--accent-muted);
}
"
);
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" }
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" } document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
document::Link{ rel: "stylesheet", href: STYLE }
sir::AppStyle { }
match *STATE.status.read() { match *STATE.status.read() {
Connected => rsx!(ServerView { config }), Connected => rsx!(ServerView { config }),
_ => rsx!(LoginView { config }), _ => rsx!(LoginView { config }),
+179 -11
View File
@@ -1,27 +1,195 @@
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
use df::tract::{DfParams, DfTract, RuntimeParams};
use dioxus::prelude::{asset, manganis, Asset};
use dioxus_asset_resolver::read_asset_bytes;
use std::cell::RefCell;
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, info};
use crate::imp;
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
// TODO: make this user configurable.
static DEFAULT_NOISE_FLOOR: f32 = 0.001;
// 200ms hold at 48kHz sample rate
static HOLD_SAMPLES_MAX: usize = 48000 / 5; // 9600 samples = 200ms
/// Indicates the transmission state after processing audio.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransmitState {
/// Audio is above threshold, or below but within hold period - transmit normally
Transmitting,
/// Hold period expired - send this frame as terminator (end_bit = true)
Terminator,
/// Silent and not transmitting - don't send anything
Silent,
}
enum DenoisingModelState {
Nothing,
Downloading(Arc<AtomicCell<Option<DfParams>>>),
Availible(Box<DfTract>),
}
fn with_denoising_model<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
// thread) while AudioProcessing itself might change threads whenever.
thread_local! {
static STATE: RefCell<DenoisingModelState> = const { RefCell::new(DenoisingModelState::Nothing) };
}
STATE.with_borrow_mut(|state| match state {
DenoisingModelState::Nothing => {
let cell = Arc::new(AtomicCell::new(None));
let cell_task = cell.clone();
*state = DenoisingModelState::Downloading(cell);
spawn.spawn(async move {
let model_bytes = match read_asset_bytes(&DF_MODEL).await {
Ok(b) => b,
Err(e) => {
error!("could not read denoising model from \"{DF_MODEL}\": {e:?}");
return;
}
};
let params = match DfParams::from_bytes(&model_bytes) {
Ok(p) => p,
Err(e) => {
error!("could not load denoising model parameters: {e:?}");
return;
}
};
cell_task.store(Some(params));
});
None
}
DenoisingModelState::Downloading(cell) => {
if let Some(params) = cell.take() {
let mut tract = match DfTract::new(params, &RuntimeParams::default_with_ch(1)) {
Ok(t) => Box::new(t),
Err(e) => {
error!("could not create denoising engine: {e:?}");
return None;
}
};
info!("instantiated denoising engine");
let out = func(&mut tract);
*state = DenoisingModelState::Availible(tract);
Some(out)
} else {
None
}
}
DenoisingModelState::Availible(tract) => Some(func(tract)),
})
}
#[derive(Default)]
pub struct AudioProcessor { pub struct AudioProcessor {
df: Option<::df::DFState>, denoise: bool,
spawn: imp::SpawnHandle,
buffer: Vec<f32>,
noise_floor: f32,
/// Whether we were transmitting in the previous frame
was_transmitting: bool,
/// Number of samples we've been below threshold (for hold period)
hold_samples: usize,
} }
impl AudioProcessor { impl AudioProcessor {
pub fn new_plain() -> Self {
AudioProcessor {
denoise: false,
spawn: imp::SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
hold_samples: 0,
}
}
pub fn new_denoising() -> Self { pub fn new_denoising() -> Self {
let df = ::df::DFState::default(); AudioProcessor {
AudioProcessor { df: Some(df) } denoise: true,
spawn: imp::SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
hold_samples: 0,
}
} }
} }
impl AudioProcessor { impl AudioProcessor {
pub fn process(&mut self, audio: &[f32]) -> Box<[f32]> { pub fn process(&mut self, audio: &[f32], channels: usize, output: &mut Vec<f32>) -> TransmitState {
let mut output: Box<[f32]> = vec![0f32; audio.len()].into(); let mut include_raw = true;
if let Some(df) = &mut self.df { if self.denoise {
df.process_frame(audio, &mut output); with_denoising_model(&self.spawn, |df| {
} else { include_raw = false;
output.copy_from_slice(audio);
self.buffer.extend(audio.iter().step_by(channels).copied());
output.reserve(audio.len());
let hop = df.hop_size;
let mut i = 0;
while self.buffer[i..].len() >= hop {
let audio = &self.buffer[i..][..hop];
i += audio.len();
let j = output.len();
output.extend(std::iter::repeat_n(0f32, audio.len()));
let output = &mut output[j..];
df.process(
slice_as_arrayview(audio, &[audio.len()])
.into_shape((1, audio.len()))
.unwrap(),
mut_slice_as_arrayviewmut(output, &[output.len()])
.into_shape((1, output.len()))
.unwrap(),
);
}
self.buffer.splice(..i, []);
});
} }
output
if include_raw {
output.extend(audio.iter().step_by(channels).copied());
}
// Calculate average amplitude for VAD
let avg: f32 = if output.is_empty() {
0.0
} else {
output.iter().map(|x| x.abs()).sum::<f32>() / output.len() as f32
};
let above_threshold = avg >= self.noise_floor;
let samples_in_frame = output.len();
let state = if above_threshold {
// Above threshold - reset hold counter and transmit
self.hold_samples = 0;
self.was_transmitting = true;
TransmitState::Transmitting
} else if self.was_transmitting && self.hold_samples < HOLD_SAMPLES_MAX {
// Below threshold but in hold period - keep transmitting
self.hold_samples += samples_in_frame;
TransmitState::Transmitting
} else if self.was_transmitting {
// Hold period expired - send terminator
self.was_transmitting = false;
self.hold_samples = 0;
TransmitState::Terminator
} else {
// Not transmitting and below threshold - stay silent
output.clear(); // Don't accumulate stale audio during silence
TransmitState::Silent
};
state
} }
} }
+110
View File
@@ -0,0 +1,110 @@
use crate::app::Command;
use color_eyre::eyre::{bail, Error};
use dioxus::hooks::UnboundedReceiver;
use mumble_protocol::control::ClientControlCodec;
use std::net::ToSocketAddrs;
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
use tokio_rustls::rustls::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{info, instrument};
use mumble_web2_common::{ClientConfig, ServerStatus};
#[derive(Debug)]
struct NoCertificateVerification;
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}
#[instrument]
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let addr = format!("{}:{}", address, 64738)
.to_socket_addrs()?
.next()
.unwrap();
let server_tcp = TcpStream::connect(addr).await?;
let server_stream = connector
//.connect("127.0.0.1".try_into()?, server_tcp)
.connect(address.try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
let read_codec = ClientControlCodec::new();
let write_codec = ClientControlCodec::new();
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
crate::network_loop(username, event_rx, reader, writer).await
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet")
}
+46 -219
View File
@@ -1,242 +1,69 @@
use crate::app::Command; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use crate::effects::AudioProcessor;
use color_eyre::eyre::{eyre, Error};
use cpal::traits::{DeviceTrait, HostTrait};
use dioxus::hooks::{UnboundedReceiver, UnboundedSender};
use futures::io::{AsyncRead, AsyncWrite};
use mumble_protocol::control::{ClientControlCodec, ControlPacket};
use mumble_protocol::Serverbound;
use mumble_web2_common::ClientConfig; use mumble_web2_common::ClientConfig;
use std::net::ToSocketAddrs; use std::collections::HashMap;
use std::sync::Mutex; pub use tokio::runtime::Handle as SpawnHandle;
use std::{fmt, io, sync::Arc};
use tokio::net::TcpStream;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
use tokio_rustls::rustls::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{error, info, instrument, warn};
pub use tokio::task::spawn; pub use tokio::task::spawn;
pub use tokio::time::sleep; pub use tokio::time::sleep;
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {} pub use super::connect::*;
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {} pub use super::native_audio::*;
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {} fn get_config_path() -> std::path::PathBuf {
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {} let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "com".to_string(),
pub struct AudioSystem { author: "Ohea Corp".to_string(),
output: cpal::Device, app_name: "Mumble Web2".to_string(),
input: cpal::Device, })
.expect("failed to choose app strategy");
strategy.config_dir().join("config.json")
} }
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>; fn load_config_map() -> HashMap<String, String> {
let config_path = get_config_path();
impl AudioSystem { match std::fs::read_to_string(&config_path) {
pub fn new() -> Result<Self, Error> { Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
// TODO Err(_) => HashMap::new(),
let host = cpal::default_host();
let name = host.id();
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:?}"))?,
})
}
pub fn set_processor(&self, processor: AudioProcessor) {
// TODO
}
pub fn start_recording(&mut self, each: impl FnMut(Vec<u8>) + 'static) -> Result<(), Error> {
// TODO
Ok(())
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts(
0,
0,
vec![
0;
2400 // 50ms of buffer
],
)));
let decoder = opus::Decoder::new(48_000, opus::Channels::Mono)?;
let stream = {
let buffer = buffer.clone();
self.output.build_output_stream(
&cpal::StreamConfig {
channels: 1,
sample_rate: cpal::SampleRate(48_000),
buffer_size: cpal::BufferSize::Fixed(480), // 10ms playback delay
},
move |frame, info| {
let mut buffer = buffer.lock().unwrap();
for x in frame.iter_mut() {
match buffer.pop() {
Some(y) => {
*x = y;
}
None => {
*x = 0;
}
}
}
},
move |err| error!("could not create output stream {err:?}"),
None,
)?
};
Ok(AudioPlayer {
decoder,
stream,
buffer,
tmp: vec![0; 2400],
})
} }
} }
pub struct AudioPlayer { fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
decoder: opus::Decoder, let config_path = get_config_path();
stream: cpal::Stream, if let Some(parent) = config_path.parent() {
buffer: Buffer, std::fs::create_dir_all(parent)?;
tmp: Vec<i16>,
}
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
let len = loop {
match self.decoder.decode(payload, &mut self.tmp, false) {
Ok(l) => break l,
Err(e) => {
error!("opus decode error {e:?}");
return;
}
}
};
let mut buffer = self.buffer.lock().unwrap();
let mut overrun = 0;
for x in &self.tmp[..len] {
if let Some(_) = buffer.push(*x) {
overrun += 1;
}
}
if overrun > 0 {
warn!("playback overrun by {overrun} samples");
}
} }
} let contents = serde_json::to_string_pretty(config)?;
std::fs::write(&config_path, contents)?;
#[derive(Debug)] Ok(())
struct NoCertificateVerification;
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}
#[instrument]
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let addr = format!("{}:{}", address, 64738)
.to_socket_addrs()?
.next()
.unwrap();
let server_tcp = TcpStream::connect(addr).await?;
let server_stream = connector
//.connect("127.0.0.1".try_into()?, server_tcp)
.connect(address.try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
let read_codec = ClientControlCodec::new();
let write_codec = ClientControlCodec::new();
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
crate::network_loop(username, event_rx, reader, writer).await
} }
pub fn set_default_username(username: &str) -> Option<()> { pub fn set_default_username(username: &str) -> Option<()> {
None let mut config = load_config_map();
config.insert("username".to_string(), username.to_string());
save_config_map(&config).ok()
}
pub fn set_default_server(server: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("server".to_string(), server.to_string());
save_config_map(&config).ok()
} }
pub fn load_username() -> Option<String> { pub fn load_username() -> Option<String> {
return None; let config = load_config_map();
config.get("username").cloned()
}
pub fn load_server_url() -> Option<String> {
let config = load_config_map();
config.get("server").cloned()
} }
pub async fn load_config() -> color_eyre::Result<ClientConfig> { pub async fn load_config() -> color_eyre::Result<ClientConfig> {
color_eyre::eyre::bail!( Ok(ClientConfig {
"there is no config on desktop because desktops cannot be configured as they are tables" proxy_url: None,
) cert_hash: None,
any_server: true,
})
} }
pub fn init_logging() { pub fn init_logging() {
+67
View File
@@ -0,0 +1,67 @@
use android_permissions::{PermissionManager, RECORD_AUDIO};
use jni::{objects::JObject, JavaVM};
use mumble_web2_common::ClientConfig;
use std::collections::HashMap;
pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
pub use super::connect::*;
pub use super::native_audio::*;
pub fn set_default_username(username: &str) -> Option<()> {
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 {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
pub fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
#[cfg(feature = "mobile")]
pub fn request_permissions() {
request_recording_permission();
}
#[cfg(target_os = "android")]
pub fn request_recording_permission() {
let ctx = ndk_context::android_context();
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() };
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
let manager = PermissionManager::create(vm, activity).unwrap();
if !manager.check(&RECORD_AUDIO).unwrap() {
manager.request(&[&RECORD_AUDIO]).unwrap();
}
}
+21 -3
View File
@@ -1,11 +1,29 @@
#[cfg(feature = "web")] #[cfg(feature = "web")]
mod web; mod web;
#[cfg(any(feature = "desktop", feature = "mobile"))]
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(all(feature = "web", not(feature = "desktop")))] mod mobile;
pub use web::*;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
pub use desktop::*; pub use desktop::*;
#[cfg(feature = "mobile")]
pub use mobile::*;
#[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::*;
+222
View File
@@ -0,0 +1,222 @@
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{eyre, Error};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
use futures::io::{AsyncRead, AsyncWrite};
use std::mem::replace;
use std::sync::Arc;
use std::sync::Mutex;
use tracing::{error, info, warn};
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
pub struct AudioSystem {
output: cpal::Device,
input: cpal::Device,
processors: AudioProcessorSender,
recording_stream: Option<cpal::Stream>,
}
const SAMPLE_RATE: u32 = 48_000;
const PACKET_SAMPLES: u32 = 960;
fn encode_and_send(
state: TransmitState,
output_buffer: &mut Vec<f32>,
encoder: &mut opus::Encoder,
each: &mut impl FnMut(Vec<u8>, bool),
) {
let (is_terminator, should_encode) = match state {
TransmitState::Silent => return,
TransmitState::Transmitting => (false, output_buffer.len() >= PACKET_SAMPLES as usize),
TransmitState::Terminator => {
output_buffer.resize(PACKET_SAMPLES as usize, 0.0);
(true, true)
}
};
if should_encode {
let remainder = output_buffer.split_off(PACKET_SAMPLES as usize);
let frame = replace(output_buffer, remainder);
match encoder.encode_vec_float(&frame, frame.len() * 2) {
Ok(encoded) => each(encoded, is_terminator),
Err(e) => error!("error encoding {} samples: {e:?}", frame.len()),
}
}
}
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
impl AudioSystem {
pub async fn new() -> Result<Self, Error> {
// TODO
let host = cpal::default_host();
let name = host.id();
let processors = AudioProcessorSender::default();
Ok(AudioSystem {
output: host
.default_output_device()
.ok_or(eyre!("no output devices from {name:?}"))?,
input: host
.default_input_device()
.ok_or(eyre!("no input devices from {name:?}"))?,
processors,
recording_stream: None,
})
}
pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
fn choose_config(
&self,
configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>,
) -> Result<cpal::StreamConfig, Error> {
let mut supported_configs: Vec<_> = configs
.filter_map(|cfg| cfg.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE)))
.filter(|cfg| cfg.sample_format() == cpal::SampleFormat::I16)
.map(|cfg| cpal::StreamConfig {
buffer_size: cpal::BufferSize::Fixed(match *cfg.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => 480.clamp(min, max),
cpal::SupportedBufferSize::Unknown => 480,
}),
..cfg.config()
})
.collect();
supported_configs.sort_by(|a, b| {
let cpal::BufferSize::Fixed(a_buf) = a.buffer_size else {
unreachable!()
};
let cpal::BufferSize::Fixed(b_buf) = b.buffer_size else {
unreachable!()
};
Ord::cmp(&a.channels, &b.channels).then(Ord::cmp(&a_buf, &b_buf))
});
supported_configs
.get(0)
.cloned()
.ok_or(eyre!("no supported stream configs"))
}
pub fn start_recording(
&mut self,
mut each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error> {
let config = self.choose_config(self.input.supported_input_configs()?)?;
info!(
"creating recording on {:?} with {:#?}",
self.input.name()?,
config
);
let mut encoder =
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
let mut current_processor = AudioProcessor::new_plain();
let mut output_buffer = Vec::new();
let processors = self.processors.clone();
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
let data_callback = move |frame: &[f32], _: &cpal::InputCallbackInfo| {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
}
let state = current_processor.process(frame, config.channels as usize, &mut output_buffer);
encode_and_send(state, &mut output_buffer, &mut encoder, &mut each);
};
match self
.input
.build_input_stream(&config, data_callback, error_callback, None)
{
Ok(stream) => {
stream.play()?;
self.recording_stream = Some(stream);
Ok(())
}
Err(err) => {
self.recording_stream = None;
Err(err.into())
}
}
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let config = self.choose_config(self.output.supported_output_configs()?)?;
info!(
"creating player on {:?} with {:#?}",
self.output.name().ok(),
&config
);
let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts(
0,
0,
vec![
0;
SAMPLE_RATE as usize/4 // 250ms of buffer
],
)));
let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?;
let stream = {
let buffer = buffer.clone();
self.output.build_output_stream(
&config,
move |frame, _info| {
let mut buffer = buffer.lock().unwrap();
for x in frame.chunks_mut(config.channels as usize) {
match buffer.pop() {
Some(y) => {
x.fill(y);
}
None => {
x.fill(0);
}
}
}
},
move |err| error!("could not create output stream {err:?}"),
None,
)?
};
stream.play()?;
Ok(AudioPlayer {
decoder,
stream,
buffer,
tmp: vec![0; 2400],
})
}
}
pub struct AudioPlayer {
decoder: opus::Decoder,
stream: cpal::Stream,
buffer: Buffer,
tmp: Vec<i16>,
}
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
let len = loop {
match self.decoder.decode(payload, &mut self.tmp, false) {
Ok(l) => break l,
Err(e) => {
error!("opus decode error {e:?}");
return;
}
}
};
let mut buffer = self.buffer.lock().unwrap();
let mut overrun = 0;
for x in &self.tmp[..len] {
if let Some(_) = buffer.push(*x) {
overrun += 1;
}
}
if overrun > 0 {
warn!("playback overrun by {overrun} samples");
}
}
}
+139 -76
View File
@@ -1,20 +1,22 @@
use crate::app::Command; use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender}; 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 dioxus::prelude::*; use dioxus::prelude::*;
use futures::{AsyncRead, AsyncWrite}; 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; use mumble_web2_common::{ClientConfig, ServerStatus};
use reqwest::Url; use reqwest::Url;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing::{debug, error, info, instrument}; use tracing::{debug, error, info, instrument};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::js_sys::{Promise, Reflect, Uint8Array}; use web_sys::js_sys::{Promise, Reflect, Uint8Array};
use web_sys::AudioContext;
use web_sys::AudioContextOptions; use web_sys::AudioContextOptions;
use web_sys::AudioData; use web_sys::AudioData;
use web_sys::AudioDecoder; use web_sys::AudioDecoder;
@@ -29,14 +31,13 @@ use web_sys::EncodedAudioChunkInit;
use web_sys::EncodedAudioChunkType; use web_sys::EncodedAudioChunkType;
use web_sys::MediaStream; use web_sys::MediaStream;
use web_sys::MediaStreamConstraints; use web_sys::MediaStreamConstraints;
use web_sys::MediaStreamTrackGenerator;
use web_sys::MediaStreamTrackGeneratorInit;
use web_sys::MessageEvent; use web_sys::MessageEvent;
use web_sys::WebTransport; use web_sys::WebTransport;
use web_sys::WebTransportBidirectionalStream; use web_sys::WebTransportBidirectionalStream;
use web_sys::WebTransportOptions; use web_sys::WebTransportOptions;
use web_sys::WorkletOptions; use web_sys::WorkletOptions;
use web_sys::{console, window}; use web_sys::{console, window};
use web_sys::{AudioContext, AudioDataCopyToOptions};
pub use wasm_bindgen_futures::spawn_local as spawn; pub use wasm_bindgen_futures::spawn_local as spawn;
@@ -77,23 +78,52 @@ pub struct AudioSystem {
processors: AudioProcessorSender, processors: AudioProcessorSender,
} }
async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
// Create worklets to process mic and speaker audio
// Speaker audio processing worklet only required on
// browsers that don't support MediaStreamTrackGenerator
let options = WorkletOptions::new();
Reflect::set(
&options,
&"processorOptions".into(),
&wasm_bindgen::module(),
)
.ey()?;
let module = asset!("assets/rust_audio_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
audio_context
.audio_worklet()
.ey()?
.add_module_with_options(&module, &options)
.ey()?
.into_future()
.await
.ey()?;
Ok(())
}
impl AudioSystem { impl AudioSystem {
pub fn new() -> Result<Self, Error> { pub 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();
let processor = AudioProcessorSender::default(); attach_worklet(&webctx).await?;
Ok(AudioSystem {
webctx, let processors = AudioProcessorSender::default();
processors: processor,
}) Ok(AudioSystem { webctx, processors })
} }
pub fn set_processor(&self, processor: AudioProcessor) { pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor)) self.processors.store(Some(processor))
} }
pub fn start_recording(&mut self, each: impl FnMut(Vec<u8>) + 'static) -> Result<(), Error> { pub fn start_recording(
&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 {
@@ -106,18 +136,10 @@ impl AudioSystem {
} }
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> { pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let audio_stream_generator = let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?;
MediaStreamTrackGenerator::new(&MediaStreamTrackGeneratorInit::new("audio")).ey()?;
// Create MediaStream from MediaStreamTrackGenerator // Connect worklet to destination
let js_tracks = web_sys::js_sys::Array::new(); sink_node
js_tracks.push(&audio_stream_generator);
let media_stream = MediaStream::new_with_tracks(&js_tracks).ey()?;
// Create MediaStreamAudioSourceNode
let audio_source = self.webctx.create_media_stream_source(&media_stream).ey()?;
// Connect output of audio_source to audio_context (browser audio)
audio_source
.connect_with_audio_node(&self.webctx.destination()) .connect_with_audio_node(&self.webctx.destination())
.ey()?; .ey()?;
@@ -126,28 +148,31 @@ impl AudioSystem {
error!("error decoding audio {:?}", e); error!("error decoding audio {:?}", e);
}) as Box<dyn FnMut(JsValue)>); }) as Box<dyn FnMut(JsValue)>);
// This knows what MediaStreamTrackGenerator to use as it closes around it let sink_port = sink_node.port().ey()?;
let output = Closure::wrap(Box::new(move |audio_data: AudioData| { let output = Closure::wrap(Box::new(move |audio_data: AudioData| {
let writable = audio_stream_generator.writable(); // Extract planar PCM from AudioData into an ArrayBuffer or Float32Array
if writable.locked() { // Here we assume f32 samples, 1 channel for brevity.
return; let number_of_frames = audio_data.number_of_frames();
}
if let Err(e) = writable.get_writer().map(|writer| { let js_buffer = Float32Array::new_with_length(number_of_frames);
spawn(async move {
if let Err(e) = JsFuture::from(writer.ready()).await.ey() { let audio_data_copy_to_options = &AudioDataCopyToOptions::new(0);
error!("write chunk ready error {:?}", e); audio_data_copy_to_options.set_format(web_sys::AudioSampleFormat::F32);
}
if let Err(e) = JsFuture::from(writer.write_with_chunk(&audio_data)) if let Err(e) = audio_data
.await .copy_to_with_buffer_source(&js_buffer.buffer(), &audio_data_copy_to_options)
.ey() {
{ error!("could not copy audio data to array {:?}", e);
error!("write chunk error {:?}", e);
};
writer.release_lock();
});
}) {
error!("error writing audio data {:?}", e);
} }
// Post to the worklet; include sampleRate and channel count if needed.
let msg = js_sys::Object::new();
js_sys::Reflect::set(&msg, &"samples".into(), &js_buffer).unwrap();
sink_port.post_message(&msg).unwrap();
audio_data.close();
}) as Box<dyn FnMut(AudioData)>); }) as Box<dyn FnMut(AudioData)>);
let audio_decoder = AudioDecoder::new(&AudioDecoderInit::new( let audio_decoder = AudioDecoder::new(&AudioDecoderInit::new(
@@ -202,21 +227,26 @@ impl PromiseExt for Promise {
} }
} }
fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) { fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) -> TransmitState {
let Ok(samples) = Reflect::get(&frame, &"data".into()) else { let Ok(samples) = Reflect::get(&frame, &"data".into()) else {
return; return TransmitState::Silent;
}; };
let Ok(samples) = samples.dyn_into::<Float32Array>() else { let Ok(samples) = samples.dyn_into::<Float32Array>() else {
return; return TransmitState::Silent;
}; };
let input = samples.to_vec(); let input = samples.to_vec();
let output = processor.process(&input); let mut output = Vec::with_capacity(input.len());
samples.copy_from(&output); let state = processor.process(&input, 1, &mut output);
if !output.is_empty() {
samples.copy_from(&output);
}
state
} }
async fn run_encoder_worklet( async fn run_encoder_worklet(
audio_context: &AudioContext, audio_context: &AudioContext,
mut each: impl FnMut(Vec<u8>) + 'static, mut each: impl FnMut(Vec<u8>, bool) + 'static,
processors: AudioProcessorSender, processors: AudioProcessorSender,
) -> Result<AudioWorkletNode, Error> { ) -> Result<AudioWorkletNode, Error> {
let constraints = MediaStreamConstraints::new(); let constraints = MediaStreamConstraints::new();
@@ -235,37 +265,25 @@ async fn run_encoder_worklet(
.map_err(|e| JsError::new(&format!("not a stream: {e:?}"))) .map_err(|e| JsError::new(&format!("not a stream: {e:?}")))
.ey()?; .ey()?;
let options = WorkletOptions::new();
Reflect::set(
&options,
&"processorOptions".into(),
&wasm_bindgen::module(),
)
.ey()?;
let module = asset!("assets/rust_mic_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
audio_context
.audio_worklet()
.ey()?
.add_module_with_options(&module, &options)
.ey()?
.into_future()
.await
.ey()?;
let source = audio_context.create_media_stream_source(&stream).ey()?; let source = audio_context.create_media_stream_source(&stream).ey()?;
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet").ey()?; let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet").ey()?;
let encoder_error: Closure<dyn FnMut(JsValue)> = let encoder_error: Closure<dyn FnMut(JsValue)> =
Closure::new(|e| error!("error encoding audio {:?}", e)); Closure::new(|e| error!("error encoding audio {:?}", e));
// Shared state to signal terminator between onmessage and output closures
// The output closure runs asynchronously after encoding completes
let pending_terminator = Arc::new(AtomicCell::new(false));
let pending_terminator_output = pending_terminator.clone();
// This knows what MediaStreamTrackGenerator to use as it closes around it // This knows what MediaStreamTrackGenerator to use as it closes around it
let output: Closure<dyn FnMut(EncodedAudioChunk)> = let output: Closure<dyn FnMut(EncodedAudioChunk)> =
Closure::new(move |audio_data: EncodedAudioChunk| { Closure::new(move |audio_data: EncodedAudioChunk| {
let mut array = vec![0u8; audio_data.byte_length() as usize]; let mut array = vec![0u8; audio_data.byte_length() as usize];
audio_data.copy_to_with_u8_slice(&mut array); audio_data.copy_to_with_u8_slice(&mut array);
each(array); // Check if this frame was marked as a terminator
let is_terminator = pending_terminator_output.swap(false);
each(array, is_terminator);
}); });
let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new( let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new(
@@ -285,15 +303,26 @@ async fn run_encoder_worklet(
audio_encoder.configure(&encoder_config); audio_encoder.configure(&encoder_config);
info!("created audio encoder"); info!("created audio encoder");
let mut current_processor = AudioProcessor::default(); let mut current_processor = AudioProcessor::new_plain();
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| { let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
if let Some(new_processor) = processors.take() { if let Some(new_processor) = processors.take() {
current_processor = new_processor; current_processor = new_processor;
} }
let frame = event.data(); let frame = event.data();
process_audio(&frame, &mut current_processor); let state = process_audio(&frame, &mut current_processor);
match state {
TransmitState::Silent => {
// Don't encode or send anything
return;
}
TransmitState::Transmitting => (), // Normal transmission
TransmitState::Terminator => {
// Mark this as a terminator before encoding
pending_terminator.store(true);
}
}
match AudioData::new(frame.unchecked_ref()) { match AudioData::new(frame.unchecked_ref()) {
Ok(data) => { Ok(data) => {
let _ = audio_encoder.encode(&data); let _ = audio_encoder.encode(&data);
@@ -397,6 +426,10 @@ pub fn set_default_username(username: &str) -> Option<()> {
.ok() .ok()
} }
pub fn set_default_server(username: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> { pub fn load_username() -> Option<String> {
web_sys::window() web_sys::window()
.unwrap() .unwrap()
@@ -406,14 +439,20 @@ pub fn load_username() -> Option<String> {
.ok()? .ok()?
} }
pub fn load_server_url() -> Option<String> {
None
}
pub fn absolute_url(path: &str) -> Result<Url, Error> {
let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
let location = window.location();
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> { pub async fn load_config() -> color_eyre::Result<ClientConfig> {
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") { let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
Some(url) => Url::parse(url)?, Some(url) => Url::parse(url)?,
None => { None => absolute_url("config")?,
let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
let location = window.location();
Url::parse(&location.href().ey()?)?.join("config")?
}
}; };
info!("loading config from {}", config_url); info!("loading config from {}", config_url);
@@ -425,6 +464,15 @@ pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(config) 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() { pub fn init_logging() {
// copied from tracing_web example usage // copied from tracing_web example usage
@@ -446,3 +494,18 @@ pub fn init_logging() {
info!("logging initiated"); 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);
}
}
+7 -7
View File
@@ -20,12 +20,9 @@ use mumble_protocol::voice::VoicePacket;
use mumble_protocol::voice::VoicePacketPayload; use mumble_protocol::voice::VoicePacketPayload;
use mumble_protocol::Clientbound; use mumble_protocol::Clientbound;
use mumble_protocol::Serverbound; use mumble_protocol::Serverbound;
use mumble_web2_common::ClientConfig;
use once_cell::sync::Lazy;
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tracing::debug;
use tracing::error; use tracing::error;
use tracing::info; use tracing::info;
@@ -113,18 +110,18 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
}); });
} }
let mut audio = imp::AudioSystem::new()?; let mut audio = imp::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;
audio.start_recording(move |opus_frame| { audio.start_recording(move |opus_frame, is_terminator| {
let _ = let _ =
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio { send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
_dst: std::marker::PhantomData, _dst: std::marker::PhantomData,
target: 0, target: 0,
session_id: (), session_id: (),
seq_num: sequence_num, seq_num: sequence_num,
payload: VoicePacketPayload::Opus(opus_frame.into(), false), payload: VoicePacketPayload::Opus(opus_frame.into(), is_terminator),
position_info: None, position_info: None,
}))); })));
sequence_num = sequence_num.wrapping_add(2); sequence_num = sequence_num.wrapping_add(2);
@@ -289,7 +286,7 @@ fn accept_command(
if denoise { if denoise {
audio.set_processor(AudioProcessor::new_denoising()); audio.set_processor(AudioProcessor::new_denoising());
} else { } else {
audio.set_processor(AudioProcessor::default()); audio.set_processor(AudioProcessor::new_plain());
} }
} }
} }
@@ -407,6 +404,9 @@ fn accept_packet(
if u.has_deaf() { if u.has_deaf() {
state.deaf = u.get_deaf(); state.deaf = u.get_deaf();
} }
if u.has_suppress() {
state.suppress = u.get_suppress();
}
if u.has_self_mute() { if u.has_self_mute() {
state.self_mute = u.get_self_mute(); state.self_mute = u.get_self_mute();
} }
+2 -8
View File
@@ -1,12 +1,6 @@
use mumble_web2_gui::{app, imp::init_logging}; use mumble_web2_gui::{app, imp};
pub fn main() { pub fn main() {
#[cfg(feature = "desktop")] imp::init_logging();
let _guard = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.enter();
init_logging();
dioxus::launch(app::app); dioxus::launch(app::app);
} }
+2 -2
View File
@@ -7,13 +7,13 @@ edition = "2021"
color-eyre = "^0.6" color-eyre = "^0.6"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "^1.37", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-rustls = "0.26" tokio-rustls = "0.26"
toml = "0.8" toml = "0.8"
tracing = { version = "^0.1.40", features = ["async-await"] } tracing = { version = "^0.1.40", features = ["async-await"] }
tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] } tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] }
mumble-web2-common = { workspace = true } mumble-web2-common = { workspace = true }
salvo = { version = "^0.74.2", features = [ salvo = { version = "^0.84.2", features = [
"quinn", "quinn",
"eyre", "eyre",
"rustls", "rustls",
+12 -19
View File
@@ -1,10 +1,6 @@
use color_eyre::eyre::{anyhow, bail, Context, Result}; use color_eyre::eyre::{anyhow, bail, Context, Result};
use color_eyre::owo_colors::OwoColorize;
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerStatus};
use once_cell::sync::OnceCell;
use rand::Rng; use rand::Rng;
use rcgen::date_time_ymd;
use rustls::server;
use salvo::conn::rustls::{Keycert, RustlsConfig}; use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors}; use salvo::cors::{AllowOrigin, Cors};
use salvo::logging::Logger; use salvo::logging::Logger;
@@ -38,7 +34,7 @@ fn default_cert_alt_names() -> Vec<String> {
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct Config { struct Config {
public_url: Url, proxy_url: Option<Url>,
https_listen_address: SocketAddr, https_listen_address: SocketAddr,
http_listen_address: Option<SocketAddr>, http_listen_address: Option<SocketAddr>,
cert_path: Option<PathBuf>, cert_path: Option<PathBuf>,
@@ -82,9 +78,12 @@ async fn main() -> Result<()> {
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?; .map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
let mut client_config = ClientConfig { let mut client_config = ClientConfig {
proxy_url: Some(server_config.public_url.join("proxy")?.to_string()), proxy_url: match &server_config.proxy_url {
status_url: Some(server_config.public_url.join("status")?.to_string()), Some(url) => Some(url.to_string()),
None => None,
},
cert_hash: None, cert_hash: None,
any_server: false,
}; };
let (cert, key) = match (&server_config.cert_path, &server_config.key_path) { let (cert, key) = match (&server_config.cert_path, &server_config.key_path) {
@@ -336,19 +335,13 @@ async fn connect_proxy_impl(
info!("connected to Mumble server"); info!("connected to Mumble server");
// Spawn tasks to handle transmitting data between the WebTransport client and Mumble TCP Server // Handle transmitting data between the WebTransport client and Mumble TCP Server
let c2s = tokio::spawn( // When one direction completes/fails, the other is dropped and its streams are closed
pass_bytes_loop(incoming, write_server)
.instrument(info_span!("Handler", "Client to server")),
);
let s2c = tokio::spawn(
pass_bytes_loop(read_server, outgoing)
.instrument(info_span!("Handler", "Server to client")),
);
tokio::select! { tokio::select! {
res = c2s => res??, res = pass_bytes_loop(incoming, write_server)
res = s2c => res??, .instrument(info_span!("Handler", "Client to server")) => res?,
res = pass_bytes_loop(read_server, outgoing)
.instrument(info_span!("Handler", "Server to client")) => res?,
}; };
Ok(()) Ok(())
} }