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: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libdbus-1-dev \
|
||||
pkg-config \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libasound2-dev
|
||||
|
||||
@@ -63,7 +58,7 @@ jobs:
|
||||
|
||||
- name: Check
|
||||
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
|
||||
working-directory: src
|
||||
@@ -71,39 +66,13 @@ jobs:
|
||||
|
||||
- name: Clippy
|
||||
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
|
||||
|
||||
# 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:
|
||||
name: Build Desktop (${{ matrix.platform }})
|
||||
needs: [test, build-frontend]
|
||||
needs: test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -135,21 +104,10 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libdbus-1-dev \
|
||||
pkg-config \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-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
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -163,27 +121,27 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
working-directory: src
|
||||
run: cargo build --release
|
||||
run: cargo build --release -p re-teamspeak
|
||||
|
||||
- name: Package (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
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 .
|
||||
|
||||
- name: Package (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Package (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
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 .
|
||||
|
||||
- name: Upload artifact
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = [
|
||||
"tsaudio",
|
||||
"tsdb",
|
||||
"shared",
|
||||
"tauri-app/src-tauri",
|
||||
"iced-app",
|
||||
]
|
||||
|
||||
[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 capture;
|
||||
pub mod codec;
|
||||
pub mod engine;
|
||||
pub mod playback;
|
||||
pub mod vad;
|
||||
|
||||
pub use buffer::*;
|
||||
pub use capture::*;
|
||||
pub use codec::*;
|
||||
pub use engine::*;
|
||||
pub use playback::*;
|
||||
pub use vad::*;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user