Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b6e81a21d | |||
| 801a182e7c |
Generated
+262
-13
@@ -607,6 +607,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
@@ -839,6 +845,20 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa 1.0.15",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.0"
|
||||
@@ -1193,6 +1213,32 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"crossterm_winapi",
|
||||
"futures-core",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
@@ -1321,8 +1367,18 @@ version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.21.3",
|
||||
"darling_macro 0.21.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1338,13 +1394,37 @@ dependencies = [
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.21.3",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
@@ -2221,7 +2301,7 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.21.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
@@ -2369,6 +2449,12 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -2672,7 +2758,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"rustix 1.1.2",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -3050,6 +3136,17 @@ dependencies = [
|
||||
"ahash 0.8.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.0"
|
||||
@@ -3058,7 +3155,7 @@ checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3442,6 +3539,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.19.0"
|
||||
@@ -3460,6 +3566,19 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.21"
|
||||
@@ -3763,6 +3882,12 @@ dependencies = [
|
||||
"x11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -3909,6 +4034,15 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -4075,7 +4209,7 @@ version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"rustix 1.1.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4154,6 +4288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -4235,7 +4370,6 @@ dependencies = [
|
||||
"dasp_ring_buffer",
|
||||
"deep_filter",
|
||||
"dioxus-asset-resolver",
|
||||
"dioxus-signals",
|
||||
"etcetera",
|
||||
"futures",
|
||||
"futures-channel",
|
||||
@@ -4314,6 +4448,25 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mumble-web2-tui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"dioxus-core",
|
||||
"dioxus-signals",
|
||||
"futures",
|
||||
"futures-channel",
|
||||
"generational-box",
|
||||
"mumble-web2-client",
|
||||
"mumble-web2-common",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
@@ -5627,6 +5780,27 @@ dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cassowary",
|
||||
"compact_str 0.8.1",
|
||||
"crossterm",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools 0.13.0",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.5.2"
|
||||
@@ -6007,6 +6181,19 @@ dependencies = [
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.2"
|
||||
@@ -6016,7 +6203,7 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -6187,7 +6374,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"compact_str",
|
||||
"compact_str 0.9.0",
|
||||
"eyre",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -6746,6 +6933,17 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.6"
|
||||
@@ -6964,6 +7162,34 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsecond"
|
||||
version = "0.7.3"
|
||||
@@ -7158,7 +7384,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"rustix 1.1.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -7853,6 +8079,29 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools 0.13.0",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -8735,7 +8984,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix",
|
||||
"rustix 1.1.2",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
@@ -8752,7 +9001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
"rustix 1.1.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["client", "common", "gui", "proxy"]
|
||||
members = ["client", "common", "gui", "proxy", "tui"]
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1.0.214", features = ["derive"] }
|
||||
|
||||
+1
-1
@@ -66,7 +66,6 @@ etcetera = { version = "0.10.0", optional = true }
|
||||
|
||||
# Base Dependencies
|
||||
# ================
|
||||
dioxus-signals = "0.7.2"
|
||||
manganis = "0.7.2"
|
||||
once_cell = "1.19.0"
|
||||
asynchronous-codec = { workspace = true }
|
||||
@@ -114,6 +113,7 @@ tract-onnx = "=0.12.4"
|
||||
tract-pulse = "=0.12.4"
|
||||
|
||||
[features]
|
||||
embed-denoiser = []
|
||||
web = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
||||
+18
-16
@@ -1,8 +1,8 @@
|
||||
use dioxus_signals::{ReadableExt as _, Signal};
|
||||
use mime_guess::Mime;
|
||||
use mumble_web2_common::ProxyOverrides;
|
||||
use ordermap::OrderSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
pub type ChannelId = u32;
|
||||
@@ -21,16 +21,10 @@ pub struct AudioSettings {
|
||||
pub denoise: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConnectTarget {
|
||||
Direct { host: String, port: u16 },
|
||||
Proxy(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Command {
|
||||
Connect {
|
||||
target: ConnectTarget,
|
||||
address: String,
|
||||
username: String,
|
||||
config: ProxyOverrides,
|
||||
},
|
||||
@@ -203,19 +197,27 @@ impl ServerState {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub status: Signal<ConnectionState>,
|
||||
pub server: Signal<ServerState>,
|
||||
pub audio: Signal<AudioSettings>,
|
||||
pub trait Reactivity {
|
||||
type Signal<T>;
|
||||
|
||||
fn new<T: 'static>(value: T) -> Self::Signal<T>;
|
||||
fn read<T: 'static>(signal: &Self::Signal<T>) -> impl Deref<Target = T>;
|
||||
fn write<T: 'static>(signal: &Self::Signal<T>) -> impl DerefMut<Target = T>;
|
||||
}
|
||||
|
||||
impl fmt::Debug for State {
|
||||
pub struct State<R: Reactivity> {
|
||||
pub status: R::Signal<ConnectionState>,
|
||||
pub server: R::Signal<ServerState>,
|
||||
pub audio: R::Signal<AudioSettings>,
|
||||
}
|
||||
|
||||
impl<R: Reactivity> fmt::Debug for State<R> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("State")
|
||||
.field("status", &self.status.read())
|
||||
.field("server", &self.server.read())
|
||||
.field("status", &*R::read(&self.status))
|
||||
.field("server", &*R::read(&self.server))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedState = Arc<State>;
|
||||
pub type SharedState<R> = Arc<State<R>>;
|
||||
|
||||
+24
-6
@@ -1,15 +1,34 @@
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
|
||||
use df::tract::{DfParams, DfTract, RuntimeParams};
|
||||
use dioxus_asset_resolver::read_asset_bytes;
|
||||
use manganis::{asset, Asset};
|
||||
use std::borrow::Cow;
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::imp::SpawnHandle;
|
||||
|
||||
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
|
||||
#[cfg(not(feature = "embed-denoiser"))]
|
||||
async fn denoiser_model_bytes() -> color_eyre::Result<Cow<'static, [u8]>> {
|
||||
use color_eyre::eyre::eyre;
|
||||
use manganis::{asset, Asset};
|
||||
|
||||
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
|
||||
let bytes = dioxus_asset_resolver::read_asset_bytes(&DF_MODEL.to_string())
|
||||
.await
|
||||
.map_err(|err| eyre!("could not read denoising model: {err}"))?;
|
||||
Ok(Cow::Owned(bytes))
|
||||
}
|
||||
|
||||
#[cfg(feature = "embed-denoiser")]
|
||||
async fn denoiser_model_bytes() -> color_eyre::Result<Cow<'static, [u8]>> {
|
||||
static DF_MODEL: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/DeepFilterNet3_ll_onnx.tar.gz"
|
||||
));
|
||||
Ok(Cow::Borrowed(DF_MODEL))
|
||||
}
|
||||
|
||||
// TODO: make this user configurable.
|
||||
static DEFAULT_NOISE_FLOOR: f32 = 0.001;
|
||||
// 200ms hold at 48kHz sample rate
|
||||
@@ -44,12 +63,11 @@ fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract)
|
||||
let cell = Arc::new(AtomicCell::new(None));
|
||||
let cell_task = cell.clone();
|
||||
*state = DenoisingModelState::Downloading(cell);
|
||||
let model = DF_MODEL.to_string();
|
||||
spawn.spawn(async move {
|
||||
let model_bytes = match read_asset_bytes(&model).await {
|
||||
let model_bytes = match denoiser_model_bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error!("could not read denoising model from \"{model}\": {e:?}");
|
||||
error!("{e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use color_eyre::eyre::{bail, Error};
|
||||
use crate::app::{Command, SharedState};
|
||||
use crate::Reactivity;
|
||||
use color_eyre::eyre::Error;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_protocol::control::ClientControlCodec;
|
||||
use std::net::ToSocketAddrs;
|
||||
@@ -70,21 +71,14 @@ impl ServerCertVerifier for NoCertificateVerification {
|
||||
|
||||
#[instrument]
|
||||
pub async fn network_connect(
|
||||
target: ConnectTarget,
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
state: SharedState<impl Reactivity>,
|
||||
) -> Result<(), Error> {
|
||||
info!("connecting");
|
||||
|
||||
let (host, port) = match target {
|
||||
ConnectTarget::Direct { host, port } => (host, port),
|
||||
ConnectTarget::Proxy(_) => {
|
||||
bail!("desktop/mobile platform requires a direct host:port, not a proxy URL")
|
||||
}
|
||||
};
|
||||
|
||||
let config = ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
||||
@@ -92,14 +86,15 @@ pub async fn network_connect(
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
|
||||
let addr = (&*host, port)
|
||||
let addr = format!("{}:{}", address, 64738)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let server_tcp = TcpStream::connect(addr).await?;
|
||||
let server_stream = connector
|
||||
.connect(host.try_into()?, server_tcp)
|
||||
//.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);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use crate::app::{Command, SharedState};
|
||||
use crate::Reactivity;
|
||||
use color_eyre::eyre::Error;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
@@ -24,24 +25,20 @@ impl super::PlatformInterface for DesktopPlatform {
|
||||
}
|
||||
|
||||
async fn network_connect(
|
||||
target: ConnectTarget,
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
state: SharedState<impl Reactivity>,
|
||||
) -> Result<(), Error> {
|
||||
super::connect::network_connect(target, username, event_rx, overrides, state).await
|
||||
super::connect::network_connect(address, username, event_rx, overrides, state).await
|
||||
}
|
||||
|
||||
async fn get_status(
|
||||
_client: &reqwest::Client,
|
||||
address: &str,
|
||||
) -> color_eyre::Result<ServerStatus> {
|
||||
let (host, port) = match address.rsplit_once(':') {
|
||||
Some((h, p)) => (h, p.parse().unwrap_or(64738)),
|
||||
None => (address, 64738),
|
||||
};
|
||||
mumble_web2_common::ping_server(host, port).await
|
||||
mumble_web2_common::ping_server(address, 64738).await
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use crate::app::{Command, SharedState};
|
||||
use crate::Reactivity;
|
||||
use color_eyre::eyre::Error;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
@@ -20,24 +21,20 @@ impl super::PlatformInterface for MobilePlatform {
|
||||
}
|
||||
|
||||
async fn network_connect(
|
||||
target: ConnectTarget,
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
state: SharedState<impl Reactivity>,
|
||||
) -> Result<(), Error> {
|
||||
super::connect::network_connect(target, username, event_rx, overrides, state).await
|
||||
super::connect::network_connect(address, username, event_rx, overrides, state).await
|
||||
}
|
||||
|
||||
async fn get_status(
|
||||
_client: &reqwest::Client,
|
||||
address: &str,
|
||||
) -> color_eyre::Result<ServerStatus> {
|
||||
let (host, port) = match address.rsplit_once(':') {
|
||||
Some((h, p)) => (h, p.parse().unwrap_or(64738)),
|
||||
None => (address, 64738),
|
||||
};
|
||||
mumble_web2_common::ping_server(host, port).await
|
||||
mumble_web2_common::ping_server(address, 64738).await
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! The traits make the platform boundary explicit and provide compile-time verification.
|
||||
#![allow(async_fn_in_trait)]
|
||||
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use crate::app::{Command, SharedState};
|
||||
use crate::effects::AudioProcessor;
|
||||
use crate::Reactivity;
|
||||
use color_eyre::eyre::Error;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
@@ -79,11 +80,11 @@ pub trait PlatformInterface {
|
||||
|
||||
/// Establish a connection to the Mumble server and run the network loop.
|
||||
fn network_connect(
|
||||
target: ConnectTarget,
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
proxy_overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
state: SharedState<impl Reactivity>,
|
||||
) -> impl Future<Output = Result<(), Error>>;
|
||||
|
||||
/// Get server status (user count, version, etc.) for the given address.
|
||||
|
||||
@@ -28,8 +28,12 @@ impl super::ConfigSystemInterface for NativeConfigSystem {
|
||||
match serde_json::from_value::<T>(value_untyped) {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => {
|
||||
let default_value = config_get_default(key)?;
|
||||
serde_json::from_value::<T>(default_value).ok()
|
||||
let default_value = config_get_default(key)
|
||||
.expect("Default value required after config parse failure");
|
||||
Some(
|
||||
serde_json::from_value::<T>(default_value)
|
||||
.expect("Default value could not be parsed"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/// Stub implementation of the platform interface, so that we can
|
||||
/// `cargo check` without any --feature flags.
|
||||
use crate::{
|
||||
app::{ConnectTarget, SharedState},
|
||||
effects::AudioProcessor,
|
||||
};
|
||||
use crate::{app::SharedState, effects::AudioProcessor, Reactivity};
|
||||
use color_eyre::eyre::Error;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
@@ -24,11 +21,11 @@ impl super::PlatformInterface for StubPlatform {
|
||||
}
|
||||
|
||||
fn network_connect(
|
||||
_target: ConnectTarget,
|
||||
_address: String,
|
||||
_username: String,
|
||||
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
|
||||
_overrides: &ProxyOverrides,
|
||||
_state: SharedState,
|
||||
_state: SharedState<impl Reactivity>,
|
||||
) -> impl Future<Output = Result<(), Error>> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
|
||||
+6
-11
@@ -1,5 +1,6 @@
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use crate::app::{Command, SharedState};
|
||||
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
|
||||
use crate::Reactivity;
|
||||
use color_eyre::eyre::{bail, eyre, Error};
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
@@ -108,19 +109,13 @@ impl super::PlatformInterface for WebPlatform {
|
||||
}
|
||||
|
||||
async fn network_connect(
|
||||
target: ConnectTarget,
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
state: SharedState<impl Reactivity>,
|
||||
) -> Result<(), Error> {
|
||||
let url = match target {
|
||||
ConnectTarget::Proxy(url) => url,
|
||||
ConnectTarget::Direct { .. } => {
|
||||
bail!("web platform requires a proxy URL, not a direct host:port")
|
||||
}
|
||||
};
|
||||
network_connect(url, username, event_rx, overrides, state).await
|
||||
network_connect(address, username, event_rx, overrides, state).await
|
||||
}
|
||||
|
||||
async fn get_status(
|
||||
@@ -448,7 +443,7 @@ pub async fn network_connect(
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
state: SharedState<impl Reactivity>,
|
||||
) -> Result<(), Error> {
|
||||
info!("connecting");
|
||||
|
||||
|
||||
+28
-26
@@ -3,11 +3,10 @@ use crate::AudioSettings;
|
||||
use crate::Chat;
|
||||
use crate::Command;
|
||||
use crate::ConnectionState;
|
||||
use crate::Reactivity;
|
||||
use asynchronous_codec::FramedRead;
|
||||
use asynchronous_codec::FramedWrite;
|
||||
use color_eyre::eyre::{bail, Error};
|
||||
use dioxus_signals::ReadableExt as _;
|
||||
use dioxus_signals::WritableExt as _;
|
||||
use futures::select;
|
||||
use futures::AsyncRead;
|
||||
use futures::AsyncWrite;
|
||||
@@ -36,10 +35,13 @@ use crate::imp::{
|
||||
Platform, PlatformInterface as _,
|
||||
};
|
||||
|
||||
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>, state: SharedState) {
|
||||
pub async fn network_entrypoint<X: Reactivity>(
|
||||
mut event_rx: UnboundedReceiver<Command>,
|
||||
state: SharedState<X>,
|
||||
) {
|
||||
loop {
|
||||
let Some(Command::Connect {
|
||||
target,
|
||||
address,
|
||||
username,
|
||||
config,
|
||||
}) = event_rx.next().await
|
||||
@@ -47,16 +49,16 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>, state:
|
||||
panic!("did not receive connect command")
|
||||
};
|
||||
|
||||
*state.server.write_unchecked() = Default::default();
|
||||
*state.status.write_unchecked() = ConnectionState::Connecting;
|
||||
*X::write(&state.server) = Default::default();
|
||||
*X::write(&state.status) = ConnectionState::Connecting;
|
||||
if let Err(error) =
|
||||
Platform::network_connect(target, username, &mut event_rx, &config, state.clone())
|
||||
Platform::network_connect(address, username, &mut event_rx, &config, state.clone())
|
||||
.await
|
||||
{
|
||||
error!("could not connect {:?}", error);
|
||||
*state.status.write_unchecked() = ConnectionState::Failed(error.to_string());
|
||||
*X::write(&state.status) = ConnectionState::Failed(error.to_string());
|
||||
} else {
|
||||
*state.status.write_unchecked() = ConnectionState::Disconnected;
|
||||
*X::write(&state.status) = ConnectionState::Disconnected;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,14 +78,14 @@ pub(crate) async fn sender_loop<W: AsyncWrite + Unpin + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
|
||||
pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static, X: Reactivity>(
|
||||
username: String,
|
||||
state: SharedState,
|
||||
state: SharedState<X>,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
mut outgoing: UnboundedSender<ControlPacket<Serverbound>>,
|
||||
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
||||
) -> Result<(), Error> {
|
||||
let audio_settings = state.audio.read().clone();
|
||||
let audio_settings = X::read(&state.audio).clone();
|
||||
|
||||
// Get version packet
|
||||
let version = match reader.next().await {
|
||||
@@ -190,14 +192,14 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn accept_command(
|
||||
fn accept_command<X: Reactivity>(
|
||||
command: Command,
|
||||
send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
|
||||
audio: &mut AudioSystem,
|
||||
state: &State,
|
||||
state: &State<X>,
|
||||
) -> Result<(), Error> {
|
||||
use Command::*;
|
||||
let Some(session) = state.server.read().session else {
|
||||
let Some(session) = X::read(&state.server).session else {
|
||||
bail!("no session id")
|
||||
};
|
||||
|
||||
@@ -220,7 +222,7 @@ fn accept_command(
|
||||
};
|
||||
|
||||
{
|
||||
let mut server = state.server.write_unchecked();
|
||||
let mut server = X::write(&state.server);
|
||||
let Some(me) = server.session else {
|
||||
bail!("not signed in with a session id")
|
||||
};
|
||||
@@ -261,7 +263,7 @@ fn accept_command(
|
||||
};
|
||||
|
||||
{
|
||||
let mut server = state.server.write_unchecked();
|
||||
let mut server = X::write(&state.server);
|
||||
let Some(me) = server.session else {
|
||||
bail!("not signed in with a session id")
|
||||
};
|
||||
@@ -304,11 +306,11 @@ fn accept_command(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn accept_packet(
|
||||
fn accept_packet<X: Reactivity>(
|
||||
msg: ControlPacket<mumble_protocol::Clientbound>,
|
||||
audio_context: &mut AudioSystem,
|
||||
player_map: &mut HashMap<u32, AudioPlayer>,
|
||||
state: &State,
|
||||
state: &State<X>,
|
||||
) -> Result<(), Error> {
|
||||
match msg {
|
||||
ControlPacket::UDPTunnel(u) => {
|
||||
@@ -345,15 +347,15 @@ fn accept_packet(
|
||||
}
|
||||
}
|
||||
ControlPacket::ChannelState(u) => {
|
||||
let mut server = state.server.write_unchecked();
|
||||
let mut server = X::write(&state.server);
|
||||
server.channels_state.update_from_channel_state(&u);
|
||||
}
|
||||
ControlPacket::ChannelRemove(u) => {
|
||||
let mut server = state.server.write_unchecked();
|
||||
let mut server = X::write(&state.server);
|
||||
server.channels_state.update_from_channel_remove(&u);
|
||||
}
|
||||
ControlPacket::UserState(u) => {
|
||||
let mut server = state.server.write_unchecked();
|
||||
let mut server = X::write(&state.server);
|
||||
let server = &mut *server;
|
||||
let id = u.get_session();
|
||||
|
||||
@@ -397,7 +399,7 @@ fn accept_packet(
|
||||
}
|
||||
}
|
||||
ControlPacket::UserRemove(u) => {
|
||||
let mut server = state.server.write_unchecked();
|
||||
let mut server = X::write(&state.server);
|
||||
let id = u.get_session();
|
||||
if let Some(state) = server.users.remove(&id) {
|
||||
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
||||
@@ -406,7 +408,7 @@ fn accept_packet(
|
||||
}
|
||||
}
|
||||
ControlPacket::TextMessage(u) => {
|
||||
let mut server = state.server.write_unchecked();
|
||||
let mut server = X::write(&state.server);
|
||||
if u.has_message() {
|
||||
let text = u.get_message().to_string();
|
||||
server.chat.push(Chat {
|
||||
@@ -421,8 +423,8 @@ fn accept_packet(
|
||||
}
|
||||
}
|
||||
ControlPacket::ServerSync(u) => {
|
||||
*state.status.write_unchecked() = ConnectionState::Connected;
|
||||
let mut server = state.server.write_unchecked();
|
||||
*X::write(&state.status) = ConnectionState::Connected;
|
||||
let mut server = X::write(&state.server);
|
||||
if u.has_welcome_text() {
|
||||
let text = u.get_welcome_text().to_string();
|
||||
server.chat.push(Chat {
|
||||
|
||||
+4
-13
@@ -17,16 +17,6 @@ pub struct ServerStatus {
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Mumble UDP ping protocol.
|
||||
///
|
||||
/// Send a 12-byte packet: 4 zero bytes + 8-byte identifier.
|
||||
@@ -35,11 +25,12 @@ pub struct ServerEntry {
|
||||
#[cfg(feature = "networking")]
|
||||
pub async fn ping_server(address: &str, port: u16) -> color_eyre::Result<ServerStatus> {
|
||||
use color_eyre::eyre::{bail, eyre};
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
use tokio::net::{lookup_host, UdpSocket};
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
let dest = lookup_host(format!("{}:{}", address, port))
|
||||
.await?
|
||||
let dest = format!("{}:{}", address, port)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| eyre!("could not resolve address"))?;
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 601 B |
@@ -1,8 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 862 B |
@@ -1,135 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,5 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -432,365 +432,3 @@ a:visited {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.server-list-page h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login_version {
|
||||
font-size: 0.55em;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.server-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Rounded card */
|
||||
.server-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.server-card__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.65;
|
||||
filter: brightness(0) invert(0.8); /* light gray */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1; /* pushes the connect button to the far right */
|
||||
min-width: 0; /* prevents text overflow from breaking flex layout */
|
||||
}
|
||||
|
||||
.server-card__name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.server-card__address {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
|
||||
.server-card__action {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.server-card__action img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(0.8); /* light gray */
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.server-card__action:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.server-card__action:hover img {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.server-card__action:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Add server — dashed outline style to distinguish from real cards */
|
||||
.add-server-btn {
|
||||
width: 100%;
|
||||
padding: 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.add-server-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.0);
|
||||
z-index: 999;
|
||||
animation: backdrop-fade-in 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.modal {
|
||||
pointer-events: auto;
|
||||
|
||||
/* Make this solid or nearly solid instead of see-through */
|
||||
background: #141414;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.modal-field input:user-invalid,
|
||||
.modal-field--strict input:invalid {
|
||||
border-color: rgba(255, 90, 90, 0.85);
|
||||
box-shadow: 0 0 0 1px rgba(255, 90, 90, 0.45);
|
||||
}
|
||||
|
||||
.modal-field__error {
|
||||
display: none;
|
||||
font-size: 0.75rem;
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.modal-field:has(input:user-invalid) .modal-field__error,
|
||||
.modal-field--strict:has(input:invalid) .modal-field__error {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
||||
+162
-614
@@ -3,28 +3,34 @@
|
||||
use dioxus::prelude::*;
|
||||
use mumble_web2_client::{
|
||||
network_entrypoint, reqwest, AudioSettings, ChannelId, Command, ConfigSystem,
|
||||
ConfigSystemInterface as _, ConnectTarget, ConnectionState, Platform, PlatformInterface as _,
|
||||
SharedState, State, UserId, UserState, VERSION,
|
||||
ConfigSystemInterface as _, ConnectionState, Platform, PlatformInterface as _, UserId,
|
||||
UserState, VERSION,
|
||||
};
|
||||
use mumble_web2_common::{ProxyOverrides, ServerEntry};
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
use Command::*;
|
||||
use ConnectionState::*;
|
||||
|
||||
const ADDRESS_PATTERN: &str = "[A-Za-z0-9.-]+";
|
||||
pub struct DioxusReactivity;
|
||||
|
||||
fn address_is_valid(addr: &str) -> bool {
|
||||
!addr.is_empty() && !addr.contains(':')
|
||||
}
|
||||
impl mumble_web2_client::Reactivity for DioxusReactivity {
|
||||
type Signal<T> = Signal<T>;
|
||||
|
||||
fn split_host_port(input: &str) -> (String, Option<String>) {
|
||||
if let Some((host, port)) = input.rsplit_once(':') {
|
||||
if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
|
||||
return (host.to_string(), Some(port.to_string()));
|
||||
}
|
||||
fn new<T: 'static>(value: T) -> Signal<T> {
|
||||
Signal::new(value)
|
||||
}
|
||||
|
||||
fn read<T: 'static>(signal: &Signal<T>) -> impl std::ops::Deref<Target = T> {
|
||||
signal.read_unchecked()
|
||||
}
|
||||
|
||||
fn write<T: 'static>(signal: &Signal<T>) -> impl std::ops::DerefMut<Target = T> {
|
||||
signal.write_unchecked()
|
||||
}
|
||||
(input.to_string(), None)
|
||||
}
|
||||
|
||||
pub type SharedState = mumble_web2_client::SharedState<DioxusReactivity>;
|
||||
pub type State = mumble_web2_client::State<DioxusReactivity>;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UserIcon {
|
||||
Normal,
|
||||
@@ -510,641 +516,183 @@ pub fn ServerView(overrides: Resource<ProxyOverrides>) -> Element {
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ServerCard(
|
||||
idx: usize,
|
||||
server: ServerEntry,
|
||||
editing_index: Signal<Option<usize>>,
|
||||
overrides: Resource<ProxyOverrides>,
|
||||
) -> Element {
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
|
||||
let address = format!("{}:{}", server.address, server.port);
|
||||
let connect_entry = server.clone();
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "server-card",
|
||||
img {
|
||||
class: "server-card__icon",
|
||||
src: asset!("assets/earth-14-svgrepo-com.svg"),
|
||||
alt: "Server icon",
|
||||
}
|
||||
div {
|
||||
class: "server-card__info",
|
||||
span { class: "server-card__name", "{server.name}" }
|
||||
span { class: "server-card__address", "{address}" }
|
||||
}
|
||||
ServerPingInfo {
|
||||
address: server.address.clone(),
|
||||
port: server.port,
|
||||
}
|
||||
button {
|
||||
class: "server-card__action",
|
||||
onclick: move |_| editing_index.set(Some(idx)),
|
||||
img {
|
||||
src: asset!("assets/edit-3-svgrepo-com.svg"),
|
||||
alt: "Edit",
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "server-card__action server-card__action--connect",
|
||||
onclick: {
|
||||
let entry = connect_entry.clone();
|
||||
move |_| {
|
||||
net.send(Connect {
|
||||
target: ConnectTarget::Direct {
|
||||
host: entry.address.clone(),
|
||||
port: entry.port,
|
||||
},
|
||||
username: entry.username.clone(),
|
||||
config: overrides.read().clone().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
},
|
||||
img {
|
||||
src: asset!("assets/arrow-right-svgrepo-com.svg"),
|
||||
alt: "Connect",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn OverrideLoginView(overrides: Resource<ProxyOverrides>) -> Element {
|
||||
let user_config = use_context::<ConfigSystem>();
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let state = use_context::<SharedState>();
|
||||
|
||||
let proxy_url = overrides
|
||||
.read()
|
||||
.as_ref()
|
||||
.and_then(|c| c.proxy_url.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut username = use_signal(|| {
|
||||
user_config
|
||||
.config_get::<String>("username")
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let is_connecting = matches!(&*state.status.read(), Connecting);
|
||||
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "server-list-page",
|
||||
h1 {
|
||||
"Mumble Web"
|
||||
match VERSION {
|
||||
Some(v) => rsx!(div { class: "login_version", "({v})" }),
|
||||
None => rsx!(),
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "server-list",
|
||||
div {
|
||||
class: "server-card",
|
||||
img {
|
||||
class: "server-card__icon",
|
||||
src: asset!("assets/earth-14-svgrepo-com.svg"),
|
||||
alt: "Server icon",
|
||||
}
|
||||
div {
|
||||
class: "server-card__info",
|
||||
span { class: "server-card__name", "Server" }
|
||||
span { class: "server-card__address", "{proxy_url}" }
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "override-username-row",
|
||||
input {
|
||||
class: "override-username-input",
|
||||
r#type: "text",
|
||||
placeholder: "Username",
|
||||
value: "{username.read()}",
|
||||
oninput: move |evt| username.set(evt.value().clone()),
|
||||
}
|
||||
button {
|
||||
class: "server-card__action server-card__action--connect",
|
||||
disabled: is_connecting || username.read().is_empty(),
|
||||
onclick: {
|
||||
let proxy_url = proxy_url.clone();
|
||||
let user_config = user_config.clone();
|
||||
move |_| {
|
||||
user_config.config_set("username", &*username.read());
|
||||
net.send(Connect {
|
||||
target: ConnectTarget::Proxy(proxy_url.clone()),
|
||||
username: username.read().clone(),
|
||||
config: overrides.read().clone().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
},
|
||||
img {
|
||||
src: asset!("assets/arrow-right-svgrepo-com.svg"),
|
||||
alt: "Connect",
|
||||
}
|
||||
}
|
||||
}
|
||||
match &*state.status.read() {
|
||||
Failed(msg) => rsx!(
|
||||
div {
|
||||
class: "login_error",
|
||||
"Failed to connect:"
|
||||
pre { "{msg}" }
|
||||
}
|
||||
),
|
||||
_ => rsx!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoginView(overrides: Resource<ProxyOverrides>) -> Element {
|
||||
let user_config = use_context::<ConfigSystem>();
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let state = use_context::<SharedState>();
|
||||
|
||||
let mut servers = use_signal(|| {
|
||||
user_config
|
||||
.config_get::<Vec<ServerEntry>>("servers")
|
||||
.unwrap_or_default()
|
||||
let mut address_input = use_signal(|| user_config.config_get::<String>("server_url"));
|
||||
let address = use_memo(move || {
|
||||
if let Some(addr) = address_input() {
|
||||
addr.clone()
|
||||
} else {
|
||||
overrides()
|
||||
.and_then(|c| c.proxy_url.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
});
|
||||
let mut show_add_modal = use_signal(|| false);
|
||||
let mut editing_index = use_signal(|| None::<usize>);
|
||||
|
||||
let is_override_mode = overrides
|
||||
.read()
|
||||
.as_ref()
|
||||
.is_some_and(|c| !c.any_server);
|
||||
|
||||
// --- Overrides mode: single preset server, username-only input ---
|
||||
if is_override_mode {
|
||||
return rsx!(OverrideLoginView { overrides });
|
||||
}
|
||||
|
||||
// --- Normal mode: editable server list ---
|
||||
rsx!(
|
||||
div {
|
||||
class: "server-list-page",
|
||||
h1 {
|
||||
"Mumble Web"
|
||||
match VERSION {
|
||||
Some(v) => rsx!(div { class: "login_version", "({v})" }),
|
||||
None => rsx!(),
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "server-list",
|
||||
for (idx, server) in servers.read().iter().enumerate() {
|
||||
ServerCard {
|
||||
key: "{idx}",
|
||||
idx,
|
||||
server: server.clone(),
|
||||
editing_index,
|
||||
overrides,
|
||||
}
|
||||
}
|
||||
}
|
||||
match &*state.status.read() {
|
||||
Failed(msg) => rsx!(
|
||||
div {
|
||||
class: "server-list",
|
||||
div {
|
||||
class: "login_error",
|
||||
"Failed to connect:"
|
||||
pre { "{msg}" }
|
||||
}
|
||||
}
|
||||
),
|
||||
_ => rsx!(),
|
||||
}
|
||||
button {
|
||||
class: "add-server-btn",
|
||||
onclick: move |_| show_add_modal.set(true),
|
||||
"+ Add Server"
|
||||
}
|
||||
|
||||
ServerModals { servers, show_add_modal, editing_index }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ServerModals(
|
||||
servers: Signal<Vec<ServerEntry>>,
|
||||
show_add_modal: Signal<bool>,
|
||||
editing_index: Signal<Option<usize>>,
|
||||
) -> Element {
|
||||
let user_config = use_context::<ConfigSystem>();
|
||||
|
||||
rsx!(
|
||||
if *show_add_modal.read() {
|
||||
{
|
||||
let user_config = user_config.clone();
|
||||
let mut servers = servers;
|
||||
let mut show_add_modal = show_add_modal;
|
||||
rsx!(AddServerModal {
|
||||
on_save: move |entry: ServerEntry| {
|
||||
servers.write().push(entry);
|
||||
user_config.config_set("servers", &*servers.read());
|
||||
show_add_modal.set(false);
|
||||
},
|
||||
on_cancel: move |_| show_add_modal.set(false),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(idx) = *editing_index.read() {
|
||||
if let Some(entry) = servers.read().get(idx).cloned() {
|
||||
{
|
||||
let user_config_save = user_config.clone();
|
||||
let user_config_del = user_config.clone();
|
||||
let mut servers = servers;
|
||||
let mut editing_index = editing_index;
|
||||
rsx!(EditServerModal {
|
||||
entry,
|
||||
on_save: move |updated: ServerEntry| {
|
||||
servers.write()[idx] = updated;
|
||||
user_config_save.config_set("servers", &*servers.read());
|
||||
editing_index.set(None);
|
||||
},
|
||||
on_delete: move |_| {
|
||||
servers.write().remove(idx);
|
||||
user_config_del.config_set("servers", &*servers.read());
|
||||
editing_index.set(None);
|
||||
},
|
||||
on_cancel: move |_| editing_index.set(None),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ServerPingInfo(address: String, port: u16) -> Element {
|
||||
let ping_result = use_resource(move || {
|
||||
let addr = format!("{}:{}", address.clone(), port);
|
||||
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
|
||||
use_resource(move || {
|
||||
let addr = address();
|
||||
async move {
|
||||
let client = reqwest::Client::new();
|
||||
Platform::get_status(&client, &addr).await
|
||||
loop {
|
||||
*last_status.write_unchecked() = Some(Platform::get_status(&client, &addr).await);
|
||||
Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let read = ping_result.read();
|
||||
match &*read {
|
||||
Some(Ok(status)) => {
|
||||
let users_text = match (status.users, status.max_users) {
|
||||
(Some(u), Some(m)) => format!("{u}/{m}"),
|
||||
(Some(u), None) => format!("{u} online"),
|
||||
_ => String::new(),
|
||||
};
|
||||
rsx!(
|
||||
div {
|
||||
class: "server-card__ping",
|
||||
if !users_text.is_empty() {
|
||||
span { "{users_text}" }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Some(Err(_)) => rsx!(
|
||||
div {
|
||||
class: "server-card__ping",
|
||||
span { "offline" }
|
||||
}
|
||||
),
|
||||
None => rsx!(
|
||||
div {
|
||||
class: "server-card__ping",
|
||||
span { "..." }
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AddServerModal(on_save: EventHandler<ServerEntry>, on_cancel: EventHandler<()>) -> Element {
|
||||
let user_config = use_context::<ConfigSystem>();
|
||||
let mut name = use_signal(|| String::new());
|
||||
let mut address = use_signal(|| String::new());
|
||||
let mut port = use_signal(|| "64738".to_string());
|
||||
let mut username = use_signal(|| {
|
||||
user_config
|
||||
.config_get::<String>("username")
|
||||
.unwrap_or_default()
|
||||
.unwrap_or(String::new())
|
||||
});
|
||||
let mut password = use_signal(|| String::new());
|
||||
let mut address_paste_pending = use_signal(|| false);
|
||||
let mut submit_attempted = use_signal(|| false);
|
||||
|
||||
let do_save = move |_| {
|
||||
let Ok(port_num) = port.read().parse::<u16>() else {
|
||||
submit_attempted.set(true);
|
||||
return;
|
||||
};
|
||||
if name.read().is_empty()
|
||||
|| !address_is_valid(&address.read())
|
||||
|| username.read().is_empty()
|
||||
{
|
||||
submit_attempted.set(true);
|
||||
return;
|
||||
let do_connect = move |_| {
|
||||
let _ = user_config.config_set::<String>("username", &username.read());
|
||||
if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
|
||||
user_config.config_set::<String>("server_url", &address.read());
|
||||
}
|
||||
on_save.call(ServerEntry {
|
||||
name: name.read().clone(),
|
||||
net.send(Connect {
|
||||
address: address.read().clone(),
|
||||
port: port_num,
|
||||
username: username.read().clone(),
|
||||
password: if password.read().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(password.read().clone())
|
||||
},
|
||||
});
|
||||
config: overrides.read().clone().unwrap_or_default(),
|
||||
})
|
||||
};
|
||||
|
||||
let field_class = if submit_attempted() {
|
||||
"modal-field modal-field--strict"
|
||||
} else {
|
||||
"modal-field"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "modal-backdrop",
|
||||
onclick: move |_| on_cancel.call(()),
|
||||
}
|
||||
div {
|
||||
class: "modal-container",
|
||||
onclick: move |evt| evt.stop_propagation(),
|
||||
let state = use_context::<SharedState>();
|
||||
let status = &state.status;
|
||||
let bottom = match &*status.read() {
|
||||
Disconnected => rsx! {
|
||||
button {
|
||||
class: "login_bttn",
|
||||
onclick: do_connect.clone(),
|
||||
"Connect"
|
||||
}
|
||||
},
|
||||
Connecting => rsx! {
|
||||
div {
|
||||
class: "modal",
|
||||
h2 { "Add Server" }
|
||||
div {
|
||||
class: "{field_class}",
|
||||
label { "Name" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "My Mumble Server",
|
||||
value: "{name.read()}",
|
||||
oninput: move |evt| name.set(evt.value().clone()),
|
||||
required: true,
|
||||
}
|
||||
div {
|
||||
class: "modal-field__error",
|
||||
"Enter a name for this server."
|
||||
}
|
||||
class: "login_bttn",
|
||||
"Connecting..."
|
||||
}
|
||||
},
|
||||
Failed(msg) => rsx!(
|
||||
button {
|
||||
class: "login_bttn",
|
||||
onclick: do_connect.clone(),
|
||||
"Reconnect"
|
||||
}
|
||||
div {
|
||||
class: "login_error",
|
||||
"Failed to connect:"
|
||||
pre {
|
||||
"{msg}"
|
||||
}
|
||||
}
|
||||
),
|
||||
Connected => unreachable!(),
|
||||
};
|
||||
rsx!(
|
||||
div {
|
||||
class: "login",
|
||||
h1 {
|
||||
"Mumble Web"
|
||||
match VERSION {
|
||||
Some(v) => rsx!(" " span { class: "login_version", "({v})" }),
|
||||
None => rsx!(),
|
||||
}
|
||||
}
|
||||
if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
|
||||
div {
|
||||
class: "{field_class}",
|
||||
label { "Address" }
|
||||
label {
|
||||
for: "address-entry",
|
||||
"Server Address:"
|
||||
}
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "mumble.example.com",
|
||||
pattern: ADDRESS_PATTERN,
|
||||
id: "address-entry",
|
||||
placeholder: "address",
|
||||
value: "{address.read()}",
|
||||
onpaste: move |_| address_paste_pending.set(true),
|
||||
oninput: move |evt| {
|
||||
if address_paste_pending() {
|
||||
address_paste_pending.set(false);
|
||||
let (host, maybe_port) = split_host_port(&evt.value());
|
||||
address.set(host);
|
||||
if let Some(p) = maybe_port {
|
||||
port.set(p);
|
||||
}
|
||||
} else {
|
||||
address.set(evt.value());
|
||||
}
|
||||
},
|
||||
onblur: move |_| {
|
||||
let (host, maybe_port) = split_host_port(&address.read());
|
||||
if let Some(p) = maybe_port {
|
||||
address.set(host);
|
||||
port.set(p);
|
||||
}
|
||||
},
|
||||
required: true,
|
||||
}
|
||||
div {
|
||||
class: "modal-field__error",
|
||||
"Enter a hostname or IP address only — do not include a port."
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "{field_class}",
|
||||
label { "Port" }
|
||||
input {
|
||||
r#type: "number",
|
||||
placeholder: "64738",
|
||||
value: "{port.read()}",
|
||||
oninput: move |evt| port.set(evt.value().clone()),
|
||||
required: true,
|
||||
}
|
||||
div {
|
||||
class: "modal-field__error",
|
||||
"Enter a port number."
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "{field_class}",
|
||||
label { "Username" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Nickname",
|
||||
value: "{username.read()}",
|
||||
oninput: move |evt| username.set(evt.value().clone()),
|
||||
required: true,
|
||||
}
|
||||
div {
|
||||
class: "modal-field__error",
|
||||
"Enter a username."
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "modal-field",
|
||||
label { "Password (optional)" }
|
||||
input {
|
||||
r#type: "password",
|
||||
placeholder: "Password",
|
||||
value: "{password.read()}",
|
||||
oninput: move |evt| password.set(evt.value().clone()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "modal-actions",
|
||||
button {
|
||||
class: "modal-btn",
|
||||
onclick: move |_| on_cancel.call(()),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "modal-btn modal-btn--primary",
|
||||
onclick: do_save,
|
||||
"Save"
|
||||
autofocus: "true",
|
||||
oninput: move |evt| address_input.set(Some(evt.value().clone())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn EditServerModal(
|
||||
entry: ServerEntry,
|
||||
on_save: EventHandler<ServerEntry>,
|
||||
on_delete: EventHandler<()>,
|
||||
on_cancel: EventHandler<()>,
|
||||
) -> Element {
|
||||
let mut name = use_signal(|| entry.name.clone());
|
||||
let mut address = use_signal(|| entry.address.clone());
|
||||
let mut port = use_signal(|| entry.port.to_string());
|
||||
let mut username = use_signal(|| entry.username.clone());
|
||||
let mut password = use_signal(|| entry.password.clone().unwrap_or_default());
|
||||
let mut address_paste_pending = use_signal(|| false);
|
||||
|
||||
let do_save = move |_| {
|
||||
let port_num: u16 = port.read().parse().unwrap_or(64738);
|
||||
on_save.call(ServerEntry {
|
||||
name: name.read().clone(),
|
||||
address: address.read().clone(),
|
||||
port: port_num,
|
||||
username: username.read().clone(),
|
||||
password: if password.read().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(password.read().clone())
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "modal-backdrop",
|
||||
onclick: move |_| on_cancel.call(()),
|
||||
}
|
||||
div {
|
||||
class: "modal-container",
|
||||
onclick: move |evt| evt.stop_propagation(),
|
||||
div {
|
||||
class: "modal",
|
||||
h2 { "Edit Server" }
|
||||
div {
|
||||
class: "modal-field modal-field--strict",
|
||||
label { "Name" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "My Mumble Server",
|
||||
value: "{name.read()}",
|
||||
oninput: move |evt| name.set(evt.value().clone()),
|
||||
required: true,
|
||||
}
|
||||
div {
|
||||
class: "modal-field__error",
|
||||
"Enter a name for this server."
|
||||
}
|
||||
label {
|
||||
for: "username-entry",
|
||||
"Username:"
|
||||
//style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
|
||||
}
|
||||
div {
|
||||
class: "modal-field modal-field--strict",
|
||||
label { "Address" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "mumble.example.com",
|
||||
pattern: ADDRESS_PATTERN,
|
||||
value: "{address.read()}",
|
||||
onpaste: move |_| address_paste_pending.set(true),
|
||||
oninput: move |evt| {
|
||||
if address_paste_pending() {
|
||||
address_paste_pending.set(false);
|
||||
let (host, maybe_port) = split_host_port(&evt.value());
|
||||
address.set(host);
|
||||
if let Some(p) = maybe_port {
|
||||
port.set(p);
|
||||
}
|
||||
} else {
|
||||
address.set(evt.value());
|
||||
}
|
||||
},
|
||||
onblur: move |_| {
|
||||
let (host, maybe_port) = split_host_port(&address.read());
|
||||
if let Some(p) = maybe_port {
|
||||
address.set(host);
|
||||
port.set(p);
|
||||
}
|
||||
},
|
||||
required: true,
|
||||
}
|
||||
div {
|
||||
class: "modal-field__error",
|
||||
"Enter a hostname or IP address only — do not include a port."
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "modal-field modal-field--strict",
|
||||
label { "Port" }
|
||||
input {
|
||||
r#type: "number",
|
||||
placeholder: "64738",
|
||||
value: "{port.read()}",
|
||||
oninput: move |evt| port.set(evt.value().clone()),
|
||||
required: true,
|
||||
}
|
||||
div {
|
||||
class: "modal-field__error",
|
||||
"Enter a port number."
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "modal-field modal-field--strict",
|
||||
label { "Username" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Nickname",
|
||||
value: "{username.read()}",
|
||||
oninput: move |evt| username.set(evt.value().clone()),
|
||||
required: true,
|
||||
}
|
||||
div {
|
||||
class: "modal-field__error",
|
||||
"Enter a username."
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "modal-field",
|
||||
label { "Password (optional)" }
|
||||
input {
|
||||
r#type: "password",
|
||||
placeholder: "Password",
|
||||
value: "{password.read()}",
|
||||
oninput: move |evt| password.set(evt.value().clone()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "modal-actions",
|
||||
button {
|
||||
class: "modal-btn modal-btn--danger",
|
||||
onclick: move |_| on_delete.call(()),
|
||||
"Delete"
|
||||
}
|
||||
span { class: "modal-actions__spacer" }
|
||||
button {
|
||||
class: "modal-btn",
|
||||
onclick: move |_| on_cancel.call(()),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "modal-btn modal-btn--primary",
|
||||
disabled: !address_is_valid(&address.read()) || username.read().is_empty(),
|
||||
onclick: do_save,
|
||||
"Save"
|
||||
}
|
||||
input {
|
||||
id: "username-entry",
|
||||
placeholder: "username",
|
||||
value: "{username.read()}",
|
||||
autofocus: "true",
|
||||
oninput: move |evt| username.set(evt.value().clone()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
match &*last_status.read() {
|
||||
None => rsx!(div {
|
||||
class: "login_status",
|
||||
span {"···"}
|
||||
}),
|
||||
Some(Ok(ServerStatus { success: false, .. })) => rsx!(div {
|
||||
class: "login_status is_error",
|
||||
span {
|
||||
"Could not reach server"
|
||||
}
|
||||
}),
|
||||
Some(Ok(status)) => rsx!(div {
|
||||
class: "login_status",
|
||||
if let (Some(users), Some(max_users)) = (status.users, status.max_users) {
|
||||
span {"{users}/{max_users} Online"}
|
||||
} else {
|
||||
span {"Unknown Online"}
|
||||
}
|
||||
span {"-"}
|
||||
if let Some((maj, min, pat)) = status.version {
|
||||
span {"Version: {maj}.{min}.{pat}"}
|
||||
} else {
|
||||
span {"Unknown Version"}
|
||||
}
|
||||
}),
|
||||
Some(Err(_)) => rsx!(div {
|
||||
class: "login_status is_error",
|
||||
span {
|
||||
"Could not reach server"
|
||||
}
|
||||
}),
|
||||
}
|
||||
div {
|
||||
{bottom}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
// rsx!(
|
||||
// div {
|
||||
// class: "{login_box}",
|
||||
// h1 {
|
||||
// "Mumble Web"
|
||||
// }
|
||||
// input {
|
||||
// placeholder: "username",
|
||||
// value: "{username.read()}",
|
||||
// autofocus: "true",
|
||||
// oninput: move |evt| username.set(evt.value().clone()),
|
||||
// }
|
||||
// input {
|
||||
// placeholder: "server address",
|
||||
// value: "{address.read()}",
|
||||
// autofocus: "true",
|
||||
// oninput: move |evt| address_input.set(Some(evt.value().clone())),
|
||||
// }
|
||||
// {bottom}
|
||||
// }
|
||||
// )
|
||||
}
|
||||
|
||||
#[component]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "mumble-web2-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
mumble-web2-client = { version = "0.1.0", path = "../client", features = ["desktop", "embed-denoiser"] }
|
||||
mumble-web2-common = { version = "0.1.0", path = "../common" }
|
||||
ratatui = "0.29"
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
tokio = { version = "^1.41.1", features = ["rt", "macros"] }
|
||||
futures-channel = "^0.3.30"
|
||||
futures = "^0.3.30"
|
||||
dioxus-signals = "0.7.2"
|
||||
dioxus-core = "0.7.2"
|
||||
generational-box = "0.7.2"
|
||||
color-eyre = "^0.6.3"
|
||||
tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] }
|
||||
tracing = "^0.1.40"
|
||||
+775
@@ -0,0 +1,775 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use dioxus_core::with_owner;
|
||||
use futures_channel::mpsc;
|
||||
use generational_box::Owner;
|
||||
use mumble_web2_client::{
|
||||
network_entrypoint, AudioSettings, ChannelId, Command, ConfigSystem,
|
||||
ConfigSystemInterface as _, ConnectionState, Platform, PlatformInterface as _, ServerState,
|
||||
UserState,
|
||||
};
|
||||
use mumble_web2_common::ProxyOverrides;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub struct RefCellReactivity;
|
||||
|
||||
impl mumble_web2_client::Reactivity for RefCellReactivity {
|
||||
type Signal<T> = RefCell<T>;
|
||||
|
||||
fn new<T: 'static>(value: T) -> Self::Signal<T> {
|
||||
RefCell::new(value)
|
||||
}
|
||||
|
||||
fn read<T: 'static>(signal: &Self::Signal<T>) -> impl std::ops::Deref<Target = T> {
|
||||
signal.borrow()
|
||||
}
|
||||
|
||||
fn write<T: 'static>(signal: &Self::Signal<T>) -> impl std::ops::DerefMut<Target = T> {
|
||||
signal.borrow_mut()
|
||||
}
|
||||
}
|
||||
|
||||
pub type State = mumble_web2_client::State<RefCellReactivity>;
|
||||
pub type SharedState = mumble_web2_client::SharedState<RefCellReactivity>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App state (TUI-local, not shared with client)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Address,
|
||||
Username,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Pane {
|
||||
Channels,
|
||||
Chat,
|
||||
}
|
||||
|
||||
struct App {
|
||||
state: SharedState,
|
||||
tx: mpsc::UnboundedSender<Command>,
|
||||
config: ConfigSystem,
|
||||
overrides: ProxyOverrides,
|
||||
|
||||
// Login fields
|
||||
address: String,
|
||||
username: String,
|
||||
login_focus: Focus,
|
||||
|
||||
// Server view
|
||||
active_pane: Pane,
|
||||
chat_input: String,
|
||||
chat_focused: bool,
|
||||
channel_list: Vec<(ChannelId, u16)>, // (id, depth) - flattened tree for navigation
|
||||
channel_cursor: usize,
|
||||
|
||||
should_quit: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(
|
||||
state: SharedState,
|
||||
tx: mpsc::UnboundedSender<Command>,
|
||||
config: ConfigSystem,
|
||||
overrides: ProxyOverrides,
|
||||
) -> Self {
|
||||
let address = config
|
||||
.config_get::<String>("server_url")
|
||||
.or_else(|| overrides.proxy_url.clone())
|
||||
.unwrap_or_default();
|
||||
let username = config.config_get::<String>("username").unwrap_or_default();
|
||||
Self {
|
||||
state,
|
||||
tx,
|
||||
config,
|
||||
overrides,
|
||||
address,
|
||||
username,
|
||||
login_focus: Focus::Username,
|
||||
active_pane: Pane::Channels,
|
||||
chat_input: String::new(),
|
||||
chat_focused: false,
|
||||
channel_list: Vec::new(),
|
||||
channel_cursor: 0,
|
||||
should_quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&self, cmd: Command) {
|
||||
let _ = self.tx.unbounded_send(cmd);
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
matches!(&*self.state.status.borrow(), ConnectionState::Connected)
|
||||
}
|
||||
|
||||
// Build a flat list of (channel_id, depth) by walking the tree.
|
||||
fn rebuild_channel_list(&mut self) {
|
||||
self.channel_list.clear();
|
||||
let server = self.state.server.borrow();
|
||||
// Find root channels (no parent)
|
||||
let mut roots: Vec<ChannelId> = server
|
||||
.channels_state
|
||||
.channels
|
||||
.iter()
|
||||
.filter(|(_, ch)| ch.parent.is_none())
|
||||
.map(|(&id, _)| id)
|
||||
.collect();
|
||||
roots.sort();
|
||||
for root in roots {
|
||||
Self::walk_channel(&mut self.channel_list, &server, root, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_channel(
|
||||
list: &mut Vec<(ChannelId, u16)>,
|
||||
server: &ServerState,
|
||||
id: ChannelId,
|
||||
depth: u16,
|
||||
) {
|
||||
list.push((id, depth));
|
||||
let Some(ch) = server.channels_state.channels.get(&id) else {
|
||||
return;
|
||||
};
|
||||
for &child in ch.children.iter() {
|
||||
Self::walk_channel(list, server, child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User icon helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn user_indicator(user: &UserState) -> &'static str {
|
||||
if user.deaf || user.self_deaf {
|
||||
"D"
|
||||
} else if user.mute || user.self_mute {
|
||||
"M"
|
||||
} else if user.suppress {
|
||||
"S"
|
||||
} else {
|
||||
" "
|
||||
}
|
||||
}
|
||||
|
||||
fn user_style(user: &UserState) -> Style {
|
||||
if user.deaf || user.self_deaf {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if user.mute || user.self_mute || user.suppress {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn draw(frame: &mut Frame, app: &mut App) {
|
||||
if app.is_connected() {
|
||||
draw_server(frame, app);
|
||||
} else {
|
||||
draw_login(frame, app);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_login(frame: &mut Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
|
||||
// Center a box
|
||||
let vert = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(10),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(area);
|
||||
let horiz = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(50),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(vert[1]);
|
||||
let box_area = horiz[1];
|
||||
|
||||
let block = Block::default()
|
||||
.title(" Mumble Web 2 ")
|
||||
.borders(Borders::ALL);
|
||||
let inner = block.inner(box_area);
|
||||
frame.render_widget(Clear, box_area);
|
||||
frame.render_widget(block, box_area);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // address label
|
||||
Constraint::Length(1), // address input
|
||||
Constraint::Length(1), // spacer
|
||||
Constraint::Length(1), // username label
|
||||
Constraint::Length(1), // username input
|
||||
Constraint::Length(1), // spacer
|
||||
Constraint::Length(1), // status / button hint
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let status = &*app.state.status.borrow();
|
||||
|
||||
// Address
|
||||
if app.overrides.any_server {
|
||||
let label_style = if app.login_focus == Focus::Address {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new("Server Address:").style(label_style),
|
||||
chunks[0],
|
||||
);
|
||||
let input_style = if app.login_focus == Focus::Address {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("> {}", app.address)).style(input_style),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
|
||||
// Username
|
||||
let label_style = if app.login_focus == Focus::Username {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
frame.render_widget(Paragraph::new("Username:").style(label_style), chunks[3]);
|
||||
let input_style = if app.login_focus == Focus::Username {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("> {}", app.username)).style(input_style),
|
||||
chunks[4],
|
||||
);
|
||||
|
||||
// Status line
|
||||
let status_line = match status {
|
||||
ConnectionState::Disconnected => Line::from(Span::styled(
|
||||
"[Enter] Connect",
|
||||
Style::default().fg(Color::Green),
|
||||
)),
|
||||
ConnectionState::Connecting => Line::from(Span::styled(
|
||||
"Connecting...",
|
||||
Style::default().fg(Color::Yellow),
|
||||
)),
|
||||
ConnectionState::Failed(msg) => Line::from(vec![
|
||||
Span::styled("Failed: ", Style::default().fg(Color::Red)),
|
||||
Span::raw(msg.clone()),
|
||||
Span::styled(" [Enter] Retry", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
ConnectionState::Connected => unreachable!(),
|
||||
};
|
||||
frame.render_widget(Paragraph::new(status_line), chunks[6]);
|
||||
}
|
||||
|
||||
fn draw_server(frame: &mut Frame, app: &mut App) {
|
||||
app.rebuild_channel_list();
|
||||
let server = app.state.server.borrow();
|
||||
let audio = app.state.audio.borrow();
|
||||
|
||||
// Main layout: channels left, chat right, controls bottom
|
||||
let vert = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(3)])
|
||||
.split(frame.area());
|
||||
|
||||
let horiz = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
|
||||
.split(vert[0]);
|
||||
|
||||
// --- Channel tree ---
|
||||
let chan_block = Block::default()
|
||||
.title(" Channels ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if app.active_pane == Pane::Channels && !app.chat_focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
|
||||
let mut items: Vec<ListItem> = Vec::new();
|
||||
for (i, &(ch_id, depth)) in app.channel_list.iter().enumerate() {
|
||||
let Some(ch) = server.channels_state.channels.get(&ch_id) else {
|
||||
continue;
|
||||
};
|
||||
let indent = " ".repeat(depth as usize);
|
||||
let marker = if ch.children.is_empty() { " " } else { "▾ " };
|
||||
let is_selected = i == app.channel_cursor;
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
let prefix = if is_selected { ">" } else { " " };
|
||||
|
||||
// Channel name line
|
||||
let mut lines = vec![Line::from(Span::styled(
|
||||
format!("{prefix}{indent}{marker}{}", ch.name),
|
||||
style,
|
||||
))];
|
||||
|
||||
// Users in this channel
|
||||
for &uid in ch.users.iter() {
|
||||
if let Some(user) = server.users.get(&uid) {
|
||||
let is_self = server.session == Some(uid);
|
||||
let ind = user_indicator(user);
|
||||
let u_style = if is_self {
|
||||
user_style(user).add_modifier(Modifier::UNDERLINED)
|
||||
} else {
|
||||
user_style(user)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {indent} [{ind}] {}", user.name),
|
||||
u_style,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
items.push(ListItem::new(lines));
|
||||
}
|
||||
|
||||
let channel_list = List::new(items).block(chan_block);
|
||||
frame.render_widget(channel_list, horiz[0]);
|
||||
|
||||
// --- Chat panel ---
|
||||
let chat_area = horiz[1];
|
||||
let chat_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(3)])
|
||||
.split(chat_area);
|
||||
|
||||
let chat_block = Block::default()
|
||||
.title(" Chat ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if app.active_pane == Pane::Chat && !app.chat_focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
|
||||
let chat_lines: Vec<Line> = server
|
||||
.chat
|
||||
.iter()
|
||||
.map(|msg| {
|
||||
let sender = msg
|
||||
.sender
|
||||
.and_then(|uid| server.users.get(&uid))
|
||||
.map(|u| u.name.as_str())
|
||||
.unwrap_or("server");
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{sender}: "),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(&msg.raw),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Show last N lines that fit
|
||||
let chat_inner_height = chat_block.inner(chat_layout[0]).height as usize;
|
||||
let skip = chat_lines.len().saturating_sub(chat_inner_height);
|
||||
let visible_lines: Vec<Line> = chat_lines.into_iter().skip(skip).collect();
|
||||
|
||||
let chat_widget = Paragraph::new(visible_lines)
|
||||
.block(chat_block)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(chat_widget, chat_layout[0]);
|
||||
|
||||
// Chat input
|
||||
let input_block = Block::default()
|
||||
.title(if app.chat_focused {
|
||||
" Input (Esc to cancel) "
|
||||
} else {
|
||||
" [t] to type "
|
||||
})
|
||||
.borders(Borders::ALL)
|
||||
.border_style(if app.chat_focused {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
let input_widget = Paragraph::new(app.chat_input.as_str()).block(input_block);
|
||||
frame.render_widget(input_widget, chat_layout[1]);
|
||||
|
||||
// --- Controls bar ---
|
||||
let this_user = server.this_user();
|
||||
let (self_mute, mute, suppress, self_deaf, deaf) = this_user
|
||||
.map(|u| (u.self_mute, u.mute, u.suppress, u.self_deaf, u.deaf))
|
||||
.unwrap_or_default();
|
||||
|
||||
let muted = mute || suppress || self_mute;
|
||||
let deafened = deaf || self_deaf;
|
||||
|
||||
let status_text = match &*app.state.status.borrow() {
|
||||
ConnectionState::Connected => "Connected",
|
||||
ConnectionState::Connecting => "Connecting",
|
||||
ConnectionState::Disconnected => "Disconnected",
|
||||
ConnectionState::Failed(_) => "Failed",
|
||||
};
|
||||
|
||||
let current_channel = this_user
|
||||
.and_then(|u| server.channels_state.channels.get(&u.channel))
|
||||
.map(|ch| ch.name.as_str())
|
||||
.unwrap_or("?");
|
||||
|
||||
let controls = Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {status_text} "),
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
Span::styled(
|
||||
format!("#{current_channel} "),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
Span::raw("│ "),
|
||||
Span::styled(
|
||||
if muted { "[m]ute ✓ " } else { "[m]ute " },
|
||||
if muted {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
},
|
||||
),
|
||||
Span::styled(
|
||||
if deafened { "[d]eaf ✓ " } else { "[d]eaf " },
|
||||
if deafened {
|
||||
Style::default().fg(Color::Red)
|
||||
} else {
|
||||
Style::default()
|
||||
},
|
||||
),
|
||||
Span::styled(
|
||||
if audio.denoise {
|
||||
"[n]oise ✓ "
|
||||
} else {
|
||||
"[n]oise "
|
||||
},
|
||||
if audio.denoise {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
},
|
||||
),
|
||||
Span::raw("│ "),
|
||||
Span::styled("[q]uit", Style::default().fg(Color::DarkGray)),
|
||||
]);
|
||||
|
||||
let controls_block = Block::default().borders(Borders::ALL);
|
||||
let controls_widget = Paragraph::new(controls).block(controls_block);
|
||||
frame.render_widget(controls_widget, vert[1]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn handle_login_key(app: &mut App, code: KeyCode) {
|
||||
match code {
|
||||
KeyCode::Tab | KeyCode::BackTab => {
|
||||
app.login_focus = match app.login_focus {
|
||||
Focus::Address => Focus::Username,
|
||||
Focus::Username => {
|
||||
if app.overrides.any_server {
|
||||
Focus::Address
|
||||
} else {
|
||||
Focus::Username
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let status = &*app.state.status.borrow();
|
||||
if matches!(
|
||||
status,
|
||||
ConnectionState::Disconnected | ConnectionState::Failed(_)
|
||||
) {
|
||||
app.config.config_set::<String>("username", &app.username);
|
||||
if app.overrides.any_server {
|
||||
app.config.config_set::<String>("server_url", &app.address);
|
||||
}
|
||||
app.send(Command::Connect {
|
||||
address: app.address.clone(),
|
||||
username: app.username.clone(),
|
||||
config: app.overrides.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
let field = match app.login_focus {
|
||||
Focus::Address => &mut app.address,
|
||||
Focus::Username => &mut app.username,
|
||||
};
|
||||
field.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let field = match app.login_focus {
|
||||
Focus::Address => &mut app.address,
|
||||
Focus::Username => &mut app.username,
|
||||
};
|
||||
field.pop();
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.should_quit = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_server_key(app: &mut App, code: KeyCode) {
|
||||
if app.chat_focused {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
app.chat_focused = false;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !app.chat_input.is_empty() {
|
||||
let server = app.state.server.borrow();
|
||||
if let Some(user) = server.this_user() {
|
||||
let channels = vec![user.channel];
|
||||
let markdown = std::mem::take(&mut app.chat_input);
|
||||
drop(server);
|
||||
app.send(Command::SendChat { markdown, channels });
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.chat_input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.chat_input.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match code {
|
||||
KeyCode::Char('q') => {
|
||||
app.send(Command::Disconnect);
|
||||
app.should_quit = true;
|
||||
}
|
||||
KeyCode::Char('m') => {
|
||||
let server = app.state.server.borrow();
|
||||
if let Some(user) = server.this_user() {
|
||||
if !user.mute && !user.suppress {
|
||||
let new_mute = !user.self_mute;
|
||||
drop(server);
|
||||
app.send(Command::SetMute { mute: new_mute });
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
let server = app.state.server.borrow();
|
||||
if let Some(user) = server.this_user() {
|
||||
if !user.deaf {
|
||||
let new_deaf = !user.self_deaf;
|
||||
drop(server);
|
||||
app.send(Command::SetDeaf { deaf: new_deaf });
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
let audio = app.state.audio.borrow().clone();
|
||||
let new_denoise = !audio.denoise;
|
||||
*app.state.audio.borrow_mut() = AudioSettings {
|
||||
denoise: new_denoise,
|
||||
};
|
||||
app.send(Command::UpdateAudioSettings(AudioSettings {
|
||||
denoise: new_denoise,
|
||||
}));
|
||||
app.config.config_set::<bool>("denoise", &new_denoise);
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
app.chat_focused = true;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
app.active_pane = match app.active_pane {
|
||||
Pane::Channels => Pane::Chat,
|
||||
Pane::Chat => Pane::Channels,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
if !app.channel_list.is_empty() {
|
||||
app.channel_cursor = (app.channel_cursor + 1).min(app.channel_list.len() - 1);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
app.channel_cursor = app.channel_cursor.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(&(ch_id, _)) = app.channel_list.get(app.channel_cursor) {
|
||||
let server = app.state.server.borrow();
|
||||
if let Some(uid) = server.session {
|
||||
drop(server);
|
||||
app.send(Command::EnterChannel {
|
||||
channel: ch_id,
|
||||
user: uid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(app: &mut App, ev: Event) {
|
||||
let Event::Key(key) = ev else { return };
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl-C always quits
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
app.send(Command::Disconnect);
|
||||
app.should_quit = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if app.is_connected() {
|
||||
handle_server_key(app, key.code);
|
||||
} else {
|
||||
handle_login_key(app, key.code);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn init_file_logging() -> color_eyre::Result<()> {
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::filter::EnvFilter;
|
||||
|
||||
let log_path = std::env::var("MUMBLE_TUI_LOG")
|
||||
.unwrap_or_else(|_| std::env::temp_dir().join("mumble-tui.log").to_string_lossy().into_owned());
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)?;
|
||||
|
||||
let env_filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_ansi(false)
|
||||
.with_env_filter(env_filter)
|
||||
.with_writer(file)
|
||||
.init();
|
||||
|
||||
eprintln!("logging to {log_path}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
init_file_logging()?;
|
||||
|
||||
// Use a single-threaded runtime since dioxus Signals are !Send.
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
local.block_on(&rt, async {
|
||||
let config = ConfigSystem::new()?;
|
||||
let overrides = Platform::load_proxy_overrides().await.unwrap_or_default();
|
||||
|
||||
let state = SharedState::new(State {
|
||||
status: RefCell::new(ConnectionState::Disconnected),
|
||||
server: RefCell::new(Default::default()),
|
||||
audio: RefCell::new(AudioSettings {
|
||||
denoise: config.config_get::<bool>("denoise").unwrap_or(true),
|
||||
}),
|
||||
});
|
||||
|
||||
let (tx, rx) = mpsc::unbounded::<Command>();
|
||||
|
||||
// Spawn the network loop on the local task set (not Send-bound).
|
||||
let net_state = state.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
network_entrypoint(rx, net_state).await;
|
||||
});
|
||||
|
||||
// Setup terminal
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
let mut stdout = std::io::stdout();
|
||||
crossterm::execute!(
|
||||
stdout,
|
||||
crossterm::terminal::EnterAlternateScreen,
|
||||
crossterm::event::EnableMouseCapture
|
||||
)?;
|
||||
let backend = ratatui::backend::CrosstermBackend::new(stdout);
|
||||
let mut terminal = ratatui::Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new(state, tx, config, overrides);
|
||||
|
||||
// Event loop
|
||||
loop {
|
||||
terminal.draw(|f| draw(f, &mut app))?;
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
|
||||
// Poll with a short timeout so we re-render when state changes.
|
||||
// Yield to the tokio runtime between polls so network tasks can progress.
|
||||
if crossterm::event::poll(std::time::Duration::from_millis(16))? {
|
||||
let ev = crossterm::event::read()?;
|
||||
handle_event(&mut app, ev);
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
// Restore terminal
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
crossterm::execute!(
|
||||
terminal.backend_mut(),
|
||||
crossterm::terminal::LeaveAlternateScreen,
|
||||
crossterm::event::DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user