Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bbb3140ef | |||
| 59afbdab7b | |||
| 7f35a216cd | |||
| f0ce15000e | |||
| 7337b3e49b | |||
| d67a19c478 | |||
| 518c50d8a4 | |||
| 847c636f41 | |||
| 9006a082b0 | |||
| 083a11274e | |||
| 2fcb853c30 | |||
| feaa9f2bda | |||
| aa3fcf09cf | |||
| a30082eebe | |||
| 7c75e64a64 | |||
| 65883917b0 | |||
| c8119d0efa | |||
| d7b88874df | |||
| f001a192e1 | |||
| 37c0bce57e | |||
| 4abb130a77 | |||
| af35d72e4e | |||
| 889bdf6b80 | |||
| 391d18a11e | |||
| ca8a3d1b92 | |||
| 5d2c2a93c7 | |||
| 96589e28c6 | |||
| e7e7b945c5 |
@@ -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
|
||||
|
||||
- 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
|
||||
|
||||
@@ -83,3 +83,26 @@ jobs:
|
||||
name: mumble-web2-gui-windows
|
||||
path: gui/dist
|
||||
retention-days: 5
|
||||
|
||||
android_build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build dioxus project (x86_64-linux-android)
|
||||
run: dx build --platform android --target x86_64-linux-android --release -p mumble-web2-gui
|
||||
|
||||
- name: Build dioxus project (aarch64-linux-android)
|
||||
run: dx build --platform android --target aarch64-linux-android --release -p mumble-web2-gui
|
||||
|
||||
- name: Upload mumble-web2-gui Android Artifact
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: mumble-web2-android
|
||||
path: target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk
|
||||
retention-days: 5
|
||||
|
||||
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
@@ -16,3 +16,13 @@ pub struct ServerStatus {
|
||||
pub max_users: Option<u32>,
|
||||
pub bandwidth: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
|
||||
pub struct ServerEntry {
|
||||
pub name: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:z
|
||||
#- caddy_data:/data
|
||||
#- caddy_config:/config
|
||||
depends_on:
|
||||
@@ -35,8 +35,8 @@ services:
|
||||
image: rust:latest
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ..:/app
|
||||
- ./proxy-config.toml:/app/config.toml
|
||||
- ..:/app:z
|
||||
- ./proxy-config.toml:/app/config.toml:z
|
||||
ports:
|
||||
- "4433:4433/tcp"
|
||||
- "4433:4433/udp"
|
||||
|
||||
@@ -44,10 +44,12 @@ RUN choco install rustup.install -y --no-progress
|
||||
RUN rustup toolchain install stable-x86_64-pc-windows-msvc
|
||||
RUN rustup default stable-x86_64-pc-windows-msvc
|
||||
|
||||
# Install cargo binstall
|
||||
RUN Set-ExecutionPolicy Unrestricted -Scope Process; `
|
||||
iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content
|
||||
|
||||
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
|
||||
# Install dioxus-cli from git HEAD with cargo
|
||||
# This is to work around a bug in the windows builder upstream.
|
||||
# Dioxus has released 0.7.2, but it seems to be broken for now.
|
||||
RUN cargo install --git https://github.com/DioxusLabs/dioxus dioxus-cli
|
||||
# Install dioxus-cli
|
||||
RUN cargo binstall dioxus-cli@0.7.3
|
||||
|
||||
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
|
||||
|
||||
+27
-5
@@ -63,10 +63,11 @@ tokio-rustls = { version = "^0.26.0", optional = true }
|
||||
opus = { version = "0.3.0", optional = true }
|
||||
cpal = { version = "0.15.3", optional = true }
|
||||
dasp_ring_buffer = { version = "0.11.0", optional = true }
|
||||
etcetera = { version = "0.10.0", optional = true }
|
||||
|
||||
# Base Dependencies
|
||||
# ================
|
||||
dioxus = { version = "0.7.1" }
|
||||
dioxus = { version = "0.7.2" }
|
||||
once_cell = "1.19.0"
|
||||
asynchronous-codec = { workspace = true }
|
||||
futures = "^0.3.30"
|
||||
@@ -87,12 +88,12 @@ tracing = "^0.1.40"
|
||||
color-eyre = "^0.6.3"
|
||||
crossbeam-queue = "^0.3.11"
|
||||
lol_html = "^2.2.0"
|
||||
rfd = { git = "https://github.com/samsartor/rfd.git", version = "^0.16.0", default-features = false }
|
||||
base64 = "^0.22"
|
||||
mime_guess = "^2.0.5"
|
||||
async_cell = "^0.2.3"
|
||||
reqwest = { version = "^0.12.22", features = ["json"] }
|
||||
dioxus-asset-resolver = "0.7.1"
|
||||
dioxus-asset-resolver = "0.7.2"
|
||||
|
||||
|
||||
# Denoising
|
||||
# =========
|
||||
@@ -101,6 +102,18 @@ deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d3
|
||||
] }
|
||||
crossbeam = "0.8.4"
|
||||
|
||||
# Platform Integration
|
||||
# ====================
|
||||
# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
|
||||
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true }
|
||||
|
||||
# Android dependencies for requesting permissions
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android-permissions = "0.1.2"
|
||||
jni = "0.21.1"
|
||||
ndk-context = "0.1.1"
|
||||
|
||||
[patch.crates-io]
|
||||
tract-hir = "=0.12.4"
|
||||
tract-core = "=0.12.4"
|
||||
@@ -120,6 +133,7 @@ web = [
|
||||
"gloo-timers",
|
||||
"tracing-web",
|
||||
"deep_filter/wasm",
|
||||
"rfd",
|
||||
]
|
||||
desktop = [
|
||||
"dioxus/desktop",
|
||||
@@ -130,6 +144,14 @@ desktop = [
|
||||
"cpal",
|
||||
"dasp_ring_buffer",
|
||||
"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
|
||||
asset_dir = "public"
|
||||
|
||||
android_manifest = "build/AndroidManifest.xml"
|
||||
|
||||
[web.app]
|
||||
# HTML title tag content
|
||||
title = "Mumble Web 2"
|
||||
@@ -23,7 +25,7 @@ watch_path = ["src", "assets"]
|
||||
# CSS style file
|
||||
style = []
|
||||
# Javascript code file
|
||||
script = []
|
||||
script = ["loader.js"]
|
||||
|
||||
[web.resource.dev]
|
||||
# serve: [dev-server] only
|
||||
@@ -33,7 +35,7 @@ style = []
|
||||
script = []
|
||||
|
||||
[bundle]
|
||||
identifier = "xyz.ohea.mumble-web-2"
|
||||
identifier = "xyz.ohea.mumble_web_2"
|
||||
publisher = "OheaCorp"
|
||||
icon = [
|
||||
"icons/32x32.png",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#000000" fill-rule="evenodd" d="M11.7071,4.29289 L15.4142,8 L11.7071,11.7071 C11.3166,12.0976 10.6834,12.0976 10.2929,11.7071 C9.90237,11.3166 9.90237,10.6834 10.2929,10.2929 L11.5858,9 L2,9 C1.44771,9 1,8.55228 1,8 C1,7.44772 1.44771,7 2,7 L11.5858,7 L10.2929,5.70711 C9.90237,5.31658 9.90237,4.68342 10.2929,4.29289 C10.6834,3.90237 11.3166,3.90237 11.7071,4.29289 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 601 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 12V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 12V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 10V18C6 19.6569 7.34315 21 9 21H15C16.6569 21 18 19.6569 18 18V10" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 862 B |
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#000000;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M491.878,156.348C472.437,110.39,439.989,71.33,399.14,43.731C358.307,16.131,308.964-0.008,256,0
|
||||
c-35.304,0-69.011,7.167-99.652,20.122C110.39,39.564,71.33,72.011,43.731,112.86C16.131,153.693-0.008,203.036,0,256
|
||||
c0,35.304,7.167,69.02,20.122,99.653c19.442,45.957,51.889,85.016,92.738,112.616c40.832,27.6,90.176,43.74,143.14,43.731
|
||||
c35.305,0,69.02-7.166,99.653-20.122c45.957-19.442,85.017-51.889,112.617-92.738c27.6-40.832,43.74-90.176,43.731-143.14
|
||||
C512,220.697,504.842,186.98,491.878,156.348z M427.814,110.348c0.774,0.915,1.53,1.856,2.294,2.789
|
||||
c-1.496-0.454-2.991-0.908-4.486-1.37C426.353,111.297,427.084,110.819,427.814,110.348z M382.832,101.182
|
||||
C387.142,100.754,380.446,101.434,382.832,101.182c-1.798-0.126-3.159-0.858-4.066-2.177
|
||||
C384.579,95.217,388.747,100.585,382.832,101.182z M290.917,81.127c1.613,4.142-9.956,0.277-11.216-0.336
|
||||
c-0.739-0.739-1.294-1.58-1.663-2.52C278.021,79.203,290.388,79.749,290.917,81.127z M258.79,75.406
|
||||
c2.823,0.958,14.022-1.572,14.383,1.722c0.673,6.049-3.99,0.058-4.956,0.058c-2.622,0,1.21,2.78,1.31,2.923
|
||||
c-0.656-0.957-8.461-0.857-10.107-0.462C254.656,88.352,241.675,69.592,258.79,75.406z M271.711,87.026
|
||||
c-3.108,1.142-8.443,0.168-11.754,0.168C253.808,85.58,276.718,85.194,271.711,87.026z M79.236,313.19
|
||||
C79.06,315.812,79.346,311.544,79.236,313.19c0.042-0.663,1.126-5.755,2.084-6.049c1.513-0.453,4.613,6.999,4.487,8.041
|
||||
C85.11,321.206,78.892,318.148,79.236,313.19z M136.15,339.169c-3.252-3.983-5.192-8.461-8.284-12.528
|
||||
c-0.63-0.84-11.031-6.754-9.058-7.796c4.243-2.21,39.505,18.517,37.934,21.676c-0.152,0.303-7.889-6.797-9.847-5.52
|
||||
c-1.302,0.848,2.689,3.932,2.689,5.419c2.016,1.033,7.149,8.646,2.932,8.872C147.862,349.545,138.872,342.512,136.15,339.169z
|
||||
M154.894,340.546c0.21,1.37-3.646-1.185-3.873-1.277C151.777,337.884,154.718,339.412,154.894,340.546z M151.92,353.208
|
||||
c-3.898-2.487,12.569-0.554,13.459-0.487c3.252,0.243,11.418,0.076,13.552,3.974C179.569,357.862,155.574,355.544,151.92,353.208
|
||||
C154.356,354.77,150.122,352.066,151.92,353.208z M188.686,317.644c4.125-4.201,8.839,3.235,9.174,2.932
|
||||
c-2.596,2.369-6.486,6.94-1.252,9.671c3.932,2.058-4.672,3.537-4.672,4.268c0,2.966-4.771,10.795-7.814,12.014
|
||||
c0.177-0.067-11.913-3.419-12.972-3.722c-2.638-0.764-3.445-8.082-5.058-10.426C166.093,328.036,184.51,321.895,188.686,317.644z
|
||||
M167.53,281.869c1.756-1.328,3.378-1.479,4.848-0.454C173.546,287.801,165.514,282.785,167.53,281.869z M195.718,306.864
|
||||
c0.009-0.017,0.017-0.025,0.034-0.034c0.067-0.059,0.101-0.084,0.092-0.076c1.05-0.857,3.806-3.302,4.898-3.546
|
||||
C203.716,302.529,193.676,308.502,195.718,306.864z M201.658,294.278c-0.563-3.353-0.151-3.974,0.093-6.68
|
||||
c0.428-4.713,5.915-6.772,7.007-1.092c0.193,0.975-5.721,9.88-1.218,9.317c3.612-0.454,6.301-0.622,5.134,3.932
|
||||
c-1.076-0.714-7.814-4.075-8.637-3.898c-0.798,0.168-3.36,8.746-6.049,7.939C199.162,304.151,201.809,295.95,201.658,294.278z
|
||||
M200.734,269.073c0.084-1.328,2.042-5.125,4.176-4.688c2.461,0.513-0.731,10.141-2.487,9.032c-1.26-1.193-1.823-2.639-1.689-4.335
|
||||
C200.7,269.678,200.608,271.199,200.734,269.073z M213.479,300.403c0.016-0.008,0.016-0.016,0.034-0.016
|
||||
c4.797-2.42,3.452,4.218,3.965,4.452C216.831,304.546,213.101,300.613,213.479,300.403z M218.184,308.04
|
||||
C217.26,307.889,219.276,308.217,218.184,308.04c1.521,0.244,2.269,3.369,2.42,4.378c0.621,4.243,0.546,2.411-2.26,3.52
|
||||
c-0.765,0.294,0.084,2.747-1.672,2.865c-0.84,0.05-5.436-0.908-3.948-2.672c3.091-3.663-5.663-0.815-5.663-1.134
|
||||
c0-1.227,1.386-4.075,3.184-3.697C215.504,312.384,215.58,307.62,218.184,308.04z M223.435,335.086
|
||||
c-1.311-1.748-0.118-6.075,1.159-7.512c-1.016,1.142,2.706,3.058,2.882,2.26C227.081,331.632,223.661,335.388,223.435,335.086z
|
||||
M230.03,343.202c-0.076-0.739-6.041-0.588-6.948-0.73C221.51,342.471,229.593,338.766,230.03,343.202z M223.704,262.839
|
||||
c-3.907,0.89,0.816-2.747,1.664-2.823C226.518,259.907,223.914,262.554,223.704,262.839z M222.393,355.989
|
||||
c1.529,3.941-8.503,4.672-9.906,5.008c1.076-0.261,7.284-2.588,7.898-4.26C220.494,356.426,221.612,353.964,222.393,355.989z
|
||||
M200.557,343.369c-2.243,1.227,0.21,5.831-2.16,6.066c-3.503,0.352-1.638-8.755-0.866-11.208
|
||||
c3.798-11.998,9.352,4.495,12.469-3.47c-0.252,0.647-10.847-2.655-9.091-3.201c0.025-0.008,0.051-0.008,0.076-0.017
|
||||
c2.89-0.873,12.997-3.184,15.408-2.596c0.571,0.134-6.662,10.132-10.636,9.83c4.798,0.369,2.705,7.419,0.941,9.351
|
||||
c-0.63,0.689,4.411,2.646,3.722,3.117C207.993,352.914,197.574,345,200.557,343.369z M206.497,356.938
|
||||
c0.597,0.303-2.731,1.025-3.159,1.109c-1.377,0.47-2.688,0.412-3.932-0.176C198.465,357.115,205.691,356.535,206.497,356.938z
|
||||
M199.297,358.946c-0.647,0.404-1.336,0.404-2.067,0C195.256,357.744,198.348,357.938,199.297,358.946z M205.548,300.05
|
||||
c0,0,0,0-0.009,0c-0.025-0.025-0.051-0.042-0.076-0.058c0.017,0.008,0.034,0.025,0.058,0.041c-0.235-0.159-2.68-1.814-0.84-2.008
|
||||
C207.278,297.757,205.582,300.076,205.548,300.05z M208.783,308.284c-2.798,0-3.016-4.05-3.134-5.621
|
||||
C207.748,296.421,209.429,308.284,208.783,308.284z M209.975,358.132c0.74-0.387,8.208-2.403,7.704-1.613
|
||||
c-0.471,0.748-8.838,4.604-9.679,2.924C208.228,358.527,208.883,358.09,209.975,358.132z M218.839,343.202c0.008,0,0.017,0,0.017,0
|
||||
c-0.026,0-0.009,0-0.026,0c-1.142,0.051-1.622-0.328-1.403-1.143c2.445-1.126,3.344,1.303,1.429,1.143
|
||||
C219.318,343.244,219.091,343.226,218.839,343.202z M209.707,304.738c-0.093-0.042-0.06-0.025-0.009-0.008
|
||||
c-1.294-0.512-0.109-3.394,1.084-2.118C211.177,303.041,210.454,305.016,209.707,304.738z M212.244,305.528
|
||||
c-0.084,0.68-0.605,3.453-1.84,3.453c-0.218-0.74-0.218-1.479,0-2.218C210.539,305.629,211.151,305.218,212.244,305.528z
|
||||
M202.355,305.184L202.355,305.184c-0.58,1.168-7.78,9.838-8.839,5.184C193.542,310.486,201.884,306.142,202.355,305.184z
|
||||
M194.903,356.737c-1.512,0.395-2.789,1.294-4.47,0.344C188.719,356.115,194.836,356.737,194.903,356.737z M187.451,356.325
|
||||
c-0.992,0-0.446-0.656,0.268-0.739C188.442,355.502,188.35,356.325,187.451,356.325z M281.365,457.809
|
||||
c-5.629,13.039-11.771-7.864-10.998-7.62C277.752,452.726,286.145,446.736,281.365,457.809z M293.849,421.691
|
||||
c-1.949,5.721-3.814,11.728-7.343,16.845c-4.864,7.049-8.561,3.294-15.064,3.234c-3.723-0.034-2.849,2.866-6.916,1.278
|
||||
c-2.285-0.899-5.402-1.597-7.444-2.916c2.521,1.63-4.394-9.427-3.764-5.411c-0.344-2.167-2.823-1.084-4.848-1.479
|
||||
c1.093-1.386,1.042-3.537,2.151-4.915c-2.747-0.067-5.091,1.135-6.814,3.277c-7.738-9.561-14.93-10.452-27.372-7.327
|
||||
c-2.882,0.723-8.604,5.361-10.208,5.361c-6.537,0-8.041-0.261-12.914,3.596c-3.344,2.646-11.703,0.84-12.678-3.596
|
||||
c-0.446-2.016,3.218-4.797,3.226-6.89c0.008-1.327-3.84-2.596-4.31-3.94c-0.723-2.108,1-4.596-0.781-6.343
|
||||
c-0.748-0.731-4.125-3.15-4.243-4.15c-0.151-1.319,2.874-2.731,2.874-4.386c0-2.336,0-4.663,0-6.999
|
||||
c0-5.284,14.291-6.46,19.962-9.057c6.629-3.025,1.764-4.906,5.881-8.427c1.236-1.05,5.529,1.008,6.822,0
|
||||
c0.739-0.571-0.218-3.36,0.781-4.478c1.412-1.562,7.796-8.108,8.284-1.874c0.286,3.697-0.067,3.402,3.99,3.402
|
||||
c3.268,0-0.176-1.604,1.302-2.948c2.244-2.05,5-8.645,7.898-9.511c1.697-0.513,10.183,3.268,12.914,3.277
|
||||
c-0.95,2.73-1.874,5.478-2.865,8.2c4.267,2.16,12.258,8.788,17.223,5.906c2.318-1.352,3.268-13.014,3.949-15.745
|
||||
c2.881,4.453,7.83,7.755,9.519,12.09c2.579,6.604,6.864,6.83,10.729,12.233c3.604,5.016,6.906,9.864,9.906,15.199
|
||||
C297.757,412.423,296.564,413.717,293.849,421.691z M237.466,240.155c1.05,1.444-3.789,8.956-4.907,6.982
|
||||
c-0.772-1.37-0.982-4.73-1.554-6.428c-0.109-0.319-0.319-0.958,0-0.008C230.064,237.928,236.24,238.475,237.466,240.155z
|
||||
M267.989,202.381c3.991-0.008,4.168,5.125,8.604,3.772c-0.866,0.26,2.478,3.621-2.16,4.486c-2.916,0.529-6.965,3.73-10.166,1.63
|
||||
c0.428,0.286,0.218,0.151,0.008,0.016c1.95,1.311-3.47,2.412-3.671,0.89C260.604,213.202,270.182,202.372,267.989,202.381z
|
||||
M268.569,177.066c0,0.092,1.604-2.21,2.252-3.176c-0.706,2.824,6.435,16.526,4.335,17.173c-1.31,0.404-3.101-2.134-3.596-1.529
|
||||
c-2.209,2.697,1.74,6.83,0.479,7.973c0.319-0.286-2.958,0.723-3.772,1.193c0.017,0.152,0.042,0.236,0.067,0.236
|
||||
c-0.335,0-0.302-0.093-0.067-0.236C268.023,196.458,268.712,178.62,268.569,177.066z M291.959,363.442
|
||||
c-7.687,1.613-17.484-13.88-21.886-7.05c-3.151,4.89-16.854-2.201-17.409-0.083c0.782-2.992,5.84-0.933,2.579-5.294
|
||||
c-2.805-3.756-6.351-3.546-10.897-4.201c-3.251-0.47-2.352-2.453-3.772-4.268c-0.874-1.117-1.832,1.958-2.689,1.639
|
||||
c-0.118-0.042-4.848-6.982-4.848-7.209c5.864-7.688,7.494,2.764,10.771,4.1c6.612,2.697,6.704-4.73,13.098-1.806
|
||||
c3.251,1.487,27.7,10.418,26.877,12.493c-0.428,0.496-0.949,0.874-1.571,1.118C283.382,354.77,291.144,363.618,291.959,363.442z
|
||||
M239.138,239.726c0-0.009,0-0.009,0-0.017c0.907-2.848,8.679-3.907,7.301-0.756C245.725,240.592,238.088,243.12,239.138,239.726z
|
||||
M261.218,221.948c0.008-0.176,3.126-11.066,4.722-8.15c1.756,3.218,0.95,19.433-2.874,21.769
|
||||
c-0.991-0.084-1.352-0.597-1.076-1.538c0,2.05-13.325,5.05-13.14,5.234c-2.075-2.151,1.344-3.428-3.756-2.941
|
||||
c0.075,0-11.309,1.487-7.746-0.546c3.798-2.168,7.074-2.714,11.527-2.798c3.646-0.067,3.319-5.276,5.94-5.511
|
||||
c1.227-0.109,0.538,3.369,2.748,1.537C260.478,226.586,260.881,225.46,261.218,221.948z M292.53,351.729
|
||||
c-2,0.488-7.343-2.218-5.478-2.453c-1.646,0.201-0.588,0.067,0.017-0.009c3.285-0.412,7.612-1.697,10.217-1.966
|
||||
C299.899,347.041,293.539,351.486,292.53,351.729z M299.706,347.142c-0.513-0.538-0.908-1.134-1.16-1.806
|
||||
C296.354,341.832,303.705,348.604,299.706,347.142z M376.371,386.555c-0.924-0.143-1.252-0.672-0.991-1.58
|
||||
c0.984-2.823,3.059,1.033,3.386,1.37C377.968,386.412,377.169,386.488,376.371,386.555z M379.958,381.53
|
||||
c-0.865,0,1.924-3.042,3.588-1.395C385.26,381.824,380.295,381.446,379.958,381.53z M397.502,128.882
|
||||
c-4.588-0.865-3.108-4.159-6.713-4.462c-4.226-0.353-1.932,5.016-5.713,4c-13.897-3.73-0.63,5.469-4.882,9.612
|
||||
c0.428-0.412-7.335-1.093-8.712-0.622c-2.815,0.966-6.738,1.622-9.108,3.352c-3.193,2.336-6.385,4.672-9.578,6.999
|
||||
c-1.68,1.236-3.822-2.016-6.15-0.932c-2.731,1.269-8.78,0.513-10.595,1.697c-1.941,1.277-3.369,7.192-4.318,9.208
|
||||
c-3.756,8.007-8.108,25.087-19.727,26.222c-0.572-4.79-5.957-24.718,3.73-25.752c7.747-0.84,18.82-12.014,16.728-19.845
|
||||
c-7.771,0.311-3.847,4.739-7.183,6.235c-6.084,2.722-6.94-3.168-9.074-2.286c-6.377,2.655-6.974,2.454-8.284,8.755
|
||||
c-0.487,2.336-8.847,0.774-11.107,0.689c-6.436-0.227-22.358-2.588-27.138,0.605c-7.427,4.957-14.854,9.914-22.282,14.871
|
||||
c3.982,7.579,14.846,2.512,17.224,10.167c1.571,5.023-5.192,14.938-8.914,18.76c-3.664,3.772-7.335,7.545-11.006,11.318
|
||||
c-3.512,3.612-3.991-0.488-7.352,1.411c-5.218,2.941-5.201,11.897-12.57,12.14c3.117,4.109,3.681,7.016,4.058,11.847
|
||||
c0.378,4.89-3.243,4.487-8.007,6.192c-0.143-3.016-0.588-6.04,0-9.023c0.849-4.26-4.074-0.336-3.226-4.588
|
||||
c1.47-7.385-7.906-3.201-11.846-2.134c0.302-2.134-0.412-4.444,0-6.561c-4.067,2.294-8.132,4.595-12.199,6.889
|
||||
c3.688,3.185,6.284,7.276,11.838,4.587c0.571,4.075-3.126,4.537-6.814,6.562c5.142,3,5.436,9.83,6.948,14.779
|
||||
c1.932,6.285-2.252,8.41-6.645,13.561c-4.672,5.478-5.655,6.982-12.746,8.931c-3.932,1.092-7.864,2.176-11.796,3.26
|
||||
c-2,0.554-0.319,3.428-4.117,3.428c-0.982-6.73-8.847-1.386-11.258,1.512c-3.579,4.284,3.159,7.486,6.756,10.998
|
||||
c9.653,9.436-3.874,15.123-10.931,19.962c-2.563-6.772-10.234-8.67-12.208-14.106c-2.689,9.838-3.285,10.074,4.033,17.568
|
||||
c5.335,5.469,6.058,9.796,8.175,16.87c-4.31-1.966-9.377-2.907-11.073-7.142c-2.076-5.167-3.621-8.048-7.47-12.267
|
||||
c-3.965-4.352-4.134-17.256-6.225-23.231c-1.555,1.168-4.201,1.118-5.747,2.294c-1.16-4.436-4.217-16.913-9.687-18.694
|
||||
c-4.453-1.453-14.241,4.646-17.82,7.267c-4.721,3.47-15.476,8.226-15.636,14.09c-0.268,10.234-0.538,10.998-8.897,17.677
|
||||
c-8.007-11.67-12.108-21.239-14.359-34.775c-7.662,3.755-11.872-6.578-16.509-10.821c-2.151-1.957-6.16-2.151-10.461-1.966
|
||||
c-0.092-2.655-0.15-5.318-0.15-7.99c0-31.145,6.301-60.728,17.694-87.672c16.123-38.135,42.504-70.936,75.649-94.922
|
||||
c2.142-0.05,4.251,0.286,6.326,1.294c4.025,1.966,9.368-4.974,15.392-2.924c-2.142-8.746,18.668-9.174,13.997,0.328
|
||||
c4.537-1.336,8.486-3.184,13.208-2.487c7.746,1.126,5.578,2.403,4.529,9.368c-0.042,0.261-24.189,8.838-15.812,10.014
|
||||
c8.108,1.143,15.132-4.327,23.542-3.713c9.687,0.698,14.005,4.89,24.424,5.184c-4.159-9.99,12.728-2.789,17.946-1.638
|
||||
c-5.78,4.748,5.738,10.158,9.696,13.452c-0.143-7.301,14.871-5.973,21.6-5.629c3.706,0.194,1.983-6.638,6.57-5.839
|
||||
c6.226,1.084,12.443,2.167,18.668,3.251c5.252,0.916,8.015-0.344,13.148,2.403c3.512,1.882,1.143,6.394,5.948,5.882
|
||||
c3.42-0.362,11.595-2.806,14.048-0.362c3.655,3.638,1.597,7.814,7.596,9.805c1.352-6.663,12.871-6.612,18.66-4.924
|
||||
c2.68,0.79,14.972,10.544,10.41-1.311c10.871,2.21,21.734,4.411,32.606,6.621c4.26,0.866,4.352,4.192,7.956,5.848
|
||||
c2.344,1.076,8.856,0.748,12.2,1.966C413.851,126.9,403.425,130.008,397.502,128.882z M406.693,134.654
|
||||
c0.176-0.588,0.362-1.176,0.546-1.764c1.429-1.412,9.62-1.546,10.814,0.428C419.136,135.108,407.441,135.293,406.693,134.654z
|
||||
M437.107,168.185c4.974-4.864,9.897-9.754,15.081-14.425c-10.149,0.513-12.468,2.932-14.358-6.562
|
||||
c-2.151,1.31-4.31,2.621-6.461,3.932c-2.638-3.923-8.838-9.552-2.874-13.778c1.782-1.252,6.008,0.487,7.898-0.656
|
||||
c1.832-1.1,3.293-6.008,4.31-7.873c-5.176-1.84-5.31,2.723-9.334,2.63c-3.881-0.092-9.258-3.293-12.922-4.596
|
||||
c3.377-6.99,11.485-5.906,17.013-6.939c2.521,3.318,4.982,6.687,7.326,10.158c6.41,9.494,12.09,19.508,17.022,29.943
|
||||
C453.406,164.791,445.156,170.715,437.107,168.185z M453.18,278.096c-1.051,0.194-1.673-1.747-0.236-1.747
|
||||
C454.381,276.349,454.381,277.878,453.18,278.096z M457.011,283.785c-0.47-0.58-0.227-0.278-0.008-0.017
|
||||
c-2.243-2.739-1.663-6.209,2.403-4.352C461.498,280.356,458.43,285.499,457.011,283.785z"/>
|
||||
<path class="st0" d="M231.005,240.701v0.008C231.055,240.86,231.089,240.945,231.005,240.701z"/>
|
||||
<path class="st0" d="M287.07,349.268c-0.008,0-0.008,0.009-0.017,0.009C287.608,349.2,287.414,349.226,287.07,349.268z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.2799 6.40005L11.7399 15.94C10.7899 16.89 7.96987 17.33 7.33987 16.7C6.70987 16.07 7.13987 13.25 8.08987 12.3L17.6399 2.75002C17.8754 2.49308 18.1605 2.28654 18.4781 2.14284C18.7956 1.99914 19.139 1.92124 19.4875 1.9139C19.8359 1.90657 20.1823 1.96991 20.5056 2.10012C20.8289 2.23033 21.1225 2.42473 21.3686 2.67153C21.6147 2.91833 21.8083 3.21243 21.9376 3.53609C22.0669 3.85976 22.1294 4.20626 22.1211 4.55471C22.1128 4.90316 22.0339 5.24635 21.8894 5.5635C21.7448 5.88065 21.5375 6.16524 21.2799 6.40005V6.40005Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 4H6C4.93913 4 3.92178 4.42142 3.17163 5.17157C2.42149 5.92172 2 6.93913 2 8V18C2 19.0609 2.42149 20.0783 3.17163 20.8284C3.92178 21.5786 4.93913 22 6 22H17C19.21 22 20 20.2 20 18V13" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
+480
-11
@@ -83,6 +83,44 @@ a:visited {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.channel_header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.channel_arrow {
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.channel_arrow--placeholder {
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* The whole right side of the row is the dblclick target */
|
||||
.channel_row_click {
|
||||
flex: 1;
|
||||
padding: 0.1rem 0.25rem 0.1rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hover highlight for whole row area (title + blank space) */
|
||||
.channel_row_click:hover {
|
||||
background-color: var(--channel-hover-bg, #222); /* pick your color */
|
||||
}
|
||||
|
||||
|
||||
/* still keep text non-selectable if desired */
|
||||
.channel_details {
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.channel {
|
||||
&_details {
|
||||
flex: 0 0 100%;
|
||||
@@ -168,26 +206,68 @@ a:visited {
|
||||
background-color: oklch(0.53 0.1431 264.18);
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||
}
|
||||
}
|
||||
|
||||
.button_row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: clamp(4px, 1vw, 10px);
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
min-height: 0;
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.connection_status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.user_info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
|
||||
.user_name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user_data {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle_button {
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
padding: clamp(4px, 0.5vw, 8px);
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-color: unset;
|
||||
|
||||
border: solid rgb(255 255 255 / 0.1) 3px;
|
||||
border-radius: 10px;
|
||||
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
|
||||
border-radius: clamp(4px, 0.8vw, 10px);
|
||||
color: rgb(255 255 255 / 50%);
|
||||
|
||||
transition: all 0.5s ease-in-out;
|
||||
@@ -200,7 +280,6 @@ a:visited {
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||
vertical-align: middle;
|
||||
font-size: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,16 +324,60 @@ a:visited {
|
||||
}
|
||||
|
||||
&_control_box {
|
||||
padding: 16px;
|
||||
margin: 16px;
|
||||
padding: clamp(6px, 0.8vw, 12px);
|
||||
margin: clamp(6px, 0.8vw, 12px);
|
||||
background-color: var(--light-bg-color);
|
||||
border-radius: 10px;
|
||||
border-radius: clamp(6px, 1vw, 10px);
|
||||
overflow: hidden;
|
||||
grid-area: control;
|
||||
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: clamp(4px, 0.8vw, 8px);
|
||||
flex-direction: column;
|
||||
|
||||
// Dynamic font sizing for control elements
|
||||
--control-icon-size: clamp(16px, 2.5vw, 30px);
|
||||
--control-text-size: clamp(12px, 2vw, 25px);
|
||||
--control-small-text-size: clamp(10px, 1.5vw, 20px);
|
||||
--user-icon-size: clamp(24px, 4vw, 45px);
|
||||
--toggle-icon-size: clamp(18px, 3vw, 35px);
|
||||
|
||||
.connection_status {
|
||||
.material-symbols-outlined {
|
||||
font-size: var(--control-icon-size);
|
||||
}
|
||||
.status_text {
|
||||
font-size: var(--control-text-size);
|
||||
}
|
||||
.channel_text {
|
||||
font-size: var(--control-small-text-size);
|
||||
}
|
||||
}
|
||||
|
||||
.user_edit_button {
|
||||
.material-symbols-outlined {
|
||||
font-size: var(--user-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.user_info {
|
||||
.user_name {
|
||||
font-size: var(--control-text-size);
|
||||
}
|
||||
.user_data {
|
||||
font-size: var(--control-small-text-size);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle_button {
|
||||
.material-symbols-outlined {
|
||||
font-size: var(--toggle-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,4 +430,350 @@ a:visited {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.server-list-page h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login_version {
|
||||
font-size: 0.55em;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.server-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Rounded card */
|
||||
.server-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.server-card__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.65;
|
||||
filter: brightness(0) invert(0.8); /* light gray */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1; /* pushes the connect button to the far right */
|
||||
min-width: 0; /* prevents text overflow from breaking flex layout */
|
||||
}
|
||||
|
||||
.server-card__name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.server-card__address {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
|
||||
.server-card__action {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.server-card__action img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(0.8); /* light gray */
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.server-card__action:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.server-card__action:hover img {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.server-card__action:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Add server — dashed outline style to distinguish from real cards */
|
||||
.add-server-btn {
|
||||
width: 100%;
|
||||
padding: 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.add-server-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.0);
|
||||
z-index: 999;
|
||||
animation: backdrop-fade-in 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.modal {
|
||||
pointer-events: auto;
|
||||
|
||||
/* Make this solid or nearly solid instead of see-through */
|
||||
/* Old: background: rgba(255, 255, 255, 0.05); */
|
||||
background: #141414; /* or #151822, or rgb(15, 15, 20) */
|
||||
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
|
||||
padding: 1.25rem 1.5rem 1.4rem;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
animation: modal-pop-in 160ms ease-out forwards;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Form layout */
|
||||
|
||||
.modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-field label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.modal-field input {
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.modal-field input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.modal-field input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Actions row */
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Secondary button (Cancel) */
|
||||
|
||||
.modal-btn {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.modal-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-btn:active {
|
||||
transform: translateY(0) scale(0.97);
|
||||
}
|
||||
|
||||
/* Primary button (Save) */
|
||||
|
||||
.modal-btn--primary {
|
||||
background: rgba(67, 156, 255, 0.85);
|
||||
border-color: rgba(67, 156, 255, 1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.modal-btn--primary:hover {
|
||||
background: rgba(92, 174, 255, 0.95);
|
||||
border-color: rgba(135, 196, 255, 1);
|
||||
}
|
||||
|
||||
/* Delete button (danger) */
|
||||
|
||||
.modal-btn--danger {
|
||||
background: rgba(220, 60, 60, 0.85);
|
||||
border-color: rgba(220, 60, 60, 1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.modal-btn--danger:hover {
|
||||
background: rgba(240, 80, 80, 0.95);
|
||||
border-color: rgba(255, 120, 120, 1);
|
||||
}
|
||||
|
||||
.modal-actions__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Override mode username row */
|
||||
|
||||
.override-username-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.override-username-input {
|
||||
flex: 1;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.override-username-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.override-username-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Connect action button highlight */
|
||||
|
||||
.server-card__action--connect:hover {
|
||||
background: rgba(67, 156, 255, 0.3);
|
||||
border-color: rgba(67, 156, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Ping info on server card */
|
||||
|
||||
.server-card__ping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.1rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
|
||||
@keyframes backdrop-fade-in {
|
||||
from { background: rgba(0, 0, 0, 0.0); }
|
||||
to { background: rgba(0, 0, 0, 0.4); }
|
||||
}
|
||||
|
||||
@keyframes modal-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
+670
-227
File diff suppressed because it is too large
Load Diff
+69
-9
@@ -7,9 +7,24 @@ use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::imp;
|
||||
use crate::imp::SpawnHandle;
|
||||
|
||||
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
|
||||
// TODO: make this user configurable.
|
||||
static DEFAULT_NOISE_FLOOR: f32 = 0.001;
|
||||
// 200ms hold at 48kHz sample rate
|
||||
static HOLD_SAMPLES_MAX: usize = 48000 / 5; // 9600 samples = 200ms
|
||||
|
||||
/// Indicates the transmission state after processing audio.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransmitState {
|
||||
/// Audio is above threshold, or below but within hold period - transmit normally
|
||||
Transmitting,
|
||||
/// Hold period expired - send this frame as terminator (end_bit = true)
|
||||
Terminator,
|
||||
/// Silent and not transmitting - don't send anything
|
||||
Silent,
|
||||
}
|
||||
|
||||
enum DenoisingModelState {
|
||||
Nothing,
|
||||
@@ -17,10 +32,7 @@ enum DenoisingModelState {
|
||||
Availible(Box<DfTract>),
|
||||
}
|
||||
|
||||
fn with_denoising_model<O>(
|
||||
spawn: &imp::SpawnHandle,
|
||||
func: impl FnOnce(&mut DfTract) -> O,
|
||||
) -> Option<O> {
|
||||
fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract) -> O) -> Option<O> {
|
||||
// Using a thread local is super gross, but DfTract is not Send (so it can never leave the current
|
||||
// thread) while AudioProcessing itself might change threads whenever.
|
||||
thread_local! {
|
||||
@@ -74,30 +86,46 @@ fn with_denoising_model<O>(
|
||||
|
||||
pub struct AudioProcessor {
|
||||
denoise: bool,
|
||||
spawn: imp::SpawnHandle,
|
||||
spawn: SpawnHandle,
|
||||
buffer: Vec<f32>,
|
||||
noise_floor: f32,
|
||||
/// Whether we were transmitting in the previous frame
|
||||
was_transmitting: bool,
|
||||
/// Number of samples we've been below threshold (for hold period)
|
||||
hold_samples: usize,
|
||||
}
|
||||
|
||||
impl AudioProcessor {
|
||||
pub fn new_plain() -> Self {
|
||||
AudioProcessor {
|
||||
denoise: false,
|
||||
spawn: imp::SpawnHandle::current(),
|
||||
spawn: SpawnHandle::current(),
|
||||
buffer: Vec::new(),
|
||||
noise_floor: DEFAULT_NOISE_FLOOR,
|
||||
was_transmitting: false,
|
||||
hold_samples: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_denoising() -> Self {
|
||||
AudioProcessor {
|
||||
denoise: true,
|
||||
spawn: imp::SpawnHandle::current(),
|
||||
spawn: SpawnHandle::current(),
|
||||
buffer: Vec::new(),
|
||||
noise_floor: DEFAULT_NOISE_FLOOR,
|
||||
was_transmitting: false,
|
||||
hold_samples: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioProcessor {
|
||||
pub fn process(&mut self, audio: &[f32], channels: usize, output: &mut Vec<f32>) {
|
||||
pub fn process(
|
||||
&mut self,
|
||||
audio: &[f32],
|
||||
channels: usize,
|
||||
output: &mut Vec<f32>,
|
||||
) -> TransmitState {
|
||||
let mut include_raw = true;
|
||||
if self.denoise {
|
||||
with_denoising_model(&self.spawn, |df| {
|
||||
@@ -132,6 +160,38 @@ impl AudioProcessor {
|
||||
if include_raw {
|
||||
output.extend(audio.iter().step_by(channels).copied());
|
||||
}
|
||||
|
||||
// Calculate average amplitude for VAD
|
||||
let avg: f32 = if output.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
output.iter().map(|x| x.abs()).sum::<f32>() / output.len() as f32
|
||||
};
|
||||
|
||||
let above_threshold = avg >= self.noise_floor;
|
||||
let samples_in_frame = output.len();
|
||||
|
||||
let state = if above_threshold {
|
||||
// Above threshold - reset hold counter and transmit
|
||||
self.hold_samples = 0;
|
||||
self.was_transmitting = true;
|
||||
TransmitState::Transmitting
|
||||
} else if self.was_transmitting && self.hold_samples < HOLD_SAMPLES_MAX {
|
||||
// Below threshold but in hold period - keep transmitting
|
||||
self.hold_samples += samples_in_frame;
|
||||
TransmitState::Transmitting
|
||||
} else if self.was_transmitting {
|
||||
// Hold period expired - send terminator
|
||||
self.was_transmitting = false;
|
||||
self.hold_samples = 0;
|
||||
TransmitState::Terminator
|
||||
} else {
|
||||
// Not transmitting and below threshold - stay silent
|
||||
output.clear(); // Don't accumulate stale audio during silence
|
||||
TransmitState::Silent
|
||||
};
|
||||
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
+147
-315
@@ -1,350 +1,182 @@
|
||||
use crate::app::Command;
|
||||
use crate::effects::{AudioProcessor, AudioProcessorSender};
|
||||
use color_eyre::eyre::{bail, eyre, Context, Error};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
|
||||
use color_eyre::eyre::{bail, Error};
|
||||
use dioxus::hooks::UnboundedReceiver;
|
||||
use futures::io::{AsyncRead, AsyncWrite};
|
||||
use mumble_protocol::control::ClientControlCodec;
|
||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
||||
use std::mem::replace;
|
||||
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};
|
||||
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
||||
use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
pub use tokio::runtime::Handle as SpawnHandle;
|
||||
pub use tokio::task::spawn;
|
||||
pub use tokio::time::sleep;
|
||||
/// Desktop platform implementation using Tokio and native audio.
|
||||
pub struct DesktopPlatform;
|
||||
|
||||
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
|
||||
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
|
||||
impl super::PlatformInterface for DesktopPlatform {
|
||||
type AudioSystem = super::native_audio::NativeAudioSystem;
|
||||
|
||||
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
|
||||
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
|
||||
async fn sleep(duration: Duration) {
|
||||
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;
|
||||
const PACKET_SAMPLES: u32 = 960;
|
||||
|
||||
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
|
||||
|
||||
impl AudioSystem {
|
||||
pub async fn new() -> Result<Self, Error> {
|
||||
// TODO
|
||||
let host = cpal::default_host();
|
||||
let name = host.id();
|
||||
let processors = AudioProcessorSender::default();
|
||||
Ok(AudioSystem {
|
||||
output: host
|
||||
.default_output_device()
|
||||
.ok_or(eyre!("no output devices from {name:?}"))?,
|
||||
input: host
|
||||
.default_input_device()
|
||||
.ok_or(eyre!("no input devices from {name:?}"))?,
|
||||
processors,
|
||||
recording_stream: None,
|
||||
async fn load_config() -> color_eyre::Result<ClientConfig> {
|
||||
Ok(ClientConfig {
|
||||
proxy_url: None,
|
||||
cert_hash: None,
|
||||
any_server: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_processor(&self, processor: AudioProcessor) {
|
||||
self.processors.store(Some(processor))
|
||||
fn load_username() -> Option<String> {
|
||||
let config = load_config_map();
|
||||
config.get("username").cloned()
|
||||
}
|
||||
|
||||
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"))
|
||||
fn load_server_url() -> Option<String> {
|
||||
let config = load_config_map();
|
||||
config.get("server").cloned()
|
||||
}
|
||||
|
||||
pub fn start_recording(
|
||||
&mut self,
|
||||
mut each: impl FnMut(Vec<u8>) + Send + 'static,
|
||||
fn set_default_username(username: &str) -> Option<()> {
|
||||
let mut config = load_config_map();
|
||||
config.insert("username".to_string(), username.to_string());
|
||||
save_config_map(&config).ok()
|
||||
}
|
||||
|
||||
fn set_default_server(server: &str) -> Option<()> {
|
||||
let mut config = load_config_map();
|
||||
config.insert("server".to_string(), server.to_string());
|
||||
save_config_map(&config).ok()
|
||||
}
|
||||
|
||||
fn load_servers() -> Vec<ServerEntry> {
|
||||
let config = load_config_map();
|
||||
config
|
||||
.get("servers")
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_servers(servers: &[ServerEntry]) {
|
||||
let mut config = load_config_map();
|
||||
if let Ok(json) = serde_json::to_string(servers) {
|
||||
config.insert("servers".to_string(), json);
|
||||
let _ = save_config_map(&config);
|
||||
}
|
||||
}
|
||||
|
||||
async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
gui_config: &ClientConfig,
|
||||
) -> Result<(), Error> {
|
||||
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;
|
||||
}
|
||||
current_processor.process(frame, config.channels as usize, &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(&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())
|
||||
}
|
||||
}
|
||||
super::connect::network_connect(address, username, event_rx, gui_config).await
|
||||
}
|
||||
|
||||
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
|
||||
let config = self.choose_config(self.input.supported_input_configs()?)?;
|
||||
info!(
|
||||
"creating player on {:?} with {:#?}",
|
||||
self.output.name().ok(),
|
||||
&config
|
||||
);
|
||||
let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts(
|
||||
0,
|
||||
0,
|
||||
vec![
|
||||
0;
|
||||
SAMPLE_RATE as usize/4 // 250ms of buffer
|
||||
],
|
||||
)));
|
||||
let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?;
|
||||
let stream = {
|
||||
let buffer = buffer.clone();
|
||||
self.output.build_output_stream(
|
||||
&config,
|
||||
move |frame, _info| {
|
||||
let mut buffer = buffer.lock().unwrap();
|
||||
for x in frame.chunks_mut(config.channels as usize) {
|
||||
match buffer.pop() {
|
||||
Some(y) => {
|
||||
x.fill(y);
|
||||
}
|
||||
None => {
|
||||
x.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
move |err| error!("could not create output stream {err:?}"),
|
||||
None,
|
||||
)?
|
||||
};
|
||||
stream.play()?;
|
||||
Ok(AudioPlayer {
|
||||
decoder,
|
||||
stream,
|
||||
buffer,
|
||||
tmp: vec![0; 2400],
|
||||
})
|
||||
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||
super::connect::get_status(client).await
|
||||
}
|
||||
|
||||
async fn ping_server(address: &str, port: u16) -> color_eyre::Result<ServerStatus> {
|
||||
mumble_udp_ping(address, port).await
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::filter::EnvFilter;
|
||||
|
||||
let env_filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_env_filter(env_filter)
|
||||
.init();
|
||||
}
|
||||
|
||||
fn request_permissions() {
|
||||
// No-op on desktop
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioPlayer {
|
||||
decoder: opus::Decoder,
|
||||
stream: cpal::Stream,
|
||||
buffer: Buffer,
|
||||
tmp: Vec<i16>,
|
||||
fn get_config_path() -> std::path::PathBuf {
|
||||
let strategy = choose_app_strategy(AppStrategyArgs {
|
||||
top_level_domain: "xyz".to_string(),
|
||||
author: "ohea".to_string(),
|
||||
app_name: "Mumble Web2".to_string(),
|
||||
})
|
||||
.expect("failed to choose app strategy");
|
||||
strategy.config_dir().join("config.json")
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
]
|
||||
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(())
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
gui_config: &ClientConfig,
|
||||
) -> Result<(), Error> {
|
||||
info!("connecting");
|
||||
/// Mumble UDP ping protocol.
|
||||
///
|
||||
/// Send a 12-byte packet: 4 zero bytes + 8-byte identifier.
|
||||
/// Receive a 24-byte response: 4 bytes version (1 byte each: major.minor.patch + padding)
|
||||
/// + 8 bytes identifier echo + 4 bytes current_users + 4 bytes max_users + 4 bytes bandwidth.
|
||||
async fn mumble_udp_ping(address: &str, port: u16) -> color_eyre::Result<ServerStatus> {
|
||||
use std::net::ToSocketAddrs;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
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)
|
||||
let dest = format!("{}:{}", address, port)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.unwrap();
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("could not resolve address"))?;
|
||||
|
||||
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 bind_addr = if dest.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" };
|
||||
let socket = UdpSocket::bind(bind_addr).await?;
|
||||
socket.connect(dest).await?;
|
||||
|
||||
let read_codec = ClientControlCodec::new();
|
||||
let write_codec = ClientControlCodec::new();
|
||||
// Build ping packet: 4 zero bytes + 8-byte request ID
|
||||
let request_id: u64 = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
|
||||
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
|
||||
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
|
||||
let mut buf = [0u8; 12];
|
||||
buf[4..12].copy_from_slice(&request_id.to_be_bytes());
|
||||
socket.send(&buf).await?;
|
||||
|
||||
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 {
|
||||
proxy_url: None,
|
||||
cert_hash: None,
|
||||
any_server: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||
bail!("status not supported on desktop yet")
|
||||
}
|
||||
|
||||
pub fn init_logging() {
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::filter::EnvFilter;
|
||||
|
||||
let env_filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_env_filter(env_filter)
|
||||
.init();
|
||||
let mut response = [0u8; 24];
|
||||
let timeout = tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut response)).await;
|
||||
|
||||
match timeout {
|
||||
Ok(Ok(len)) if len >= 24 => {
|
||||
let version_major = response[0] as u32;
|
||||
let version_minor = response[1] as u32;
|
||||
let version_patch = response[2] as u32;
|
||||
let users = u32::from_be_bytes([response[12], response[13], response[14], response[15]]);
|
||||
let max_users = u32::from_be_bytes([response[16], response[17], response[18], response[19]]);
|
||||
let bandwidth = u32::from_be_bytes([response[20], response[21], response[22], response[23]]);
|
||||
|
||||
Ok(ServerStatus {
|
||||
success: true,
|
||||
version: Some((version_major, version_minor, version_patch)),
|
||||
users: Some(users),
|
||||
max_users: Some(max_users),
|
||||
bandwidth: Some(bandwidth),
|
||||
})
|
||||
}
|
||||
Ok(Ok(_)) => bail!("ping response too short"),
|
||||
Ok(Err(e)) => Err(e.into()),
|
||||
Err(_) => bail!("ping timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
use crate::app::Command;
|
||||
use color_eyre::eyre::Error;
|
||||
use dioxus::hooks::UnboundedReceiver;
|
||||
use mumble_web2_common::{ClientConfig, ServerEntry, 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
|
||||
}
|
||||
|
||||
fn load_servers() -> Vec<ServerEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn save_servers(_servers: &[ServerEntry]) {}
|
||||
|
||||
async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
gui_config: &ClientConfig,
|
||||
) -> Result<(), Error> {
|
||||
super::connect::network_connect(address, username, event_rx, gui_config).await
|
||||
}
|
||||
|
||||
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||
super::connect::get_status(client).await
|
||||
}
|
||||
|
||||
async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result<ServerStatus> {
|
||||
color_eyre::eyre::bail!("ping not supported on mobile yet")
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_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();
|
||||
}
|
||||
}
|
||||
+173
-6
@@ -1,11 +1,178 @@
|
||||
//! 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, ServerEntry, ServerStatus};
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
// ============================================================================
|
||||
// Trait Definitions
|
||||
// ============================================================================
|
||||
|
||||
/// Platform-specific audio subsystem for capturing microphone input and creating playback streams.
|
||||
///
|
||||
/// The audio system handles Opus encoding internally - callers receive encoded frames
|
||||
/// ready for network transmission.
|
||||
pub trait AudioSystemInterface: Sized {
|
||||
/// The player type returned by [`create_player`](Self::create_player).
|
||||
type AudioPlayer: AudioPlayerInterface;
|
||||
|
||||
/// Initialize the audio system.
|
||||
async fn new() -> Result<Self, Error>;
|
||||
|
||||
/// Set the processor for the microphone input, mainly noise cancellation settings.
|
||||
fn set_processor(&self, processor: AudioProcessor);
|
||||
|
||||
/// Begin listening to microphone input, calling the `each` function with
|
||||
/// encoded opus frames.
|
||||
fn start_recording(
|
||||
&mut self,
|
||||
each: impl FnMut(Vec<u8>, bool) + Send + 'static,
|
||||
) -> Result<(), Error>;
|
||||
|
||||
/// Begin playback of an audio stream, returning an object that can be passed opus frames.
|
||||
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error>;
|
||||
}
|
||||
|
||||
/// A handle to an active audio playback stream for a single remote user.
|
||||
///
|
||||
/// Each connected user gets their own `AudioPlayer` instance, which decodes
|
||||
/// incoming Opus frames and outputs PCM audio to the platform's audio device.
|
||||
/// The player manages its own decoder state and output buffer.
|
||||
pub trait AudioPlayerInterface {
|
||||
/// Decode and play an Opus-encoded audio frame.
|
||||
fn play_opus(&mut self, payload: &[u8]);
|
||||
}
|
||||
|
||||
/// This is the main trait that each platform must implement. It combines all
|
||||
/// platform-specific functionality into a single interface, providing compile-time
|
||||
/// verification that all platforms implement the required functionality.
|
||||
pub trait PlatformInterface {
|
||||
type AudioSystem: AudioSystemInterface;
|
||||
|
||||
/// Initialize logging for the platform.
|
||||
fn init_logging();
|
||||
|
||||
/// Request runtime permissions (Android audio recording, etc.).
|
||||
fn request_permissions();
|
||||
|
||||
/// Establish a connection to the Mumble server and run the network loop.
|
||||
fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
gui_config: &ClientConfig,
|
||||
) -> impl Future<Output = Result<(), Error>>;
|
||||
|
||||
/// Get server status (user count, version, etc.) via the web proxy status endpoint.
|
||||
fn get_status(
|
||||
client: &reqwest::Client,
|
||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
|
||||
|
||||
/// Ping a mumble server via UDP to get version, user count, etc.
|
||||
fn ping_server(
|
||||
address: &str,
|
||||
port: u16,
|
||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
|
||||
|
||||
/// Load the proxy overrides (proxy URL, cert hash, etc.).
|
||||
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>>;
|
||||
|
||||
/// Load saved username.
|
||||
fn load_username() -> Option<String>;
|
||||
|
||||
/// Load saved server URL.
|
||||
fn load_server_url() -> Option<String>;
|
||||
|
||||
/// Save the default username.
|
||||
fn set_default_username(username: &str) -> Option<()>;
|
||||
|
||||
/// Save the default server URL.
|
||||
fn set_default_server(server: &str) -> Option<()>;
|
||||
|
||||
/// Load the saved server list.
|
||||
fn load_servers() -> Vec<ServerEntry>;
|
||||
|
||||
/// Save the server list.
|
||||
fn save_servers(servers: &[ServerEntry]);
|
||||
|
||||
/// Async sleep for the given duration.
|
||||
fn sleep(duration: Duration) -> impl Future<Output = ()>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Platform Modules
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
||||
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")]
|
||||
mod web;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop;
|
||||
// ============================================================================
|
||||
// Platform Type Alias
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(all(feature = "web", not(feature = "desktop")))]
|
||||
pub use web::*;
|
||||
#[cfg(feature = "web")]
|
||||
pub type Platform = web::WebPlatform;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub use desktop::*;
|
||||
#[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")]
|
||||
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,134 @@
|
||||
/// Stub implementation of the platform interface, so that we can
|
||||
/// `cargo check` without any --feature flags.
|
||||
use crate::effects::AudioProcessor;
|
||||
use color_eyre::eyre::Error;
|
||||
use dioxus::hooks::UnboundedReceiver;
|
||||
use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
|
||||
use std::future::Future;
|
||||
|
||||
pub struct StubPlatform;
|
||||
|
||||
impl super::PlatformInterface for StubPlatform {
|
||||
type AudioSystem = StubAudioSystem;
|
||||
|
||||
fn init_logging() {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn request_permissions() {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn network_connect(
|
||||
_address: String,
|
||||
_username: String,
|
||||
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
|
||||
_gui_config: &ClientConfig,
|
||||
) -> impl Future<Output = Result<(), Error>> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
|
||||
fn get_status(
|
||||
_client: &reqwest::Client,
|
||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
|
||||
fn ping_server(
|
||||
_address: &str,
|
||||
_port: u16,
|
||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
|
||||
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
|
||||
fn load_username() -> Option<String> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn load_server_url() -> Option<String> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn set_default_username(_username: &str) -> Option<()> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn set_default_server(_server: &str) -> Option<()> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn load_servers() -> Vec<ServerEntry> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn save_servers(_servers: &[ServerEntry]) {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StubAudioSystem;
|
||||
|
||||
impl super::AudioSystemInterface for StubAudioSystem {
|
||||
type AudioPlayer = StubAudioPlayer;
|
||||
|
||||
async fn new() -> Result<Self, Error> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn set_processor(&self, _processor: AudioProcessor) {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn start_recording(
|
||||
&mut self,
|
||||
_each: impl FnMut(Vec<u8>, bool) + Send + 'static,
|
||||
) -> Result<(), Error> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StubAudioPlayer;
|
||||
|
||||
impl super::AudioPlayerInterface for StubAudioPlayer {
|
||||
fn play_opus(&mut self, _payload: &[u8]) {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub struct SpawnHandle;
|
||||
|
||||
impl SpawnHandle {
|
||||
#[allow(unused)]
|
||||
pub fn spawn<F>(&self, _future: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn current() -> Self {
|
||||
SpawnHandle
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn spawn<F>(_future: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
+179
-107
@@ -1,14 +1,15 @@
|
||||
use crate::app::Command;
|
||||
use crate::effects::{AudioProcessor, AudioProcessorSender};
|
||||
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
|
||||
use color_eyre::eyre::{bail, eyre, Error};
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use dioxus::prelude::*;
|
||||
use futures::{AsyncRead, AsyncWrite};
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use js_sys::Float32Array;
|
||||
use mumble_protocol::control::ClientControlCodec;
|
||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
||||
use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
|
||||
use reqwest::Url;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing::{debug, error, info, instrument};
|
||||
@@ -27,7 +28,6 @@ use web_sys::AudioWorkletNode;
|
||||
use web_sys::EncodedAudioChunk;
|
||||
use web_sys::EncodedAudioChunkInit;
|
||||
use web_sys::EncodedAudioChunkType;
|
||||
use web_sys::MediaStream;
|
||||
use web_sys::MediaStreamConstraints;
|
||||
use web_sys::MessageEvent;
|
||||
use web_sys::WebTransport;
|
||||
@@ -37,16 +37,142 @@ use web_sys::WorkletOptions;
|
||||
use web_sys::{console, window};
|
||||
use web_sys::{AudioContext, AudioDataCopyToOptions};
|
||||
|
||||
#[allow(unused)]
|
||||
pub use wasm_bindgen_futures::spawn_local as spawn;
|
||||
|
||||
pub trait ImpRead: AsyncRead + Unpin + 'static {}
|
||||
impl<T: AsyncRead + Unpin + 'static> ImpRead for T {}
|
||||
#[allow(unused)]
|
||||
#[derive(Clone)]
|
||||
pub struct SpawnHandle;
|
||||
|
||||
pub trait ImpWrite: AsyncWrite + Unpin + 'static {}
|
||||
impl<T: AsyncWrite + Unpin + 'static> ImpWrite for T {}
|
||||
impl SpawnHandle {
|
||||
pub fn spawn<F>(&self, future: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
}
|
||||
|
||||
pub async fn sleep(d: Duration) {
|
||||
TimeoutFuture::new(d.as_millis() as u32).await
|
||||
pub fn current() -> Self {
|
||||
SpawnHandle
|
||||
}
|
||||
}
|
||||
|
||||
/// Web platform implementation using WebTransport and Web Audio API.
|
||||
pub struct WebPlatform;
|
||||
|
||||
impl super::PlatformInterface for WebPlatform {
|
||||
type AudioSystem = WebAudioSystem;
|
||||
|
||||
fn init_logging() {
|
||||
// copied from tracing_web example usage
|
||||
|
||||
use tracing_subscriber::fmt::format::Pretty;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_web::{performance_layer, MakeWebConsoleWriter};
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
.with_ansi(false) // Only partially supported across browsers
|
||||
.without_time() // std::time is not available in browsers
|
||||
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
|
||||
.with_filter(LevelFilter::DEBUG);
|
||||
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt_layer)
|
||||
.with(perf_layer)
|
||||
.init();
|
||||
|
||||
info!("logging initiated");
|
||||
}
|
||||
|
||||
fn request_permissions() {
|
||||
// No-op on web
|
||||
}
|
||||
|
||||
async fn load_config() -> color_eyre::Result<ClientConfig> {
|
||||
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
|
||||
Some(url) => Url::parse(url)?,
|
||||
None => absolute_url("config")?,
|
||||
};
|
||||
info!("loading config from {}", config_url);
|
||||
|
||||
let config = reqwest::get(config_url)
|
||||
.await?
|
||||
.json::<ClientConfig>()
|
||||
.await?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn load_username() -> Option<String> {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.ok()??
|
||||
.get_item("username")
|
||||
.ok()?
|
||||
}
|
||||
|
||||
fn load_server_url() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn set_default_username(username: &str) -> Option<()> {
|
||||
web_sys::window()?
|
||||
.local_storage()
|
||||
.ok()??
|
||||
.set_item("username", username)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn set_default_server(_server: &str) -> Option<()> {
|
||||
None
|
||||
}
|
||||
|
||||
fn load_servers() -> Vec<ServerEntry> {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok()?)
|
||||
.and_then(|s| s.get_item("servers").ok()?)
|
||||
.and_then(|json| serde_json::from_str(&json).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_servers(servers: &[ServerEntry]) {
|
||||
if let Ok(json) = serde_json::to_string(servers) {
|
||||
if let Some(storage) = web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok()?)
|
||||
{
|
||||
let _ = storage.set_item("servers", &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
gui_config: &ClientConfig,
|
||||
) -> Result<(), Error> {
|
||||
network_connect(address, username, event_rx, gui_config).await
|
||||
}
|
||||
|
||||
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||
Ok(client
|
||||
.get(absolute_url("status")?)
|
||||
.send()
|
||||
.await?
|
||||
.json::<ServerStatus>()
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result<ServerStatus> {
|
||||
// UDP ping not available in browsers; use get_status via HTTP proxy instead
|
||||
color_eyre::eyre::bail!("UDP ping not supported on web platform")
|
||||
}
|
||||
|
||||
async fn sleep(duration: Duration) {
|
||||
TimeoutFuture::new(duration.as_millis() as u32).await;
|
||||
}
|
||||
}
|
||||
|
||||
trait ResultExt<T> {
|
||||
@@ -71,7 +197,7 @@ impl<T> ResultExt<T> for Result<T, JsError> {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioSystem {
|
||||
pub struct WebAudioSystem {
|
||||
webctx: AudioContext,
|
||||
processors: AudioProcessorSender,
|
||||
}
|
||||
@@ -102,8 +228,10 @@ async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl AudioSystem {
|
||||
pub async fn new() -> Result<Self, Error> {
|
||||
impl super::AudioSystemInterface for WebAudioSystem {
|
||||
type AudioPlayer = WebAudioPlayer;
|
||||
|
||||
async fn new() -> Result<Self, Error> {
|
||||
// Create MediaStreams to playback decoded audio
|
||||
// The audio context is used to reproduce audio.
|
||||
let webctx = configure_audio_context();
|
||||
@@ -111,14 +239,14 @@ impl AudioSystem {
|
||||
|
||||
let processors = AudioProcessorSender::default();
|
||||
|
||||
Ok(AudioSystem { webctx, processors })
|
||||
Ok(WebAudioSystem { webctx, processors })
|
||||
}
|
||||
|
||||
pub fn set_processor(&self, processor: AudioProcessor) {
|
||||
fn set_processor(&self, processor: AudioProcessor) {
|
||||
self.processors.store(Some(processor))
|
||||
}
|
||||
|
||||
pub fn start_recording(&mut self, each: impl FnMut(Vec<u8>) + 'static) -> Result<(), Error> {
|
||||
fn start_recording(&mut self, each: impl FnMut(Vec<u8>, bool) + 'static) -> Result<(), Error> {
|
||||
let audio_context_worklet = self.webctx.clone();
|
||||
let processors = self.processors.clone();
|
||||
spawn(async move {
|
||||
@@ -130,7 +258,7 @@ impl AudioSystem {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
|
||||
fn create_player(&mut self) -> Result<WebAudioPlayer, Error> {
|
||||
let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?;
|
||||
|
||||
// Connect worklet to destination
|
||||
@@ -183,14 +311,14 @@ impl AudioSystem {
|
||||
decoder_error.forget();
|
||||
output.forget();
|
||||
|
||||
Ok(AudioPlayer(audio_decoder))
|
||||
Ok(WebAudioPlayer(audio_decoder))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioPlayer(AudioDecoder);
|
||||
pub struct WebAudioPlayer(AudioDecoder);
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn play_opus(&mut self, payload: &[u8]) {
|
||||
impl super::AudioPlayerInterface for WebAudioPlayer {
|
||||
fn play_opus(&mut self, payload: &[u8]) {
|
||||
let js_audio_payload = Uint8Array::from(payload);
|
||||
let _ = self.0.decode(
|
||||
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
|
||||
@@ -222,22 +350,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 {
|
||||
return;
|
||||
return TransmitState::Silent;
|
||||
};
|
||||
let Ok(samples) = samples.dyn_into::<Float32Array>() else {
|
||||
return;
|
||||
return TransmitState::Silent;
|
||||
};
|
||||
let input = samples.to_vec();
|
||||
let mut output = Vec::with_capacity(input.len());
|
||||
processor.process(&input, 1, &mut output);
|
||||
samples.copy_from(&output);
|
||||
let state = processor.process(&input, 1, &mut output);
|
||||
if !output.is_empty() {
|
||||
samples.copy_from(&output);
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
async fn run_encoder_worklet(
|
||||
audio_context: &AudioContext,
|
||||
mut each: impl FnMut(Vec<u8>) + 'static,
|
||||
mut each: impl FnMut(Vec<u8>, bool) + 'static,
|
||||
processors: AudioProcessorSender,
|
||||
) -> Result<AudioWorkletNode, Error> {
|
||||
let constraints = MediaStreamConstraints::new();
|
||||
@@ -262,12 +394,19 @@ async fn run_encoder_worklet(
|
||||
let encoder_error: Closure<dyn FnMut(JsValue)> =
|
||||
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
|
||||
let output: Closure<dyn FnMut(EncodedAudioChunk)> =
|
||||
Closure::new(move |audio_data: EncodedAudioChunk| {
|
||||
let mut array = vec![0u8; audio_data.byte_length() as usize];
|
||||
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(
|
||||
@@ -294,8 +433,19 @@ async fn run_encoder_worklet(
|
||||
}
|
||||
|
||||
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()) {
|
||||
Ok(data) => {
|
||||
let _ = audio_encoder.encode(&data);
|
||||
@@ -391,86 +541,8 @@ pub async fn network_connect(
|
||||
crate::network_loop(username, event_rx, reader, writer).await
|
||||
}
|
||||
|
||||
pub fn set_default_username(username: &str) -> Option<()> {
|
||||
web_sys::window()?
|
||||
.local_storage()
|
||||
.ok()??
|
||||
.set_item("username", username)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn load_username() -> Option<String> {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.ok()??
|
||||
.get_item("username")
|
||||
.ok()?
|
||||
}
|
||||
|
||||
pub fn absolute_url(path: &str) -> Result<Url, Error> {
|
||||
let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
|
||||
let location = window.location();
|
||||
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
|
||||
}
|
||||
|
||||
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
|
||||
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
|
||||
Some(url) => Url::parse(url)?,
|
||||
None => absolute_url("config")?,
|
||||
};
|
||||
info!("loading config from {}", config_url);
|
||||
|
||||
let config = reqwest::get(config_url)
|
||||
.await?
|
||||
.json::<ClientConfig>()
|
||||
.await?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||
Ok(client
|
||||
.get(absolute_url("status")?)
|
||||
.send()
|
||||
.await?
|
||||
.json::<ServerStatus>()
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub fn init_logging() {
|
||||
// copied from tracing_web example usage
|
||||
|
||||
use tracing_subscriber::fmt::format::Pretty;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_web::{performance_layer, MakeWebConsoleWriter};
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
.with_ansi(false) // Only partially supported across browsers
|
||||
.without_time() // std::time is not available in browsers
|
||||
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
|
||||
.with_filter(LevelFilter::DEBUG);
|
||||
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt_layer)
|
||||
.with(perf_layer)
|
||||
.init();
|
||||
|
||||
info!("logging initiated");
|
||||
}
|
||||
|
||||
pub struct SpawnHandle;
|
||||
|
||||
impl SpawnHandle {
|
||||
pub fn current() -> Self {
|
||||
SpawnHandle
|
||||
}
|
||||
|
||||
pub fn spawn<F>(&self, future: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
spawn(future);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-47
@@ -7,11 +7,12 @@ use asynchronous_codec::FramedWrite;
|
||||
use color_eyre::eyre::{bail, Error};
|
||||
use dioxus::prelude::*;
|
||||
use futures::select;
|
||||
use futures::AsyncRead;
|
||||
use futures::AsyncWrite;
|
||||
use futures::FutureExt as _;
|
||||
use futures::SinkExt as _;
|
||||
use futures::StreamExt as _;
|
||||
use futures_channel::mpsc::UnboundedSender;
|
||||
pub use imp::spawn;
|
||||
use msghtml::process_message_html;
|
||||
use mumble_protocol::control::msgs;
|
||||
use mumble_protocol::control::ControlCodec;
|
||||
@@ -20,17 +21,17 @@ use mumble_protocol::voice::VoicePacket;
|
||||
use mumble_protocol::voice::VoicePacketPayload;
|
||||
use mumble_protocol::Clientbound;
|
||||
use mumble_protocol::Serverbound;
|
||||
use mumble_web2_common::ClientConfig;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
|
||||
use crate::effects::AudioProcessor;
|
||||
use crate::imp::AudioSystem;
|
||||
use crate::imp::{
|
||||
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
|
||||
PlatformInterface as _,
|
||||
};
|
||||
|
||||
pub mod app;
|
||||
mod effects;
|
||||
@@ -50,7 +51,9 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
|
||||
|
||||
*STATE.server.write() = Default::default();
|
||||
*STATE.status.write() = ConnectionState::Connecting;
|
||||
if let Err(error) = imp::network_connect(address, username, &mut event_rx, &config).await {
|
||||
if let Err(error) =
|
||||
Platform::network_connect(address, username, &mut event_rx, &config).await
|
||||
{
|
||||
error!("could not connect {:?}", error);
|
||||
*STATE.status.write() = ConnectionState::Failed(error.to_string());
|
||||
} else {
|
||||
@@ -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,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
||||
@@ -108,23 +111,23 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
|
||||
break;
|
||||
}
|
||||
|
||||
imp::sleep(Duration::from_millis(3000)).await;
|
||||
Platform::sleep(Duration::from_millis(3000)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut audio = imp::AudioSystem::new().await?;
|
||||
let mut audio = AudioSystem::new().await?;
|
||||
{
|
||||
let send_chan = send_chan.clone();
|
||||
let mut sequence_num = 0;
|
||||
audio.start_recording(move |opus_frame| {
|
||||
audio.start_recording(move |opus_frame, is_terminator| {
|
||||
let _ =
|
||||
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
|
||||
_dst: std::marker::PhantomData,
|
||||
target: 0,
|
||||
session_id: (),
|
||||
seq_num: sequence_num,
|
||||
payload: VoicePacketPayload::Opus(opus_frame.into(), false),
|
||||
payload: VoicePacketPayload::Opus(opus_frame.into(), is_terminator),
|
||||
position_info: None,
|
||||
})));
|
||||
sequence_num = sequence_num.wrapping_add(2);
|
||||
@@ -299,8 +302,8 @@ fn accept_command(
|
||||
|
||||
fn accept_packet(
|
||||
msg: ControlPacket<mumble_protocol::Clientbound>,
|
||||
audio_context: &mut imp::AudioSystem,
|
||||
player_map: &mut HashMap<u32, imp::AudioPlayer>,
|
||||
audio_context: &mut AudioSystem,
|
||||
player_map: &mut HashMap<u32, AudioPlayer>,
|
||||
) -> Result<(), Error> {
|
||||
match msg {
|
||||
ControlPacket::UDPTunnel(u) => {
|
||||
@@ -338,41 +341,11 @@ fn accept_packet(
|
||||
}
|
||||
ControlPacket::ChannelState(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let id = u.get_channel_id();
|
||||
|
||||
let state = server.channels.entry(id).or_default();
|
||||
let new_parent = if u.has_parent() {
|
||||
if let Some(parent) = state.parent.and_then(|p| server.channels.get_mut(&p)) {
|
||||
parent.children.remove(&id);
|
||||
}
|
||||
|
||||
let parent_id = u.get_parent();
|
||||
let parent = server.channels.entry(parent_id).or_default();
|
||||
if u.has_position() && u.get_position() as usize <= parent.children.len() {
|
||||
// TODO: what if positions are received out of order? we need to sort afterwards?
|
||||
parent.children.insert_before(u.get_position() as usize, id);
|
||||
} else {
|
||||
parent.children.insert(id);
|
||||
}
|
||||
Some(parent_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state = server.channels.entry(id).or_default();
|
||||
state.parent = new_parent;
|
||||
if u.has_name() {
|
||||
state.name = u.get_name().to_string();
|
||||
}
|
||||
server.channels_state.update_from_channel_state(&u);
|
||||
}
|
||||
ControlPacket::ChannelRemove(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let id = u.get_channel_id();
|
||||
if let Some(channel) = server.channels.remove(&id) {
|
||||
if let Some(parent) = channel.parent.and_then(|p| server.channels.get_mut(&p)) {
|
||||
parent.children.remove(&id);
|
||||
}
|
||||
}
|
||||
server.channels_state.update_from_channel_remove(&u);
|
||||
}
|
||||
ControlPacket::UserState(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
@@ -384,12 +357,13 @@ fn accept_packet(
|
||||
let state = state_entry.or_default();
|
||||
// the server might now send a channel_id if the user is in channel=0
|
||||
if u.has_channel_id() || new {
|
||||
if let Some(parent) = server.channels.get_mut(&state.channel) {
|
||||
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
||||
parent.users.remove(&id);
|
||||
}
|
||||
|
||||
let channel_id = u.get_channel_id();
|
||||
server
|
||||
.channels_state
|
||||
.channels
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
@@ -407,6 +381,9 @@ fn accept_packet(
|
||||
if u.has_deaf() {
|
||||
state.deaf = u.get_deaf();
|
||||
}
|
||||
if u.has_suppress() {
|
||||
state.suppress = u.get_suppress();
|
||||
}
|
||||
if u.has_self_mute() {
|
||||
state.self_mute = u.get_self_mute();
|
||||
}
|
||||
@@ -418,7 +395,7 @@ fn accept_packet(
|
||||
let mut server = STATE.server.write();
|
||||
let id = u.get_session();
|
||||
if let Some(state) = server.users.remove(&id) {
|
||||
if let Some(parent) = server.channels.get_mut(&state.channel) {
|
||||
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
||||
parent.users.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
use mumble_web2_gui::{app, imp};
|
||||
use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
|
||||
|
||||
pub fn main() {
|
||||
imp::init_logging();
|
||||
Platform::init_logging();
|
||||
dioxus::launch(app::app);
|
||||
}
|
||||
|
||||
+6
-12
@@ -335,19 +335,13 @@ async fn connect_proxy_impl(
|
||||
|
||||
info!("connected to Mumble server");
|
||||
|
||||
// Spawn tasks to handle transmitting data between the WebTransport client and Mumble TCP Server
|
||||
let c2s = tokio::spawn(
|
||||
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")),
|
||||
);
|
||||
|
||||
// Handle transmitting data between the WebTransport client and Mumble TCP Server
|
||||
// When one direction completes/fails, the other is dropped and its streams are closed
|
||||
tokio::select! {
|
||||
res = c2s => res??,
|
||||
res = s2c => res??,
|
||||
res = pass_bytes_loop(incoming, write_server)
|
||||
.instrument(info_span!("Handler", "Client to server")) => res?,
|
||||
res = pass_bytes_loop(read_server, outgoing)
|
||||
.instrument(info_span!("Handler", "Server to client")) => res?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user