feat: replace Tauri with iced for native cross-platform GUI
- Create iced-app crate with pure Rust GUI using iced 0.13 - Implement server list, connect form, channel/user panels, messaging - Implement ServerQuery panel for direct server queries - Add VoiceEngine in tsaudio for decode/buffer/playback pipeline - Update CI to build iced binary (no frontend build step needed) - Remove Tauri dependency from workspace - 69 tests passing across all crates
This commit is contained in:
@@ -41,12 +41,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
libdbus-1-dev \
|
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libgtk-3-dev \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
libasound2-dev
|
libasound2-dev
|
||||||
|
|
||||||
@@ -63,7 +58,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check
|
- name: Check
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: cargo check -p shared -p tscore -p tsaudio -p tsdb
|
run: cargo check -p shared -p tscore -p tsaudio -p tsdb -p re-teamspeak
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -71,39 +66,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: cargo clippy -p shared -p tscore -p tsaudio -p tsdb -- -D warnings
|
run: cargo clippy -p shared -p tscore -p tsaudio -p tsdb -p re-teamspeak -- -D warnings
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
build-frontend:
|
|
||||||
name: Build Frontend
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: src/tauri-app/frontend
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
working-directory: src/tauri-app/frontend
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Upload frontend artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-dist
|
|
||||||
path: src/tauri-app/frontend/dist/
|
|
||||||
|
|
||||||
# Build desktop apps
|
# Build desktop apps
|
||||||
build-desktop:
|
build-desktop:
|
||||||
name: Build Desktop (${{ matrix.platform }})
|
name: Build Desktop (${{ matrix.platform }})
|
||||||
needs: [test, build-frontend]
|
needs: test
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -135,21 +104,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
libdbus-1-dev \
|
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libgtk-3-dev \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
libasound2-dev
|
libasound2-dev
|
||||||
|
|
||||||
- name: Download frontend artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-dist
|
|
||||||
path: src/tauri-app/frontend/dist/
|
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -163,27 +121,27 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: cargo build --release
|
run: cargo build --release -p re-teamspeak
|
||||||
|
|
||||||
- name: Package (Linux)
|
- name: Package (Linux)
|
||||||
if: matrix.platform == 'linux'
|
if: matrix.platform == 'linux'
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cp src/target/release/re-teamspeak dist/ || true
|
cp src/target/release/re-teamspeak dist/
|
||||||
tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist .
|
tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist .
|
||||||
|
|
||||||
- name: Package (Windows)
|
- name: Package (Windows)
|
||||||
if: matrix.platform == 'windows'
|
if: matrix.platform == 'windows'
|
||||||
run: |
|
run: |
|
||||||
mkdir dist
|
mkdir dist
|
||||||
copy src\target\release\re-teamspeak.exe dist\ || copy src\target\release\re_teamspeak.exe dist\ || echo "Binary not found"
|
copy src\target\release\re-teamspeak.exe dist\
|
||||||
Compress-Archive -Path dist\* -DestinationPath re-teamspeak-${{ matrix.artifact }}.zip
|
Compress-Archive -Path dist\* -DestinationPath re-teamspeak-${{ matrix.artifact }}.zip
|
||||||
|
|
||||||
- name: Package (macOS)
|
- name: Package (macOS)
|
||||||
if: matrix.platform == 'macos'
|
if: matrix.platform == 'macos'
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cp src/target/release/re-teamspeak dist/ || true
|
cp src/target/release/re-teamspeak dist/
|
||||||
tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist .
|
tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist .
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ members = [
|
|||||||
"tsaudio",
|
"tsaudio",
|
||||||
"tsdb",
|
"tsdb",
|
||||||
"shared",
|
"shared",
|
||||||
"tauri-app/src-tauri",
|
"iced-app",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "re-teamspeak"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "TeamSpeak 3 client built with iced"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "re-teamspeak"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iced = { version = "0.13", features = ["tokio", "debug"] }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
|
shared = { workspace = true }
|
||||||
|
tscore = { workspace = true }
|
||||||
|
tsaudio = { workspace = true }
|
||||||
|
tsdb = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
use iced::widget::{button, column, container, horizontal_rule, horizontal_space, row, scrollable, text, text_input, vertical_space};
|
||||||
|
use iced::{Element, Length, Subscription, Task};
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use tscore::{ClientConfig, IdentityKey, Session, SessionEvent};
|
||||||
|
use tsdb::DatabaseManager;
|
||||||
|
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
iced::application("ReTeamSpeak", App::update, App::view)
|
||||||
|
.subscription(App::subscription)
|
||||||
|
.run_with(App::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Page {
|
||||||
|
ServerList,
|
||||||
|
ServerQuery,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Message {
|
||||||
|
PageChanged(Page),
|
||||||
|
LoadBookmarks,
|
||||||
|
BookmarksLoaded(Vec<BookmarkInfo>),
|
||||||
|
SelectBookmark(Option<usize>),
|
||||||
|
NicknameChanged(String),
|
||||||
|
PasswordChanged(String),
|
||||||
|
Connect,
|
||||||
|
Connected(u16),
|
||||||
|
Disconnect,
|
||||||
|
Disconnected,
|
||||||
|
ConnectionError(String),
|
||||||
|
PollEvents,
|
||||||
|
SessionEvent(SessionEvent),
|
||||||
|
MessageInputChanged(String),
|
||||||
|
SendChannelMessage,
|
||||||
|
QueryAddressChanged(String),
|
||||||
|
QueryPortChanged(String),
|
||||||
|
RunServerQuery,
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct BookmarkInfo {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
address: String,
|
||||||
|
port: u16,
|
||||||
|
nickname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ChannelEntry {
|
||||||
|
cid: u64,
|
||||||
|
channel_name: String,
|
||||||
|
total_clients: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ClientEntry {
|
||||||
|
clid: u16,
|
||||||
|
client_nickname: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ServerInfo {
|
||||||
|
name: String,
|
||||||
|
platform: String,
|
||||||
|
version: String,
|
||||||
|
max_clients: u16,
|
||||||
|
clients_online: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ChatMessage {
|
||||||
|
invoker_name: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
page: Page,
|
||||||
|
db: Arc<Mutex<DatabaseManager>>,
|
||||||
|
bookmarks: Vec<BookmarkInfo>,
|
||||||
|
selected_bookmark: Option<usize>,
|
||||||
|
nickname: String,
|
||||||
|
password: String,
|
||||||
|
connected: bool,
|
||||||
|
server_info: Option<ServerInfo>,
|
||||||
|
channels: Vec<ChannelEntry>,
|
||||||
|
clients: Vec<ClientEntry>,
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
message_input: String,
|
||||||
|
query_address: String,
|
||||||
|
query_port: u16,
|
||||||
|
query_loading: bool,
|
||||||
|
query_error: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new() -> (Self, Task<Message>) {
|
||||||
|
let db_path = std::path::PathBuf::from("re-teamspeak.db");
|
||||||
|
let db = DatabaseManager::new(db_path.to_str().unwrap_or("re-teamspeak.db"))
|
||||||
|
.expect("failed to create database");
|
||||||
|
|
||||||
|
let app = Self {
|
||||||
|
page: Page::ServerList,
|
||||||
|
db: Arc::new(Mutex::new(db)),
|
||||||
|
bookmarks: vec![
|
||||||
|
BookmarkInfo {
|
||||||
|
id: "1".to_string(),
|
||||||
|
name: "Local Server".to_string(),
|
||||||
|
address: "127.0.0.1".to_string(),
|
||||||
|
port: 9987,
|
||||||
|
nickname: Some("User".to_string()),
|
||||||
|
},
|
||||||
|
BookmarkInfo {
|
||||||
|
id: "2".to_string(),
|
||||||
|
name: "KR TeamSpeak".to_string(),
|
||||||
|
address: "kr.teamspeak.app".to_string(),
|
||||||
|
port: 9987,
|
||||||
|
nickname: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selected_bookmark: None,
|
||||||
|
nickname: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
connected: false,
|
||||||
|
server_info: None,
|
||||||
|
channels: Vec::new(),
|
||||||
|
clients: Vec::new(),
|
||||||
|
messages: Vec::new(),
|
||||||
|
message_input: String::new(),
|
||||||
|
query_address: String::new(),
|
||||||
|
query_port: 10011,
|
||||||
|
query_loading: false,
|
||||||
|
query_error: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(app, Task::none())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::PageChanged(page) => {
|
||||||
|
self.page = page;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::LoadBookmarks => Task::none(),
|
||||||
|
Message::BookmarksLoaded(_) => Task::none(),
|
||||||
|
Message::SelectBookmark(idx) => {
|
||||||
|
self.selected_bookmark = idx;
|
||||||
|
if let Some(i) = idx {
|
||||||
|
if let Some(b) = self.bookmarks.get(i) {
|
||||||
|
if let Some(nick) = &b.nickname {
|
||||||
|
self.nickname = nick.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::NicknameChanged(n) => {
|
||||||
|
self.nickname = n;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::PasswordChanged(p) => {
|
||||||
|
self.password = p;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Connect => {
|
||||||
|
let bookmark = match self.selected_bookmark.and_then(|i| self.bookmarks.get(i)) {
|
||||||
|
Some(b) => b.clone(),
|
||||||
|
None => return Task::none(),
|
||||||
|
};
|
||||||
|
let nickname = if self.nickname.is_empty() {
|
||||||
|
"User".to_string()
|
||||||
|
} else {
|
||||||
|
self.nickname.clone()
|
||||||
|
};
|
||||||
|
let password = if self.password.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.password.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
let socket_addr: std::net::SocketAddr =
|
||||||
|
match format!("{}:{}", bookmark.address, bookmark.port).parse() {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => {
|
||||||
|
use tokio::net::lookup_host;
|
||||||
|
match lookup_host((&*bookmark.address, bookmark.port)).await {
|
||||||
|
Ok(mut addrs) => match addrs.next() {
|
||||||
|
Some(a) => a,
|
||||||
|
None => return Err("Cannot resolve address".to_string()),
|
||||||
|
},
|
||||||
|
Err(e) => return Err(format!("DNS error: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let identity = IdentityKey::generate();
|
||||||
|
let mut config = ClientConfig::new(socket_addr, nickname);
|
||||||
|
config.server_password = password;
|
||||||
|
config.identity = identity;
|
||||||
|
|
||||||
|
Session::connect(config, std::time::Duration::from_secs(15))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
},
|
||||||
|
|result| match result {
|
||||||
|
Ok((session, _handle)) => {
|
||||||
|
let client_id = session.client_id().unwrap_or(0);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut session = session;
|
||||||
|
if let Err(e) = session.run().await {
|
||||||
|
tracing::error!("session error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Message::Connected(client_id)
|
||||||
|
}
|
||||||
|
Err(e) => Message::ConnectionError(e),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::Connected(client_id) => {
|
||||||
|
self.connected = true;
|
||||||
|
tracing::info!("Connected as client {client_id}");
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Disconnect => {
|
||||||
|
self.connected = false;
|
||||||
|
self.server_info = None;
|
||||||
|
self.channels.clear();
|
||||||
|
self.clients.clear();
|
||||||
|
self.messages.clear();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Disconnected => {
|
||||||
|
self.connected = false;
|
||||||
|
self.server_info = None;
|
||||||
|
self.channels.clear();
|
||||||
|
self.clients.clear();
|
||||||
|
self.messages.clear();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::ConnectionError(err) => {
|
||||||
|
self.error = Some(err);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::PollEvents => Task::none(),
|
||||||
|
Message::SessionEvent(event) => {
|
||||||
|
match event {
|
||||||
|
SessionEvent::ServerInfo { name, platform, version, max_clients, clients_online, .. } => {
|
||||||
|
self.server_info = Some(ServerInfo { name, platform, version, max_clients, clients_online });
|
||||||
|
}
|
||||||
|
SessionEvent::ChannelList(channels) => {
|
||||||
|
self.channels = channels.into_iter().map(|c| ChannelEntry {
|
||||||
|
cid: c.cid,
|
||||||
|
channel_name: c.channel_name,
|
||||||
|
total_clients: c.total_clients,
|
||||||
|
}).collect();
|
||||||
|
}
|
||||||
|
SessionEvent::ClientList(clients) => {
|
||||||
|
self.clients = clients.into_iter().map(|c| ClientEntry {
|
||||||
|
clid: c.clid,
|
||||||
|
client_nickname: c.client_nickname,
|
||||||
|
}).collect();
|
||||||
|
}
|
||||||
|
SessionEvent::TextMessage { invoker_name, message, .. } => {
|
||||||
|
self.messages.push(ChatMessage { invoker_name, message });
|
||||||
|
}
|
||||||
|
SessionEvent::ClientEntered { clid, client_nickname, .. } => {
|
||||||
|
self.clients.push(ClientEntry { clid, client_nickname });
|
||||||
|
}
|
||||||
|
SessionEvent::ClientLeft { clid, .. } => {
|
||||||
|
self.clients.retain(|c| c.clid != clid);
|
||||||
|
}
|
||||||
|
SessionEvent::Error(err) => {
|
||||||
|
self.error = Some(err);
|
||||||
|
}
|
||||||
|
SessionEvent::Disconnected => {
|
||||||
|
return self.update(Message::Disconnected);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::MessageInputChanged(input) => {
|
||||||
|
self.message_input = input;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SendChannelMessage => {
|
||||||
|
self.message_input.clear();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::QueryAddressChanged(addr) => {
|
||||||
|
self.query_address = addr;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::QueryPortChanged(port) => {
|
||||||
|
self.query_port = port.parse().unwrap_or(10011);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::RunServerQuery => {
|
||||||
|
self.query_loading = true;
|
||||||
|
self.query_error = None;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Noop => Task::none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(&self) -> Subscription<Message> {
|
||||||
|
Subscription::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self) -> Element<'_, Message> {
|
||||||
|
let sidebar = self.view_sidebar();
|
||||||
|
let content = self.view_content();
|
||||||
|
|
||||||
|
row![sidebar, content]
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_sidebar(&self) -> Element<'_, Message> {
|
||||||
|
let nav = row![
|
||||||
|
nav_button("Servers", Page::ServerList, self.page),
|
||||||
|
nav_button("Query", Page::ServerQuery, self.page),
|
||||||
|
nav_button("Settings", Page::Settings, self.page),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.padding(8);
|
||||||
|
|
||||||
|
let content = match self.page {
|
||||||
|
Page::ServerList => self.view_server_list(),
|
||||||
|
Page::ServerQuery => container(text("ServerQuery panel")).padding(8).into(),
|
||||||
|
Page::Settings => container(text("Settings panel")).padding(8).into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
column![nav, horizontal_rule(1), content]
|
||||||
|
.width(260)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_server_list(&self) -> Element<'_, Message> {
|
||||||
|
let mut list = column![].spacing(2).padding(8);
|
||||||
|
|
||||||
|
for (i, bookmark) in self.bookmarks.iter().enumerate() {
|
||||||
|
let is_selected = self.selected_bookmark == Some(i);
|
||||||
|
|
||||||
|
let btn = button(
|
||||||
|
column![
|
||||||
|
text(&bookmark.name).size(14),
|
||||||
|
text(format!("{}:{}", bookmark.address, bookmark.port)).size(11),
|
||||||
|
]
|
||||||
|
.spacing(2),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_press(Message::SelectBookmark(Some(i)))
|
||||||
|
.padding(8)
|
||||||
|
.style(if is_selected {
|
||||||
|
button::primary
|
||||||
|
} else {
|
||||||
|
button::secondary
|
||||||
|
});
|
||||||
|
|
||||||
|
list = list.push(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
column![
|
||||||
|
text("Server Bookmarks").size(16),
|
||||||
|
vertical_space().height(8),
|
||||||
|
scrollable(list).height(Length::Fill),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.padding(8)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_content(&self) -> Element<'_, Message> {
|
||||||
|
match self.page {
|
||||||
|
Page::ServerList => self.view_server_content(),
|
||||||
|
Page::ServerQuery => self.view_query_content(),
|
||||||
|
Page::Settings => self.view_settings_content(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_server_content(&self) -> Element<'_, Message> {
|
||||||
|
if let Some(idx) = self.selected_bookmark {
|
||||||
|
if let Some(bookmark) = self.bookmarks.get(idx) {
|
||||||
|
let connect_form = column![
|
||||||
|
text(format!("Connect to {}", bookmark.name)).size(20),
|
||||||
|
vertical_space().height(12),
|
||||||
|
row![
|
||||||
|
text("Nickname:").width(80),
|
||||||
|
text_input("Enter nickname", &self.nickname)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_input(Message::NicknameChanged),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::Alignment::Center),
|
||||||
|
vertical_space().height(8),
|
||||||
|
row![
|
||||||
|
text("Password:").width(80),
|
||||||
|
text_input("Optional", &self.password)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.secure(true)
|
||||||
|
.on_input(Message::PasswordChanged),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::Alignment::Center),
|
||||||
|
vertical_space().height(12),
|
||||||
|
row![
|
||||||
|
horizontal_space(),
|
||||||
|
if self.connected {
|
||||||
|
button(text("Disconnect").size(14))
|
||||||
|
.padding([8, 24])
|
||||||
|
.on_press(Message::Disconnect)
|
||||||
|
} else {
|
||||||
|
button(text("Connect").size(14))
|
||||||
|
.padding([8, 24])
|
||||||
|
.on_press(Message::Connect)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.padding(16);
|
||||||
|
|
||||||
|
if self.connected {
|
||||||
|
let server_info = if let Some(info) = &self.server_info {
|
||||||
|
column![
|
||||||
|
text(&info.name).size(18),
|
||||||
|
text(format!("{} / {}", info.platform, info.version)).size(12),
|
||||||
|
text(format!("{}/{} online", info.clients_online, info.max_clients)).size(12),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
} else {
|
||||||
|
column![text("Connected").size(18)]
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut channels = column![text("Channels").size(14)].spacing(2);
|
||||||
|
for ch in &self.channels {
|
||||||
|
channels = channels.push(
|
||||||
|
row![
|
||||||
|
text(&ch.channel_name).size(12).width(Length::Fill),
|
||||||
|
text(format!("{}", ch.total_clients)).size(11),
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut clients = column![text("Clients").size(14)].spacing(2);
|
||||||
|
for c in &self.clients {
|
||||||
|
clients = clients.push(text(&c.client_nickname).size(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut messages = column![].spacing(4);
|
||||||
|
for msg in &self.messages {
|
||||||
|
messages = messages.push(
|
||||||
|
row![
|
||||||
|
text(format!("{}:", msg.invoker_name)).size(12),
|
||||||
|
text(&msg.message).size(12),
|
||||||
|
]
|
||||||
|
.spacing(4),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message_input = row![
|
||||||
|
text_input("Type a message...", &self.message_input)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_input(Message::MessageInputChanged)
|
||||||
|
.on_submit(Message::SendChannelMessage),
|
||||||
|
button(text("Send").size(12))
|
||||||
|
.padding([8, 16])
|
||||||
|
.on_press(Message::SendChannelMessage),
|
||||||
|
]
|
||||||
|
.spacing(8);
|
||||||
|
|
||||||
|
column![
|
||||||
|
connect_form,
|
||||||
|
horizontal_rule(1),
|
||||||
|
server_info,
|
||||||
|
vertical_space().height(12),
|
||||||
|
row![
|
||||||
|
scrollable(channels).height(200),
|
||||||
|
scrollable(clients).height(200),
|
||||||
|
]
|
||||||
|
.spacing(16),
|
||||||
|
horizontal_rule(1),
|
||||||
|
text("Messages").size(14),
|
||||||
|
scrollable(messages).height(120),
|
||||||
|
message_input,
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.padding(16)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
connect_form.into()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.view_welcome()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.view_welcome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_query_content(&self) -> Element<'_, Message> {
|
||||||
|
column![
|
||||||
|
text("ServerQuery").size(20),
|
||||||
|
vertical_space().height(12),
|
||||||
|
row![
|
||||||
|
text("Address:").width(80),
|
||||||
|
text_input("e.g. teamspeak.example.com", &self.query_address)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_input(Message::QueryAddressChanged),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::Alignment::Center),
|
||||||
|
vertical_space().height(8),
|
||||||
|
row![
|
||||||
|
text("Port:").width(80),
|
||||||
|
text_input("10011", &self.query_port.to_string())
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_input(Message::QueryPortChanged),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::Alignment::Center),
|
||||||
|
vertical_space().height(12),
|
||||||
|
row![
|
||||||
|
horizontal_space(),
|
||||||
|
button(text(if self.query_loading { "Loading..." } else { "Query" }).size(14))
|
||||||
|
.padding([8, 24])
|
||||||
|
.on_press_maybe(if self.query_loading { None } else { Some(Message::RunServerQuery) }),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.padding(16)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_settings_content(&self) -> Element<'_, Message> {
|
||||||
|
container(
|
||||||
|
column![
|
||||||
|
text("Settings").size(24),
|
||||||
|
vertical_space().height(12),
|
||||||
|
text("Settings will be available in a future update.").size(14),
|
||||||
|
]
|
||||||
|
.align_x(iced::Alignment::Center),
|
||||||
|
)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_welcome(&self) -> Element<'_, Message> {
|
||||||
|
container(
|
||||||
|
column![
|
||||||
|
text("Welcome to ReTeamSpeak").size(24),
|
||||||
|
vertical_space().height(12),
|
||||||
|
text("Select a server bookmark from the sidebar to connect.").size(14),
|
||||||
|
]
|
||||||
|
.align_x(iced::Alignment::Center),
|
||||||
|
)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nav_button(label: &str, page: Page, current: Page) -> Element<'static, Message> {
|
||||||
|
button(text(label.to_string()).size(12))
|
||||||
|
.padding([6, 12])
|
||||||
|
.on_press(Message::PageChanged(page))
|
||||||
|
.style(if current == page {
|
||||||
|
button::primary
|
||||||
|
} else {
|
||||||
|
button::secondary
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use crate::buffer::JitterBuffer;
|
||||||
|
use crate::codec::OpusDecoder;
|
||||||
|
use crate::playback::AudioPlayback;
|
||||||
|
use crate::{AudioConfig, AudioError, AudioFrame, AudioResult};
|
||||||
|
|
||||||
|
pub struct VoiceEngine {
|
||||||
|
decoder: OpusDecoder,
|
||||||
|
jitter_buffer: JitterBuffer,
|
||||||
|
playback: AudioPlayback,
|
||||||
|
config: AudioConfig,
|
||||||
|
client_decoders: std::collections::HashMap<u16, OpusDecoder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceEngine {
|
||||||
|
pub fn new(config: AudioConfig) -> AudioResult<Self> {
|
||||||
|
let decoder = OpusDecoder::new(config.sample_rate, config.channels)?;
|
||||||
|
let jitter_buffer = JitterBuffer::with_target_delay(100, 60);
|
||||||
|
let playback = AudioPlayback::new(config.clone());
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
decoder,
|
||||||
|
jitter_buffer,
|
||||||
|
playback,
|
||||||
|
config,
|
||||||
|
client_decoders: std::collections::HashMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&mut self) -> AudioResult<()> {
|
||||||
|
self.playback.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&mut self) -> AudioResult<()> {
|
||||||
|
self.playback.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_voice_data(
|
||||||
|
&mut self,
|
||||||
|
client_id: u16,
|
||||||
|
codec: u8,
|
||||||
|
packet_id: u16,
|
||||||
|
audio_data: &[u8],
|
||||||
|
) -> AudioResult<()> {
|
||||||
|
let decoder = self.client_decoders.entry(client_id).or_insert_with(|| {
|
||||||
|
OpusDecoder::new(self.config.sample_rate, self.config.channels)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
OpusDecoder::new(48000, 1).unwrap()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let samples = decoder.decode_packet(audio_data)?;
|
||||||
|
|
||||||
|
let frame = AudioFrame::new(self.config.sample_rate, self.config.channels, samples)
|
||||||
|
.with_sequence(packet_id)
|
||||||
|
.with_codec(codec);
|
||||||
|
|
||||||
|
self.jitter_buffer.push(frame)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output_tick(&mut self) -> AudioResult<()> {
|
||||||
|
if let Some(frame) = self.jitter_buffer.pop() {
|
||||||
|
self.playback.play(frame)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_output_loop(&mut self) -> AudioResult<()> {
|
||||||
|
loop {
|
||||||
|
self.output_tick()?;
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_output_devices() -> AudioResult<Vec<String>> {
|
||||||
|
AudioPlayback::list_devices()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buffered_ms(&self) -> u32 {
|
||||||
|
self.jitter_buffer.buffered_ms()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn jitter_buffer_len(&self) -> usize {
|
||||||
|
self.jitter_buffer.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn playback_buffer_len(&self) -> usize {
|
||||||
|
self.playback.buffer_len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_jitter_buffer(&mut self) {
|
||||||
|
self.jitter_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_client(&mut self, client_id: u16) {
|
||||||
|
self.client_decoders.remove(&client_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_voice_engine_new() {
|
||||||
|
let config = AudioConfig::default();
|
||||||
|
let engine = VoiceEngine::new(config);
|
||||||
|
assert!(engine.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_voice_engine_list_devices() {
|
||||||
|
let devices = VoiceEngine::list_output_devices();
|
||||||
|
assert!(devices.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@
|
|||||||
pub mod buffer;
|
pub mod buffer;
|
||||||
pub mod capture;
|
pub mod capture;
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
|
pub mod engine;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod vad;
|
pub mod vad;
|
||||||
|
|
||||||
pub use buffer::*;
|
pub use buffer::*;
|
||||||
pub use capture::*;
|
pub use capture::*;
|
||||||
pub use codec::*;
|
pub use codec::*;
|
||||||
|
pub use engine::*;
|
||||||
pub use playback::*;
|
pub use playback::*;
|
||||||
pub use vad::*;
|
pub use vad::*;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user