Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aac401e841 |
@@ -6,4 +6,4 @@ server_hash.txt
|
|||||||
proxy/bundle
|
proxy/bundle
|
||||||
/config.toml
|
/config.toml
|
||||||
proxy/config.toml
|
proxy/config.toml
|
||||||
*_onnx.tar.gz
|
gui/assets/*_onnx.tar.gz
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["client", "common", "gui", "proxy", "tui"]
|
members = ["common", "gui", "proxy"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
serde = { version = "1.0.214", features = ["derive"] }
|
serde = { version = "1.0.214", features = ["derive"] }
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "mumble-web2-client"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# Web Dependencies
|
|
||||||
# ================
|
|
||||||
wasm-bindgen = { version = "^0.2.92", optional = true }
|
|
||||||
wasm-bindgen-futures = { version = "^0.4.42", optional = true }
|
|
||||||
wasm-streams = { version = "^0.4.0", optional = true }
|
|
||||||
serde-wasm-bindgen = { version = "^0.6.5", optional = true }
|
|
||||||
js-sys = { version = "=0.3.82", optional = true }
|
|
||||||
web-sys = { version = "=0.3.82", features = [
|
|
||||||
"WebTransport",
|
|
||||||
"console",
|
|
||||||
"WebTransportOptions",
|
|
||||||
"WebTransportBidirectionalStream",
|
|
||||||
"WebTransportSendStream",
|
|
||||||
"WebTransportReceiveStream",
|
|
||||||
"Navigator",
|
|
||||||
"MediaDevices",
|
|
||||||
"AudioDecoder",
|
|
||||||
"AudioDecoderInit",
|
|
||||||
"AudioData",
|
|
||||||
"AudioEncoderConfig",
|
|
||||||
"AudioDecoderConfig",
|
|
||||||
"EncodedAudioChunk",
|
|
||||||
"EncodedAudioChunkInit",
|
|
||||||
"EncodedAudioChunkType",
|
|
||||||
"CodecState",
|
|
||||||
"AudioContext",
|
|
||||||
"AudioContextOptions",
|
|
||||||
"MediaStream",
|
|
||||||
"GainNode",
|
|
||||||
"MediaStreamAudioSourceNode",
|
|
||||||
"BaseAudioContext",
|
|
||||||
"AudioDestinationNode",
|
|
||||||
"AudioWorkletNode",
|
|
||||||
"AudioWorklet",
|
|
||||||
"AudioWorkletProcessor",
|
|
||||||
"MessagePort",
|
|
||||||
"MediaStreamConstraints",
|
|
||||||
"WorkletOptions",
|
|
||||||
"AudioEncoder",
|
|
||||||
"AudioEncoderInit",
|
|
||||||
"AudioDataInit",
|
|
||||||
"HtmlAnchorElement",
|
|
||||||
"Url",
|
|
||||||
"Blob",
|
|
||||||
"AudioDataCopyToOptions",
|
|
||||||
"AudioSampleFormat",
|
|
||||||
"Storage",
|
|
||||||
], optional = true }
|
|
||||||
gloo-timers = { version = "^0.3.0", features = ["futures"], optional = true }
|
|
||||||
tracing-web = { version = "^0.1.3", optional = true }
|
|
||||||
|
|
||||||
# Desktop Dependecies
|
|
||||||
# ===================
|
|
||||||
tokio = { version = "^1.41.1", features = ["net", "rt"], optional = true }
|
|
||||||
tokio-rustls = { version = "^0.26.0", optional = true }
|
|
||||||
opus = { version = "0.3.0", optional = true }
|
|
||||||
cpal = { version = "0.15.3", optional = true }
|
|
||||||
dasp_ring_buffer = { version = "0.11.0", optional = true }
|
|
||||||
etcetera = { version = "0.10.0", optional = true }
|
|
||||||
|
|
||||||
# Base Dependencies
|
|
||||||
# ================
|
|
||||||
manganis = "0.7.2"
|
|
||||||
once_cell = "1.19.0"
|
|
||||||
asynchronous-codec = { workspace = true }
|
|
||||||
futures = "^0.3.30"
|
|
||||||
merge-io = "^0.3.0"
|
|
||||||
mumble-protocol = { workspace = true }
|
|
||||||
serde_json = "1"
|
|
||||||
tokio-util = { version = "^0.7.11", features = ["codec", "compat"] }
|
|
||||||
byteorder = "1.5"
|
|
||||||
ogg = "^0.9.1"
|
|
||||||
ordermap = "^0.5.3"
|
|
||||||
html-purifier = "^0.3.0"
|
|
||||||
markdown = "^0.3.0"
|
|
||||||
futures-channel = "^0.3.30"
|
|
||||||
mumble-web2-common = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
tracing-subscriber = { version = "^0.3.18", features = ["ansi"] }
|
|
||||||
tracing = "^0.1.40"
|
|
||||||
color-eyre = "^0.6.3"
|
|
||||||
crossbeam-queue = "^0.3.11"
|
|
||||||
lol_html = "^2.2.0"
|
|
||||||
base64 = "^0.22"
|
|
||||||
mime_guess = "^2.0.5"
|
|
||||||
async_cell = "^0.2.3"
|
|
||||||
reqwest = { version = "^0.12.22", features = ["json"] }
|
|
||||||
dioxus-asset-resolver = "0.7.2"
|
|
||||||
|
|
||||||
# Denoising
|
|
||||||
# =========
|
|
||||||
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
|
|
||||||
"tract",
|
|
||||||
] }
|
|
||||||
crossbeam = "0.8.4"
|
|
||||||
|
|
||||||
# Android dependencies for requesting permissions
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
|
||||||
android-permissions = "0.1.2"
|
|
||||||
jni = "0.21.1"
|
|
||||||
ndk-context = "0.1.1"
|
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
tract-hir = "=0.12.4"
|
|
||||||
tract-core = "=0.12.4"
|
|
||||||
tract-onnx = "=0.12.4"
|
|
||||||
tract-pulse = "=0.12.4"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
embed-denoiser = []
|
|
||||||
web = [
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"wasm-streams",
|
|
||||||
"serde-wasm-bindgen",
|
|
||||||
"js-sys",
|
|
||||||
"web-sys",
|
|
||||||
"gloo-timers",
|
|
||||||
"tracing-web",
|
|
||||||
"deep_filter/wasm",
|
|
||||||
]
|
|
||||||
desktop = [
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tracing-subscriber/env-filter",
|
|
||||||
"opus",
|
|
||||||
"cpal",
|
|
||||||
"dasp_ring_buffer",
|
|
||||||
"etcetera",
|
|
||||||
"mumble-web2-common/networking",
|
|
||||||
]
|
|
||||||
mobile = [
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tracing-subscriber/env-filter",
|
|
||||||
"opus",
|
|
||||||
"cpal",
|
|
||||||
"dasp_ring_buffer",
|
|
||||||
"mumble-web2-common/networking",
|
|
||||||
]
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
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;
|
|
||||||
pub type UserId = u32;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ConnectionState {
|
|
||||||
Disconnected,
|
|
||||||
Connecting,
|
|
||||||
Connected,
|
|
||||||
Failed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AudioSettings {
|
|
||||||
pub denoise: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum Command {
|
|
||||||
Connect {
|
|
||||||
address: String,
|
|
||||||
username: String,
|
|
||||||
config: ProxyOverrides,
|
|
||||||
},
|
|
||||||
SendChat {
|
|
||||||
markdown: String,
|
|
||||||
channels: Vec<ChannelId>,
|
|
||||||
},
|
|
||||||
SendFile {
|
|
||||||
bytes: Vec<u8>,
|
|
||||||
name: String,
|
|
||||||
mime: Option<Mime>,
|
|
||||||
channels: Vec<ChannelId>,
|
|
||||||
},
|
|
||||||
SetMute {
|
|
||||||
mute: bool,
|
|
||||||
},
|
|
||||||
SetDeaf {
|
|
||||||
deaf: bool,
|
|
||||||
},
|
|
||||||
EnterChannel {
|
|
||||||
channel: ChannelId,
|
|
||||||
user: UserId,
|
|
||||||
},
|
|
||||||
UpdateAudioSettings(AudioSettings),
|
|
||||||
Disconnect,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
pub struct UserState {
|
|
||||||
pub name: String,
|
|
||||||
pub channel: ChannelId,
|
|
||||||
pub deaf: bool,
|
|
||||||
pub mute: bool,
|
|
||||||
pub suppress: bool,
|
|
||||||
pub self_deaf: bool,
|
|
||||||
pub self_mute: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Chat {
|
|
||||||
pub raw: String,
|
|
||||||
pub dangerous_html: String,
|
|
||||||
pub sender: Option<UserId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
pub struct ChannelState {
|
|
||||||
pub name: String,
|
|
||||||
pub children: OrderSet<ChannelId>,
|
|
||||||
pub users: OrderSet<UserId>,
|
|
||||||
pub parent: Option<ChannelId>,
|
|
||||||
pub position: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelState {
|
|
||||||
pub fn update_from_channel_state(
|
|
||||||
&mut self,
|
|
||||||
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
|
||||||
) {
|
|
||||||
if channel_state.has_position() {
|
|
||||||
self.position = channel_state.get_position();
|
|
||||||
}
|
|
||||||
if channel_state.has_parent() {
|
|
||||||
self.parent = Some(channel_state.get_parent());
|
|
||||||
}
|
|
||||||
if channel_state.has_name() {
|
|
||||||
self.name = channel_state.get_name().to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
pub struct ChannelsState {
|
|
||||||
pub channels: HashMap<ChannelId, ChannelState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelsState {
|
|
||||||
pub fn update_from_channel_state(
|
|
||||||
&mut self,
|
|
||||||
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
|
||||||
) {
|
|
||||||
self.channels
|
|
||||||
.entry(channel_state.get_channel_id())
|
|
||||||
.or_default()
|
|
||||||
.update_from_channel_state(channel_state);
|
|
||||||
|
|
||||||
self.update_channel_parents();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_from_channel_remove(
|
|
||||||
&mut self,
|
|
||||||
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
|
|
||||||
) {
|
|
||||||
self.channels.remove(&channel_remove.get_channel_id());
|
|
||||||
self.update_channel_parents();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_channel_parents(&mut self) {
|
|
||||||
// Zero out existing children
|
|
||||||
for state in self.channels.values_mut() {
|
|
||||||
state.children.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
|
|
||||||
for (id, state) in self.channels.iter() {
|
|
||||||
// Handle channels with no parent (the root channel)
|
|
||||||
let Some(parent_id) = state.parent else {
|
|
||||||
to_sort.push((*id, None, 0, state.name.clone()));
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// If a channel has a parent that we haven't gotten a channel
|
|
||||||
// state packet for, ignore it
|
|
||||||
if !self.channels.contains_key(&parent_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pos_name: HashMap<ChannelId, (i32, String)> = self
|
|
||||||
.channels
|
|
||||||
.iter()
|
|
||||||
.map(|(&id, state)| (id, (state.position, state.name.clone())))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut updated: HashSet<ChannelId> = HashSet::new();
|
|
||||||
|
|
||||||
while updated.len() < to_sort.len() {
|
|
||||||
for &(id, ref parent_id, position, ref name) in &to_sort {
|
|
||||||
let Some(parent_id) = parent_id else {
|
|
||||||
updated.insert(id);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if updated.contains(&id) || !updated.contains(&parent_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap should never fail here since we pre filter
|
|
||||||
let parent = self.channels.get_mut(&parent_id).unwrap();
|
|
||||||
|
|
||||||
let mut insert_index = parent.children.len();
|
|
||||||
for (i, &child) in parent.children.iter().enumerate() {
|
|
||||||
let (p, ref n) = pos_name[&child];
|
|
||||||
if (position == p && name < n) || p > position {
|
|
||||||
insert_index = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parent.children.insert_before(insert_index, id);
|
|
||||||
updated.insert(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
pub struct ServerState {
|
|
||||||
pub channels_state: ChannelsState,
|
|
||||||
pub users: HashMap<UserId, UserState>,
|
|
||||||
pub chat: Vec<Chat>,
|
|
||||||
pub session: Option<UserId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServerState {
|
|
||||||
pub fn this_user(&self) -> Option<&UserState> {
|
|
||||||
self.users.get(&self.session?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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", &*R::read(&self.status))
|
|
||||||
.field("server", &*R::read(&self.server))
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type SharedState<R> = Arc<State<R>>;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
mod app;
|
|
||||||
mod effects;
|
|
||||||
mod imp;
|
|
||||||
mod mainloop;
|
|
||||||
mod msghtml;
|
|
||||||
|
|
||||||
pub use app::*;
|
|
||||||
pub use imp::*;
|
|
||||||
pub use mainloop::*;
|
|
||||||
pub use mime_guess;
|
|
||||||
pub use reqwest;
|
|
||||||
@@ -3,10 +3,5 @@ name = "mumble-web2-common"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[features]
|
|
||||||
networking = ["dep:tokio", "dep:color-eyre"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
tokio = { version = "1", features = ["net", "time"], optional = true }
|
|
||||||
color-eyre = { version = "0.6", optional = true }
|
|
||||||
|
|||||||
@@ -16,62 +16,3 @@ pub struct ServerStatus {
|
|||||||
pub max_users: Option<u32>,
|
pub max_users: Option<u32>,
|
||||||
pub bandwidth: Option<u32>,
|
pub bandwidth: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mumble UDP ping protocol.
|
|
||||||
///
|
|
||||||
/// Send a 12-byte packet: 4 zero bytes + 8-byte identifier.
|
|
||||||
/// Receive a 24-byte response: 4 bytes version + 8 bytes identifier echo
|
|
||||||
/// + 4 bytes current_users + 4 bytes max_users + 4 bytes bandwidth.
|
|
||||||
#[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::UdpSocket;
|
|
||||||
|
|
||||||
let dest = format!("{}:{}", address, port)
|
|
||||||
.to_socket_addrs()?
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| eyre!("could not resolve address"))?;
|
|
||||||
|
|
||||||
let bind_addr = if dest.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" };
|
|
||||||
let socket = UdpSocket::bind(bind_addr).await?;
|
|
||||||
socket.connect(dest).await?;
|
|
||||||
|
|
||||||
let request_id: u64 = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_nanos() as u64;
|
|
||||||
|
|
||||||
let mut buf = [0u8; 12];
|
|
||||||
buf[4..12].copy_from_slice(&request_id.to_be_bytes());
|
|
||||||
socket.send(&buf).await?;
|
|
||||||
|
|
||||||
let mut response = [0u8; 24];
|
|
||||||
let timeout = tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut response)).await;
|
|
||||||
|
|
||||||
match timeout {
|
|
||||||
Ok(Ok(len)) if len >= 24 => {
|
|
||||||
let version_major = response[0] as u32;
|
|
||||||
let version_minor = response[1] as u32;
|
|
||||||
let version_patch = response[2] as u32;
|
|
||||||
let users =
|
|
||||||
u32::from_be_bytes([response[12], response[13], response[14], response[15]]);
|
|
||||||
let max_users =
|
|
||||||
u32::from_be_bytes([response[16], response[17], response[18], response[19]]);
|
|
||||||
let bandwidth =
|
|
||||||
u32::from_be_bytes([response[20], response[21], response[22], response[23]]);
|
|
||||||
|
|
||||||
Ok(ServerStatus {
|
|
||||||
success: true,
|
|
||||||
version: Some((version_major, version_minor, version_patch)),
|
|
||||||
users: Some(users),
|
|
||||||
max_users: Some(max_users),
|
|
||||||
bandwidth: Some(bandwidth),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Ok(Ok(_)) => bail!("ping response too short"),
|
|
||||||
Ok(Err(e)) => Err(e.into()),
|
|
||||||
Err(_) => bail!("ping timed out"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
|
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
||||||
IMAGE_NAME="mumble-web2/android-release-builder:local"
|
|
||||||
|
|
||||||
TARGET="${1:-aarch64-linux-android}"
|
|
||||||
|
|
||||||
echo "==> Building Android builder Docker image..."
|
|
||||||
docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/android-release-builder.Dockerfile" "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
echo "==> Building Android APK (target: $TARGET)..."
|
|
||||||
docker run --rm \
|
|
||||||
-v "$PROJECT_ROOT:/app" \
|
|
||||||
-w /app \
|
|
||||||
"$IMAGE_NAME" \
|
|
||||||
dx build --platform android --target "$TARGET" --release -p mumble-web2-gui
|
|
||||||
|
|
||||||
echo "==> Done! APK should be at:"
|
|
||||||
echo " target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk"
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
FROM archlinux:latest
|
||||||
|
|
||||||
|
# Base system + toolchain deps
|
||||||
|
RUN pacman -Sy --noconfirm archlinux-keyring && \
|
||||||
|
pacman-key --init && \
|
||||||
|
pacman-key --populate archlinux && \
|
||||||
|
pacman -Syu --noconfirm && \
|
||||||
|
pacman -S --noconfirm --needed \
|
||||||
|
base-devel git sudo xdotool
|
||||||
|
|
||||||
|
# Create non-root build user for AUR
|
||||||
|
RUN useradd -m -G wheel -s /bin/bash builder && \
|
||||||
|
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
|
||||||
|
|
||||||
|
USER builder
|
||||||
|
WORKDIR /home/builder
|
||||||
|
|
||||||
|
# Install yay from AUR
|
||||||
|
RUN git clone https://aur.archlinux.org/yay.git /home/builder/yay && \
|
||||||
|
cd /home/builder/yay && \
|
||||||
|
makepkg -si --noconfirm
|
||||||
|
|
||||||
|
# Use yay to install claude-code (or claude-code-stable)
|
||||||
|
RUN yay -S --noconfirm claude-code
|
||||||
|
|
||||||
|
# Optional: switch back to root for cleanup
|
||||||
|
USER root
|
||||||
|
RUN rm -rf /home/builder/yay && \
|
||||||
|
pacman -Scc --noconfirm
|
||||||
|
|
||||||
|
# Default working user/environment
|
||||||
|
USER builder
|
||||||
|
WORKDIR /home/builder
|
||||||
@@ -4,31 +4,150 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# Web Dependencies
|
||||||
|
# ================
|
||||||
|
dioxus-web = { version = "0.7.1", optional = true }
|
||||||
|
wasm-bindgen = { version = "^0.2.92", optional = true }
|
||||||
|
wasm-bindgen-futures = { version = "^0.4.42", optional = true }
|
||||||
|
wasm-streams = { version = "^0.4.0", optional = true }
|
||||||
|
serde-wasm-bindgen = { version = "^0.6.5", optional = true }
|
||||||
|
js-sys = { version = "^0.3.70", optional = true }
|
||||||
|
web-sys = { version = "^0.3.72", features = [
|
||||||
|
"WebTransport",
|
||||||
|
"console",
|
||||||
|
"WebTransportOptions",
|
||||||
|
"WebTransportBidirectionalStream",
|
||||||
|
"WebTransportSendStream",
|
||||||
|
"WebTransportReceiveStream",
|
||||||
|
"Navigator",
|
||||||
|
"MediaDevices",
|
||||||
|
"AudioDecoder",
|
||||||
|
"AudioDecoderInit",
|
||||||
|
"AudioData",
|
||||||
|
"AudioEncoderConfig",
|
||||||
|
"AudioDecoderConfig",
|
||||||
|
"EncodedAudioChunk",
|
||||||
|
"EncodedAudioChunkInit",
|
||||||
|
"EncodedAudioChunkType",
|
||||||
|
"CodecState",
|
||||||
|
"AudioContext",
|
||||||
|
"AudioContextOptions",
|
||||||
|
"MediaStream",
|
||||||
|
"GainNode",
|
||||||
|
"MediaStreamAudioSourceNode",
|
||||||
|
"BaseAudioContext",
|
||||||
|
"AudioDestinationNode",
|
||||||
|
"AudioWorkletNode",
|
||||||
|
"AudioWorklet",
|
||||||
|
"AudioWorkletProcessor",
|
||||||
|
"MessagePort",
|
||||||
|
"MediaStreamConstraints",
|
||||||
|
"WorkletOptions",
|
||||||
|
"AudioEncoder",
|
||||||
|
"AudioEncoderInit",
|
||||||
|
"AudioDataInit",
|
||||||
|
"HtmlAnchorElement",
|
||||||
|
"Url",
|
||||||
|
"Blob",
|
||||||
|
"AudioDataCopyToOptions",
|
||||||
|
"AudioSampleFormat",
|
||||||
|
"Storage",
|
||||||
|
], optional = true }
|
||||||
|
gloo-timers = { version = "^0.3.0", features = ["futures"], optional = true }
|
||||||
|
tracing-web = { version = "^0.1.3", optional = true }
|
||||||
|
|
||||||
|
# Desktop Dependecies
|
||||||
|
# ===================
|
||||||
|
tokio = { version = "^1.41.1", features = ["net", "rt"], optional = true }
|
||||||
|
tokio-rustls = { version = "^0.26.0", optional = true }
|
||||||
|
opus = { version = "0.3.0", optional = true }
|
||||||
|
cpal = { version = "0.15.3", optional = true }
|
||||||
|
dasp_ring_buffer = { version = "0.11.0", optional = true }
|
||||||
|
etcetera = { version = "0.10.0", optional = true }
|
||||||
|
|
||||||
|
# Base Dependencies
|
||||||
|
# ================
|
||||||
dioxus = { version = "0.7.2" }
|
dioxus = { version = "0.7.2" }
|
||||||
dioxus-web = { version = "0.7.2", optional = true }
|
dioxus-native = { git = "https://github.com/DioxusLabs/blitz", rev = "e64a3d8", features = ["prelude"], optional = true }
|
||||||
mumble-web2-client = { version = "0.1.0", path = "../client" }
|
once_cell = "1.19.0"
|
||||||
mumble-web2-common = { version = "0.1.0", path = "../common" }
|
asynchronous-codec = { workspace = true }
|
||||||
|
futures = "^0.3.30"
|
||||||
|
merge-io = "^0.3.0"
|
||||||
|
mumble-protocol = { workspace = true }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio-util = { version = "^0.7.11", features = ["codec", "compat"] }
|
||||||
|
byteorder = "1.5"
|
||||||
|
ogg = "^0.9.1"
|
||||||
|
ordermap = "^0.5.3"
|
||||||
|
html-purifier = "^0.3.0"
|
||||||
|
markdown = "^0.3.0"
|
||||||
|
futures-channel = "^0.3.30"
|
||||||
|
mumble-web2-common = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
tracing-subscriber = { version = "^0.3.18", features = ["ansi"] }
|
||||||
|
tracing = "^0.1.40"
|
||||||
color-eyre = "^0.6.3"
|
color-eyre = "^0.6.3"
|
||||||
|
crossbeam-queue = "^0.3.11"
|
||||||
|
lol_html = "^2.2.0"
|
||||||
|
base64 = "^0.22"
|
||||||
|
mime_guess = "^2.0.5"
|
||||||
|
async_cell = "^0.2.3"
|
||||||
|
reqwest = { version = "^0.12.22", features = ["json"] }
|
||||||
|
dioxus-asset-resolver = "0.7.2"
|
||||||
|
|
||||||
|
|
||||||
|
# Denoising
|
||||||
|
# =========
|
||||||
|
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
|
||||||
|
"tract",
|
||||||
|
] }
|
||||||
|
crossbeam = "0.8.4"
|
||||||
|
|
||||||
# Platform Integration
|
# Platform Integration
|
||||||
# ====================
|
# ====================
|
||||||
# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
|
# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
|
||||||
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
|
||||||
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true }
|
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.17.0", default-features = false, optional = true }
|
||||||
|
|
||||||
|
# Android dependencies for requesting permissions
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
android-permissions = "0.1.2"
|
||||||
|
jni = "0.21.1"
|
||||||
|
ndk-context = "0.1.1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
web = [
|
web = [
|
||||||
"dioxus/web",
|
"dioxus/web",
|
||||||
"dioxus-web",
|
"dioxus-web",
|
||||||
"mumble-web2-client/web",
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
|
"serde-wasm-bindgen",
|
||||||
|
"js-sys",
|
||||||
|
"web-sys",
|
||||||
|
"gloo-timers",
|
||||||
|
"tracing-web",
|
||||||
|
"deep_filter/wasm",
|
||||||
"rfd",
|
"rfd",
|
||||||
]
|
]
|
||||||
desktop = [
|
desktop = [
|
||||||
"dioxus/desktop",
|
"dioxus/desktop",
|
||||||
"mumble-web2-client/desktop",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tracing-subscriber/env-filter",
|
||||||
|
"opus",
|
||||||
|
"cpal",
|
||||||
|
"dasp_ring_buffer",
|
||||||
"rfd/xdg-portal",
|
"rfd/xdg-portal",
|
||||||
|
"etcetera",
|
||||||
]
|
]
|
||||||
mobile = [
|
mobile = [
|
||||||
"dioxus/mobile",
|
"dioxus/mobile",
|
||||||
"mumble-web2-client/mobile"
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tracing-subscriber/env-filter",
|
||||||
|
"opus",
|
||||||
|
"cpal",
|
||||||
|
"dasp_ring_buffer",
|
||||||
]
|
]
|
||||||
|
blitz = ["dioxus-native"]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M18 15.75q0 2.6-1.825 4.425T11.75 22t-4.425-1.825T5.5 15.75V6.5q0-1.875 1.313-3.187T10 2t3.188 1.313T14.5 6.5v8.75q0 1.15-.8 1.95t-1.95.8t-1.95-.8t-.8-1.95V6h2v9.25q0 .325.213.538t.537.212t.538-.213t.212-.537V6.5q-.025-1.05-.737-1.775T10 4t-1.775.725T7.5 6.5v9.25q-.025 1.775 1.225 3.013T11.75 20q1.75 0 2.975-1.237T16 15.75V6h2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 453 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M11 21V7h2v14zm-4-3v-8h2v8zm8 0v-8h2v8zM3 15v-2h2v2zm16 0v-2h2v2zM2 10V8h1.175q1.05 0 1.963-.525T6.6 6.05q.85-1.425 2.288-2.238T12 3t3.113.813T17.4 6.05q.55.9 1.463 1.425T20.825 8H22v2h-1.15q-1.575 0-2.963-.775T15.7 7.1q-.575-.975-1.562-1.537T12 5q-1.125 0-2.113.563T8.326 7.1q-.8 1.35-2.187 2.125T3.175 10z"/></svg>
|
||||||
|
After Width: | Height: | Size: 431 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12.713 16.713Q13 16.425 13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17t.713-.288M11 13h2V7h-2zm1 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M7 18V6h2v12zm4 4V2h2v20zm-8-8v-4h2v4zm12 4V6h2v12zm4-4v-4h2v4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 187 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M9.875 13.125Q9 12.25 9 11V5q0-1.25.875-2.125T12 2t2.125.875T15 5v6q0 1.25-.875 2.125T12 14t-2.125-.875M11 21v-3.075q-2.6-.35-4.3-2.325T5 11h2q0 2.075 1.463 3.538T12 16t3.538-1.463T17 11h2q0 2.625-1.7 4.6T13 17.925V21z"/></svg>
|
||||||
|
After Width: | Height: | Size: 342 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M17.75 14.95L16.3 13.5q.35-.575.525-1.2T17 11h2q0 1.1-.325 2.088t-.925 1.862m-2.95-3L9 6.15V5q0-1.25.875-2.125T12 2t2.125.875T15 5v6q0 .275-.062.5t-.138.45M11 21v-3.1q-2.6-.35-4.3-2.312T5 11h2q0 2.075 1.463 3.538T12 16q.85 0 1.613-.262T15 15l1.425 1.425q-.725.575-1.588.963T13 17.9V21zm8.8 1.6L1.4 4.2l1.4-1.4l18.4 18.4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M14 21v-3.075l5.525-5.5q.225-.225.5-.325t.55-.1q.3 0 .575.113t.5.337l.925.925q.2.225.313.5t.112.55t-.1.563t-.325.512l-5.5 5.5zM4 20v-2.8q0-.85.438-1.562T5.6 14.55q1.55-.775 3.15-1.162T12 13q.925 0 1.825.113t1.8.362L12 17.1V20zm16.575-4.6l.925-.975l-.925-.925l-.95.95zm-11.4-4.575Q8 9.65 8 8t1.175-2.825T12 4t2.825 1.175T16 8t-1.175 2.825T12 12t-2.825-1.175"/></svg>
|
||||||
|
After Width: | Height: | Size: 480 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M3 20v-6l8-2l-8-2V4l19 8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 149 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M5 20v-6h3v6zm6 0V9h3v11zm6 0V4h3v16z"/></svg>
|
||||||
|
After Width: | Height: | Size: 161 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M5 20v-6h3v6zm6 0V9h3v11z"/></svg>
|
||||||
|
After Width: | Height: | Size: 149 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="m17.1 14.275l-1.225-1.225q.55-.65.838-1.425T17 10q0-1-.4-1.9t-1.1-1.6l1.2-1.2q.95.95 1.475 2.15T18.7 10q0 1.2-.425 2.288T17.1 14.275M14.125 11.3L10.7 7.875q.3-.175.625-.275T12 7.5q1.05 0 1.775.725T14.5 10q0 .35-.1.675t-.275.625m5.375 5.35l-1.2-1.2q1-1.125 1.5-2.537T20.3 10q0-1.65-.612-3.187T17.9 4.1l1.2-1.2q1.375 1.45 2.138 3.275T22 10q0 1.85-.638 3.563T19.5 16.65m.275 5.95L13 15.825V21h-2v-7.175L7 9.85V10q0 1 .4 1.9t1.1 1.6l-1.2 1.2q-.95-.95-1.475-2.15T5.3 10q0-.425.05-.825t.175-.825L4.25 7.075q-.275.725-.413 1.45T3.7 10q0 1.65.612 3.188T6.1 15.9l-1.2 1.2q-1.375-1.45-2.137-3.275T2 10q0-1.1.238-2.162t.712-2.063L1.4 4.225L2.8 2.8l18.4 18.4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 771 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="m19.8 22.6l-3.025-3.025q-.625.4-1.325.688t-1.45.462v-2.05q.35-.125.688-.25t.637-.3L12 14.8V20l-5-5H3V9h3.2L1.4 4.2l1.4-1.4l18.4 18.4zm-.2-5.8l-1.45-1.45q.425-.775.638-1.625t.212-1.75q0-2.35-1.375-4.2T14 5.275v-2.05q3.1.7 5.05 3.138T21 11.975q0 1.325-.363 2.55T19.6 16.8m-3.35-3.35L14 11.2V7.95q1.175.55 1.838 1.65T16.5 12q0 .375-.062.738t-.188.712M12 9.2L9.4 6.6L12 4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 492 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M14 20.725v-2.05q2.25-.65 3.625-2.5t1.375-4.2t-1.375-4.2T14 5.275v-2.05q3.1.7 5.05 3.138T21 11.975t-1.95 5.613T14 20.725M3 15V9h4l5-5v16l-5-5zm11 1V7.95q1.175.55 1.838 1.65T16.5 12q0 1.275-.663 2.363T14 16"/></svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
@@ -16,7 +16,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#main {
|
#main {
|
||||||
visibility: visible;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -174,6 +173,7 @@ a:visited {
|
|||||||
&_box {
|
&_box {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
background-color: var(--light-bg-color);
|
background-color: var(--light-bg-color);
|
||||||
@@ -186,6 +186,12 @@ a:visited {
|
|||||||
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
color: white;
|
color: white;
|
||||||
background-color: var(--light-bg-color);
|
background-color: var(--light-bg-color);
|
||||||
@@ -208,10 +214,10 @@ a:visited {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
.material-symbols-outlined {
|
align-items: center;
|
||||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
justify-content: center;
|
||||||
}
|
padding: clamp(4px, 0.5vw, 8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button_row {
|
.button_row {
|
||||||
@@ -233,9 +239,9 @@ a:visited {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
|
|
||||||
.material-symbols-outlined {
|
> div {
|
||||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
display: flex;
|
||||||
vertical-align: middle;
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,19 +275,16 @@ a:visited {
|
|||||||
|
|
||||||
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
|
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
|
||||||
border-radius: clamp(4px, 0.8vw, 10px);
|
border-radius: clamp(4px, 0.8vw, 10px);
|
||||||
color: rgb(255 255 255 / 50%);
|
|
||||||
|
|
||||||
transition: all 0.5s ease-in-out;
|
transition: all 0.5s ease-in-out;
|
||||||
|
|
||||||
&.is_on {
|
&.is_on {
|
||||||
background-color: oklch(0.5 0.1381 21.71 / 20.12%);
|
background-color: oklch(0.5 0.1381 21.71 / 20.12%);
|
||||||
color: oklch(0.53 0.1505 21.71 / 89.38%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-symbols-outlined {
|
display: flex;
|
||||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
align-items: center;
|
||||||
vertical-align: middle;
|
justify-content: center;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.server {
|
.server {
|
||||||
@@ -345,7 +348,10 @@ a:visited {
|
|||||||
|
|
||||||
.connection_status {
|
.connection_status {
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-size: var(--control-icon-size);
|
width: 24px;
|
||||||
|
width: var(--control-icon-size);
|
||||||
|
height: 24px;
|
||||||
|
height: var(--control-icon-size);
|
||||||
}
|
}
|
||||||
.status_text {
|
.status_text {
|
||||||
font-size: var(--control-text-size);
|
font-size: var(--control-text-size);
|
||||||
@@ -357,7 +363,10 @@ a:visited {
|
|||||||
|
|
||||||
.user_edit_button {
|
.user_edit_button {
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-size: var(--user-icon-size);
|
width: 36px;
|
||||||
|
width: var(--user-icon-size);
|
||||||
|
height: 36px;
|
||||||
|
height: var(--user-icon-size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +381,10 @@ a:visited {
|
|||||||
|
|
||||||
.toggle_button {
|
.toggle_button {
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-size: var(--toggle-icon-size);
|
width: 28px;
|
||||||
|
width: var(--toggle-icon-size);
|
||||||
|
height: 28px;
|
||||||
|
height: var(--toggle-icon-size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,918 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
#[cfg(feature = "blitz")]
|
||||||
|
use dioxus_native::prelude::*;
|
||||||
|
#[cfg(not(feature = "blitz"))]
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use mime_guess::Mime;
|
||||||
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
|
use ordermap::OrderSet;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::imp::{ConfigSystem, ConfigSystemInterface as _, Platform, PlatformInterface as _};
|
||||||
|
|
||||||
|
// Material Symbols icon component.
|
||||||
|
// On blitz builds, renders <img> with a data URI SVG containing the explicit fill color,
|
||||||
|
// since web fonts aren't available and <img> can't inherit CSS color.
|
||||||
|
// On non-blitz builds, renders the icon font span as usual.
|
||||||
|
|
||||||
|
fn icon_svg_path(name: &str) -> &'static str {
|
||||||
|
// Paths from Google Material Symbols Outlined, weight 700, FILL 1, 24px.
|
||||||
|
// Coordinate space: viewBox="0 -960 960 960"
|
||||||
|
match name {
|
||||||
|
"attach_file" => "M772-320q0 117-87 195.5T479-46q-119 0-205-78.5T188-320v-392q0-86 63.5-144T403-914q88 0 150.5 58T616-712v371q0 55-40.5 92.5T479-211q-56 0-95.5-37.5T344-341v-370h116v370q0 7 5.5 11t13.5 4q8 0 14.5-3.5T500-341v-370q0-38-29-63t-68-25q-39 0-69 24.5T304-712v392q0 69 52.5 114T480-161q71 0 123.5-45T656-320v-429h116v429Z",
|
||||||
|
"cadence" => "M417-86v-555h125v555H417ZM252-210v-308h125v308H252Zm330 0v-308h125v308H582ZM86-301v-126h126v126H86Zm661 0v-126h126v126H747ZM46-542v-125h69q37.98 0 70.99-18.5T239-737q37.96-64.01 102.17-100.5 64.2-36.5 139.02-36.5 74.81 0 138.87 36.5Q683.12-801.01 721-737q19.75 32.31 52.52 51.15Q806.29-667 844-667h70v125h-69q-72 0-133-34.5T614-672q-20.82-35.75-56.59-56.38Q521.65-749 480-749q-42 0-77.63 20.62Q366.75-707.75 346-672q-37 61-98 95.5T115-542H46Z",
|
||||||
|
"error" => "M479.77-246Q509-246 529-265.77q20-19.77 20-49t-19.77-49.73q-19.77-20.5-49-20.5T431-364.5q-20 20.5-20 49.73 0 29.23 19.77 49t49 19.77ZM417-438h126v-263H417v263Zm63 392q-91 0-169.99-34.08-78.98-34.09-137.41-92.52-58.43-58.43-92.52-137.41Q46-389 46-480q0-91 34.08-169.99 34.09-78.98 92.52-137.41 58.43-58.43 137.41-92.52Q389-914 480-914q91 0 169.99 34.08 78.98 34.09 137.41 92.52 58.43 58.43 92.52 137.41Q914-571 914-480q0 91-34.08 169.99-34.09 78.98-92.52 137.41-58.43 58.43-137.41 92.52Q571-46 480-46Z",
|
||||||
|
"graphic_eq" => "M255-215v-530h111v530H255ZM425-46v-868h110v868H425ZM86-385v-190h111v190H86Zm507 170v-530h111v530H593Zm170-170v-190h111v190H763Z",
|
||||||
|
"mic" => "M479.88-354Q414-354 368-400.08 322-446.17 322-512v-233q0-65.83 46.12-111.92 46.12-46.08 112-46.08T592-856.92q46 46.09 46 111.92v233q0 65.83-46.12 111.92-46.12 46.08-112 46.08ZM425-59v-127q-121-16-199-109.12T148-512h111q0 92 64.7 156.5T480.2-291q91.8 0 156.3-64.64Q701-420.29 701-512h111q0 124-78 217T535-186v127H425Z",
|
||||||
|
"mic_off" => "m772-347-82-82q9-18 13-37.5t4-45.5h110q0 44-11 87t-34 78ZM639-480 336-783v-11q13-44 54-76.5t95-32.5q66 0 112.5 46T644-745v233q0 9-1.5 18t-3.5 14ZM430-59v-127q-121-16-199-109t-78-217h111q0 92 64.5 156.5T485-291q43 0 81.5-15.5T634-349l80 80q-35 33-79 54.5T540-186v127H430Zm357-5L51-800l66-66 736 736-66 66Z",
|
||||||
|
"person_edit" => "M554-86v-151l227-226q12-12.18 26.67-17.59Q822.33-486 837-486q16 0 30.55 6T894-462l37 37q10.82 12 16.91 26.67Q954-383.67 954-369q0 16-5.5 30.5T931-312L705-86H554Zm-428-23v-148q0-43.3 22.7-79.6 22.69-36.3 60.3-55.4 65-32 132.96-48.5Q409.92-457 480-457q42 0 81.33 4.97Q600.67-447.05 640-436L474-270v161H126Zm721-231 27-29-37-37-28 28 38 38ZM480-497q-81 0-137.5-56.5T286-691q0-81 56.5-137T480-884q81 0 137.5 56T674-691q0 81-56.5 137.5T480-497Z",
|
||||||
|
"send" => "M89-128v-244l366-108L89-588v-244l831 352L89-128Z",
|
||||||
|
"signal_cellular_alt" => "M176-126v-208h166v208H176Zm246 0v-408h166v408H422Zm246 0v-708h166v708H668Z",
|
||||||
|
"signal_cellular_alt_2_bar" => "M176-126v-208h166v208H176Zm246 0v-408h166v408H422Z",
|
||||||
|
"signal_disconnected" => "m703-385-66-66q17-24 26-52t9-57q0-38-15-73t-42-62l65-65q40 40 62.5 91.5T765-560q0 48-16.5 92.5T703-385ZM588-500 420-668q14-8 29-11.5t31-3.5q51 0 87 36t36 87q0 16-3.5 31T588-500Zm225 224-66-66q38-46 58.5-102T826-560q0-69-26.5-132.5T724-804l65-66q62 62 95.5 142T918-560q0 78-27 151t-78 133Zm16 263L543-299v202H417v-328L288-554v2q2 36 16.5 68.5T345-425l-65 65q-41-40-63-91.5T195-560q0-20 2.5-38.5T206-636l-48-48q-11 30-17.5 61t-6.5 63q0 69 26.5 132T236-316l-66 66q-60-63-94-142.5T42-560q0-51 11-100t34-94l-74-75 67-67L896-80l-67 67Z",
|
||||||
|
"volume_off" => "M802-24 L679-149q-21 12-44.5 21T585-114v-99l12-4q6-2 12-5L505-328v249L258-326H78v-308h130L22-826l66-66L869-91l-67 67Zm18-253-69-71q17-30 25.5-63.5T785-481q0-93-56-166.5T585-749v-99q130 29 213 131.5T881-481q0 57-16 108t-45 96ZM687-413 585-517v-137q51 24 83.5 70.5T701-480q0 18-3.5 35T687-413ZM505-600 367-743l138-138v281Z",
|
||||||
|
"volume_up" => "M586-114v-99q89-28 144.5-101.5T786-481q0-93-55.5-166.5T586-749v-99q130 29 212.5 131.5T881-481q0 133-82.5 235.5T586-114ZM79-326v-308h180l247-247v802L259-326H79Zm507 18v-346q51 25 83 71t32 103q0 56-32 102t-83 70Z",
|
||||||
|
_ => panic!("unknown icon: {name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_data_uri(name: &str, color: &str, opacity: f64) -> String {
|
||||||
|
use base64::Engine;
|
||||||
|
let path_d = icon_svg_path(name);
|
||||||
|
let opacity_attr = if opacity < 1.0 {
|
||||||
|
format!(r#" fill-opacity="{opacity}""#)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let svg = format!(
|
||||||
|
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="{color}"{opacity_attr} d="{path_d}"/></svg>"#
|
||||||
|
);
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
|
||||||
|
format!("data:image/svg+xml;base64,{b64}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn Icon(
|
||||||
|
name: String,
|
||||||
|
#[props(default)] style: String,
|
||||||
|
#[props(default)] color: String,
|
||||||
|
#[props(default = 1.0)] opacity: f64,
|
||||||
|
) -> Element {
|
||||||
|
let fill = if color.is_empty() { "white" } else { &color };
|
||||||
|
let src = icon_data_uri(&name, fill, opacity);
|
||||||
|
rsx!(img {
|
||||||
|
class: "material-symbols-outlined",
|
||||||
|
style: "{style}",
|
||||||
|
src: "{src}",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ChannelId = u32;
|
||||||
|
pub type UserId = u32;
|
||||||
|
|
||||||
|
pub enum ConnectionState {
|
||||||
|
Disconnected,
|
||||||
|
Connecting,
|
||||||
|
Connected,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Command {
|
||||||
|
Connect {
|
||||||
|
address: String,
|
||||||
|
username: String,
|
||||||
|
config: ProxyOverrides,
|
||||||
|
},
|
||||||
|
SendChat {
|
||||||
|
markdown: String,
|
||||||
|
channels: Vec<ChannelId>,
|
||||||
|
},
|
||||||
|
SendFile {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
name: String,
|
||||||
|
mime: Option<Mime>,
|
||||||
|
channels: Vec<ChannelId>,
|
||||||
|
},
|
||||||
|
SetMute {
|
||||||
|
mute: bool,
|
||||||
|
},
|
||||||
|
SetDeaf {
|
||||||
|
deaf: bool,
|
||||||
|
},
|
||||||
|
EnterChannel {
|
||||||
|
channel: ChannelId,
|
||||||
|
user: UserId,
|
||||||
|
},
|
||||||
|
UpdateMicEffects {
|
||||||
|
denoise: bool,
|
||||||
|
},
|
||||||
|
Disconnect,
|
||||||
|
}
|
||||||
|
|
||||||
|
use Command::*;
|
||||||
|
use ConnectionState::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct UserState {
|
||||||
|
pub name: String,
|
||||||
|
pub channel: ChannelId,
|
||||||
|
pub deaf: bool,
|
||||||
|
pub mute: bool,
|
||||||
|
pub suppress: bool,
|
||||||
|
pub self_deaf: bool,
|
||||||
|
pub self_mute: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserState {
|
||||||
|
pub fn icon(&self) -> UserIcon {
|
||||||
|
if self.deaf || self.self_deaf {
|
||||||
|
UserIcon::Deafened
|
||||||
|
} else if self.mute || self.self_mute {
|
||||||
|
UserIcon::Muted
|
||||||
|
} else if self.suppress {
|
||||||
|
UserIcon::Suppressed
|
||||||
|
} else {
|
||||||
|
UserIcon::Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Chat {
|
||||||
|
pub raw: String,
|
||||||
|
pub dangerous_html: String,
|
||||||
|
pub sender: Option<UserId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ChannelState {
|
||||||
|
pub name: String,
|
||||||
|
pub children: OrderSet<ChannelId>,
|
||||||
|
pub users: OrderSet<UserId>,
|
||||||
|
pub parent: Option<ChannelId>,
|
||||||
|
pub position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelState {
|
||||||
|
pub fn update_from_channel_state(
|
||||||
|
&mut self,
|
||||||
|
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
||||||
|
) {
|
||||||
|
if channel_state.has_position() {
|
||||||
|
self.position = channel_state.get_position();
|
||||||
|
}
|
||||||
|
if channel_state.has_parent() {
|
||||||
|
self.parent = Some(channel_state.get_parent());
|
||||||
|
}
|
||||||
|
if channel_state.has_name() {
|
||||||
|
self.name = channel_state.get_name().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ChannelsState {
|
||||||
|
pub channels: HashMap<ChannelId, ChannelState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelsState {
|
||||||
|
pub fn update_from_channel_state(
|
||||||
|
&mut self,
|
||||||
|
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
||||||
|
) {
|
||||||
|
self.channels
|
||||||
|
.entry(channel_state.get_channel_id())
|
||||||
|
.or_default()
|
||||||
|
.update_from_channel_state(channel_state);
|
||||||
|
|
||||||
|
self.update_channel_parents();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_from_channel_remove(
|
||||||
|
&mut self,
|
||||||
|
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
|
||||||
|
) {
|
||||||
|
self.channels.remove(&channel_remove.get_channel_id());
|
||||||
|
self.update_channel_parents();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_channel_parents(&mut self) {
|
||||||
|
// Zero out existing children
|
||||||
|
for state in self.channels.values_mut() {
|
||||||
|
state.children.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
|
||||||
|
for (id, state) in self.channels.iter() {
|
||||||
|
// Handle channels with no parent (the root channel)
|
||||||
|
let Some(parent_id) = state.parent else {
|
||||||
|
to_sort.push((*id, None, 0, state.name.clone()));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If a channel has a parent that we haven't gotten a channel
|
||||||
|
// state packet for, ignore it
|
||||||
|
if !self.channels.contains_key(&parent_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos_name: HashMap<ChannelId, (i32, String)> = self
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.map(|(&id, state)| (id, (state.position, state.name.clone())))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut updated: HashSet<ChannelId> = HashSet::new();
|
||||||
|
|
||||||
|
while updated.len() < to_sort.len() {
|
||||||
|
for &(id, ref parent_id, position, ref name) in &to_sort {
|
||||||
|
let Some(parent_id) = parent_id else {
|
||||||
|
updated.insert(id);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if updated.contains(&id) || !updated.contains(&parent_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap should never fail here since we pre filter
|
||||||
|
let parent = self.channels.get_mut(&parent_id).unwrap();
|
||||||
|
|
||||||
|
let mut insert_index = parent.children.len();
|
||||||
|
for (i, &child) in parent.children.iter().enumerate() {
|
||||||
|
let (p, ref n) = pos_name[&child];
|
||||||
|
if (position == p && name < n) || p > position {
|
||||||
|
insert_index = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.children.insert_before(insert_index, id);
|
||||||
|
updated.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ServerState {
|
||||||
|
pub channels_state: ChannelsState,
|
||||||
|
pub users: HashMap<UserId, UserState>,
|
||||||
|
pub chat: Vec<Chat>,
|
||||||
|
pub session: Option<UserId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerState {
|
||||||
|
pub fn this_user(&self) -> Option<&UserState> {
|
||||||
|
self.users.get(&self.session?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct State {
|
||||||
|
pub status: GlobalSignal<ConnectionState>,
|
||||||
|
pub server: GlobalSignal<ServerState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static STATE: State = State {
|
||||||
|
status: Signal::global(|| Disconnected),
|
||||||
|
server: Signal::global(|| Default::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum UserIcon {
|
||||||
|
Normal,
|
||||||
|
Muted,
|
||||||
|
Deafened,
|
||||||
|
Suppressed,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserIcon {
|
||||||
|
pub fn url(self) -> Option<Asset> {
|
||||||
|
// speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/
|
||||||
|
// mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/
|
||||||
|
|
||||||
|
use UserIcon::*;
|
||||||
|
Some(match self {
|
||||||
|
Normal => asset!("assets/mic-svgrepo-com.svg"),
|
||||||
|
Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"),
|
||||||
|
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
|
||||||
|
None => return Option::None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
|
||||||
|
let color = match icon {
|
||||||
|
UserIcon::Normal => "var(--accent-normal)",
|
||||||
|
UserIcon::Muted => "var(--accent-muted)",
|
||||||
|
UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)",
|
||||||
|
UserIcon::None => "var(--accent-normal)",
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx!(
|
||||||
|
div {
|
||||||
|
class: match isself { true => "userpil is_self", false => "userpil" },
|
||||||
|
style: "background-color: {color}",
|
||||||
|
{ icon.url().map(|url| rsx!(img { src: url })) }
|
||||||
|
"\u{00A0}{name}\u{00A0}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn User(id: UserId) -> Element {
|
||||||
|
let server = STATE.server.read();
|
||||||
|
match server.users.get(&id) {
|
||||||
|
Some(state) => rsx!(UserPill {
|
||||||
|
name: state.name.clone(),
|
||||||
|
icon: state.icon(),
|
||||||
|
isself: server.session.unwrap() == id,
|
||||||
|
}),
|
||||||
|
None => rsx!(UserPill {
|
||||||
|
name: format!("unknown user ({id})"),
|
||||||
|
icon: UserIcon::None,
|
||||||
|
isself: false,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Channel(id: ChannelId) -> Element {
|
||||||
|
let net: Coroutine<Command> = use_coroutine_handle();
|
||||||
|
let server = STATE.server.read();
|
||||||
|
let user = server.session.unwrap();
|
||||||
|
let Some(state) = server.channels_state.channels.get(&id) else {
|
||||||
|
return rsx!("missing channel {id}");
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut open = use_signal(|| true);
|
||||||
|
|
||||||
|
let has_children = !state.users.is_empty() || !state.children.is_empty();
|
||||||
|
|
||||||
|
rsx!(
|
||||||
|
div {
|
||||||
|
class: "channel_details",
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: "channel_header",
|
||||||
|
// Arrow: only toggles open
|
||||||
|
if has_children {
|
||||||
|
span {
|
||||||
|
class: "channel_arrow",
|
||||||
|
onclick: move |evt| {
|
||||||
|
evt.stop_propagation();
|
||||||
|
evt.prevent_default();
|
||||||
|
let mut w = open.write();
|
||||||
|
*w = !*w;
|
||||||
|
},
|
||||||
|
if *open.read() { "▾" } else { "▸" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span {
|
||||||
|
class: "channel_arrow channel_arrow--placeholder",
|
||||||
|
" "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clickable row area (everything except the arrow)
|
||||||
|
div {
|
||||||
|
class: "channel_row_click",
|
||||||
|
ondblclick: move |evt| {
|
||||||
|
evt.stop_propagation();
|
||||||
|
evt.prevent_default();
|
||||||
|
net.send(EnterChannel { channel: id, user })
|
||||||
|
},
|
||||||
|
// remove dblclick from the inner span
|
||||||
|
span {
|
||||||
|
class: "channel_title",
|
||||||
|
"{state.name}"
|
||||||
|
}
|
||||||
|
// if you add icons/badges later, put them here too
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *open.read() && has_children {
|
||||||
|
div {
|
||||||
|
class: "channel_children",
|
||||||
|
for id in state.users.iter() {
|
||||||
|
User { id: *id }
|
||||||
|
}
|
||||||
|
for child in state.children.iter() {
|
||||||
|
Channel { id: *child }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(feature = "desktop", feature = "web"))]
|
||||||
|
pub fn pick_and_send_file(net: &Coroutine<Command>) {
|
||||||
|
let channels = if let Some(user) = STATE.server.read().this_user() {
|
||||||
|
vec![user.channel]
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dialog = rfd::AsyncFileDialog::new().pick_file();
|
||||||
|
let sender = net.tx();
|
||||||
|
spawn(async move {
|
||||||
|
let Some(handle) = dialog.await else { return };
|
||||||
|
let name = handle.file_name();
|
||||||
|
let bytes = handle.read().await;
|
||||||
|
let mime = mime_guess::from_path(&name).first();
|
||||||
|
let _ = sender.unbounded_send(SendFile {
|
||||||
|
bytes,
|
||||||
|
name,
|
||||||
|
mime,
|
||||||
|
channels,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#[cfg(not(any(feature = "desktop", feature = "web")))]
|
||||||
|
pub fn pick_and_send_file(net: &Coroutine<Command>) {}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ChatView() -> Element {
|
||||||
|
let net: Coroutine<Command> = use_coroutine_handle();
|
||||||
|
let server = STATE.server.read();
|
||||||
|
let mut draft = use_signal(|| "".to_string());
|
||||||
|
|
||||||
|
let mut do_send = move || {
|
||||||
|
if let Some(user) = STATE.server.read().this_user() {
|
||||||
|
net.send(SendChat {
|
||||||
|
markdown: draft.write().split_off(0),
|
||||||
|
channels: vec![user.channel],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx!(
|
||||||
|
div {
|
||||||
|
class: "chat_panel",
|
||||||
|
div {
|
||||||
|
class: "chat_history",
|
||||||
|
for chat in server.chat.iter() {
|
||||||
|
div {
|
||||||
|
class: "chat_message",
|
||||||
|
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
|
||||||
|
UserPill {
|
||||||
|
name: sender.name.clone(),
|
||||||
|
icon: UserIcon::None,
|
||||||
|
isself: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
dangerous_inner_html: "{chat.dangerous_html}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
class: "chat_box_wrapper",
|
||||||
|
div {
|
||||||
|
class: "chat_box",
|
||||||
|
input {
|
||||||
|
placeholder: "say something",
|
||||||
|
value: "{draft.read()}",
|
||||||
|
oninput: move |evt| draft.set(evt.value().clone()),
|
||||||
|
onkeypress: move |evt: Event<KeyboardData>| {
|
||||||
|
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
|
||||||
|
do_send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
span {
|
||||||
|
onclick: move |_| pick_and_send_file(&net),
|
||||||
|
Icon { name: "attach_file", color: "#ffffff", opacity: 0.5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
span {
|
||||||
|
onclick: move |_| do_send(),
|
||||||
|
Icon { name: "send", color: "#ffffff", opacity: 0.5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//button {
|
||||||
|
// onclick: move |_| do_send(),
|
||||||
|
// "Send"
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
|
||||||
|
let net: Coroutine<Command> = use_coroutine_handle();
|
||||||
|
let status = &STATE.status;
|
||||||
|
let server = STATE.server.read();
|
||||||
|
let Some(&UserState {
|
||||||
|
deaf,
|
||||||
|
self_deaf,
|
||||||
|
mute,
|
||||||
|
suppress,
|
||||||
|
self_mute,
|
||||||
|
ref name,
|
||||||
|
channel,
|
||||||
|
..
|
||||||
|
}) = server.this_user()
|
||||||
|
else {
|
||||||
|
return rsx!();
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_channel_name = server.channels_state.channels[&channel].name.clone();
|
||||||
|
|
||||||
|
let proxy_url = overrides
|
||||||
|
.read_unchecked()
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|overrides| overrides.proxy_url.clone());
|
||||||
|
|
||||||
|
let connecting_color = "yellow";
|
||||||
|
let connected_color = "oklch(0.55 0.1184 141.35)";
|
||||||
|
let disconnected_color = "gray";
|
||||||
|
let failed_color = "red";
|
||||||
|
|
||||||
|
let connection_status = match &*status.read() {
|
||||||
|
Connecting => rsx! {
|
||||||
|
div {
|
||||||
|
class: "connection_status",
|
||||||
|
style: "color: {connecting_color};",
|
||||||
|
div {
|
||||||
|
Icon { name: "signal_cellular_alt_2_bar", color: "yellow" }
|
||||||
|
span {
|
||||||
|
class: "status_text",
|
||||||
|
" Connecting"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Connected => rsx! {
|
||||||
|
div {
|
||||||
|
class: "connection_status",
|
||||||
|
div {
|
||||||
|
style: "color: {connected_color};",
|
||||||
|
Icon { name: "signal_cellular_alt", color: "#46823e" }
|
||||||
|
span {
|
||||||
|
class: "status_text",
|
||||||
|
" Connected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
class: "channel_text",
|
||||||
|
span { "{current_channel_name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Disconnected => rsx! {
|
||||||
|
div {
|
||||||
|
class: "connection_status",
|
||||||
|
style: "color: {disconnected_color};",
|
||||||
|
div {
|
||||||
|
Icon { name: "signal_disconnected", color: "gray" }
|
||||||
|
span {
|
||||||
|
class: "status_text",
|
||||||
|
" Disconnected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Failed(_) => rsx! {
|
||||||
|
div {
|
||||||
|
class: "connection_status",
|
||||||
|
style: "color: {failed_color};",
|
||||||
|
div {
|
||||||
|
Icon { name: "error", color: "red" }
|
||||||
|
span {
|
||||||
|
class: "status_text",
|
||||||
|
" Failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let denoise = use_signal(|| false);
|
||||||
|
rsx!(
|
||||||
|
// Server control
|
||||||
|
div {
|
||||||
|
class: "button_row",
|
||||||
|
div {
|
||||||
|
{connection_status}
|
||||||
|
}
|
||||||
|
span { class: "spacer" }
|
||||||
|
button {
|
||||||
|
class: "toggle_button",
|
||||||
|
onclick: move |_| net.send(Disconnect),
|
||||||
|
Icon { name: "signal_disconnected", color: "#ffffff", opacity: 0.5 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hr { style: "width: 100%;" }
|
||||||
|
// User control
|
||||||
|
div {
|
||||||
|
class: "button_row",
|
||||||
|
button {
|
||||||
|
class: "user_edit_button",
|
||||||
|
Icon { name: "person_edit", color: "#fa3f36" }
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
class: "user_info",
|
||||||
|
div {
|
||||||
|
span { class: "user_name", "{name}" }
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
span { class: "user_data", "some data" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span { class: "spacer" }
|
||||||
|
button {
|
||||||
|
class: match denoise() {
|
||||||
|
true => "toggle_button is_on",
|
||||||
|
false => "toggle_button",
|
||||||
|
},
|
||||||
|
role: "switch",
|
||||||
|
aria_checked: denoise(),
|
||||||
|
onclick: move |_| {
|
||||||
|
let new_denoise = !denoise();
|
||||||
|
*denoise.write_unchecked() = new_denoise;
|
||||||
|
net.send(UpdateMicEffects { denoise: new_denoise })
|
||||||
|
},
|
||||||
|
match denoise() {
|
||||||
|
true => rsx!(Icon { name: "cadence", color: "#b23f43", opacity: 0.8938 }),
|
||||||
|
false => rsx!(Icon { name: "graphic_eq", color: "#ffffff", opacity: 0.5 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: match mute || suppress || self_mute {
|
||||||
|
true => "toggle_button is_on",
|
||||||
|
false => "toggle_button",
|
||||||
|
},
|
||||||
|
role: "switch",
|
||||||
|
aria_checked: mute || suppress || self_mute,
|
||||||
|
disabled: mute || suppress,
|
||||||
|
onclick: move |_| net.send(SetMute { mute: !self_mute }),
|
||||||
|
match mute || suppress || self_mute {
|
||||||
|
true => rsx!(Icon { name: "mic_off", color: "#b23f43", opacity: 0.8938 }),
|
||||||
|
false => rsx!(Icon { name: "mic", color: "#ffffff", opacity: 0.5 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: match deaf || self_deaf {
|
||||||
|
true => "toggle_button in_on",
|
||||||
|
false => "toggle_button",
|
||||||
|
},
|
||||||
|
role: "switch",
|
||||||
|
aria_checked: deaf || self_deaf,
|
||||||
|
disabled: deaf,
|
||||||
|
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
|
||||||
|
match deaf || self_deaf {
|
||||||
|
true => rsx!(Icon { name: "volume_off", color: "#b23f43", opacity: 0.8938 }),
|
||||||
|
false => rsx!(Icon { name: "volume_up", color: "#ffffff", opacity: 0.5 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ServerView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> Element {
|
||||||
|
let net: Coroutine<Command> = use_coroutine_handle();
|
||||||
|
let server = STATE.server.read();
|
||||||
|
let Some(&UserState {
|
||||||
|
deaf,
|
||||||
|
self_deaf,
|
||||||
|
mute,
|
||||||
|
self_mute,
|
||||||
|
..
|
||||||
|
}) = server.this_user()
|
||||||
|
else {
|
||||||
|
return rsx!();
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx!(
|
||||||
|
div {
|
||||||
|
class: "server_grid",
|
||||||
|
div {
|
||||||
|
class: "server_channel_box",
|
||||||
|
for (id, state) in server.channels_state.channels.iter() {
|
||||||
|
if state.parent.is_none() {
|
||||||
|
Channel { id: *id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
class: "server_chat_box",
|
||||||
|
ChatView {}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
class: "server_control_box",
|
||||||
|
ControlView { overrides }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LoginView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> Element {
|
||||||
|
let net: Coroutine<Command> = use_coroutine_handle();
|
||||||
|
|
||||||
|
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
|
||||||
|
use_resource(move || async move {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
loop {
|
||||||
|
*last_status.write_unchecked() = Some(Platform::get_status(&client).await);
|
||||||
|
Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 previous_username = user_config.config_get::<String>("username");
|
||||||
|
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
net.send(Connect {
|
||||||
|
address: address.read().clone(),
|
||||||
|
username: username.read().clone(),
|
||||||
|
config: overrides.read().clone().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let status = &STATE.status;
|
||||||
|
let bottom = match &*status.read() {
|
||||||
|
Disconnected => rsx! {
|
||||||
|
button {
|
||||||
|
class: "login_bttn",
|
||||||
|
onclick: do_connect.clone(),
|
||||||
|
"Connect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Connecting => rsx! {
|
||||||
|
div {
|
||||||
|
class: "login_bttn",
|
||||||
|
"Connecting..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Failed(msg) => rsx!(
|
||||||
|
button {
|
||||||
|
class: "login_bttn",
|
||||||
|
onclick: do_connect.clone(),
|
||||||
|
"Reconnect"
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
class: "login_error",
|
||||||
|
"Failed to connect:"
|
||||||
|
pre {
|
||||||
|
"{msg}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Connected => unreachable!(),
|
||||||
|
};
|
||||||
|
let version = option_env!("MUMBLE_WEB2_VERSION");
|
||||||
|
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 {
|
||||||
|
label {
|
||||||
|
for: "address-entry",
|
||||||
|
"Server Address:"
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
id: "address-entry",
|
||||||
|
placeholder: "address",
|
||||||
|
value: "{address.read()}",
|
||||||
|
autofocus: "true",
|
||||||
|
oninput: move |evt| address_input.set(Some(evt.value().clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
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;",
|
||||||
|
}
|
||||||
|
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 proxy 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}
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app() -> Element {
|
||||||
|
static STYLE: Asset = asset!("/assets/main.scss");
|
||||||
|
|
||||||
|
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
|
||||||
|
let overrides = use_resource(|| async move {
|
||||||
|
match Platform::load_proxy_overrides().await {
|
||||||
|
Ok(overrides) => overrides,
|
||||||
|
Err(_) => ProxyOverrides::default(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let user_config = ConfigSystem::new().unwrap();
|
||||||
|
|
||||||
|
Platform::request_permissions();
|
||||||
|
|
||||||
|
rsx!(
|
||||||
|
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
|
||||||
|
document::Link{ rel: "stylesheet", href: STYLE }
|
||||||
|
|
||||||
|
match *STATE.status.read() {
|
||||||
|
Connected => rsx!(ServerView { overrides, user_config }),
|
||||||
|
_ => rsx!(LoginView { overrides, user_config }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,27 +1,18 @@
|
|||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam::atomic::AtomicCell;
|
||||||
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
|
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
|
||||||
use df::tract::{DfParams, DfTract, RuntimeParams};
|
use df::tract::{DfParams, DfTract, RuntimeParams};
|
||||||
use std::borrow::Cow;
|
#[cfg(feature = "blitz")]
|
||||||
|
use dioxus_native::prelude::{asset, manganis, Asset};
|
||||||
|
#[cfg(not(feature = "blitz"))]
|
||||||
|
use dioxus::prelude::{asset, manganis, Asset};
|
||||||
|
use dioxus_asset_resolver::read_asset_bytes;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::imp::SpawnHandle;
|
use crate::imp::SpawnHandle;
|
||||||
|
|
||||||
#[cfg(not(feature = "embed-denoiser"))]
|
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
|
||||||
async fn denoiser_model_bytes() -> color_eyre::Result<Cow<'static, [u8]>> {
|
|
||||||
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?;
|
|
||||||
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.
|
// TODO: make this user configurable.
|
||||||
static DEFAULT_NOISE_FLOOR: f32 = 0.001;
|
static DEFAULT_NOISE_FLOOR: f32 = 0.001;
|
||||||
// 200ms hold at 48kHz sample rate
|
// 200ms hold at 48kHz sample rate
|
||||||
@@ -57,10 +48,10 @@ fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract)
|
|||||||
let cell_task = cell.clone();
|
let cell_task = cell.clone();
|
||||||
*state = DenoisingModelState::Downloading(cell);
|
*state = DenoisingModelState::Downloading(cell);
|
||||||
spawn.spawn(async move {
|
spawn.spawn(async move {
|
||||||
let model_bytes = match denoiser_model_bytes().await {
|
let model_bytes = match read_asset_bytes(&DF_MODEL).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("could not read denoising model: {e:?}");
|
error!("could not read denoising model from \"{DF_MODEL}\": {e:?}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -108,9 +99,20 @@ pub struct AudioProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AudioProcessor {
|
impl AudioProcessor {
|
||||||
pub fn new(denoise: bool) -> Self {
|
pub fn new_plain() -> Self {
|
||||||
AudioProcessor {
|
AudioProcessor {
|
||||||
denoise,
|
denoise: false,
|
||||||
|
spawn: SpawnHandle::current(),
|
||||||
|
buffer: Vec::new(),
|
||||||
|
noise_floor: DEFAULT_NOISE_FLOOR,
|
||||||
|
was_transmitting: false,
|
||||||
|
hold_samples: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_denoising() -> Self {
|
||||||
|
AudioProcessor {
|
||||||
|
denoise: true,
|
||||||
spawn: SpawnHandle::current(),
|
spawn: SpawnHandle::current(),
|
||||||
buffer: Vec::new(),
|
buffer: Vec::new(),
|
||||||
noise_floor: DEFAULT_NOISE_FLOOR,
|
noise_floor: DEFAULT_NOISE_FLOOR,
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::app::{Command, SharedState};
|
use crate::app::Command;
|
||||||
use crate::Reactivity;
|
use color_eyre::eyre::{bail, Error};
|
||||||
use color_eyre::eyre::Error;
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
use futures_channel::mpsc::UnboundedReceiver;
|
|
||||||
use mumble_protocol::control::ClientControlCodec;
|
use mumble_protocol::control::ClientControlCodec;
|
||||||
use std::net::ToSocketAddrs;
|
use std::net::ToSocketAddrs;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -15,7 +14,7 @@ use tokio_rustls::TlsConnector;
|
|||||||
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
|
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
|
||||||
use tracing::{info, instrument};
|
use tracing::{info, instrument};
|
||||||
|
|
||||||
use mumble_web2_common::ProxyOverrides;
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct NoCertificateVerification;
|
struct NoCertificateVerification;
|
||||||
@@ -75,7 +74,6 @@ pub async fn network_connect(
|
|||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
overrides: &ProxyOverrides,
|
overrides: &ProxyOverrides,
|
||||||
state: SharedState<impl Reactivity>,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
info!("connecting");
|
info!("connecting");
|
||||||
|
|
||||||
@@ -104,9 +102,11 @@ pub async fn network_connect(
|
|||||||
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
|
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
|
||||||
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
|
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
|
||||||
|
|
||||||
let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded();
|
crate::network_loop(username, event_rx, reader, writer).await
|
||||||
spawn(crate::sender_loop(outgoing_recv, writer));
|
}
|
||||||
crate::network_loop(username, state, event_rx, outgoing_send, reader).await
|
|
||||||
|
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||||
|
bail!("status not supported on desktop yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use crate::app::{Command, SharedState};
|
use crate::app::Command;
|
||||||
use crate::Reactivity;
|
|
||||||
use color_eyre::eyre::Error;
|
use color_eyre::eyre::Error;
|
||||||
use futures_channel::mpsc::UnboundedReceiver;
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
|
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
||||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Desktop platform implementation using Tokio and native audio.
|
/// Desktop platform implementation using Tokio and native audio.
|
||||||
@@ -29,16 +30,12 @@ impl super::PlatformInterface for DesktopPlatform {
|
|||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
overrides: &ProxyOverrides,
|
overrides: &ProxyOverrides,
|
||||||
state: SharedState<impl Reactivity>,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
super::connect::network_connect(address, username, event_rx, overrides, state).await
|
super::connect::network_connect(address, username, event_rx, overrides).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_status(
|
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||||
_client: &reqwest::Client,
|
super::connect::get_status(client).await
|
||||||
address: &str,
|
|
||||||
) -> color_eyre::Result<ServerStatus> {
|
|
||||||
mumble_web2_common::ping_server(address, 64738).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_logging() {
|
fn init_logging() {
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::app::{Command, SharedState};
|
use crate::app::Command;
|
||||||
use crate::Reactivity;
|
|
||||||
use color_eyre::eyre::Error;
|
use color_eyre::eyre::Error;
|
||||||
use futures_channel::mpsc::UnboundedReceiver;
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -25,16 +24,12 @@ impl super::PlatformInterface for MobilePlatform {
|
|||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
overrides: &ProxyOverrides,
|
overrides: &ProxyOverrides,
|
||||||
state: SharedState<impl Reactivity>,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
super::connect::network_connect(address, username, event_rx, overrides, state).await
|
super::connect::network_connect(address, username, event_rx, overrides).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_status(
|
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||||
_client: &reqwest::Client,
|
super::connect::get_status(client).await
|
||||||
address: &str,
|
|
||||||
) -> color_eyre::Result<ServerStatus> {
|
|
||||||
mumble_web2_common::ping_server(address, 64738).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_logging() {
|
fn init_logging() {
|
||||||
@@ -4,11 +4,9 @@
|
|||||||
//! The traits make the platform boundary explicit and provide compile-time verification.
|
//! The traits make the platform boundary explicit and provide compile-time verification.
|
||||||
#![allow(async_fn_in_trait)]
|
#![allow(async_fn_in_trait)]
|
||||||
|
|
||||||
use crate::app::{Command, SharedState};
|
use crate::{app::Command, effects::AudioProcessor};
|
||||||
use crate::effects::AudioProcessor;
|
|
||||||
use crate::Reactivity;
|
|
||||||
use color_eyre::eyre::Error;
|
use color_eyre::eyre::Error;
|
||||||
use futures_channel::mpsc::UnboundedReceiver;
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -53,7 +51,7 @@ pub trait AudioPlayerInterface {
|
|||||||
fn play_opus(&mut self, payload: &[u8]);
|
fn play_opus(&mut self, payload: &[u8]);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ConfigSystemInterface: Sized + Clone {
|
pub trait ConfigSystemInterface: Sized {
|
||||||
fn new() -> Result<Self, Error>;
|
fn new() -> Result<Self, Error>;
|
||||||
|
|
||||||
fn config_get<T>(&self, key: &str) -> Option<T>
|
fn config_get<T>(&self, key: &str) -> Option<T>
|
||||||
@@ -84,17 +82,11 @@ pub trait PlatformInterface {
|
|||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
proxy_overrides: &ProxyOverrides,
|
proxy_overrides: &ProxyOverrides,
|
||||||
state: SharedState<impl Reactivity>,
|
|
||||||
) -> impl Future<Output = Result<(), Error>>;
|
) -> impl Future<Output = Result<(), Error>>;
|
||||||
|
|
||||||
/// Get server status (user count, version, etc.) for the given address.
|
/// Get server status (user count, version, etc.).
|
||||||
///
|
|
||||||
/// On web, this goes through the proxy's /status endpoint and ignores `address`
|
|
||||||
/// (the proxy is bound to a specific server). On desktop/mobile, this pings the
|
|
||||||
/// given address directly via UDP.
|
|
||||||
fn get_status(
|
fn get_status(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
address: &str,
|
|
||||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
|
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
|
||||||
|
|
||||||
/// Load the proxy overrides (proxy URL, cert hash, etc.).
|
/// Load the proxy overrides (proxy URL, cert hash, etc.).
|
||||||
@@ -112,7 +112,7 @@ impl super::AudioSystemInterface for NativeAudioSystem {
|
|||||||
);
|
);
|
||||||
let mut encoder =
|
let mut encoder =
|
||||||
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
|
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
|
||||||
let mut current_processor = AudioProcessor::new(false);
|
let mut current_processor = AudioProcessor::new_plain();
|
||||||
let mut output_buffer = Vec::new();
|
let mut output_buffer = Vec::new();
|
||||||
let processors = self.processors.clone();
|
let processors = self.processors.clone();
|
||||||
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
|
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
use crate::app::Command;
|
||||||
use color_eyre::eyre::Error;
|
use color_eyre::eyre::Error;
|
||||||
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
|
use mumble_web2_common::ServerStatus;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tracing::info;
|
use std::time::Duration;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub struct NativeConfigSystem {
|
pub struct NativeConfigSystem {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/// Stub implementation of the platform interface, so that we can
|
/// Stub implementation of the platform interface, so that we can
|
||||||
/// `cargo check` without any --feature flags.
|
/// `cargo check` without any --feature flags.
|
||||||
use crate::{app::SharedState, effects::AudioProcessor, Reactivity};
|
use crate::effects::AudioProcessor;
|
||||||
use color_eyre::eyre::Error;
|
use color_eyre::eyre::Error;
|
||||||
use futures_channel::mpsc::UnboundedReceiver;
|
use dioxus::hooks::UnboundedReceiver;
|
||||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
||||||
@@ -25,14 +25,12 @@ impl super::PlatformInterface for StubPlatform {
|
|||||||
_username: String,
|
_username: String,
|
||||||
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
|
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
|
||||||
_overrides: &ProxyOverrides,
|
_overrides: &ProxyOverrides,
|
||||||
_state: SharedState<impl Reactivity>,
|
|
||||||
) -> impl Future<Output = Result<(), Error>> {
|
) -> impl Future<Output = Result<(), Error>> {
|
||||||
async { panic!("stubbed platform") }
|
async { panic!("stubbed platform") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_status(
|
fn get_status(
|
||||||
_client: &reqwest::Client,
|
_client: &reqwest::Client,
|
||||||
_address: &str,
|
|
||||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
|
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
|
||||||
async { panic!("stubbed platform") }
|
async { panic!("stubbed platform") }
|
||||||
}
|
}
|
||||||
@@ -79,7 +77,6 @@ impl super::AudioPlayerInterface for StubAudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct StubConfigSystem;
|
pub struct StubConfigSystem;
|
||||||
|
|
||||||
impl super::ConfigSystemInterface for StubConfigSystem {
|
impl super::ConfigSystemInterface for StubConfigSystem {
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
use crate::app::{Command, SharedState};
|
use crate::app::Command;
|
||||||
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
|
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
|
||||||
use crate::Reactivity;
|
|
||||||
use color_eyre::eyre::{bail, eyre, Error};
|
use color_eyre::eyre::{bail, eyre, Error};
|
||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam::atomic::AtomicCell;
|
||||||
use futures_channel::mpsc::UnboundedReceiver;
|
use dioxus::prelude::*;
|
||||||
use gloo_timers::future::TimeoutFuture;
|
use gloo_timers::future::TimeoutFuture;
|
||||||
use js_sys::Float32Array;
|
use js_sys::Float32Array;
|
||||||
use manganis::asset;
|
|
||||||
use mumble_protocol::control::ClientControlCodec;
|
use mumble_protocol::control::ClientControlCodec;
|
||||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
@@ -113,15 +111,11 @@ impl super::PlatformInterface for WebPlatform {
|
|||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
overrides: &ProxyOverrides,
|
overrides: &ProxyOverrides,
|
||||||
state: SharedState<impl Reactivity>,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
network_connect(address, username, event_rx, overrides, state).await
|
network_connect(address, username, event_rx, overrides).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_status(
|
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||||
client: &reqwest::Client,
|
|
||||||
_address: &str,
|
|
||||||
) -> color_eyre::Result<ServerStatus> {
|
|
||||||
Ok(client
|
Ok(client
|
||||||
.get(absolute_url("status")?)
|
.get(absolute_url("status")?)
|
||||||
.send()
|
.send()
|
||||||
@@ -162,7 +156,7 @@ pub struct WebAudioSystem {
|
|||||||
processors: AudioProcessorSender,
|
processors: AudioProcessorSender,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn attach_worklet(audio_context: &AudioContext, worklet_url: &str) -> Result<(), Error> {
|
async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
|
||||||
// Create worklets to process mic and speaker audio
|
// Create worklets to process mic and speaker audio
|
||||||
// Speaker audio processing worklet only required on
|
// Speaker audio processing worklet only required on
|
||||||
// browsers that don't support MediaStreamTrackGenerator
|
// browsers that don't support MediaStreamTrackGenerator
|
||||||
@@ -175,11 +169,12 @@ async fn attach_worklet(audio_context: &AudioContext, worklet_url: &str) -> Resu
|
|||||||
)
|
)
|
||||||
.ey()?;
|
.ey()?;
|
||||||
|
|
||||||
info!("loading mic worklet from {worklet_url:?}");
|
let module = asset!("assets/rust_audio_worklet.js").to_string();
|
||||||
|
info!("loading mic worklet from {module:?}");
|
||||||
audio_context
|
audio_context
|
||||||
.audio_worklet()
|
.audio_worklet()
|
||||||
.ey()?
|
.ey()?
|
||||||
.add_module_with_options(worklet_url, &options)
|
.add_module_with_options(&module, &options)
|
||||||
.ey()?
|
.ey()?
|
||||||
.into_future()
|
.into_future()
|
||||||
.await
|
.await
|
||||||
@@ -194,11 +189,7 @@ impl super::AudioSystemInterface for WebAudioSystem {
|
|||||||
// Create MediaStreams to playback decoded audio
|
// Create MediaStreams to playback decoded audio
|
||||||
// The audio context is used to reproduce audio.
|
// The audio context is used to reproduce audio.
|
||||||
let webctx = configure_audio_context();
|
let webctx = configure_audio_context();
|
||||||
attach_worklet(
|
attach_worklet(&webctx).await?;
|
||||||
&webctx,
|
|
||||||
&asset!("/assets/rust_audio_worklet.js").to_string(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let processors = AudioProcessorSender::default();
|
let processors = AudioProcessorSender::default();
|
||||||
|
|
||||||
@@ -389,7 +380,7 @@ async fn run_encoder_worklet(
|
|||||||
audio_encoder.configure(&encoder_config);
|
audio_encoder.configure(&encoder_config);
|
||||||
info!("created audio encoder");
|
info!("created audio encoder");
|
||||||
|
|
||||||
let mut current_processor = AudioProcessor::new(false);
|
let mut current_processor = AudioProcessor::new_plain();
|
||||||
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
|
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
|
||||||
if let Some(new_processor) = processors.take() {
|
if let Some(new_processor) = processors.take() {
|
||||||
current_processor = new_processor;
|
current_processor = new_processor;
|
||||||
@@ -443,7 +434,6 @@ pub async fn network_connect(
|
|||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
overrides: &ProxyOverrides,
|
overrides: &ProxyOverrides,
|
||||||
state: SharedState,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
info!("connecting");
|
info!("connecting");
|
||||||
|
|
||||||
@@ -502,9 +492,7 @@ pub async fn network_connect(
|
|||||||
let writer =
|
let writer =
|
||||||
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
|
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
|
||||||
|
|
||||||
let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded();
|
crate::network_loop(username, event_rx, reader, writer).await
|
||||||
spawn(crate::sender_loop(outgoing_recv, writer));
|
|
||||||
crate::network_loop(username, state, event_rx, outgoing_send, reader).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn absolute_url(path: &str) -> Result<Url, Error> {
|
pub fn absolute_url(path: &str) -> Result<Url, Error> {
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
use crate::msghtml::process_message_html;
|
use app::Chat;
|
||||||
use crate::AudioSettings;
|
use app::Command;
|
||||||
use crate::Chat;
|
use app::ConnectionState;
|
||||||
use crate::Command;
|
use app::STATE;
|
||||||
use crate::ConnectionState;
|
|
||||||
use crate::Reactivity;
|
|
||||||
use asynchronous_codec::FramedRead;
|
use asynchronous_codec::FramedRead;
|
||||||
use asynchronous_codec::FramedWrite;
|
use asynchronous_codec::FramedWrite;
|
||||||
use color_eyre::eyre::{bail, Error};
|
use color_eyre::eyre::{bail, Error};
|
||||||
|
#[cfg(feature = "blitz")]
|
||||||
|
use dioxus_native::prelude::*;
|
||||||
|
#[cfg(not(feature = "blitz"))]
|
||||||
|
use dioxus::prelude::*;
|
||||||
use futures::select;
|
use futures::select;
|
||||||
use futures::AsyncRead;
|
use futures::AsyncRead;
|
||||||
use futures::AsyncWrite;
|
use futures::AsyncWrite;
|
||||||
use futures::FutureExt as _;
|
use futures::FutureExt as _;
|
||||||
use futures::SinkExt as _;
|
use futures::SinkExt as _;
|
||||||
use futures::StreamExt as _;
|
use futures::StreamExt as _;
|
||||||
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
use futures_channel::mpsc::UnboundedSender;
|
||||||
|
use msghtml::process_message_html;
|
||||||
use mumble_protocol::control::msgs;
|
use mumble_protocol::control::msgs;
|
||||||
use mumble_protocol::control::ControlCodec;
|
use mumble_protocol::control::ControlCodec;
|
||||||
use mumble_protocol::control::ControlPacket;
|
use mumble_protocol::control::ControlPacket;
|
||||||
@@ -27,18 +30,18 @@ use std::time::Duration;
|
|||||||
use tracing::error;
|
use tracing::error;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::app::SharedState;
|
|
||||||
use crate::app::State;
|
|
||||||
use crate::effects::AudioProcessor;
|
use crate::effects::AudioProcessor;
|
||||||
use crate::imp::{
|
use crate::imp::{
|
||||||
spawn, AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _,
|
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
|
||||||
Platform, PlatformInterface as _,
|
PlatformInterface as _,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn network_entrypoint<X: Reactivity>(
|
pub mod app;
|
||||||
mut event_rx: UnboundedReceiver<Command>,
|
mod effects;
|
||||||
state: SharedState<X>,
|
pub mod imp;
|
||||||
) {
|
mod msghtml;
|
||||||
|
|
||||||
|
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
|
||||||
loop {
|
loop {
|
||||||
let Some(Command::Connect {
|
let Some(Command::Connect {
|
||||||
address,
|
address,
|
||||||
@@ -49,43 +52,37 @@ pub async fn network_entrypoint<X: Reactivity>(
|
|||||||
panic!("did not receive connect command")
|
panic!("did not receive connect command")
|
||||||
};
|
};
|
||||||
|
|
||||||
*X::write(&state.server) = Default::default();
|
*STATE.server.write() = Default::default();
|
||||||
*X::write(&state.status) = ConnectionState::Connecting;
|
*STATE.status.write() = ConnectionState::Connecting;
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
Platform::network_connect(address, username, &mut event_rx, &config, state.clone())
|
Platform::network_connect(address, username, &mut event_rx, &config).await
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
error!("could not connect {:?}", error);
|
error!("could not connect {:?}", error);
|
||||||
*X::write(&state.status) = ConnectionState::Failed(error.to_string());
|
*STATE.status.write() = ConnectionState::Failed(error.to_string());
|
||||||
} else {
|
} else {
|
||||||
*X::write(&state.status) = ConnectionState::Disconnected;
|
*STATE.status.write() = ConnectionState::Disconnected;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn sender_loop<W: AsyncWrite + Unpin + 'static>(
|
pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin + 'static>(
|
||||||
mut outgoing: UnboundedReceiver<ControlPacket<Serverbound>>,
|
|
||||||
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
|
|
||||||
) {
|
|
||||||
while let Some(msg) = outgoing.next().await {
|
|
||||||
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
|
|
||||||
info!("sending packet {:#?}", msg);
|
|
||||||
}
|
|
||||||
if let Err(e) = writer.send(msg).await {
|
|
||||||
error!("error sending packet {:?}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static, X: Reactivity>(
|
|
||||||
username: String,
|
username: String,
|
||||||
state: SharedState<X>,
|
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
mut outgoing: UnboundedSender<ControlPacket<Serverbound>>,
|
|
||||||
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
||||||
|
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let audio_settings = X::read(&state.audio).clone();
|
let (mut send_chan, mut writer_recv_chan) = futures_channel::mpsc::unbounded();
|
||||||
|
spawn(async move {
|
||||||
|
while let Some(msg) = writer_recv_chan.next().await {
|
||||||
|
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
|
||||||
|
info!("sending packet {:#?}", msg);
|
||||||
|
}
|
||||||
|
if let Err(e) = writer.send(msg).await {
|
||||||
|
error!("error sending packet {:?}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get version packet
|
// Get version packet
|
||||||
let version = match reader.next().await {
|
let version = match reader.next().await {
|
||||||
@@ -100,17 +97,17 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static, X: Reactivity>(
|
|||||||
msg.set_version(0x000010204);
|
msg.set_version(0x000010204);
|
||||||
msg.set_release(format!("{} {}", "mumbleweb2", "6.9.0"));
|
msg.set_release(format!("{} {}", "mumbleweb2", "6.9.0"));
|
||||||
//msg.set_os("Chrome".to_string());
|
//msg.set_os("Chrome".to_string());
|
||||||
outgoing.send(msg.into()).await.unwrap();
|
send_chan.send(msg.into()).await.unwrap();
|
||||||
|
|
||||||
// Send authenticate packet
|
// Send authenticate packet
|
||||||
let mut msg = msgs::Authenticate::new();
|
let mut msg = msgs::Authenticate::new();
|
||||||
msg.set_username(username);
|
msg.set_username(username);
|
||||||
msg.set_opus(true);
|
msg.set_opus(true);
|
||||||
outgoing.send(msg.into()).await.unwrap();
|
send_chan.send(msg.into()).await.unwrap();
|
||||||
|
|
||||||
// Spawn worker to send pings
|
// Spawn worker to send pings
|
||||||
{
|
{
|
||||||
let mut send_chan = outgoing.clone();
|
let mut send_chan = send_chan.clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
if let Err(_) = send_chan.send(msgs::Ping::new().into()).await {
|
if let Err(_) = send_chan.send(msgs::Ping::new().into()).await {
|
||||||
@@ -123,11 +120,10 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static, X: Reactivity>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut audio = AudioSystem::new().await?;
|
let mut audio = AudioSystem::new().await?;
|
||||||
audio.set_processor(AudioProcessor::new(audio_settings.denoise));
|
|
||||||
{
|
{
|
||||||
let send_chan = outgoing.clone();
|
let send_chan = send_chan.clone();
|
||||||
let mut sequence_num = 0;
|
let mut sequence_num = 0;
|
||||||
if let Err(err) = audio.start_recording(move |opus_frame, is_terminator| {
|
audio.start_recording(move |opus_frame, is_terminator| {
|
||||||
let _ =
|
let _ =
|
||||||
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
|
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
|
||||||
_dst: std::marker::PhantomData,
|
_dst: std::marker::PhantomData,
|
||||||
@@ -138,9 +134,7 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static, X: Reactivity>(
|
|||||||
position_info: None,
|
position_info: None,
|
||||||
})));
|
})));
|
||||||
sequence_num = sequence_num.wrapping_add(2);
|
sequence_num = sequence_num.wrapping_add(2);
|
||||||
}) {
|
});
|
||||||
error!("could not begin recording: {err:?}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create map of session_id -> AudioDecoder
|
// Create map of session_id -> AudioDecoder
|
||||||
@@ -158,7 +152,7 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static, X: Reactivity>(
|
|||||||
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
|
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
|
||||||
info!("receiving packet {:#?}", msg);
|
info!("receiving packet {:#?}", msg);
|
||||||
}
|
}
|
||||||
let res = accept_packet(msg, &mut audio, &mut decoder_map, &state);
|
let res = accept_packet(msg, &mut audio, &mut decoder_map);
|
||||||
if let Err(err) = res {
|
if let Err(err) = res {
|
||||||
error!("error accepting packet {:?}", err)
|
error!("error accepting packet {:?}", err)
|
||||||
}
|
}
|
||||||
@@ -177,7 +171,7 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static, X: Reactivity>(
|
|||||||
match command {
|
match command {
|
||||||
Some(Command::Disconnect) => break,
|
Some(Command::Disconnect) => break,
|
||||||
Some(command) => {
|
Some(command) => {
|
||||||
let res = accept_command(command, &mut outgoing, &mut audio, &state);
|
let res = accept_command(command, &mut send_chan, &mut audio);
|
||||||
if let Err(err) = res {
|
if let Err(err) = res {
|
||||||
info!("error accepting command {:?}", err)
|
info!("error accepting command {:?}", err)
|
||||||
}
|
}
|
||||||
@@ -187,19 +181,18 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static, X: Reactivity>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = outgoing.close();
|
let _ = send_chan.close();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn accept_command<X: Reactivity>(
|
fn accept_command(
|
||||||
command: Command,
|
command: Command,
|
||||||
send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
|
send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
|
||||||
audio: &mut AudioSystem,
|
audio: &mut AudioSystem,
|
||||||
state: &State<X>,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
use Command::*;
|
use Command::*;
|
||||||
let Some(session) = X::read(&state.server).session else {
|
let Some(session) = STATE.server.read().session else {
|
||||||
bail!("no session id")
|
bail!("no session id")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,7 +215,7 @@ fn accept_command<X: Reactivity>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut server = X::write(&state.server);
|
let mut server = STATE.server.write();
|
||||||
let Some(me) = server.session else {
|
let Some(me) = server.session else {
|
||||||
bail!("not signed in with a session id")
|
bail!("not signed in with a session id")
|
||||||
};
|
};
|
||||||
@@ -263,7 +256,7 @@ fn accept_command<X: Reactivity>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut server = X::write(&state.server);
|
let mut server = STATE.server.write();
|
||||||
let Some(me) = server.session else {
|
let Some(me) = server.session else {
|
||||||
bail!("not signed in with a session id")
|
bail!("not signed in with a session id")
|
||||||
};
|
};
|
||||||
@@ -298,19 +291,22 @@ fn accept_command<X: Reactivity>(
|
|||||||
let _ = send_chan.unbounded_send(u.into());
|
let _ = send_chan.unbounded_send(u.into());
|
||||||
}
|
}
|
||||||
Connect { .. } | Disconnect => (),
|
Connect { .. } | Disconnect => (),
|
||||||
UpdateAudioSettings(AudioSettings { denoise }) => {
|
UpdateMicEffects { denoise } => {
|
||||||
audio.set_processor(AudioProcessor::new(denoise));
|
if denoise {
|
||||||
|
audio.set_processor(AudioProcessor::new_denoising());
|
||||||
|
} else {
|
||||||
|
audio.set_processor(AudioProcessor::new_plain());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn accept_packet<X: Reactivity>(
|
fn accept_packet(
|
||||||
msg: ControlPacket<mumble_protocol::Clientbound>,
|
msg: ControlPacket<mumble_protocol::Clientbound>,
|
||||||
audio_context: &mut AudioSystem,
|
audio_context: &mut AudioSystem,
|
||||||
player_map: &mut HashMap<u32, AudioPlayer>,
|
player_map: &mut HashMap<u32, AudioPlayer>,
|
||||||
state: &State<X>,
|
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
match msg {
|
match msg {
|
||||||
ControlPacket::UDPTunnel(u) => {
|
ControlPacket::UDPTunnel(u) => {
|
||||||
@@ -347,15 +343,15 @@ fn accept_packet<X: Reactivity>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ControlPacket::ChannelState(u) => {
|
ControlPacket::ChannelState(u) => {
|
||||||
let mut server = X::write(&state.server);
|
let mut server = STATE.server.write();
|
||||||
server.channels_state.update_from_channel_state(&u);
|
server.channels_state.update_from_channel_state(&u);
|
||||||
}
|
}
|
||||||
ControlPacket::ChannelRemove(u) => {
|
ControlPacket::ChannelRemove(u) => {
|
||||||
let mut server = X::write(&state.server);
|
let mut server = STATE.server.write();
|
||||||
server.channels_state.update_from_channel_remove(&u);
|
server.channels_state.update_from_channel_remove(&u);
|
||||||
}
|
}
|
||||||
ControlPacket::UserState(u) => {
|
ControlPacket::UserState(u) => {
|
||||||
let mut server = X::write(&state.server);
|
let mut server = STATE.server.write();
|
||||||
let server = &mut *server;
|
let server = &mut *server;
|
||||||
let id = u.get_session();
|
let id = u.get_session();
|
||||||
|
|
||||||
@@ -399,7 +395,7 @@ fn accept_packet<X: Reactivity>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ControlPacket::UserRemove(u) => {
|
ControlPacket::UserRemove(u) => {
|
||||||
let mut server = X::write(&state.server);
|
let mut server = STATE.server.write();
|
||||||
let id = u.get_session();
|
let id = u.get_session();
|
||||||
if let Some(state) = server.users.remove(&id) {
|
if let Some(state) = server.users.remove(&id) {
|
||||||
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
||||||
@@ -408,7 +404,7 @@ fn accept_packet<X: Reactivity>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ControlPacket::TextMessage(u) => {
|
ControlPacket::TextMessage(u) => {
|
||||||
let mut server = X::write(&state.server);
|
let mut server = STATE.server.write();
|
||||||
if u.has_message() {
|
if u.has_message() {
|
||||||
let text = u.get_message().to_string();
|
let text = u.get_message().to_string();
|
||||||
server.chat.push(Chat {
|
server.chat.push(Chat {
|
||||||
@@ -423,8 +419,8 @@ fn accept_packet<X: Reactivity>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ControlPacket::ServerSync(u) => {
|
ControlPacket::ServerSync(u) => {
|
||||||
*X::write(&state.status) = ConnectionState::Connected;
|
*STATE.status.write() = ConnectionState::Connected;
|
||||||
let mut server = X::write(&state.server);
|
let mut server = STATE.server.write();
|
||||||
if u.has_welcome_text() {
|
if u.has_welcome_text() {
|
||||||
let text = u.get_welcome_text().to_string();
|
let text = u.get_welcome_text().to_string();
|
||||||
server.chat.push(Chat {
|
server.chat.push(Chat {
|
||||||
@@ -1,761 +1,9 @@
|
|||||||
#![allow(non_snake_case)]
|
use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
use mumble_web2_client::{
|
|
||||||
network_entrypoint, reqwest, AudioSettings, ChannelId, Command, ConfigSystem,
|
|
||||||
ConfigSystemInterface as _, ConnectionState, Platform, PlatformInterface as _, ServerState,
|
|
||||||
UserId, UserState,
|
|
||||||
};
|
|
||||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::{fmt, sync::Arc};
|
|
||||||
use Command::*;
|
|
||||||
use ConnectionState::*;
|
|
||||||
|
|
||||||
pub struct DioxusReactivity;
|
|
||||||
|
|
||||||
impl mumble_web2_client::Reactivity for DioxusReactivity {
|
|
||||||
type Signal<T> = Signal<T>;
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
Muted,
|
|
||||||
Deafened,
|
|
||||||
Suppressed,
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserIcon {
|
|
||||||
pub fn icon(user: &UserState) -> UserIcon {
|
|
||||||
if user.deaf || user.self_deaf {
|
|
||||||
UserIcon::Deafened
|
|
||||||
} else if user.mute || user.self_mute {
|
|
||||||
UserIcon::Muted
|
|
||||||
} else if user.suppress {
|
|
||||||
UserIcon::Suppressed
|
|
||||||
} else {
|
|
||||||
UserIcon::Normal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn url(self) -> Option<Asset> {
|
|
||||||
// speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/
|
|
||||||
// mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/
|
|
||||||
|
|
||||||
use UserIcon::*;
|
|
||||||
Some(match self {
|
|
||||||
Normal => asset!("assets/mic-svgrepo-com.svg"),
|
|
||||||
Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"),
|
|
||||||
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
|
|
||||||
None => return Option::None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
|
|
||||||
let color = match icon {
|
|
||||||
UserIcon::Normal => "var(--accent-normal)",
|
|
||||||
UserIcon::Muted => "var(--accent-muted)",
|
|
||||||
UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)",
|
|
||||||
UserIcon::None => "var(--accent-normal)",
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
div {
|
|
||||||
class: match isself { true => "userpil is_self", false => "userpil" },
|
|
||||||
style: "background-color: {color}",
|
|
||||||
{ icon.url().map(|url| rsx!(img { src: url })) }
|
|
||||||
"\u{00A0}{name}\u{00A0}"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn User(id: UserId) -> Element {
|
|
||||||
let state = use_context::<SharedState>();
|
|
||||||
let server = state.server.read();
|
|
||||||
match server.users.get(&id) {
|
|
||||||
Some(state) => rsx!(UserPill {
|
|
||||||
name: state.name.clone(),
|
|
||||||
icon: UserIcon::icon(state),
|
|
||||||
isself: server.session.unwrap() == id,
|
|
||||||
}),
|
|
||||||
None => rsx!(UserPill {
|
|
||||||
name: format!("unknown user ({id})"),
|
|
||||||
icon: UserIcon::None,
|
|
||||||
isself: false,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Channel(id: ChannelId) -> Element {
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
let state = use_context::<SharedState>();
|
|
||||||
let server = state.server.read();
|
|
||||||
let user = server.session.unwrap();
|
|
||||||
let Some(state) = server.channels_state.channels.get(&id) else {
|
|
||||||
return rsx!("missing channel {id}");
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut open = use_signal(|| true);
|
|
||||||
|
|
||||||
let has_children = !state.users.is_empty() || !state.children.is_empty();
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
div {
|
|
||||||
class: "channel_details",
|
|
||||||
|
|
||||||
div {
|
|
||||||
class: "channel_header",
|
|
||||||
// Arrow: only toggles open
|
|
||||||
if has_children {
|
|
||||||
span {
|
|
||||||
class: "channel_arrow",
|
|
||||||
onclick: move |evt| {
|
|
||||||
evt.stop_propagation();
|
|
||||||
evt.prevent_default();
|
|
||||||
let mut w = open.write();
|
|
||||||
*w = !*w;
|
|
||||||
},
|
|
||||||
if *open.read() { "▾" } else { "▸" }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
span {
|
|
||||||
class: "channel_arrow channel_arrow--placeholder",
|
|
||||||
" "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clickable row area (everything except the arrow)
|
|
||||||
div {
|
|
||||||
class: "channel_row_click",
|
|
||||||
ondblclick: move |evt| {
|
|
||||||
evt.stop_propagation();
|
|
||||||
evt.prevent_default();
|
|
||||||
net.send(EnterChannel { channel: id, user })
|
|
||||||
},
|
|
||||||
// remove dblclick from the inner span
|
|
||||||
span {
|
|
||||||
class: "channel_title",
|
|
||||||
"{state.name}"
|
|
||||||
}
|
|
||||||
// if you add icons/badges later, put them here too
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if *open.read() && has_children {
|
|
||||||
div {
|
|
||||||
class: "channel_children",
|
|
||||||
for id in state.users.iter() {
|
|
||||||
User { id: *id }
|
|
||||||
}
|
|
||||||
for child in state.children.iter() {
|
|
||||||
Channel { id: *child }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(feature = "desktop", feature = "web"))]
|
|
||||||
pub fn pick_and_send_file(net: &Coroutine<Command>) {
|
|
||||||
let state = use_context::<SharedState>();
|
|
||||||
let channels = if let Some(user) = state.server.read().this_user() {
|
|
||||||
vec![user.channel]
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let dialog = rfd::AsyncFileDialog::new().pick_file();
|
|
||||||
let sender = net.tx();
|
|
||||||
spawn(async move {
|
|
||||||
let Some(handle) = dialog.await else { return };
|
|
||||||
let name = handle.file_name();
|
|
||||||
let bytes = handle.read().await;
|
|
||||||
let mime = mumble_web2_client::mime_guess::from_path(&name).first();
|
|
||||||
let _ = sender.unbounded_send(SendFile {
|
|
||||||
bytes,
|
|
||||||
name,
|
|
||||||
mime,
|
|
||||||
channels,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
#[cfg(not(any(feature = "desktop", feature = "web")))]
|
|
||||||
pub fn pick_and_send_file(net: &Coroutine<Command>) {}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ChatView() -> Element {
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
let state = use_context::<SharedState>();
|
|
||||||
let server = state.server.read();
|
|
||||||
let mut draft = use_signal(|| "".to_string());
|
|
||||||
|
|
||||||
let mut do_send = move || {
|
|
||||||
let state = use_context::<SharedState>();
|
|
||||||
let server = state.server.read();
|
|
||||||
if let Some(user) = server.this_user() {
|
|
||||||
net.send(SendChat {
|
|
||||||
markdown: draft.write().split_off(0),
|
|
||||||
channels: vec![user.channel],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
div {
|
|
||||||
class: "chat_panel",
|
|
||||||
div {
|
|
||||||
class: "chat_history",
|
|
||||||
for chat in server.chat.iter() {
|
|
||||||
div {
|
|
||||||
class: "chat_message",
|
|
||||||
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
|
|
||||||
UserPill {
|
|
||||||
name: sender.name.clone(),
|
|
||||||
icon: UserIcon::None,
|
|
||||||
isself: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
dangerous_inner_html: "{chat.dangerous_html}",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "chat_box_wrapper",
|
|
||||||
div {
|
|
||||||
class: "chat_box",
|
|
||||||
input {
|
|
||||||
placeholder: "say something",
|
|
||||||
value: "{draft.read()}",
|
|
||||||
oninput: move |evt| draft.set(evt.value().clone()),
|
|
||||||
onkeypress: move |evt: Event<KeyboardData>| {
|
|
||||||
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
|
|
||||||
do_send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
onclick: move |_| pick_and_send_file(&net),
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
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;",
|
|
||||||
"attach_file",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
onclick: move |_| do_send(),
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
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;",
|
|
||||||
"send",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//button {
|
|
||||||
// onclick: move |_| do_send(),
|
|
||||||
// "Send"
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
let state = use_context::<SharedState>();
|
|
||||||
let status = &state.status;
|
|
||||||
let server = state.server.read();
|
|
||||||
let audio = state.audio.read();
|
|
||||||
let Some(&UserState {
|
|
||||||
deaf,
|
|
||||||
self_deaf,
|
|
||||||
mute,
|
|
||||||
suppress,
|
|
||||||
self_mute,
|
|
||||||
ref name,
|
|
||||||
channel,
|
|
||||||
..
|
|
||||||
}) = server.this_user()
|
|
||||||
else {
|
|
||||||
return rsx!();
|
|
||||||
};
|
|
||||||
|
|
||||||
let current_channel_name = server.channels_state.channels[&channel].name.clone();
|
|
||||||
|
|
||||||
let proxy_url = overrides
|
|
||||||
.read_unchecked()
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|overrides| overrides.proxy_url.clone());
|
|
||||||
|
|
||||||
let connecting_color = "yellow";
|
|
||||||
let connected_color = "oklch(0.55 0.1184 141.35)";
|
|
||||||
let disconnected_color = "gray";
|
|
||||||
let failed_color = "red";
|
|
||||||
|
|
||||||
let connection_status = match &*status.read() {
|
|
||||||
Connecting => rsx! {
|
|
||||||
div {
|
|
||||||
class: "connection_status",
|
|
||||||
style: "color: {connecting_color};",
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"signal_cellular_alt_2_bar"
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
class: "status_text",
|
|
||||||
" Connecting"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Connected => rsx! {
|
|
||||||
div {
|
|
||||||
class: "connection_status",
|
|
||||||
div {
|
|
||||||
style: "color: {connected_color};",
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"signal_cellular_alt"
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
class: "status_text",
|
|
||||||
" Connected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "channel_text",
|
|
||||||
span { "{current_channel_name}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Disconnected => rsx! {
|
|
||||||
div {
|
|
||||||
class: "connection_status",
|
|
||||||
style: "color: {disconnected_color};",
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"signal_disconnected"
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
class: "status_text",
|
|
||||||
" Disconnected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Failed(_) => rsx! {
|
|
||||||
div {
|
|
||||||
class: "connection_status",
|
|
||||||
style: "color: {failed_color};",
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"error"
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
class: "status_text",
|
|
||||||
" Failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
// Server control
|
|
||||||
div {
|
|
||||||
class: "button_row",
|
|
||||||
div {
|
|
||||||
{connection_status}
|
|
||||||
}
|
|
||||||
span { class: "spacer" }
|
|
||||||
button {
|
|
||||||
class: "toggle_button",
|
|
||||||
onclick: move |_| net.send(Disconnect),
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"signal_disconnected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hr { style: "width: 100%;" }
|
|
||||||
// User control
|
|
||||||
div {
|
|
||||||
class: "button_row",
|
|
||||||
button {
|
|
||||||
class: "user_edit_button",
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
style: "color: oklch(0.65 0.2245 28.06);",
|
|
||||||
"person_edit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "user_info",
|
|
||||||
div {
|
|
||||||
span { class: "user_name", "{name}" }
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
span { class: "user_data", "some data" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
span { class: "spacer" }
|
|
||||||
button {
|
|
||||||
class: match audio.denoise {
|
|
||||||
true => "toggle_button is_on",
|
|
||||||
false => "toggle_button",
|
|
||||||
},
|
|
||||||
role: "switch",
|
|
||||||
aria_checked: audio.denoise,
|
|
||||||
onclick: move |_| {
|
|
||||||
let state = use_context::<SharedState>();
|
|
||||||
let mut audio = state.audio.read().clone();
|
|
||||||
audio.denoise = !audio.denoise;
|
|
||||||
let denoise = audio.denoise;
|
|
||||||
*state.audio.write_unchecked() = audio;
|
|
||||||
net.send(UpdateAudioSettings(AudioSettings { denoise: denoise }));
|
|
||||||
let user_config = use_context::<ConfigSystem>();
|
|
||||||
user_config.config_set::<bool>("denoise", &denoise);
|
|
||||||
},
|
|
||||||
match audio.denoise {
|
|
||||||
true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
|
|
||||||
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: match mute || suppress || self_mute {
|
|
||||||
true => "toggle_button is_on",
|
|
||||||
false => "toggle_button",
|
|
||||||
},
|
|
||||||
role: "switch",
|
|
||||||
aria_checked: mute || suppress || self_mute,
|
|
||||||
disabled: mute || suppress,
|
|
||||||
onclick: move |_| net.send(SetMute { mute: !self_mute }),
|
|
||||||
match mute || suppress || self_mute {
|
|
||||||
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
|
|
||||||
false => rsx!(span { class: "material-symbols-outlined", "mic"}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: match deaf || self_deaf {
|
|
||||||
true => "toggle_button in_on",
|
|
||||||
false => "toggle_button",
|
|
||||||
},
|
|
||||||
role: "switch",
|
|
||||||
aria_checked: deaf || self_deaf,
|
|
||||||
disabled: deaf,
|
|
||||||
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
|
|
||||||
match deaf || self_deaf {
|
|
||||||
true => rsx!(span { class: "material-symbols-outlined", "volume_off"}),
|
|
||||||
false => rsx!(span { class: "material-symbols-outlined", "volume_up"}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ServerView(overrides: Resource<ProxyOverrides>) -> Element {
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
let state = use_context::<SharedState>();
|
|
||||||
let server = state.server.read();
|
|
||||||
let Some(&UserState {
|
|
||||||
deaf,
|
|
||||||
self_deaf,
|
|
||||||
mute,
|
|
||||||
self_mute,
|
|
||||||
..
|
|
||||||
}) = server.this_user()
|
|
||||||
else {
|
|
||||||
return rsx!();
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
div {
|
|
||||||
class: "server_grid",
|
|
||||||
div {
|
|
||||||
class: "server_channel_box",
|
|
||||||
for (id, state) in server.channels_state.channels.iter() {
|
|
||||||
if state.parent.is_none() {
|
|
||||||
Channel { id: *id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "server_chat_box",
|
|
||||||
ChatView {}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "server_control_box",
|
|
||||||
ControlView { overrides }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn LoginView(overrides: Resource<ProxyOverrides>) -> Element {
|
|
||||||
let user_config = use_context::<ConfigSystem>();
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
|
|
||||||
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 last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
|
|
||||||
use_resource(move || {
|
|
||||||
let addr = address();
|
|
||||||
async move {
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
loop {
|
|
||||||
*last_status.write_unchecked() = Some(Platform::get_status(&client, &addr).await);
|
|
||||||
Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut username = use_signal(|| {
|
|
||||||
user_config
|
|
||||||
.config_get::<String>("username")
|
|
||||||
.unwrap_or(String::new())
|
|
||||||
});
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
net.send(Connect {
|
|
||||||
address: address.read().clone(),
|
|
||||||
username: username.read().clone(),
|
|
||||||
config: overrides.read().clone().unwrap_or_default(),
|
|
||||||
})
|
|
||||||
};
|
|
||||||
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: "login_bttn",
|
|
||||||
"Connecting..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Failed(msg) => rsx!(
|
|
||||||
button {
|
|
||||||
class: "login_bttn",
|
|
||||||
onclick: do_connect.clone(),
|
|
||||||
"Reconnect"
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "login_error",
|
|
||||||
"Failed to connect:"
|
|
||||||
pre {
|
|
||||||
"{msg}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Connected => unreachable!(),
|
|
||||||
};
|
|
||||||
let version = option_env!("MUMBLE_WEB2_VERSION");
|
|
||||||
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 {
|
|
||||||
label {
|
|
||||||
for: "address-entry",
|
|
||||||
"Server Address:"
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
id: "address-entry",
|
|
||||||
placeholder: "address",
|
|
||||||
value: "{address.read()}",
|
|
||||||
autofocus: "true",
|
|
||||||
oninput: move |evt| address_input.set(Some(evt.value().clone())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
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;",
|
|
||||||
}
|
|
||||||
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]
|
|
||||||
pub fn app() -> Element {
|
|
||||||
static STYLE: Asset = asset!("/assets/main.scss");
|
|
||||||
|
|
||||||
use_effect(|| {
|
|
||||||
Platform::request_permissions();
|
|
||||||
});
|
|
||||||
|
|
||||||
let user_config = use_root_context(|| ConfigSystem::new().unwrap());
|
|
||||||
let state = use_root_context(|| {
|
|
||||||
SharedState::new(State {
|
|
||||||
status: Signal::new(Disconnected),
|
|
||||||
server: Signal::new(Default::default()),
|
|
||||||
audio: Signal::new(AudioSettings {
|
|
||||||
denoise: user_config.config_get::<bool>("denoise").unwrap_or(true),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let network_state = state.clone();
|
|
||||||
use_coroutine(move |rx: UnboundedReceiver<Command>| {
|
|
||||||
network_entrypoint(rx, network_state.clone())
|
|
||||||
});
|
|
||||||
let overrides = use_resource(|| async move {
|
|
||||||
match Platform::load_proxy_overrides().await {
|
|
||||||
Ok(overrides) => overrides,
|
|
||||||
Err(_) => ProxyOverrides::default(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
|
|
||||||
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
|
|
||||||
document::Link{ rel: "stylesheet", href: STYLE }
|
|
||||||
|
|
||||||
match *state.status.read() {
|
|
||||||
Connected => rsx!(ServerView { overrides }),
|
|
||||||
_ => rsx!(LoginView { overrides }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
Platform::init_logging();
|
Platform::init_logging();
|
||||||
dioxus::LaunchBuilder::new()
|
#[cfg(feature = "blitz")]
|
||||||
.with_cfg(desktop! {
|
dioxus_native::launch(app::app);
|
||||||
dioxus::desktop::Config::new()
|
#[cfg(not(feature = "blitz"))]
|
||||||
// Reduce white flash on startup by setting background color and hiding main element
|
dioxus::launch(app::app);
|
||||||
.with_background_color((0, 0, 0, 255))
|
|
||||||
.with_custom_head("<style>html, body { background: black; } #main { visibility: hidden; }</style>".into())
|
|
||||||
.with_disable_context_menu(cfg!(not(debug_assertions)))
|
|
||||||
.with_window(
|
|
||||||
dioxus::desktop::WindowBuilder::new()
|
|
||||||
.with_title("Mumble Web 2")
|
|
||||||
.with_min_inner_size(dioxus::desktop::LogicalSize::new(600.0, 300.0))
|
|
||||||
.with_inner_size(dioxus::desktop::LogicalSize::new(900.0, 700.0))
|
|
||||||
.with_maximized(false),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.launch(app);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ tokio-rustls = "0.26"
|
|||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = { version = "^0.1.40", features = ["async-await"] }
|
tracing = { version = "^0.1.40", features = ["async-await"] }
|
||||||
tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] }
|
tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] }
|
||||||
mumble-web2-common = { workspace = true, features = ["networking"] }
|
mumble-web2-common = { workspace = true }
|
||||||
salvo = { version = "^0.84.2", features = [
|
salvo = { version = "^0.84.2", features = [
|
||||||
"quinn",
|
"quinn",
|
||||||
"eyre",
|
"eyre",
|
||||||
@@ -28,3 +28,4 @@ rcgen = "^0.13.2"
|
|||||||
hmac-sha256 = "^1.1.8"
|
hmac-sha256 = "^1.1.8"
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
url = { version = "2", features = ["serde"] }
|
url = { version = "2", features = ["serde"] }
|
||||||
|
rand = "0.9.2"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use color_eyre::eyre::{anyhow, bail, Context, Result};
|
use color_eyre::eyre::{anyhow, bail, Context, Result};
|
||||||
use mumble_web2_common::{ping_server, ProxyOverrides, ServerStatus};
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
|
use rand::Rng;
|
||||||
use salvo::conn::rustls::{Keycert, RustlsConfig};
|
use salvo::conn::rustls::{Keycert, RustlsConfig};
|
||||||
use salvo::cors::{AllowOrigin, Cors};
|
use salvo::cors::{AllowOrigin, Cors};
|
||||||
use salvo::logging::Logger;
|
use salvo::logging::Logger;
|
||||||
@@ -25,6 +26,8 @@ use tracing_subscriber::filter::LevelFilter;
|
|||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
mod ping;
|
||||||
|
|
||||||
fn default_cert_alt_names() -> Vec<String> {
|
fn default_cert_alt_names() -> Vec<String> {
|
||||||
vec!["localhost".into()]
|
vec!["localhost".into()]
|
||||||
}
|
}
|
||||||
@@ -176,14 +179,70 @@ pub struct StatusCraft {
|
|||||||
impl StatusCraft {
|
impl StatusCraft {
|
||||||
#[craft(handler)]
|
#[craft(handler)]
|
||||||
async fn get_status(&self) -> Json<ServerStatus> {
|
async fn get_status(&self) -> Json<ServerStatus> {
|
||||||
let addr = self.mumble_server_address;
|
let mut server_status = ServerStatus::default();
|
||||||
match ping_server(&addr.ip().to_string(), addr.port()).await {
|
|
||||||
Ok(status) => Json(status),
|
let ping_packet = ping::PingPacket {
|
||||||
|
id: rand::rng().random(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await {
|
||||||
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("ping failed: {e:#}");
|
error!("Could not bind udp socket: {}", e);
|
||||||
Json(ServerStatus::default())
|
return Json(server_status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match sock.connect(self.mumble_server_address).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Could not send ping packet: {}", e);
|
||||||
|
return Json(server_status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match sock.send(&<[u8; 12]>::from(ping_packet)).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Could not send ping packet");
|
||||||
|
return Json(server_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pong_buf: [u8; 24] = [0; 24];
|
||||||
|
|
||||||
|
match tokio::time::timeout(
|
||||||
|
tokio::time::Duration::from_secs(1),
|
||||||
|
sock.recv(&mut pong_buf),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Could not send ping packet");
|
||||||
|
return Json(server_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pong_packet = match ping::PongPacket::try_from(pong_buf.as_slice()) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Could not parse pong packet: {:?}", e);
|
||||||
|
return Json(server_status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
server_status.success = true;
|
||||||
|
server_status.version = Some((
|
||||||
|
pong_packet.version & 0xFF,
|
||||||
|
(pong_packet.version >> 8) & 0xFF,
|
||||||
|
(pong_packet.version >> 16) & 0xFF,
|
||||||
|
));
|
||||||
|
server_status.users = Some(pong_packet.users);
|
||||||
|
server_status.max_users = Some(pong_packet.max_users);
|
||||||
|
server_status.bandwidth = Some(pong_packet.bandwidth);
|
||||||
|
|
||||||
|
Json(server_status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// This code was taken from mumble-protocol-2x (https://github.com/dblsaiko/rust-mumble-protocol)
|
||||||
|
// and originally from mumble-protocol (https://github.com/Johni0702/rust-mumble-protocol)
|
||||||
|
// These projects are licensed under MIT and Apache 2.0.
|
||||||
|
|
||||||
|
//! Ping messages and codec
|
||||||
|
//!
|
||||||
|
//! A Mumble client can send periodic UDP [PingPacket]s to servers
|
||||||
|
//! in order to query their current state and measure latency.
|
||||||
|
//! A server will usually respond with a corresponding [PongPacket] containing
|
||||||
|
//! the requested details.
|
||||||
|
//!
|
||||||
|
//! Both packets are of fixed size and can be converted to/from `u8` arrays/slices via
|
||||||
|
//! the respective `From`/`TryFrom` impls.
|
||||||
|
|
||||||
|
/// A ping packet sent to the server.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct PingPacket {
|
||||||
|
/// Opaque, client-generated id.
|
||||||
|
///
|
||||||
|
/// Will be returned by the server unmodified and can be used to correlate
|
||||||
|
/// pong replies to ping requests to e.g. calculate latency.
|
||||||
|
pub id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pong packet sent to the client in reply to a previously received [PingPacket].
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct PongPacket {
|
||||||
|
/// Opaque, client-generated id.
|
||||||
|
///
|
||||||
|
/// Should match the value in the corresponding [PingPacket].
|
||||||
|
pub id: u64,
|
||||||
|
|
||||||
|
/// Server version. E.g. `0x010300` for `1.3.0`.
|
||||||
|
pub version: u32,
|
||||||
|
|
||||||
|
/// Current amount of users connected to the server.
|
||||||
|
pub users: u32,
|
||||||
|
|
||||||
|
/// Configured limit on the amount of users which can be connected to the server.
|
||||||
|
pub max_users: u32,
|
||||||
|
|
||||||
|
/// Maximum bandwidth for server-bound speech per client in bits per second
|
||||||
|
pub bandwidth: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error during parsing of a [PingPacket].
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum ParsePingError {
|
||||||
|
/// Ping packets must always be 12 bytes in size.
|
||||||
|
InvalidSize,
|
||||||
|
/// Ping packets must have an all zero header of 4 bytes.
|
||||||
|
InvalidHeader,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&[u8]> for PingPacket {
|
||||||
|
type Error = ParsePingError;
|
||||||
|
fn try_from(buf: &[u8]) -> Result<Self, Self::Error> {
|
||||||
|
match <[u8; 12]>::try_from(buf) {
|
||||||
|
Ok(array) => {
|
||||||
|
if array[0..4] != [0, 0, 0, 0] {
|
||||||
|
Err(ParsePingError::InvalidHeader)
|
||||||
|
} else {
|
||||||
|
Ok(Self {
|
||||||
|
id: u64::from_be_bytes(array[4..12].try_into().unwrap()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => Err(ParsePingError::InvalidSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PingPacket> for [u8; 12] {
|
||||||
|
fn from(packet: PingPacket) -> Self {
|
||||||
|
let id = packet.id.to_be_bytes();
|
||||||
|
// Is there no nicer way to do this?
|
||||||
|
[
|
||||||
|
0, 0, 0, 0, id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error during parsing of a [PongPacket].
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum ParsePongError {
|
||||||
|
/// Pong packets must always be 24 bytes in size.
|
||||||
|
InvalidSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&[u8]> for PongPacket {
|
||||||
|
type Error = ParsePongError;
|
||||||
|
fn try_from(buf: &[u8]) -> Result<Self, Self::Error> {
|
||||||
|
match <[u8; 24]>::try_from(buf) {
|
||||||
|
Ok(array) => Ok(Self {
|
||||||
|
version: u32::from_be_bytes(array[0..4].try_into().unwrap()),
|
||||||
|
id: u64::from_be_bytes(array[4..12].try_into().unwrap()),
|
||||||
|
users: u32::from_be_bytes(array[12..16].try_into().unwrap()),
|
||||||
|
max_users: u32::from_be_bytes(array[16..20].try_into().unwrap()),
|
||||||
|
bandwidth: u32::from_be_bytes(array[20..24].try_into().unwrap()),
|
||||||
|
}),
|
||||||
|
Err(_) => Err(ParsePongError::InvalidSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PongPacket> for [u8; 24] {
|
||||||
|
fn from(packet: PongPacket) -> Self {
|
||||||
|
let version = packet.version.to_be_bytes();
|
||||||
|
let id = packet.id.to_be_bytes();
|
||||||
|
let users = packet.users.to_be_bytes();
|
||||||
|
let max_users = packet.max_users.to_be_bytes();
|
||||||
|
let bandwidth = packet.bandwidth.to_be_bytes();
|
||||||
|
// Is there no nicer way to do this?
|
||||||
|
[
|
||||||
|
version[0],
|
||||||
|
version[1],
|
||||||
|
version[2],
|
||||||
|
version[3],
|
||||||
|
id[0],
|
||||||
|
id[1],
|
||||||
|
id[2],
|
||||||
|
id[3],
|
||||||
|
id[4],
|
||||||
|
id[5],
|
||||||
|
id[6],
|
||||||
|
id[7],
|
||||||
|
users[0],
|
||||||
|
users[1],
|
||||||
|
users[2],
|
||||||
|
users[3],
|
||||||
|
max_users[0],
|
||||||
|
max_users[1],
|
||||||
|
max_users[2],
|
||||||
|
max_users[3],
|
||||||
|
bandwidth[0],
|
||||||
|
bandwidth[1],
|
||||||
|
bandwidth[2],
|
||||||
|
bandwidth[3],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1,775 +0,0 @@
|
|||||||
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(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||