Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f3466546e | |||
| 8170383278 | |||
| 2e86f68a3c | |||
| 35b2a06e64 | |||
| 09985e6031 | |||
| 056a673bc0 | |||
| 411d923c2a | |||
| ff14f577fe | |||
| 083a11274e | |||
| 2fcb853c30 | |||
| feaa9f2bda | |||
| aa3fcf09cf | |||
| a30082eebe | |||
| 7c75e64a64 | |||
| 65883917b0 | |||
| c8119d0efa | |||
| d7b88874df | |||
| f001a192e1 | |||
| 37c0bce57e | |||
| 4abb130a77 | |||
| af35d72e4e | |||
| 889bdf6b80 | |||
| 391d18a11e | |||
| ca8a3d1b92 | |||
| 5d2c2a93c7 | |||
| 96589e28c6 | |||
| e7e7b945c5 | |||
| cd90cb628b | |||
| 37613a65c4 | |||
| d6b482528f | |||
| 5df7b0e082 |
@@ -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
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
|
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
|
||||||
|
|
||||||
- name: Install dioxus-cli
|
- name: Install dioxus-cli
|
||||||
run: cargo binstall dioxus-cli --version 0.7.1
|
run: cargo binstall dioxus-cli --version 0.7.3
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
@@ -83,3 +83,26 @@ jobs:
|
|||||||
name: mumble-web2-gui-windows
|
name: mumble-web2-gui-windows
|
||||||
path: gui/dist
|
path: gui/dist
|
||||||
retention-days: 5
|
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
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ jobs:
|
|||||||
- name: Build Windows image
|
- name: Build Windows image
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
docker pull "$(grep -m1 '^FROM' Dockerfile | awk '{print $2}')"
|
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 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
|
docker push git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"rust-analyzer.cargo.features": ["desktop","web"],
|
||||||
|
"rust-analyzer.cargo.noDefaultFeatures": false
|
||||||
|
}
|
||||||
Generated
+179
-397
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ 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,
|
pub any_server: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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]"
|
||||||
|
|||||||
@@ -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.3
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
@@ -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,8 +35,8 @@ 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"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
public_url = "https://localhost:64444"
|
|
||||||
proxy_url = "https://127.0.0.1:4433/proxy"
|
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"
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ RUN choco install rustup.install -y --no-progress
|
|||||||
RUN rustup toolchain install stable-x86_64-pc-windows-msvc
|
RUN rustup toolchain install stable-x86_64-pc-windows-msvc
|
||||||
RUN rustup default stable-x86_64-pc-windows-msvc
|
RUN rustup default stable-x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
# Install cargo binstall
|
||||||
|
RUN Set-ExecutionPolicy Unrestricted -Scope Process; `
|
||||||
|
iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content
|
||||||
|
|
||||||
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
|
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
|
||||||
# Install dioxus-cli from git HEAD with cargo
|
# Install dioxus-cli
|
||||||
# This is to work around a bug in the windows builder upstream.
|
RUN cargo binstall dioxus-cli@0.7.3
|
||||||
# Dioxus has released 0.7.2, but it seems to be broken for now.
|
|
||||||
RUN cargo install --git https://github.com/DioxusLabs/dioxus dioxus-cli
|
|
||||||
|
|
||||||
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
|
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
|
||||||
|
|||||||
+31
-7
@@ -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",
|
||||||
@@ -64,10 +63,11 @@ 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.7.1" }
|
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"
|
||||||
@@ -88,18 +88,32 @@ 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 = { git = "https://github.com/samsartor/rfd.git", version = "^0.16.0", default-features = false }
|
|
||||||
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.1"
|
dioxus-asset-resolver = "0.7.2"
|
||||||
|
|
||||||
|
|
||||||
# Denoising
|
# Denoising
|
||||||
# =========
|
# =========
|
||||||
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = ["tract"] }
|
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]
|
[patch.crates-io]
|
||||||
tract-hir = "=0.12.4"
|
tract-hir = "=0.12.4"
|
||||||
tract-core = "=0.12.4"
|
tract-core = "=0.12.4"
|
||||||
@@ -119,6 +133,7 @@ web = [
|
|||||||
"gloo-timers",
|
"gloo-timers",
|
||||||
"tracing-web",
|
"tracing-web",
|
||||||
"deep_filter/wasm",
|
"deep_filter/wasm",
|
||||||
|
"rfd",
|
||||||
]
|
]
|
||||||
desktop = [
|
desktop = [
|
||||||
"dioxus/desktop",
|
"dioxus/desktop",
|
||||||
@@ -129,5 +144,14 @@ desktop = [
|
|||||||
"cpal",
|
"cpal",
|
||||||
"dasp_ring_buffer",
|
"dasp_ring_buffer",
|
||||||
"rfd/xdg-portal",
|
"rfd/xdg-portal",
|
||||||
"rfd/tokio",
|
"etcetera",
|
||||||
|
]
|
||||||
|
mobile = [
|
||||||
|
"dioxus/mobile",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tracing-subscriber/env-filter",
|
||||||
|
"opus",
|
||||||
|
"cpal",
|
||||||
|
"dasp_ring_buffer",
|
||||||
]
|
]
|
||||||
|
|||||||
+4
-2
@@ -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
|
||||||
@@ -33,7 +35,7 @@ style = []
|
|||||||
script = []
|
script = []
|
||||||
|
|
||||||
[bundle]
|
[bundle]
|
||||||
identifier = "xyz.ohea.mumble-web-2"
|
identifier = "xyz.ohea.mumble_web_2"
|
||||||
publisher = "OheaCorp"
|
publisher = "OheaCorp"
|
||||||
icon = [
|
icon = [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
|
|||||||
+138
-10
@@ -83,6 +83,44 @@ a:visited {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.channel_header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel_arrow {
|
||||||
|
width: 1em;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel_arrow--placeholder {
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The whole right side of the row is the dblclick target */
|
||||||
|
.channel_row_click {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.1rem 0.25rem 0.1rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover highlight for whole row area (title + blank space) */
|
||||||
|
.channel_row_click:hover {
|
||||||
|
background-color: var(--channel-hover-bg, #222); /* pick your color */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* still keep text non-selectable if desired */
|
||||||
|
.channel_details {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.channel {
|
.channel {
|
||||||
&_details {
|
&_details {
|
||||||
flex: 0 0 100%;
|
flex: 0 0 100%;
|
||||||
@@ -168,26 +206,68 @@ a:visited {
|
|||||||
background-color: oklch(0.53 0.1431 264.18);
|
background-color: oklch(0.53 0.1431 264.18);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button_row {
|
.button_row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: clamp(4px, 1vw, 10px);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
flex-grow: 1;
|
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 {
|
.toggle_button {
|
||||||
padding: 8px;
|
padding: clamp(4px, 0.5vw, 8px);
|
||||||
height: 100%;
|
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
background-color: unset;
|
background-color: unset;
|
||||||
|
|
||||||
border: solid rgb(255 255 255 / 0.1) 3px;
|
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
|
||||||
border-radius: 10px;
|
border-radius: clamp(4px, 0.8vw, 10px);
|
||||||
color: rgb(255 255 255 / 50%);
|
color: rgb(255 255 255 / 50%);
|
||||||
|
|
||||||
transition: all 0.5s ease-in-out;
|
transition: all 0.5s ease-in-out;
|
||||||
@@ -200,7 +280,6 @@ a:visited {
|
|||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
font-size: 35px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,16 +324,60 @@ a:visited {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&_control_box {
|
&_control_box {
|
||||||
padding: 16px;
|
padding: clamp(6px, 0.8vw, 12px);
|
||||||
margin: 16px;
|
margin: clamp(6px, 0.8vw, 12px);
|
||||||
background-color: var(--light-bg-color);
|
background-color: var(--light-bg-color);
|
||||||
border-radius: 10px;
|
border-radius: clamp(6px, 1vw, 10px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
grid-area: control;
|
grid-area: control;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: clamp(4px, 0.8vw, 8px);
|
||||||
flex-direction: column;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +402,11 @@ a:visited {
|
|||||||
color: #b3c6b4;
|
color: #b3c6b4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&_version {
|
||||||
|
color: var(--txt-color);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
&_bttn {
|
&_bttn {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: large;
|
font-size: large;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
+55
-7
@@ -1,7 +1,39 @@
|
|||||||
|
use std::env;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
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
|
// Define the target directory and file
|
||||||
let assets_dir = "assets";
|
let assets_dir = "assets";
|
||||||
let target_file = format!("{}/DeepFilterNet3_ll_onnx.tar.gz", assets_dir);
|
let target_file = format!("{}/DeepFilterNet3_ll_onnx.tar.gz", assets_dir);
|
||||||
@@ -9,11 +41,17 @@ fn main() {
|
|||||||
|
|
||||||
// Check if the file already exists
|
// Check if the file already exists
|
||||||
if target_path.exists() {
|
if target_path.exists() {
|
||||||
println!("cargo:warning=DeepFilterNet model already exists at {}", target_file);
|
println!(
|
||||||
|
"cargo::warning=DeepFilterNet model already exists at {}",
|
||||||
|
target_file
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("cargo:warning=Downloading DeepFilterNet model to {}...", target_file);
|
println!(
|
||||||
|
"cargo::warning=Downloading DeepFilterNet model to {}...",
|
||||||
|
target_file
|
||||||
|
);
|
||||||
|
|
||||||
// Download the file using curl
|
// Download the file using curl
|
||||||
let url = "https://github.com/Rikorose/DeepFilterNet/raw/refs/heads/main/models/DeepFilterNet3_ll_onnx.tar.gz";
|
let url = "https://github.com/Rikorose/DeepFilterNet/raw/refs/heads/main/models/DeepFilterNet3_ll_onnx.tar.gz";
|
||||||
@@ -21,18 +59,28 @@ fn main() {
|
|||||||
let status = Command::new("curl")
|
let status = Command::new("curl")
|
||||||
.args([
|
.args([
|
||||||
"-L", // Follow redirects
|
"-L", // Follow redirects
|
||||||
"-o", &target_file, // Output file
|
"-o",
|
||||||
|
&target_file, // Output file
|
||||||
url,
|
url,
|
||||||
])
|
])
|
||||||
.status()
|
.status()
|
||||||
.expect("Failed to execute curl command. Make sure curl is installed.");
|
.expect("Failed to execute curl command. Make sure curl is installed.");
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
panic!("Failed to download DeepFilterNet model from {}", url);
|
println!("cargo::error=Failed to download DeepFilterNet model from {url}");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("cargo:warning=Successfully downloaded DeepFilterNet model to {}", target_file);
|
println!(
|
||||||
|
"cargo::warning=Successfully downloaded DeepFilterNet model to {}",
|
||||||
|
target_file
|
||||||
|
);
|
||||||
|
|
||||||
// Rerun this build script if the target file is deleted
|
// Rerun this build script if the target file is deleted
|
||||||
println!("cargo:rerun-if-changed={}", target_file);
|
println!("cargo::rerun-if-changed={}", target_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
version_env();
|
||||||
|
download_deepfilternet();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
+215
-86
@@ -4,9 +4,9 @@ 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 std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::imp;
|
use crate::imp::{Platform, PlatformInterface as _};
|
||||||
|
|
||||||
pub type ChannelId = u32;
|
pub type ChannelId = u32;
|
||||||
pub type UserId = u32;
|
pub type UserId = u32;
|
||||||
@@ -54,30 +54,27 @@ pub enum Command {
|
|||||||
use Command::*;
|
use Command::*;
|
||||||
use ConnectionState::*;
|
use ConnectionState::*;
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ChannelState {
|
|
||||||
pub name: String,
|
|
||||||
pub children: OrderSet<ChannelId>,
|
|
||||||
pub users: OrderSet<UserId>,
|
|
||||||
pub parent: Option<ChannelId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct UserState {
|
pub struct UserState {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,8 +86,121 @@ pub struct Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ServerState {
|
pub struct ChannelState {
|
||||||
|
pub name: String,
|
||||||
|
pub children: OrderSet<ChannelId>,
|
||||||
|
pub users: OrderSet<UserId>,
|
||||||
|
pub parent: Option<ChannelId>,
|
||||||
|
pub position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelState {
|
||||||
|
pub fn update_from_channel_state(
|
||||||
|
&mut self,
|
||||||
|
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
||||||
|
) {
|
||||||
|
if channel_state.has_position() {
|
||||||
|
self.position = channel_state.get_position();
|
||||||
|
}
|
||||||
|
if channel_state.has_parent() {
|
||||||
|
self.parent = Some(channel_state.get_parent());
|
||||||
|
}
|
||||||
|
if channel_state.has_name() {
|
||||||
|
self.name = channel_state.get_name().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ChannelsState {
|
||||||
pub channels: HashMap<ChannelId, ChannelState>,
|
pub channels: HashMap<ChannelId, ChannelState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelsState {
|
||||||
|
pub fn update_from_channel_state(
|
||||||
|
&mut self,
|
||||||
|
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
||||||
|
) {
|
||||||
|
self.channels
|
||||||
|
.entry(channel_state.get_channel_id())
|
||||||
|
.or_default()
|
||||||
|
.update_from_channel_state(channel_state);
|
||||||
|
|
||||||
|
self.update_channel_parents();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_from_channel_remove(
|
||||||
|
&mut self,
|
||||||
|
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
|
||||||
|
) {
|
||||||
|
self.channels.remove(&channel_remove.get_channel_id());
|
||||||
|
self.update_channel_parents();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_channel_parents(&mut self) {
|
||||||
|
// Zero out existing children
|
||||||
|
for state in self.channels.values_mut() {
|
||||||
|
state.children.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
|
||||||
|
for (id, state) in self.channels.iter() {
|
||||||
|
// Handle channels with no parent (the root channel)
|
||||||
|
let Some(parent_id) = state.parent else {
|
||||||
|
to_sort.push((*id, None, 0, state.name.clone()));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If a channel has a parent that we haven't gotten a channel
|
||||||
|
// state packet for, ignore it
|
||||||
|
if !self.channels.contains_key(&parent_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos_name: HashMap<ChannelId, (i32, String)> = self
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.map(|(&id, state)| (id, (state.position, state.name.clone())))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut updated: HashSet<ChannelId> = HashSet::new();
|
||||||
|
|
||||||
|
while updated.len() < to_sort.len() {
|
||||||
|
for &(id, ref parent_id, position, ref name) in &to_sort {
|
||||||
|
let Some(parent_id) = parent_id else {
|
||||||
|
updated.insert(id);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if updated.contains(&id) || !updated.contains(&parent_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap should never fail here since we pre filter
|
||||||
|
let parent = self.channels.get_mut(&parent_id).unwrap();
|
||||||
|
|
||||||
|
let mut insert_index = parent.children.len();
|
||||||
|
for (i, &child) in parent.children.iter().enumerate() {
|
||||||
|
let (p, ref n) = pos_name[&child];
|
||||||
|
if (position == p && name < n) || p > position {
|
||||||
|
insert_index = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.children.insert_before(insert_index, id);
|
||||||
|
updated.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ServerState {
|
||||||
|
pub channels_state: ChannelsState,
|
||||||
pub users: HashMap<UserId, UserState>,
|
pub users: HashMap<UserId, UserState>,
|
||||||
pub chat: Vec<Chat>,
|
pub chat: Vec<Chat>,
|
||||||
pub session: Option<UserId>,
|
pub session: Option<UserId>,
|
||||||
@@ -117,6 +227,7 @@ pub enum UserIcon {
|
|||||||
Normal,
|
Normal,
|
||||||
Muted,
|
Muted,
|
||||||
Deafened,
|
Deafened,
|
||||||
|
Suppressed,
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +239,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,7 +251,7 @@ pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
|
|||||||
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)",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,26 +287,57 @@ pub fn Channel(id: ChannelId) -> Element {
|
|||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
let net: Coroutine<Command> = use_coroutine_handle();
|
||||||
let server = STATE.server.read();
|
let server = STATE.server.read();
|
||||||
let user = server.session.unwrap();
|
let user = server.session.unwrap();
|
||||||
let Some(state) = server.channels.get(&id) else {
|
let Some(state) = server.channels_state.channels.get(&id) else {
|
||||||
return rsx!("missing channel {id}");
|
return rsx!("missing channel {id}");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut open = use_signal(|| true);
|
||||||
|
|
||||||
|
let has_children = !state.users.is_empty() || !state.children.is_empty();
|
||||||
|
|
||||||
rsx!(
|
rsx!(
|
||||||
details {
|
div {
|
||||||
class: "channel_details",
|
class: "channel_details",
|
||||||
open: true,
|
|
||||||
summary {
|
div {
|
||||||
|
class: "channel_header",
|
||||||
|
// Arrow: only toggles open
|
||||||
|
if has_children {
|
||||||
span {
|
span {
|
||||||
role: "button",
|
class: "channel_arrow",
|
||||||
ondoubleclick: move |evt| {
|
onclick: move |evt| {
|
||||||
|
evt.stop_propagation();
|
||||||
|
evt.prevent_default();
|
||||||
|
let mut w = open.write();
|
||||||
|
*w = !*w;
|
||||||
|
},
|
||||||
|
if *open.read() { "▾" } else { "▸" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span {
|
||||||
|
class: "channel_arrow channel_arrow--placeholder",
|
||||||
|
" "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clickable row area (everything except the arrow)
|
||||||
|
div {
|
||||||
|
class: "channel_row_click",
|
||||||
|
ondblclick: move |evt| {
|
||||||
evt.stop_propagation();
|
evt.stop_propagation();
|
||||||
evt.prevent_default();
|
evt.prevent_default();
|
||||||
net.send(EnterChannel { channel: id, user })
|
net.send(EnterChannel { channel: id, user })
|
||||||
},
|
},
|
||||||
|
// remove dblclick from the inner span
|
||||||
|
span {
|
||||||
|
class: "channel_title",
|
||||||
"{state.name}"
|
"{state.name}"
|
||||||
}
|
}
|
||||||
|
// if you add icons/badges later, put them here too
|
||||||
}
|
}
|
||||||
if state.users.len() + state.children.len() > 0 {
|
}
|
||||||
|
|
||||||
|
if *open.read() && has_children {
|
||||||
div {
|
div {
|
||||||
class: "channel_children",
|
class: "channel_children",
|
||||||
for id in state.users.iter() {
|
for id in state.users.iter() {
|
||||||
@@ -210,6 +352,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]
|
||||||
@@ -231,6 +374,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 {
|
||||||
@@ -317,6 +462,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,
|
||||||
@@ -326,7 +472,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
|
|||||||
return rsx!();
|
return rsx!();
|
||||||
};
|
};
|
||||||
|
|
||||||
let current_channel_name = server.channels[&channel].name.clone();
|
let current_channel_name = server.channels_state.channels[&channel].name.clone();
|
||||||
|
|
||||||
let proxy_url = config
|
let proxy_url = config
|
||||||
.read_unchecked()
|
.read_unchecked()
|
||||||
@@ -341,81 +487,71 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
|
|||||||
let connection_status = match &*status.read() {
|
let connection_status = match &*status.read() {
|
||||||
Connecting => rsx! {
|
Connecting => rsx! {
|
||||||
div {
|
div {
|
||||||
style: "color: \"{connecting_color}\";",
|
class: "connection_status",
|
||||||
|
style: "color: {connecting_color};",
|
||||||
|
div {
|
||||||
span {
|
span {
|
||||||
class: "material-symbols-outlined",
|
class: "material-symbols-outlined",
|
||||||
style: "vertical-align: middle; font-size: 30px;",
|
|
||||||
"signal_cellular_alt_2_bar"
|
"signal_cellular_alt_2_bar"
|
||||||
}
|
}
|
||||||
span {
|
span {
|
||||||
style: "width: 5px; display: inline-block;"
|
class: "status_text",
|
||||||
}
|
|
||||||
span {
|
|
||||||
style: "vertical-align: middle; font-size: 30px;",
|
|
||||||
" Connecting"
|
" Connecting"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Connected => rsx! {
|
Connected => rsx! {
|
||||||
div {
|
div {
|
||||||
|
class: "connection_status",
|
||||||
div {
|
div {
|
||||||
style: "color: \"{connected_color}\";",
|
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",
|
||||||
}
|
|
||||||
span {
|
|
||||||
style: "vertical-align: middle; font-size: 25px;",
|
|
||||||
" Connected"
|
" Connected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div {
|
div {
|
||||||
span { style: "width: 3px; display: inline-block;"}
|
class: "channel_text",
|
||||||
span { "{current_channel_name}" }
|
span { "{current_channel_name}" }
|
||||||
if let Some(proxy_url) = proxy_url {
|
|
||||||
span { " — " }
|
|
||||||
span { "{proxy_url}" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Disconnected => rsx! {
|
Disconnected => rsx! {
|
||||||
div {
|
div {
|
||||||
style: "color: \"{disconnected_color}\";",
|
class: "connection_status",
|
||||||
|
style: "color: {disconnected_color};",
|
||||||
|
div {
|
||||||
span {
|
span {
|
||||||
class: "material-symbols-outlined",
|
class: "material-symbols-outlined",
|
||||||
style: "vertical-align: middle;",
|
|
||||||
"signal_disconnected"
|
"signal_disconnected"
|
||||||
}
|
}
|
||||||
span {
|
span {
|
||||||
style: "width: 5px; display: inline-block;"
|
class: "status_text",
|
||||||
}
|
|
||||||
span {
|
|
||||||
style: "vertical-align: middle;",
|
|
||||||
" Disconnected"
|
" Disconnected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Failed(_) => rsx! {
|
Failed(_) => rsx! {
|
||||||
div {
|
div {
|
||||||
style: "color: \"{failed_color}\";",
|
class: "connection_status",
|
||||||
|
style: "color: {failed_color};",
|
||||||
|
div {
|
||||||
span {
|
span {
|
||||||
class: "material-symbols-outlined",
|
class: "material-symbols-outlined",
|
||||||
style: "vertical-align: middle;",
|
|
||||||
"error"
|
"error"
|
||||||
}
|
}
|
||||||
span {
|
span {
|
||||||
style: "width: 5px; display: inline-block;"
|
class: "status_text",
|
||||||
}
|
|
||||||
span {
|
|
||||||
style: "vertical-align: middle;",
|
|
||||||
" Failed"
|
" Failed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,16 +581,17 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
|
|||||||
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" }
|
||||||
@@ -476,15 +613,15 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
class: match mute || self_mute {
|
class: match mute || suppress || self_mute {
|
||||||
true => "toggle_button is_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", "mic_off"}),
|
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
|
||||||
false => rsx!(span { class: "material-symbols-outlined", "mic"}),
|
false => rsx!(span { class: "material-symbols-outlined", "mic"}),
|
||||||
}
|
}
|
||||||
@@ -527,7 +664,7 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
|
|||||||
class: "server_grid",
|
class: "server_grid",
|
||||||
div {
|
div {
|
||||||
class: "server_channel_box",
|
class: "server_channel_box",
|
||||||
for (id, state) in server.channels.iter() {
|
for (id, state) in server.channels_state.channels.iter() {
|
||||||
if state.parent.is_none() {
|
if state.parent.is_none() {
|
||||||
Channel { id: *id }
|
Channel { id: *id }
|
||||||
}
|
}
|
||||||
@@ -545,38 +682,20 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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(Platform::get_status(&client).await);
|
||||||
imp::sleep(std::time::Duration::from_secs_f32(1.0)).await;
|
Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut address_input = use_signal(|| None::<String>);
|
let mut address_input = use_signal(|| Platform::load_server_url());
|
||||||
let 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()
|
||||||
@@ -587,12 +706,15 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let previous_username = imp::load_username();
|
let previous_username = Platform::load_username();
|
||||||
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
|
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
|
||||||
|
|
||||||
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 _ = Platform::set_default_username(&username.read());
|
||||||
|
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
|
||||||
|
Platform::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(),
|
||||||
@@ -630,11 +752,16 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
|
|||||||
),
|
),
|
||||||
Connected => unreachable!(),
|
Connected => unreachable!(),
|
||||||
};
|
};
|
||||||
|
let version = option_env!("MUMBLE_WEB2_VERSION");
|
||||||
rsx!(
|
rsx!(
|
||||||
div {
|
div {
|
||||||
class: "login",
|
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) {
|
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
|
||||||
div {
|
div {
|
||||||
@@ -733,12 +860,14 @@ pub fn app() -> Element {
|
|||||||
|
|
||||||
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
|
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
|
||||||
let config = use_resource(|| async move {
|
let config = use_resource(|| async move {
|
||||||
match imp::load_config().await {
|
match Platform::load_config().await {
|
||||||
Ok(config) => config,
|
Ok(config) => config,
|
||||||
Err(_) => ClientConfig::default(),
|
Err(_) => ClientConfig::default(),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Platform::request_permissions();
|
||||||
|
|
||||||
rsx!(
|
rsx!(
|
||||||
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
|
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
|
||||||
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" }
|
||||||
|
|||||||
+71
-11
@@ -7,9 +7,24 @@ use std::cell::RefCell;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::imp;
|
use crate::imp::SpawnHandle;
|
||||||
|
|
||||||
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
|
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
|
||||||
|
// TODO: make this user configurable.
|
||||||
|
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 {
|
enum DenoisingModelState {
|
||||||
Nothing,
|
Nothing,
|
||||||
@@ -17,10 +32,7 @@ enum DenoisingModelState {
|
|||||||
Availible(Box<DfTract>),
|
Availible(Box<DfTract>),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_denoising_model<O>(
|
fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract) -> O) -> Option<O> {
|
||||||
spawn: &imp::SpawnHandle,
|
|
||||||
func: impl FnOnce(&mut DfTract) -> O,
|
|
||||||
) -> Option<O> {
|
|
||||||
// Using a thread local is super gross, but DfTract is not Send (so it can never leave the current
|
// Using a thread local is super gross, but DfTract is not Send (so it can never leave the current
|
||||||
// thread) while AudioProcessing itself might change threads whenever.
|
// thread) while AudioProcessing itself might change threads whenever.
|
||||||
thread_local! {
|
thread_local! {
|
||||||
@@ -74,36 +86,52 @@ fn with_denoising_model<O>(
|
|||||||
|
|
||||||
pub struct AudioProcessor {
|
pub struct AudioProcessor {
|
||||||
denoise: bool,
|
denoise: bool,
|
||||||
spawn: imp::SpawnHandle,
|
spawn: SpawnHandle,
|
||||||
buffer: Vec<f32>,
|
buffer: Vec<f32>,
|
||||||
|
noise_floor: f32,
|
||||||
|
/// 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 {
|
pub fn new_plain() -> Self {
|
||||||
AudioProcessor {
|
AudioProcessor {
|
||||||
denoise: false,
|
denoise: false,
|
||||||
spawn: imp::SpawnHandle::current(),
|
spawn: SpawnHandle::current(),
|
||||||
buffer: Vec::new(),
|
buffer: Vec::new(),
|
||||||
|
noise_floor: DEFAULT_NOISE_FLOOR,
|
||||||
|
was_transmitting: false,
|
||||||
|
hold_samples: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_denoising() -> Self {
|
pub fn new_denoising() -> Self {
|
||||||
AudioProcessor {
|
AudioProcessor {
|
||||||
denoise: true,
|
denoise: true,
|
||||||
spawn: imp::SpawnHandle::current(),
|
spawn: SpawnHandle::current(),
|
||||||
buffer: Vec::new(),
|
buffer: Vec::new(),
|
||||||
|
noise_floor: DEFAULT_NOISE_FLOOR,
|
||||||
|
was_transmitting: false,
|
||||||
|
hold_samples: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioProcessor {
|
impl AudioProcessor {
|
||||||
pub fn process(&mut self, audio: &[f32], output: &mut Vec<f32>) {
|
pub fn process(
|
||||||
|
&mut self,
|
||||||
|
audio: &[f32],
|
||||||
|
channels: usize,
|
||||||
|
output: &mut Vec<f32>,
|
||||||
|
) -> TransmitState {
|
||||||
let mut include_raw = true;
|
let mut include_raw = true;
|
||||||
if self.denoise {
|
if self.denoise {
|
||||||
with_denoising_model(&self.spawn, |df| {
|
with_denoising_model(&self.spawn, |df| {
|
||||||
include_raw = false;
|
include_raw = false;
|
||||||
|
|
||||||
self.buffer.extend_from_slice(audio);
|
self.buffer.extend(audio.iter().step_by(channels).copied());
|
||||||
output.reserve(audio.len());
|
output.reserve(audio.len());
|
||||||
|
|
||||||
let hop = df.hop_size;
|
let hop = df.hop_size;
|
||||||
@@ -130,8 +158,40 @@ impl AudioProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if include_raw {
|
if include_raw {
|
||||||
output.extend_from_slice(audio);
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
pub use tokio::spawn;
|
||||||
|
#[allow(unused)]
|
||||||
|
pub type SpawnHandle = tokio::runtime::Handle;
|
||||||
+81
-286
@@ -1,303 +1,65 @@
|
|||||||
use crate::app::Command;
|
use crate::app::Command;
|
||||||
use crate::effects::{AudioProcessor, AudioProcessorSender};
|
use color_eyre::eyre::Error;
|
||||||
use color_eyre::eyre::{eyre, Context, Error};
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait};
|
|
||||||
use dioxus::hooks::UnboundedReceiver;
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
use futures::io::{AsyncRead, AsyncWrite};
|
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
||||||
use mumble_protocol::control::ClientControlCodec;
|
use mumble_web2_common::{ClientConfig, ServerStatus};
|
||||||
use mumble_web2_common::ClientConfig;
|
use std::collections::HashMap;
|
||||||
use std::mem::replace;
|
use std::time::Duration;
|
||||||
use std::net::ToSocketAddrs;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
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::runtime::Handle as SpawnHandle;
|
/// Desktop platform implementation using Tokio and native audio.
|
||||||
pub use tokio::task::spawn;
|
pub struct DesktopPlatform;
|
||||||
pub use tokio::time::sleep;
|
|
||||||
|
|
||||||
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
|
impl super::PlatformInterface for DesktopPlatform {
|
||||||
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
|
type AudioSystem = super::native_audio::NativeAudioSystem;
|
||||||
|
|
||||||
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
|
async fn sleep(duration: Duration) {
|
||||||
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
|
tokio::time::sleep(duration).await;
|
||||||
|
|
||||||
pub struct AudioSystem {
|
|
||||||
output: cpal::Device,
|
|
||||||
input: cpal::Device,
|
|
||||||
processors: AudioProcessorSender,
|
|
||||||
recording_stream: Option<cpal::Stream>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAMPLE_RATE: u32 = 48_000;
|
async fn load_config() -> color_eyre::Result<ClientConfig> {
|
||||||
const PACKET_SAMPLES: u32 = 960;
|
|
||||||
|
|
||||||
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
|
|
||||||
|
|
||||||
impl AudioSystem {
|
|
||||||
pub 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_recording(
|
|
||||||
&mut self,
|
|
||||||
mut each: impl FnMut(Vec<u8>) + Send + 'static,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
current_processor.process(frame, &mut output_buffer);
|
|
||||||
if output_buffer.len() < PACKET_SAMPLES as usize {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let remainder = output_buffer.split_off(PACKET_SAMPLES as usize);
|
|
||||||
let frame = replace(&mut output_buffer, remainder);
|
|
||||||
match encoder.encode_vec_float(&frame, frame.len() * 2) {
|
|
||||||
Ok(buf) => {
|
|
||||||
each(buf);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("error encoding {} samples: {e:?}", frame.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match self.input.build_input_stream(
|
|
||||||
&cpal::StreamConfig {
|
|
||||||
channels: 1,
|
|
||||||
sample_rate: cpal::SampleRate(SAMPLE_RATE),
|
|
||||||
buffer_size: cpal::BufferSize::Fixed(PACKET_SAMPLES),
|
|
||||||
},
|
|
||||||
data_callback,
|
|
||||||
error_callback,
|
|
||||||
None,
|
|
||||||
) {
|
|
||||||
Ok(stream) => {
|
|
||||||
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 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(
|
|
||||||
&cpal::StreamConfig {
|
|
||||||
channels: 1,
|
|
||||||
sample_rate: cpal::SampleRate(SAMPLE_RATE),
|
|
||||||
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 {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 fn set_default_username(username: &str) -> Option<()> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_username() -> Option<String> {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
|
|
||||||
Ok(ClientConfig {
|
Ok(ClientConfig {
|
||||||
proxy_url: None,
|
proxy_url: None,
|
||||||
status_url: None,
|
|
||||||
cert_hash: None,
|
cert_hash: None,
|
||||||
any_server: true,
|
any_server: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_logging() {
|
fn load_username() -> Option<String> {
|
||||||
|
let config = load_config_map();
|
||||||
|
config.get("username").cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_server_url() -> Option<String> {
|
||||||
|
let config = load_config_map();
|
||||||
|
config.get("server").cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_default_username(username: &str) -> Option<()> {
|
||||||
|
let mut config = load_config_map();
|
||||||
|
config.insert("username".to_string(), username.to_string());
|
||||||
|
save_config_map(&config).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_default_server(server: &str) -> Option<()> {
|
||||||
|
let mut config = load_config_map();
|
||||||
|
config.insert("server".to_string(), server.to_string());
|
||||||
|
save_config_map(&config).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn network_connect(
|
||||||
|
address: String,
|
||||||
|
username: String,
|
||||||
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
|
gui_config: &ClientConfig,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
super::connect::network_connect(address, username, event_rx, gui_config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||||
|
super::connect::get_status(client).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging() {
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
use tracing_subscriber::filter::EnvFilter;
|
use tracing_subscriber::filter::EnvFilter;
|
||||||
|
|
||||||
@@ -311,3 +73,36 @@ pub fn init_logging() {
|
|||||||
.with_env_filter(env_filter)
|
.with_env_filter(env_filter)
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_permissions() {
|
||||||
|
// No-op on desktop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_config_path() -> std::path::PathBuf {
|
||||||
|
let strategy = choose_app_strategy(AppStrategyArgs {
|
||||||
|
top_level_domain: "com".to_string(),
|
||||||
|
author: "Ohea Corp".to_string(),
|
||||||
|
app_name: "Mumble Web2".to_string(),
|
||||||
|
})
|
||||||
|
.expect("failed to choose app strategy");
|
||||||
|
strategy.config_dir().join("config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config_map() -> HashMap<String, String> {
|
||||||
|
let config_path = get_config_path();
|
||||||
|
match std::fs::read_to_string(&config_path) {
|
||||||
|
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
|
||||||
|
Err(_) => HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
|
||||||
|
let config_path = get_config_path();
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let contents = serde_json::to_string_pretty(config)?;
|
||||||
|
std::fs::write(&config_path, contents)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use crate::app::Command;
|
||||||
|
use color_eyre::eyre::Error;
|
||||||
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
|
use mumble_web2_common::{ClientConfig, ServerStatus};
|
||||||
|
use std::future::Future;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
|
||||||
|
pub struct MobilePlatform;
|
||||||
|
|
||||||
|
impl super::PlatformInterface for MobilePlatform {
|
||||||
|
type AudioSystem = super::native_audio::NativeAudioSystem;
|
||||||
|
|
||||||
|
async fn load_config() -> color_eyre::Result<ClientConfig> {
|
||||||
|
Ok(ClientConfig {
|
||||||
|
proxy_url: None,
|
||||||
|
cert_hash: None,
|
||||||
|
any_server: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_username() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_server_url() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_default_username(_username: &str) -> Option<()> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_default_server(server: &str) -> Option<()> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn network_connect(
|
||||||
|
address: String,
|
||||||
|
username: String,
|
||||||
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
|
gui_config: &ClientConfig,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
super::connect::network_connect(address, username, event_rx, gui_config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||||
|
super::connect::get_status(client).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_logging() {
|
||||||
|
use tracing::level_filters::LevelFilter;
|
||||||
|
use tracing_subscriber::filter::EnvFilter;
|
||||||
|
|
||||||
|
let env_filter = EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::INFO.into())
|
||||||
|
.from_env_lossy();
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_target(true)
|
||||||
|
.with_level(true)
|
||||||
|
.with_env_filter(env_filter)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_permissions() {
|
||||||
|
request_recording_permission();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sleep(duration: Duration) {
|
||||||
|
tokio::time::sleep(duration).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub fn request_recording_permission() {}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub fn request_recording_permission() {
|
||||||
|
use android_permissions::{PermissionManager, RECORD_AUDIO};
|
||||||
|
use jni::{objects::JObject, JavaVM};
|
||||||
|
|
||||||
|
let ctx = ndk_context::android_context();
|
||||||
|
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() };
|
||||||
|
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
|
||||||
|
|
||||||
|
let manager = PermissionManager::create(vm, activity).unwrap();
|
||||||
|
if !manager.check(&RECORD_AUDIO).unwrap() {
|
||||||
|
manager.request(&[&RECORD_AUDIO]).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
-5
@@ -1,11 +1,166 @@
|
|||||||
|
//! Platform abstraction layer
|
||||||
|
//!
|
||||||
|
//! This module defines traits that each platform (web, desktop, mobile) must implement.
|
||||||
|
//! The traits make the platform boundary explicit and provide compile-time verification.
|
||||||
|
#![allow(async_fn_in_trait)]
|
||||||
|
|
||||||
|
use crate::{app::Command, effects::AudioProcessor};
|
||||||
|
use color_eyre::eyre::Error;
|
||||||
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
|
use mumble_web2_common::{ClientConfig, ServerStatus};
|
||||||
|
use std::future::Future;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Trait Definitions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Platform-specific audio subsystem for capturing microphone input and creating playback streams.
|
||||||
|
///
|
||||||
|
/// The audio system handles Opus encoding internally - callers receive encoded frames
|
||||||
|
/// ready for network transmission.
|
||||||
|
pub trait AudioSystemInterface: Sized {
|
||||||
|
/// The player type returned by [`create_player`](Self::create_player).
|
||||||
|
type AudioPlayer: AudioPlayerInterface;
|
||||||
|
|
||||||
|
/// Initialize the audio system.
|
||||||
|
async fn new() -> Result<Self, Error>;
|
||||||
|
|
||||||
|
/// Set the processor for the microphone input, mainly noise cancellation settings.
|
||||||
|
fn set_processor(&self, processor: AudioProcessor);
|
||||||
|
|
||||||
|
/// Begin listening to microphone input, calling the `each` function with
|
||||||
|
/// encoded opus frames.
|
||||||
|
fn start_recording(
|
||||||
|
&mut self,
|
||||||
|
each: impl FnMut(Vec<u8>, bool) + Send + 'static,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Begin playback of an audio stream, returning an object that can be passed opus frames.
|
||||||
|
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handle to an active audio playback stream for a single remote user.
|
||||||
|
///
|
||||||
|
/// Each connected user gets their own `AudioPlayer` instance, which decodes
|
||||||
|
/// incoming Opus frames and outputs PCM audio to the platform's audio device.
|
||||||
|
/// The player manages its own decoder state and output buffer.
|
||||||
|
pub trait AudioPlayerInterface {
|
||||||
|
/// Decode and play an Opus-encoded audio frame.
|
||||||
|
fn play_opus(&mut self, payload: &[u8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is the main trait that each platform must implement. It combines all
|
||||||
|
/// platform-specific functionality into a single interface, providing compile-time
|
||||||
|
/// verification that all platforms implement the required functionality.
|
||||||
|
pub trait PlatformInterface {
|
||||||
|
type AudioSystem: AudioSystemInterface;
|
||||||
|
|
||||||
|
/// Initialize logging for the platform.
|
||||||
|
fn init_logging();
|
||||||
|
|
||||||
|
/// Request runtime permissions (Android audio recording, etc.).
|
||||||
|
fn request_permissions();
|
||||||
|
|
||||||
|
/// Establish a connection to the Mumble server and run the network loop.
|
||||||
|
fn network_connect(
|
||||||
|
address: String,
|
||||||
|
username: String,
|
||||||
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
|
gui_config: &ClientConfig,
|
||||||
|
) -> impl Future<Output = Result<(), Error>>;
|
||||||
|
|
||||||
|
/// Get server status (user count, version, etc.).
|
||||||
|
fn get_status(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
|
||||||
|
|
||||||
|
/// Load the proxy overrides (proxy URL, cert hash, etc.).
|
||||||
|
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>>;
|
||||||
|
|
||||||
|
/// Load saved username.
|
||||||
|
fn load_username() -> Option<String>;
|
||||||
|
|
||||||
|
/// Load saved server URL.
|
||||||
|
fn load_server_url() -> Option<String>;
|
||||||
|
|
||||||
|
/// Save the default username.
|
||||||
|
fn set_default_username(username: &str) -> Option<()>;
|
||||||
|
|
||||||
|
/// Save the default server URL.
|
||||||
|
fn set_default_server(server: &str) -> Option<()>;
|
||||||
|
|
||||||
|
/// Async sleep for the given duration.
|
||||||
|
fn sleep(duration: Duration) -> impl Future<Output = ()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Platform Modules
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
||||||
|
mod connect;
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mod desktop;
|
||||||
|
#[cfg(feature = "mobile")]
|
||||||
|
mod mobile;
|
||||||
|
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
||||||
|
mod native_audio;
|
||||||
|
mod stub;
|
||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
#[cfg(feature = "desktop")]
|
// ============================================================================
|
||||||
mod desktop;
|
// Platform Type Alias
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
#[cfg(all(feature = "web", not(feature = "desktop")))]
|
#[cfg(feature = "web")]
|
||||||
pub use web::*;
|
pub type Platform = web::WebPlatform;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "desktop", not(feature = "web")))]
|
||||||
|
pub type Platform = desktop::DesktopPlatform;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "mobile", not(feature = "web"), not(feature = "desktop")))]
|
||||||
|
pub type Platform = mobile::MobilePlatform;
|
||||||
|
|
||||||
|
#[cfg(all(
|
||||||
|
not(feature = "mobile"),
|
||||||
|
not(feature = "web"),
|
||||||
|
not(feature = "desktop")
|
||||||
|
))]
|
||||||
|
pub type Platform = stub::StubPlatform;
|
||||||
|
|
||||||
|
pub type AudioSystem = <Platform as PlatformInterface>::AudioSystem;
|
||||||
|
pub type AudioPlayer = <AudioSystem as AudioSystemInterface>::AudioPlayer;
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Platform Async Runtime
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
// Note: these can not be part of the Platform because they differ in Send requiremets
|
||||||
|
#[cfg(all(any(feature = "desktop", feature = "mobile"), not(feature = "web")))]
|
||||||
|
pub use connect::{spawn, SpawnHandle};
|
||||||
|
#[cfg(all(
|
||||||
|
not(feature = "desktop"),
|
||||||
|
not(feature = "mobile"),
|
||||||
|
not(feature = "web")
|
||||||
|
))]
|
||||||
|
pub use stub::{spawn, SpawnHandle};
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
pub use web::{spawn, SpawnHandle};
|
||||||
|
|
||||||
|
// =======================
|
||||||
|
// Compile-time Assertions
|
||||||
|
// =======================
|
||||||
|
const _: () = {
|
||||||
|
fn assert_platform<T: PlatformInterface>() {}
|
||||||
|
|
||||||
|
// Check each implementation, and prevent warnings that the implementations are unused.
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
let _ = assert_platform::<web::WebPlatform>;
|
||||||
#[cfg(feature = "desktop")]
|
#[cfg(feature = "desktop")]
|
||||||
pub use desktop::*;
|
let _ = assert_platform::<desktop::DesktopPlatform>;
|
||||||
|
#[cfg(feature = "mobile")]
|
||||||
|
let _ = assert_platform::<mobile::MobilePlatform>;
|
||||||
|
let _ = assert_platform::<stub::StubPlatform>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
|
||||||
|
use color_eyre::eyre::{eyre, Error};
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
|
||||||
|
use std::mem::replace;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
pub struct NativeAudioSystem {
|
||||||
|
output: cpal::Device,
|
||||||
|
input: cpal::Device,
|
||||||
|
processors: AudioProcessorSender,
|
||||||
|
recording_stream: Option<cpal::Stream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const PACKET_SAMPLES: u32 = 960;
|
||||||
|
// Divide by 1000 to get samples per ms, then multiply by 60ms for max Opus frame size.
|
||||||
|
const MAX_DECODE_SAMPLES: usize = SAMPLE_RATE as usize / 1000 * 60;
|
||||||
|
|
||||||
|
fn encode_and_send(
|
||||||
|
state: TransmitState,
|
||||||
|
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 NativeAudioSystem {
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::AudioSystemInterface for NativeAudioSystem {
|
||||||
|
type AudioPlayer = NativeAudioPlayer;
|
||||||
|
|
||||||
|
async fn new() -> Result<Self, Error> {
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let name = host.id();
|
||||||
|
let processors = AudioProcessorSender::default();
|
||||||
|
Ok(NativeAudioSystem {
|
||||||
|
output: host
|
||||||
|
.default_output_device()
|
||||||
|
.ok_or(eyre!("no output devices from {name:?}"))?,
|
||||||
|
input: host
|
||||||
|
.default_input_device()
|
||||||
|
.ok_or(eyre!("no input devices from {name:?}"))?,
|
||||||
|
processors,
|
||||||
|
recording_stream: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_processor(&self, processor: AudioProcessor) {
|
||||||
|
self.processors.store(Some(processor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_recording(
|
||||||
|
&mut self,
|
||||||
|
mut each: impl FnMut(Vec<u8>, bool) + Send + 'static,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_player(&mut self) -> Result<NativeAudioPlayer, 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(NativeAudioPlayer {
|
||||||
|
decoder,
|
||||||
|
stream,
|
||||||
|
buffer,
|
||||||
|
tmp: vec![0; MAX_DECODE_SAMPLES],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NativeAudioPlayer {
|
||||||
|
decoder: opus::Decoder,
|
||||||
|
stream: cpal::Stream,
|
||||||
|
buffer: Buffer,
|
||||||
|
tmp: Vec<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::AudioPlayerInterface for NativeAudioPlayer {
|
||||||
|
fn play_opus(&mut self, payload: &[u8]) {
|
||||||
|
let len = match self.decoder.decode(payload, &mut self.tmp, false) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
error!("opus decode error {e:?}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/// Stub implementation of the platform interface, so that we can
|
||||||
|
/// `cargo check` without any --feature flags.
|
||||||
|
use crate::effects::AudioProcessor;
|
||||||
|
use color_eyre::eyre::Error;
|
||||||
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
|
use mumble_web2_common::{ClientConfig, ServerStatus};
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
pub struct StubPlatform;
|
||||||
|
|
||||||
|
impl super::PlatformInterface for StubPlatform {
|
||||||
|
type AudioSystem = StubAudioSystem;
|
||||||
|
|
||||||
|
fn init_logging() {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_permissions() {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn network_connect(
|
||||||
|
_address: String,
|
||||||
|
_username: String,
|
||||||
|
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
|
||||||
|
_gui_config: &ClientConfig,
|
||||||
|
) -> impl Future<Output = Result<(), Error>> {
|
||||||
|
async { panic!("stubbed platform") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_status(
|
||||||
|
_client: &reqwest::Client,
|
||||||
|
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
|
||||||
|
async { panic!("stubbed platform") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>> {
|
||||||
|
async { panic!("stubbed platform") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_username() -> Option<String> {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_server_url() -> Option<String> {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_default_username(_username: &str) -> Option<()> {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_default_server(_server: &str) -> Option<()> {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
|
||||||
|
async { panic!("stubbed platform") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StubAudioSystem;
|
||||||
|
|
||||||
|
impl super::AudioSystemInterface for StubAudioSystem {
|
||||||
|
type AudioPlayer = StubAudioPlayer;
|
||||||
|
|
||||||
|
async fn new() -> Result<Self, Error> {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_processor(&self, _processor: AudioProcessor) {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_recording(
|
||||||
|
&mut self,
|
||||||
|
_each: impl FnMut(Vec<u8>, bool) + Send + 'static,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error> {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StubAudioPlayer;
|
||||||
|
|
||||||
|
impl super::AudioPlayerInterface for StubAudioPlayer {
|
||||||
|
fn play_opus(&mut self, _payload: &[u8]) {
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
pub struct SpawnHandle;
|
||||||
|
|
||||||
|
impl SpawnHandle {
|
||||||
|
#[allow(unused)]
|
||||||
|
pub fn spawn<F>(&self, _future: F)
|
||||||
|
where
|
||||||
|
F: Future<Output = ()> + 'static,
|
||||||
|
{
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
pub fn current() -> Self {
|
||||||
|
SpawnHandle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
pub fn spawn<F>(_future: F)
|
||||||
|
where
|
||||||
|
F: Future<Output = ()> + 'static,
|
||||||
|
{
|
||||||
|
panic!("stubbed platform")
|
||||||
|
}
|
||||||
+210
-149
@@ -1,21 +1,21 @@
|
|||||||
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 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::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;
|
||||||
@@ -28,27 +28,128 @@ use web_sys::AudioWorkletNode;
|
|||||||
use web_sys::EncodedAudioChunk;
|
use web_sys::EncodedAudioChunk;
|
||||||
use web_sys::EncodedAudioChunkInit;
|
use web_sys::EncodedAudioChunkInit;
|
||||||
use web_sys::EncodedAudioChunkType;
|
use web_sys::EncodedAudioChunkType;
|
||||||
use web_sys::MediaStream;
|
|
||||||
use web_sys::MediaStreamConstraints;
|
use web_sys::MediaStreamConstraints;
|
||||||
use web_sys::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};
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
pub use wasm_bindgen_futures::spawn_local as spawn;
|
pub use wasm_bindgen_futures::spawn_local as spawn;
|
||||||
|
|
||||||
pub trait ImpRead: AsyncRead + Unpin + 'static {}
|
#[allow(unused)]
|
||||||
impl<T: AsyncRead + Unpin + 'static> ImpRead for T {}
|
#[derive(Clone)]
|
||||||
|
pub struct SpawnHandle;
|
||||||
|
|
||||||
pub trait ImpWrite: AsyncWrite + Unpin + 'static {}
|
impl SpawnHandle {
|
||||||
impl<T: AsyncWrite + Unpin + 'static> ImpWrite for T {}
|
pub fn spawn<F>(&self, future: F)
|
||||||
|
where
|
||||||
|
F: Future<Output = ()> + 'static,
|
||||||
|
{
|
||||||
|
wasm_bindgen_futures::spawn_local(future);
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn sleep(d: Duration) {
|
pub fn current() -> Self {
|
||||||
TimeoutFuture::new(d.as_millis() as u32).await
|
SpawnHandle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web platform implementation using WebTransport and Web Audio API.
|
||||||
|
pub struct WebPlatform;
|
||||||
|
|
||||||
|
impl super::PlatformInterface for WebPlatform {
|
||||||
|
type AudioSystem = WebAudioSystem;
|
||||||
|
|
||||||
|
fn init_logging() {
|
||||||
|
// copied from tracing_web example usage
|
||||||
|
|
||||||
|
use tracing_subscriber::fmt::format::Pretty;
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
|
use tracing_web::{performance_layer, MakeWebConsoleWriter};
|
||||||
|
|
||||||
|
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_ansi(false) // Only partially supported across browsers
|
||||||
|
.without_time() // std::time is not available in browsers
|
||||||
|
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
|
||||||
|
.with_filter(LevelFilter::DEBUG);
|
||||||
|
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(fmt_layer)
|
||||||
|
.with(perf_layer)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
info!("logging initiated");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_permissions() {
|
||||||
|
// No-op on web
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_config() -> color_eyre::Result<ClientConfig> {
|
||||||
|
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
|
||||||
|
Some(url) => Url::parse(url)?,
|
||||||
|
None => absolute_url("config")?,
|
||||||
|
};
|
||||||
|
info!("loading config from {}", config_url);
|
||||||
|
|
||||||
|
let config = reqwest::get(config_url)
|
||||||
|
.await?
|
||||||
|
.json::<ClientConfig>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_username() -> Option<String> {
|
||||||
|
web_sys::window()
|
||||||
|
.unwrap()
|
||||||
|
.local_storage()
|
||||||
|
.ok()??
|
||||||
|
.get_item("username")
|
||||||
|
.ok()?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_server_url() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_default_username(username: &str) -> Option<()> {
|
||||||
|
web_sys::window()?
|
||||||
|
.local_storage()
|
||||||
|
.ok()??
|
||||||
|
.set_item("username", username)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_default_server(_server: &str) -> Option<()> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn network_connect(
|
||||||
|
address: String,
|
||||||
|
username: String,
|
||||||
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
|
gui_config: &ClientConfig,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
network_connect(address, username, event_rx, gui_config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||||
|
Ok(client
|
||||||
|
.get(absolute_url("status")?)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<ServerStatus>()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sleep(duration: Duration) {
|
||||||
|
TimeoutFuture::new(duration.as_millis() as u32).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trait ResultExt<T> {
|
trait ResultExt<T> {
|
||||||
@@ -73,25 +174,56 @@ impl<T> ResultExt<T> for Result<T, JsError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioSystem {
|
pub struct WebAudioSystem {
|
||||||
webctx: AudioContext,
|
webctx: AudioContext,
|
||||||
processors: AudioProcessorSender,
|
processors: AudioProcessorSender,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioSystem {
|
async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
|
||||||
pub fn new() -> Result<Self, 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 super::AudioSystemInterface for WebAudioSystem {
|
||||||
|
type AudioPlayer = WebAudioPlayer;
|
||||||
|
|
||||||
|
async fn new() -> Result<Self, Error> {
|
||||||
// Create MediaStreams to playback decoded audio
|
// Create MediaStreams to playback decoded audio
|
||||||
// The audio context is used to reproduce audio.
|
// The audio context is used to reproduce audio.
|
||||||
let webctx = configure_audio_context();
|
let webctx = configure_audio_context();
|
||||||
|
attach_worklet(&webctx).await?;
|
||||||
|
|
||||||
let processors = AudioProcessorSender::default();
|
let processors = AudioProcessorSender::default();
|
||||||
Ok(AudioSystem { webctx, processors })
|
|
||||||
|
Ok(WebAudioSystem { webctx, processors })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_processor(&self, processor: AudioProcessor) {
|
fn set_processor(&self, processor: AudioProcessor) {
|
||||||
self.processors.store(Some(processor))
|
self.processors.store(Some(processor))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_recording(&mut self, each: impl FnMut(Vec<u8>) + 'static) -> Result<(), Error> {
|
fn start_recording(&mut self, each: impl FnMut(Vec<u8>, bool) + 'static) -> Result<(), Error> {
|
||||||
let audio_context_worklet = self.webctx.clone();
|
let audio_context_worklet = self.webctx.clone();
|
||||||
let processors = self.processors.clone();
|
let processors = self.processors.clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
@@ -103,19 +235,11 @@ impl AudioSystem {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
|
fn create_player(&mut self) -> Result<WebAudioPlayer, 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()?;
|
||||||
|
|
||||||
@@ -124,28 +248,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!("write chunk error {:?}", e);
|
error!("could not copy audio data to array {:?}", 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(
|
||||||
@@ -161,14 +288,14 @@ impl AudioSystem {
|
|||||||
decoder_error.forget();
|
decoder_error.forget();
|
||||||
output.forget();
|
output.forget();
|
||||||
|
|
||||||
Ok(AudioPlayer(audio_decoder))
|
Ok(WebAudioPlayer(audio_decoder))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioPlayer(AudioDecoder);
|
pub struct WebAudioPlayer(AudioDecoder);
|
||||||
|
|
||||||
impl AudioPlayer {
|
impl super::AudioPlayerInterface for WebAudioPlayer {
|
||||||
pub fn play_opus(&mut self, payload: &[u8]) {
|
fn play_opus(&mut self, payload: &[u8]) {
|
||||||
let js_audio_payload = Uint8Array::from(payload);
|
let js_audio_payload = Uint8Array::from(payload);
|
||||||
let _ = self.0.decode(
|
let _ = self.0.decode(
|
||||||
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
|
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
|
||||||
@@ -200,22 +327,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 mut output = Vec::with_capacity(input.len());
|
let mut output = Vec::with_capacity(input.len());
|
||||||
processor.process(&input, &mut output);
|
let state = processor.process(&input, 1, &mut output);
|
||||||
|
if !output.is_empty() {
|
||||||
samples.copy_from(&output);
|
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();
|
||||||
@@ -234,37 +365,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(
|
||||||
@@ -291,8 +410,19 @@ async fn run_encoder_worklet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -388,77 +518,8 @@ pub async fn network_connect(
|
|||||||
crate::network_loop(username, event_rx, reader, writer).await
|
crate::network_loop(username, event_rx, reader, writer).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_default_username(username: &str) -> Option<()> {
|
|
||||||
web_sys::window()?
|
|
||||||
.local_storage()
|
|
||||||
.ok()??
|
|
||||||
.set_item("username", username)
|
|
||||||
.ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_username() -> Option<String> {
|
|
||||||
web_sys::window()
|
|
||||||
.unwrap()
|
|
||||||
.local_storage()
|
|
||||||
.ok()??
|
|
||||||
.get_item("username")
|
|
||||||
.ok()?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn absolute_url(path: &str) -> Result<Url, Error> {
|
pub fn absolute_url(path: &str) -> Result<Url, Error> {
|
||||||
let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
|
let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
|
||||||
let location = window.location();
|
let location = window.location();
|
||||||
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
|
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
|
|
||||||
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
|
|
||||||
Some(url) => Url::parse(url)?,
|
|
||||||
None => absolute_url("config")?,
|
|
||||||
};
|
|
||||||
info!("loading config from {}", config_url);
|
|
||||||
|
|
||||||
let config = reqwest::get(config_url)
|
|
||||||
.await?
|
|
||||||
.json::<ClientConfig>()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init_logging() {
|
|
||||||
// copied from tracing_web example usage
|
|
||||||
|
|
||||||
use tracing_subscriber::fmt::format::Pretty;
|
|
||||||
use tracing_subscriber::prelude::*;
|
|
||||||
use tracing_web::{performance_layer, MakeWebConsoleWriter};
|
|
||||||
|
|
||||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
|
||||||
.with_ansi(false) // Only partially supported across browsers
|
|
||||||
.without_time() // std::time is not available in browsers
|
|
||||||
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
|
|
||||||
.with_filter(LevelFilter::DEBUG);
|
|
||||||
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
|
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
|
||||||
.with(fmt_layer)
|
|
||||||
.with(perf_layer)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
info!("logging initiated");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SpawnHandle;
|
|
||||||
|
|
||||||
impl SpawnHandle {
|
|
||||||
pub fn current() -> Self {
|
|
||||||
SpawnHandle
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn<F>(&self, future: F)
|
|
||||||
where
|
|
||||||
F: Future<Output = ()> + 'static,
|
|
||||||
{
|
|
||||||
spawn(future);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+24
-47
@@ -7,11 +7,12 @@ use asynchronous_codec::FramedWrite;
|
|||||||
use color_eyre::eyre::{bail, Error};
|
use color_eyre::eyre::{bail, Error};
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use futures::select;
|
use futures::select;
|
||||||
|
use futures::AsyncRead;
|
||||||
|
use futures::AsyncWrite;
|
||||||
use futures::FutureExt as _;
|
use futures::FutureExt as _;
|
||||||
use futures::SinkExt as _;
|
use futures::SinkExt as _;
|
||||||
use futures::StreamExt as _;
|
use futures::StreamExt as _;
|
||||||
use futures_channel::mpsc::UnboundedSender;
|
use futures_channel::mpsc::UnboundedSender;
|
||||||
pub use imp::spawn;
|
|
||||||
use msghtml::process_message_html;
|
use msghtml::process_message_html;
|
||||||
use mumble_protocol::control::msgs;
|
use mumble_protocol::control::msgs;
|
||||||
use mumble_protocol::control::ControlCodec;
|
use mumble_protocol::control::ControlCodec;
|
||||||
@@ -20,17 +21,17 @@ 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;
|
||||||
|
|
||||||
use crate::effects::AudioProcessor;
|
use crate::effects::AudioProcessor;
|
||||||
use crate::imp::AudioSystem;
|
use crate::imp::{
|
||||||
|
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
|
||||||
|
PlatformInterface as _,
|
||||||
|
};
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
mod effects;
|
mod effects;
|
||||||
@@ -50,7 +51,9 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
|
|||||||
|
|
||||||
*STATE.server.write() = Default::default();
|
*STATE.server.write() = Default::default();
|
||||||
*STATE.status.write() = ConnectionState::Connecting;
|
*STATE.status.write() = ConnectionState::Connecting;
|
||||||
if let Err(error) = imp::network_connect(address, username, &mut event_rx, &config).await {
|
if let Err(error) =
|
||||||
|
Platform::network_connect(address, username, &mut event_rx, &config).await
|
||||||
|
{
|
||||||
error!("could not connect {:?}", error);
|
error!("could not connect {:?}", error);
|
||||||
*STATE.status.write() = ConnectionState::Failed(error.to_string());
|
*STATE.status.write() = ConnectionState::Failed(error.to_string());
|
||||||
} else {
|
} else {
|
||||||
@@ -59,7 +62,7 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
|
pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin + 'static>(
|
||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
||||||
@@ -108,23 +111,23 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
imp::sleep(Duration::from_millis(3000)).await;
|
Platform::sleep(Duration::from_millis(3000)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut audio = imp::AudioSystem::new()?;
|
let mut audio = AudioSystem::new().await?;
|
||||||
{
|
{
|
||||||
let send_chan = send_chan.clone();
|
let send_chan = send_chan.clone();
|
||||||
let mut sequence_num = 0;
|
let mut sequence_num = 0;
|
||||||
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);
|
||||||
@@ -299,8 +302,8 @@ fn accept_command(
|
|||||||
|
|
||||||
fn accept_packet(
|
fn accept_packet(
|
||||||
msg: ControlPacket<mumble_protocol::Clientbound>,
|
msg: ControlPacket<mumble_protocol::Clientbound>,
|
||||||
audio_context: &mut imp::AudioSystem,
|
audio_context: &mut AudioSystem,
|
||||||
player_map: &mut HashMap<u32, imp::AudioPlayer>,
|
player_map: &mut HashMap<u32, AudioPlayer>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
match msg {
|
match msg {
|
||||||
ControlPacket::UDPTunnel(u) => {
|
ControlPacket::UDPTunnel(u) => {
|
||||||
@@ -338,41 +341,11 @@ fn accept_packet(
|
|||||||
}
|
}
|
||||||
ControlPacket::ChannelState(u) => {
|
ControlPacket::ChannelState(u) => {
|
||||||
let mut server = STATE.server.write();
|
let mut server = STATE.server.write();
|
||||||
let id = u.get_channel_id();
|
server.channels_state.update_from_channel_state(&u);
|
||||||
|
|
||||||
let state = server.channels.entry(id).or_default();
|
|
||||||
let new_parent = if u.has_parent() {
|
|
||||||
if let Some(parent) = state.parent.and_then(|p| server.channels.get_mut(&p)) {
|
|
||||||
parent.children.remove(&id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let parent_id = u.get_parent();
|
|
||||||
let parent = server.channels.entry(parent_id).or_default();
|
|
||||||
if u.has_position() && u.get_position() as usize <= parent.children.len() {
|
|
||||||
// TODO: what if positions are received out of order? we need to sort afterwards?
|
|
||||||
parent.children.insert_before(u.get_position() as usize, id);
|
|
||||||
} else {
|
|
||||||
parent.children.insert(id);
|
|
||||||
}
|
|
||||||
Some(parent_id)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = server.channels.entry(id).or_default();
|
|
||||||
state.parent = new_parent;
|
|
||||||
if u.has_name() {
|
|
||||||
state.name = u.get_name().to_string();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ControlPacket::ChannelRemove(u) => {
|
ControlPacket::ChannelRemove(u) => {
|
||||||
let mut server = STATE.server.write();
|
let mut server = STATE.server.write();
|
||||||
let id = u.get_channel_id();
|
server.channels_state.update_from_channel_remove(&u);
|
||||||
if let Some(channel) = server.channels.remove(&id) {
|
|
||||||
if let Some(parent) = channel.parent.and_then(|p| server.channels.get_mut(&p)) {
|
|
||||||
parent.children.remove(&id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ControlPacket::UserState(u) => {
|
ControlPacket::UserState(u) => {
|
||||||
let mut server = STATE.server.write();
|
let mut server = STATE.server.write();
|
||||||
@@ -384,12 +357,13 @@ fn accept_packet(
|
|||||||
let state = state_entry.or_default();
|
let state = state_entry.or_default();
|
||||||
// the server might now send a channel_id if the user is in channel=0
|
// the server might now send a channel_id if the user is in channel=0
|
||||||
if u.has_channel_id() || new {
|
if u.has_channel_id() || new {
|
||||||
if let Some(parent) = server.channels.get_mut(&state.channel) {
|
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
||||||
parent.users.remove(&id);
|
parent.users.remove(&id);
|
||||||
}
|
}
|
||||||
|
|
||||||
let channel_id = u.get_channel_id();
|
let channel_id = u.get_channel_id();
|
||||||
server
|
server
|
||||||
|
.channels_state
|
||||||
.channels
|
.channels
|
||||||
.entry(channel_id)
|
.entry(channel_id)
|
||||||
.or_default()
|
.or_default()
|
||||||
@@ -407,6 +381,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();
|
||||||
}
|
}
|
||||||
@@ -418,7 +395,7 @@ fn accept_packet(
|
|||||||
let mut server = STATE.server.write();
|
let mut server = STATE.server.write();
|
||||||
let id = u.get_session();
|
let id = u.get_session();
|
||||||
if let Some(state) = server.users.remove(&id) {
|
if let Some(state) = server.users.remove(&id) {
|
||||||
if let Some(parent) = server.channels.get_mut(&state.channel) {
|
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
||||||
parent.users.remove(&id);
|
parent.users.remove(&id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
use mumble_web2_gui::{app, imp};
|
use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
|
||||||
|
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
imp::init_logging();
|
Platform::init_logging();
|
||||||
dioxus::launch(app::app);
|
dioxus::launch(app::app);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-19
@@ -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,6 @@ 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>,
|
proxy_url: Option<Url>,
|
||||||
https_listen_address: SocketAddr,
|
https_listen_address: SocketAddr,
|
||||||
http_listen_address: Option<SocketAddr>,
|
http_listen_address: Option<SocketAddr>,
|
||||||
@@ -85,9 +80,8 @@ async fn main() -> Result<()> {
|
|||||||
let mut client_config = ClientConfig {
|
let mut client_config = ClientConfig {
|
||||||
proxy_url: match &server_config.proxy_url {
|
proxy_url: match &server_config.proxy_url {
|
||||||
Some(url) => Some(url.to_string()),
|
Some(url) => Some(url.to_string()),
|
||||||
None => Some(server_config.public_url.join("proxy")?.to_string()),
|
None => None,
|
||||||
},
|
},
|
||||||
status_url: Some(server_config.public_url.join("status")?.to_string()),
|
|
||||||
cert_hash: None,
|
cert_hash: None,
|
||||||
any_server: false,
|
any_server: false,
|
||||||
};
|
};
|
||||||
@@ -341,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(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user