diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 191c304..142f2ce 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,7 +10,6 @@ on:
env:
CARGO_TERM_COLOR: always
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# Test job
@@ -30,7 +29,9 @@ jobs:
target: x86_64-apple-darwin
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+ with:
+ submodules: false
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -42,12 +43,14 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y \
+ cmake \
pkg-config \
+ libopus-dev \
libssl-dev \
libasound2-dev
- name: Cache cargo
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -61,17 +64,13 @@ jobs:
working-directory: src
run: cargo check -p re-teamspeak
- - name: Check (rusqlite)
- working-directory: src
- run: cargo check -p re-teamspeak --features rusqlite
-
- name: Test
working-directory: src
run: cargo test -p re-teamspeak
- name: Clippy
working-directory: src
- run: cargo clippy -p re-teamspeak --features rusqlite -- -D warnings
+ run: cargo clippy -p re-teamspeak -- -D warnings
continue-on-error: true
# Build desktop apps
@@ -97,7 +96,9 @@ jobs:
artifact: macos-amd64
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+ with:
+ submodules: false
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -109,12 +110,14 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y \
+ cmake \
pkg-config \
+ libopus-dev \
libssl-dev \
libasound2-dev
- name: Cache cargo
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
~/.cargo/registry
@@ -150,7 +153,7 @@ jobs:
tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist .
- name: Upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: re-teamspeak-${{ matrix.artifact }}
path: re-teamspeak-${{ matrix.artifact }}.*
@@ -166,12 +169,12 @@ jobs:
steps:
- name: Download artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
path: artifacts/
- name: Create release
- uses: softprops/action-gh-release@v1
+ uses: softprops/action-gh-release@v3
with:
files: artifacts/**/*
draft: true
diff --git a/README.md b/README.md
index ed8780a..357018a 100644
--- a/README.md
+++ b/README.md
@@ -20,17 +20,17 @@ Desktop TeamSpeak 3 client built with `iced` and `tsclientlib` for Windows, Linu
- Mute speaker/output state and sync it to TeamSpeak
- Toggle headset/output hardware state and sync it to TeamSpeak
- Toggle AFK state and sync it to TeamSpeak
-- Import TeamSpeak identity from the local TS3 `settings.db`
+- Import TeamSpeak identity from an exported identity string or file
- Select input and output audio devices
- Push-to-talk and continuous voice modes
- Noise cancellation selection in settings
+- Local per-user mute and volume controls in the channel tree
+- Persistent bookmarks, favorites, recents, and settings
## Current Gaps
-- ServerQuery page is UI-only right now; execution is intentionally stubbed
-- Bookmarks are currently in-memory only
-- Identity import requires the `rusqlite` feature
-- Full audio build requires system audio/build dependencies
+- Remote audio playback still needs runtime validation against a real TeamSpeak server
+- The legacy `src/README.md` and root `Dockerfile` still need to be reconciled with the current app layout
## Build
@@ -43,26 +43,62 @@ cd src
cargo check -p re-teamspeak
```
-### With identity import
+### Audio Build
+
+Host builds need system audio libraries and a Rust toolchain new enough for `sonora`.
+On Linux that generally means `cmake`, `pkg-config`, `libasound2-dev`, and `libopus-dev`.
+
+Direct host build:
```bash
cd src
-cargo check -p re-teamspeak --features rusqlite
+cargo check -p re-teamspeak
```
-### With audio and identity import
-
-On systems missing packages, use Podman:
+Repeatable Podman build:
```bash
-podman run --rm -v $(pwd):/workspace:Z -w /workspace/src \
- docker.io/library/debian:trixie bash -lc '
- apt-get update -qq &&
- apt-get install -y -qq \
- build-essential curl cmake pkg-config libasound2-dev libssl-dev &&
- curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y &&
- . /root/.cargo/env &&
- cargo check -p re-teamspeak --features audio,rusqlite
+./scripts/check-audio-podman.sh
+```
+
+### Run the app in Podman
+
+From a desktop shell with `WAYLAND_DISPLAY` or `DISPLAY` set:
+
+```bash
+./scripts/run-audio-podman.sh
+```
+
+The runtime wrapper will try to pass through:
+
+- `/dev/snd`
+- `/dev/dri`
+- PulseAudio socket
+- PipeWire socket
+- user D-Bus socket
+- Wayland socket or X11 socket
+
+If you want to validate the container wiring without launching the GUI app, pass a custom command:
+
+```bash
+./scripts/run-audio-podman.sh -- /usr/bin/env
+```
+
+Override image or features when needed:
+
+```bash
+RETEAMSPEAK_AUDIO_IMAGE=docker.io/library/rust:1.91-bookworm \
+./scripts/check-audio-podman.sh
+```
+
+Equivalent raw Podman command:
+
+```bash
+podman run --rm -v "$(pwd):/workspace:Z" -w /workspace/src \
+ docker.io/library/rust:1.91-bookworm bash -lc '
+ apt-get update >/dev/null &&
+ apt-get install -y --no-install-recommends pkg-config libasound2-dev libopus-dev >/dev/null &&
+ /usr/local/cargo/bin/cargo check -p re-teamspeak
'
```
@@ -76,9 +112,13 @@ src/
└── src/
├── main.rs
├── audio.rs
- ├── noise_cancel.rs
├── identity.rs
- └── theme.rs
+ ├── icons.rs
+ ├── noise_cancel.rs
+ ├── persistence.rs
+ ├── theme.rs
+ ├── types.rs
+ └── view.rs
```
## Reference Material
diff --git a/docs/design_guidelines.md b/docs/design_guidelines.md
new file mode 100644
index 0000000..7adbb60
--- /dev/null
+++ b/docs/design_guidelines.md
@@ -0,0 +1,550 @@
+# Design Guidelines
+
+## Product Goal
+
+Build a modern TeamSpeak 3 client for everyday users that feels clean, fast, minimal, and voice-first. The app should stay familiar to existing TeamSpeak users while adopting an Apple-style interface inspired by Finder, FaceTime, Apple Music, and System Settings.
+
+The first version is a personal voice client, not a server administration tool.
+
+## MVP Focus
+
+The first version should prioritize:
+
+- connecting to TeamSpeak 3 servers
+- bookmarks and recent servers
+- browsing and joining channels
+- voice chat with push-to-talk and voice activation
+- microphone mute and deafen
+- input/output device selection
+- global and per-user volume controls
+- speaking, muted, deafened, and away indicators
+- basic server, channel, and private chat
+- identity management
+- hotkeys
+- notifications
+- light and dark mode
+
+The first version should explicitly exclude:
+
+- server management
+- permissions and group editors
+- ban tools and logs
+- file transfer
+- plugin support
+- whisper system
+- advanced moderation flows
+- overlays
+
+## Core Design Principles
+
+### Voice First
+
+The UI should make the user's voice state obvious at all times.
+
+Users should always be able to quickly understand:
+
+- whether they are connected
+- which channel they are in
+- whether the microphone is live or muted
+- whether output is deafened
+- whether push-to-talk or voice activation is active
+- whether audio devices are correctly selected
+
+### Simple by Default
+
+Default surfaces should stay compact and calm.
+
+Prefer:
+
+- clean navigation
+- compact controls
+- context menus
+- popovers
+- focused settings categories
+- progressive disclosure for advanced options
+
+Avoid:
+
+- dense admin-style toolbars
+- technical tables in primary flows
+- large clusters of small icons
+- exposing advanced TeamSpeak administration features in the core UI
+
+### Apple-Style UI
+
+The interface should use:
+
+- soft backgrounds
+- rounded cards and buttons
+- subtle shadows
+- thin dividers
+- spacious padding
+- calm accent colors
+- strong typography hierarchy
+- minimal visual noise
+
+The app should feel lightweight and polished rather than industrial or admin-heavy.
+
+## Visual System
+
+### Colors
+
+Light mode:
+
+- Background: `#F5F5F7`
+- Surface: `#FFFFFF`
+- Elevated Surface: `#FBFBFD`
+- Primary Accent: `#007AFF`
+- Text Primary: `#1D1D1F`
+- Text Secondary: `#6E6E73`
+- Border: `#D2D2D7`
+- Success: `#34C759`
+- Warning: `#FF9500`
+- Danger: `#FF3B30`
+- Muted: `#8E8E93`
+
+Dark mode:
+
+- Background: `#000000`
+- Surface: `#1C1C1E`
+- Elevated Surface: `#2C2C2E`
+- Primary Accent: `#0A84FF`
+- Text Primary: `#F5F5F7`
+- Text Secondary: `#AEAEB2`
+- Border: `#38383A`
+- Success: `#30D158`
+- Warning: `#FF9F0A`
+- Danger: `#FF453A`
+- Muted: `#8E8E93`
+
+### Typography
+
+- Large Title: 32px, weight 700
+- Title: 24px, weight 600
+- Section Title: 17px, weight 600
+- Body: 15px, weight 400
+- Small: 13px, weight 400
+- Caption: 11px, weight 500
+
+Preferred family order:
+
+- SF Pro Display
+- SF Pro Text
+- Inter
+- system UI fallback
+
+### Spacing And Sizing
+
+- Tiny: 4px
+- Small: 8px
+- Medium: 12px
+- Large: 16px
+- XL: 24px
+- XXL: 32px
+
+Recommended component sizes:
+
+- Sidebar width: 220px
+- Channel row height: 36px
+- User row height: 34px
+- Card radius: 16px
+- Button radius: 12px
+- Popover radius: 18px
+- Main content padding: 20px
+
+## App Structure
+
+The main information architecture should be:
+
+- Home
+- Servers
+- Bookmarks
+- Recent Servers
+- Active Server
+- Identities
+- Settings
+
+Settings should be organized into:
+
+- General
+- Audio
+- Capture
+- Playback
+- Hotkeys
+- Notifications
+- Appearance
+- Chat
+- Identities
+- Advanced
+
+## Main Layout
+
+Use a two-column structure:
+
+- left sidebar for navigation, bookmarks, and recent servers
+- main content area for home, active server, identities, and settings
+
+### Sidebar
+
+The sidebar should expose:
+
+- Home
+- Bookmarks
+- Recent Servers
+- Identities
+- Settings
+
+Server state indicators should be simple and readable:
+
+- connected
+- disconnected
+- connecting
+- error
+
+### Active Server View
+
+The active server view should emphasize:
+
+- server name and connection state
+- channel tree
+- users inside channels
+- current channel
+- chat access
+- compact persistent voice status
+
+The layout should avoid looking like a legacy server management client.
+
+## Persistent Voice Status
+
+Voice controls must always be accessible, but they do not need to live in a large bottom bar.
+
+Use a compact voice capsule near the bottom of the active server view.
+
+The capsule should show:
+
+- connection state
+- current channel
+- microphone state
+- talk mode
+- optional latency or quick device status
+
+Selecting the capsule can open a lightweight popover with:
+
+- microphone device
+- output device
+- talk mode
+- volume
+- mute mic
+- deafen
+- disconnect
+
+## Home Screen
+
+The home screen should help the user return to voice quickly.
+
+Primary content:
+
+- continue last server
+- favorite servers
+- recent servers
+- current audio device
+- current voice mode
+
+## Connection Flow
+
+The connect flow should support:
+
+- server address
+- nickname
+- password
+- default identity
+- reconnect
+- disconnect
+- connection status
+- ping and packet loss
+- clear error states
+
+Advanced connection options should stay behind a collapsed or secondary section.
+
+Errors should use human-readable copy such as:
+
+- unable to connect to server
+- wrong server password
+- server is full
+- connection lost
+
+## Bookmarks
+
+Bookmarks should support:
+
+- add
+- edit
+- delete
+- reorder
+- favorite servers
+- recent servers
+- default nickname per bookmark
+- default identity per bookmark
+- optional stored password
+- optional default channel
+
+The bookmark UI should be lightweight and optimized for quick connect.
+
+## Channel Browser
+
+The channel browser should support:
+
+- nested channel tree
+- expand and collapse
+- current channel highlighting
+- join on selection
+- user counts
+- locked, passworded, and full states
+- channel and user search
+
+The first version should not expose channel creation or editing flows.
+
+## User Presence
+
+Each user row should show, when available:
+
+- nickname
+- speaking state
+- muted state
+- deafened state
+- away state
+- idle state
+- local mute state
+- server group badge
+
+User actions for the first version:
+
+- view basic info
+- adjust per-user volume
+- mute locally
+- send private message
+- copy unique ID
+
+Do not expose moderation actions in the primary first-version UI.
+
+## Voice Features
+
+Required voice modes:
+
+- push-to-talk
+- voice activation
+- optional continuous transmission
+
+Required controls:
+
+- mute microphone
+- deafen
+- select input device
+- select output device
+- adjust output volume
+- adjust microphone gain
+- test microphone
+- test speakers
+- show microphone level
+- show activation threshold
+- adjust per-user volume
+
+Default mode should be push-to-talk.
+
+## Chat
+
+The first version should support:
+
+- server chat
+- channel chat
+- private messages
+
+Chat should include:
+
+- timestamps
+- sender nickname
+- basic formatting
+- copy message
+- clear local conversation
+- unread indicators
+- open links
+
+Do not include threads, reactions, attachments, or rich embeds.
+
+## Identities
+
+Identity handling is part of the first version.
+
+Support:
+
+- create
+- rename
+- delete
+- import
+- export
+- choose default identity
+- assign identity per bookmark
+- show unique ID
+- show security level when available
+
+## Hotkeys And Notifications
+
+Hotkeys should cover:
+
+- push-to-talk
+- mute microphone
+- deafen
+- toggle away
+- volume up and down
+- open settings
+- open search or command palette
+- disconnect
+
+The client should detect conflicts and support reset.
+
+Notifications should cover:
+
+- connected and disconnected
+- connection lost
+- current-channel join and leave events
+- private messages
+- mute and unmute state changes
+- errors
+
+## Appearance
+
+Appearance options should include:
+
+- light mode
+- dark mode
+- follow system
+- compact mode
+- comfortable mode
+- avatar visibility
+- group badge visibility
+- font size adjustments
+
+## Search And Command Palette
+
+The app should include a simple command/search interface, opened by `Cmd+K` on macOS and `Ctrl+K` elsewhere.
+
+Search targets:
+
+- bookmarks
+- recent servers
+- channels
+- users
+- settings
+
+Common actions:
+
+- join channel
+- search user
+- mute microphone
+- deafen
+- open audio settings
+- connect to server
+
+## Accessibility
+
+The client should support:
+
+- full keyboard navigation
+- screen reader labels
+- high contrast mode
+- reduce motion mode
+- large text mode
+- color-blind-safe indicators
+- visible focus states
+- text labels for important icons
+
+Voice status must never rely on color alone.
+
+## Performance
+
+The app should remain responsive on large servers.
+
+Design and implementation should aim for:
+
+- 500+ visible users
+- deep channel trees
+- smooth scrolling
+- lazy rendering for large trees
+- fast search
+- low idle CPU and memory use
+- UI and audio work staying decoupled
+- reconnect without freezing the UI
+
+## Platform Notes
+
+### macOS
+
+Prefer macOS-style patterns such as:
+
+- sidebar navigation
+- system light and dark mode
+- native-feeling shortcuts
+- menu bar integration where useful
+
+### Windows
+
+Keep the visual style Apple-inspired, but respect Windows expectations such as:
+
+- notifications
+- tray support
+- device switching
+- game-friendly push-to-talk behavior
+
+### Linux
+
+Support:
+
+- PipeWire
+- PulseAudio
+- desktop theme integration where practical
+- global hotkeys where possible
+
+## Implementation Priorities
+
+### Must Have
+
+- server connection
+- bookmarks and recents
+- channel tree
+- voice chat
+- push-to-talk and voice activation
+- mute and deafen
+- device selection
+- speaking indicators
+- chat and private messages
+- identity import and export
+- light and dark mode
+
+### Should Have
+
+- auto reconnect
+- notification profiles
+- hotkey editor
+- microphone test
+- speaker test
+- level meter
+- search
+- compact mode
+- command palette
+
+### Later
+
+- temporary channel creation
+- basic moderation actions
+- overlay
+- server management
+- permissions
+- whisper
+- file transfer
+- plugin or theme extensions
+
+## Final Standard
+
+Every UI decision for the first version should reinforce one promise:
+
+> A clean, modern, Apple-style TeamSpeak 3 client for joining servers, talking with people, and managing your own voice experience.
diff --git a/scripts/check-audio-podman.sh b/scripts/check-audio-podman.sh
new file mode 100755
index 0000000..0deab6f
--- /dev/null
+++ b/scripts/check-audio-podman.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
+REPO_ROOT="$(readlink -f "$SCRIPT_DIR/..")"
+
+IMAGE="${RETEAMSPEAK_AUDIO_IMAGE:-docker.io/library/rust:1.91-bookworm}"
+
+podman run --rm \
+ -v "$REPO_ROOT:/workspace:Z" \
+ -w /workspace/src \
+ "$IMAGE" \
+ bash -lc "apt-get update >/dev/null && \
+ apt-get install -y --no-install-recommends cmake pkg-config libasound2-dev libopus-dev >/dev/null && \
+ /usr/local/cargo/bin/cargo check -p re-teamspeak"
diff --git a/scripts/run-audio-podman.sh b/scripts/run-audio-podman.sh
new file mode 100755
index 0000000..c20d8f4
--- /dev/null
+++ b/scripts/run-audio-podman.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
+REPO_ROOT="$(readlink -f "$SCRIPT_DIR/..")"
+UID_VALUE="$(id -u)"
+GID_VALUE="$(id -g)"
+RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$UID_VALUE}"
+IMAGE="${RETEAMSPEAK_AUDIO_IMAGE:-docker.io/library/rust:1.91-bookworm}"
+
+if [[ $# -gt 0 && "$1" == "--" ]]; then
+ shift
+ if [[ $# -eq 0 ]]; then
+ printf 'Expected a container command after --\n' >&2
+ exit 1
+ fi
+ CONTAINER_CMD=("$@")
+else
+ CONTAINER_CMD=(/usr/local/cargo/bin/cargo run -p re-teamspeak)
+fi
+
+quoted_cmd() {
+ local quoted=()
+ local part
+ for part in "$@"; do
+ quoted+=("$(printf '%q' "$part")")
+ done
+ printf '%s ' "${quoted[@]}"
+}
+
+PODMAN_ARGS=(
+ run
+ --rm
+ --network host
+ -v "$REPO_ROOT:/workspace:Z"
+ -w /workspace/src
+ -e CARGO_TARGET_DIR=/tmp/re-teamspeak-target
+)
+
+if [[ -t 0 && -t 1 ]]; then
+ PODMAN_ARGS+=( -it )
+fi
+
+if [[ -e /dev/snd ]]; then
+ PODMAN_ARGS+=( --device /dev/snd )
+fi
+
+if [[ -e /dev/dri ]]; then
+ PODMAN_ARGS+=( --device /dev/dri )
+fi
+
+if [[ -S "$RUNTIME_DIR/pulse/native" ]]; then
+ PODMAN_ARGS+=(
+ -v "$RUNTIME_DIR/pulse:$RUNTIME_DIR/pulse:Z"
+ -e "PULSE_SERVER=unix:$RUNTIME_DIR/pulse/native"
+ )
+fi
+
+if [[ -S "$RUNTIME_DIR/pipewire-0" ]]; then
+ PODMAN_ARGS+=(
+ -v "$RUNTIME_DIR/pipewire-0:$RUNTIME_DIR/pipewire-0:Z"
+ -v "$RUNTIME_DIR/pipewire-0-manager:$RUNTIME_DIR/pipewire-0-manager:Z"
+ -e "PIPEWIRE_REMOTE=$RUNTIME_DIR/pipewire-0"
+ -e "XDG_RUNTIME_DIR=$RUNTIME_DIR"
+ )
+fi
+
+if [[ -S "$RUNTIME_DIR/bus" ]]; then
+ PODMAN_ARGS+=(
+ -v "$RUNTIME_DIR/bus:$RUNTIME_DIR/bus:Z"
+ -e "DBUS_SESSION_BUS_ADDRESS=unix:path=$RUNTIME_DIR/bus"
+ )
+fi
+
+if [[ -n "${WAYLAND_DISPLAY:-}" && -S "$RUNTIME_DIR/$WAYLAND_DISPLAY" ]]; then
+ PODMAN_ARGS+=(
+ -v "$RUNTIME_DIR/$WAYLAND_DISPLAY:$RUNTIME_DIR/$WAYLAND_DISPLAY:Z"
+ -e "WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
+ -e "XDG_RUNTIME_DIR=$RUNTIME_DIR"
+ )
+elif [[ -n "${DISPLAY:-}" && -d /tmp/.X11-unix ]]; then
+ PODMAN_ARGS+=(
+ -v /tmp/.X11-unix:/tmp/.X11-unix:ro
+ -e "DISPLAY=$DISPLAY"
+ )
+ if [[ -n "${XAUTHORITY:-}" && -f "$XAUTHORITY" ]]; then
+ PODMAN_ARGS+=(
+ -v "$XAUTHORITY:$XAUTHORITY:ro,Z"
+ -e "XAUTHORITY=$XAUTHORITY"
+ )
+ fi
+elif [[ ${CONTAINER_CMD[0]} == /usr/local/cargo/bin/cargo && ${CONTAINER_CMD[1]} == run ]]; then
+ printf 'No Wayland or X11 session detected in this shell.\n' >&2
+ printf 'Run this from a desktop session, or pass a custom container command with --.\n' >&2
+ exit 1
+fi
+
+APT_PACKAGES=(
+ cmake
+ pkg-config
+ libasound2-dev
+ libopus-dev
+ libxkbcommon0
+ libwayland-client0
+ libwayland-cursor0
+ libwayland-egl1
+ libx11-6
+ libxrandr2
+ libxi6
+ libxcursor1
+ libxinerama1
+ libgl1
+ libegl1
+ libdbus-1-3
+)
+
+APT_CMD="apt-get update >/dev/null && apt-get install -y --no-install-recommends $(quoted_cmd "${APT_PACKAGES[@]}") >/dev/null"
+RUN_CMD="$(quoted_cmd "${CONTAINER_CMD[@]}")"
+
+exec podman "${PODMAN_ARGS[@]}" "$IMAGE" \
+ bash -lc "$APT_CMD && $RUN_CMD"
diff --git a/src/Cargo.toml b/src/Cargo.toml
index b977e37..826007e 100644
--- a/src/Cargo.toml
+++ b/src/Cargo.toml
@@ -19,5 +19,5 @@ serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = { version = "0.4", features = ["serde"] }
-tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", branch = "master", default-features = false, features = ["default-tls"] }
+tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", branch = "master", default-features = false, features = ["default-tls", "audio"] }
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", branch = "master" }
diff --git a/src/iced-app/Cargo.toml b/src/iced-app/Cargo.toml
index 61df0f0..811b493 100644
--- a/src/iced-app/Cargo.toml
+++ b/src/iced-app/Cargo.toml
@@ -10,7 +10,7 @@ name = "re-teamspeak"
path = "src/main.rs"
[dependencies]
-iced = { version = "0.13", features = ["tokio", "debug"] }
+iced = { version = "0.13", features = ["tokio", "debug", "svg"] }
tokio = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
@@ -18,16 +18,9 @@ serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
chrono = { workspace = true }
-cpal = { version = "0.15", optional = true }
-audiopus = { version = "0.3.0-rc.0", optional = true }
-nnnoiseless = { version = "0.5", optional = true }
-sonora = { version = "0.1", optional = true }
-rusqlite = { version = "0.31", optional = true }
-
+cpal = { version = "0.15" }
+audiopus = { version = "0.3.0-rc.0" }
+nnnoiseless = { version = "0.5" }
+sonora = { version = "0.1" }
tsclientlib = { workspace = true }
tsproto-packets = { workspace = true }
-
-[features]
-default = []
-audio = ["dep:cpal", "dep:audiopus", "dep:nnnoiseless", "dep:sonora"]
-rusqlite = ["dep:rusqlite"]
diff --git a/src/iced-app/assets/icons/bookmark.svg b/src/iced-app/assets/icons/bookmark.svg
new file mode 100644
index 0000000..50dd3f9
--- /dev/null
+++ b/src/iced-app/assets/icons/bookmark.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/iced-app/assets/icons/chevron-down.svg b/src/iced-app/assets/icons/chevron-down.svg
new file mode 100644
index 0000000..d89ab44
--- /dev/null
+++ b/src/iced-app/assets/icons/chevron-down.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/iced-app/assets/icons/chevron-right.svg b/src/iced-app/assets/icons/chevron-right.svg
new file mode 100644
index 0000000..27689c4
--- /dev/null
+++ b/src/iced-app/assets/icons/chevron-right.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/iced-app/assets/icons/cog-6-tooth.svg b/src/iced-app/assets/icons/cog-6-tooth.svg
new file mode 100644
index 0000000..9efeb63
--- /dev/null
+++ b/src/iced-app/assets/icons/cog-6-tooth.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/iced-app/assets/icons/home.svg b/src/iced-app/assets/icons/home.svg
new file mode 100644
index 0000000..b338b19
--- /dev/null
+++ b/src/iced-app/assets/icons/home.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/iced-app/assets/icons/microphone.svg b/src/iced-app/assets/icons/microphone.svg
new file mode 100644
index 0000000..669dbbb
--- /dev/null
+++ b/src/iced-app/assets/icons/microphone.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/iced-app/assets/icons/speaker-wave.svg b/src/iced-app/assets/icons/speaker-wave.svg
new file mode 100644
index 0000000..97d1de7
--- /dev/null
+++ b/src/iced-app/assets/icons/speaker-wave.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/iced-app/assets/icons/speaker-x-mark.svg b/src/iced-app/assets/icons/speaker-x-mark.svg
new file mode 100644
index 0000000..8fc3796
--- /dev/null
+++ b/src/iced-app/assets/icons/speaker-x-mark.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/iced-app/assets/icons/user-circle.svg b/src/iced-app/assets/icons/user-circle.svg
new file mode 100644
index 0000000..755659b
--- /dev/null
+++ b/src/iced-app/assets/icons/user-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/iced-app/assets/icons/user-minus.svg b/src/iced-app/assets/icons/user-minus.svg
new file mode 100644
index 0000000..7ead795
--- /dev/null
+++ b/src/iced-app/assets/icons/user-minus.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/iced-app/assets/icons/user.svg b/src/iced-app/assets/icons/user.svg
new file mode 100644
index 0000000..e0dd926
--- /dev/null
+++ b/src/iced-app/assets/icons/user.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/iced-app/src/audio.rs b/src/iced-app/src/audio.rs
index f548501..72c1d7c 100644
--- a/src/iced-app/src/audio.rs
+++ b/src/iced-app/src/audio.rs
@@ -1,7 +1,10 @@
+use serde::{Deserialize, Serialize};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex};
use tsclientlib::audio::AudioHandler;
use tsclientlib::ClientId;
-use tsproto_packets::packets::{AudioData, CodecType, InAudioBuf, OutAudio};
+use tsproto_packets::packets::{AudioData, InAudioBuf};
const SAMPLE_RATE: u32 = 48000;
const CHANNELS: u16 = 2;
@@ -12,7 +15,8 @@ type PacketSender = std::sync::mpsc::Sender;
struct PlaybackState {
sender: PacketSender,
- output_device: String,
+ volumes: Arc>>,
+ muted_clients: Arc>>,
_stream: cpal::Stream,
}
@@ -65,6 +69,10 @@ impl AudioPlayback {
};
let (tx, rx) = std::sync::mpsc::channel::();
+ let volumes = Arc::new(Mutex::new(HashMap::::new()));
+ let muted_clients = Arc::new(Mutex::new(HashMap::::new()));
+ let callback_volumes = Arc::clone(&volumes);
+ let callback_muted = Arc::clone(&muted_clients);
let mut handler = AudioHandler::::new();
let stream = device
@@ -82,6 +90,19 @@ impl AudioPlayback {
for sample in data.iter_mut() {
*sample = 0.0;
}
+ let volumes = callback_volumes.lock().ok().map(|volumes| volumes.clone());
+ let muted_clients = callback_muted.lock().ok().map(|muted| muted.clone());
+ for (id, queue) in handler.get_mut_queues().iter_mut() {
+ let volume = volumes
+ .as_ref()
+ .and_then(|volumes| volumes.get(id).copied())
+ .unwrap_or(1.0);
+ let muted = muted_clients
+ .as_ref()
+ .and_then(|muted| muted.get(id).copied())
+ .unwrap_or(false);
+ queue.volume = if muted { 0.0 } else { volume };
+ }
handler.fill_buffer(data);
},
|err| tracing::error!("Audio output error: {err}"),
@@ -95,7 +116,8 @@ impl AudioPlayback {
self.state = Some(PlaybackState {
sender: tx,
- output_device: device_name_str,
+ volumes,
+ muted_clients,
_stream: stream,
});
@@ -114,21 +136,25 @@ impl AudioPlayback {
}
}
- pub fn output_device(&self) -> &str {
- self.state
- .as_ref()
- .map(|s| s.output_device.as_str())
- .unwrap_or("")
+ pub fn set_client_volume(&self, client: ClientId, volume: f32) {
+ if let Some(ref state) = self.state {
+ if let Ok(mut volumes) = state.volumes.lock() {
+ volumes.insert(client, volume.clamp(0.0, 2.0));
+ }
+ }
}
- pub fn is_active(&self) -> bool {
- self.state.is_some()
+ pub fn set_client_muted(&self, client: ClientId, muted: bool) {
+ if let Some(ref state) = self.state {
+ if let Ok(mut muted_clients) = state.muted_clients.lock() {
+ muted_clients.insert(client, muted);
+ }
+ }
}
}
struct CaptureState {
_stream: cpal::Stream,
- input_device: String,
}
unsafe impl Send for CaptureState {}
@@ -186,7 +212,6 @@ impl Microphone {
self.state = Some(CaptureState {
_stream: stream,
- input_device: device_name_str,
});
tracing::info!("Microphone started");
@@ -198,16 +223,6 @@ impl Microphone {
tracing::info!("Microphone stopped");
}
- pub fn input_device(&self) -> &str {
- self.state
- .as_ref()
- .map(|s| s.input_device.as_str())
- .unwrap_or("")
- }
-
- pub fn is_active(&self) -> bool {
- self.state.is_some()
- }
}
pub struct OpusEncoderState {
@@ -256,17 +271,9 @@ impl OpusEncoderState {
}
}
- pub fn packet_id(&self) -> u16 {
- self.packet_id
- }
-
- pub fn reset(&mut self) {
- self.sample_buf.clear();
- self.packet_id = 0;
- }
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TalkMode {
PushToTalk,
Continuous,
@@ -339,21 +346,7 @@ impl VoiceActivation {
self.state
}
- pub fn state(&self) -> VadState {
- self.state
- }
-
pub fn is_speaking(&self) -> bool {
matches!(self.state, VadState::Speaking | VadState::Hangover)
}
-
- pub fn set_threshold(&mut self, threshold: f32) {
- self.threshold = threshold;
- }
-
- pub fn reset(&mut self) {
- self.state = VadState::Silent;
- self.hangover_counter = 0;
- self.energy_history.clear();
- }
}
diff --git a/src/iced-app/src/icons.rs b/src/iced-app/src/icons.rs
new file mode 100644
index 0000000..cb395a1
--- /dev/null
+++ b/src/iced-app/src/icons.rs
@@ -0,0 +1,54 @@
+use iced::widget::svg::{self, Handle};
+use iced::widget::{svg as svg_widget, Svg};
+use iced::{Color, Length, Theme};
+use std::sync::LazyLock;
+
+#[derive(Debug, Clone, Copy)]
+pub enum Icon {
+ Home,
+ Bookmark,
+ UserCircle,
+ User,
+ UserMinus,
+ Cog,
+ ChevronRight,
+ ChevronDown,
+ Microphone,
+ SpeakerWave,
+ SpeakerXMark,
+}
+
+static HOME: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/home.svg")));
+static BOOKMARK: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/bookmark.svg")));
+static USER_CIRCLE: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/user-circle.svg")));
+static USER: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/user.svg")));
+static USER_MINUS: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/user-minus.svg")));
+static COG: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/cog-6-tooth.svg")));
+static CHEVRON_RIGHT: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/chevron-right.svg")));
+static CHEVRON_DOWN: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/chevron-down.svg")));
+static MICROPHONE: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/microphone.svg")));
+static SPEAKER_WAVE: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/speaker-wave.svg")));
+static SPEAKER_X_MARK: LazyLock = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/speaker-x-mark.svg")));
+
+fn handle(icon: Icon) -> Handle {
+ match icon {
+ Icon::Home => HOME.clone(),
+ Icon::Bookmark => BOOKMARK.clone(),
+ Icon::UserCircle => USER_CIRCLE.clone(),
+ Icon::User => USER.clone(),
+ Icon::UserMinus => USER_MINUS.clone(),
+ Icon::Cog => COG.clone(),
+ Icon::ChevronRight => CHEVRON_RIGHT.clone(),
+ Icon::ChevronDown => CHEVRON_DOWN.clone(),
+ Icon::Microphone => MICROPHONE.clone(),
+ Icon::SpeakerWave => SPEAKER_WAVE.clone(),
+ Icon::SpeakerXMark => SPEAKER_X_MARK.clone(),
+ }
+}
+
+pub fn view<'a>(icon: Icon, size: f32, color: Color) -> Svg<'a, Theme> {
+ svg_widget(handle(icon))
+ .width(Length::Fixed(size))
+ .height(Length::Fixed(size))
+ .style(move |_theme: &Theme, _status| svg::Style { color: Some(color) })
+}
diff --git a/src/iced-app/src/identity.rs b/src/iced-app/src/identity.rs
index 31da42f..7637ffc 100644
--- a/src/iced-app/src/identity.rs
+++ b/src/iced-app/src/identity.rs
@@ -1,45 +1,23 @@
+use std::fs;
+use std::path::Path;
+
use tsclientlib::Identity;
-#[cfg(feature = "rusqlite")]
-use rusqlite::Connection;
+pub fn import_identity_from_string(raw: &str) -> Result {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ return Err("Identity string is empty".to_string());
+ }
-#[cfg(feature = "rusqlite")]
-pub fn import_identity_from_ts3(path: &str) -> Result {
- let conn = Connection::open(path)
- .map_err(|e| format!("Failed to open TS3 settings database: {e}"))?;
-
- let identity_str: String = conn
- .query_row(
- "SELECT value FROM properties WHERE key = 'identity_secret_key'",
- [],
- |row| row.get(0),
- )
- .map_err(|e| format!("Failed to query identity from database: {e}"))?;
-
- tracing::info!("Found identity string: {}", &identity_str[..identity_str.len().min(50)]);
-
- Identity::new_from_ts_str(&identity_str)
+ Identity::new_from_ts_str(trimmed)
+ .or_else(|_| Identity::new_from_str(trimmed))
.map_err(|e| format!("Failed to parse identity: {e}"))
}
-#[cfg(not(feature = "rusqlite"))]
-pub fn import_identity_from_ts3(_path: &str) -> Result {
- Err("rusqlite feature not enabled".to_string())
-}
+pub fn import_identity_from_file(path: &str) -> Result {
+ let path_ref = Path::new(path);
+ let raw = fs::read_to_string(path_ref)
+ .map_err(|e| format!("Failed to read identity file '{}': {e}", path_ref.display()))?;
-pub fn find_ts3_config_dir() -> Option {
- let home = std::env::var("HOME").ok()?;
-
- let paths = [
- format!("{}/.ts3client/settings.db", home),
- format!("{}/.config/teamspeak3/settings.db", home),
- ];
-
- for path in &paths {
- if std::path::Path::new(path).exists() {
- return Some(path.clone());
- }
- }
-
- None
+ import_identity_from_string(&raw)
}
diff --git a/src/iced-app/src/main.rs b/src/iced-app/src/main.rs
index a5a0e72..95358c4 100644
--- a/src/iced-app/src/main.rs
+++ b/src/iced-app/src/main.rs
@@ -1,28 +1,34 @@
+#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
+
use iced::futures::sink::SinkExt;
-use iced::futures::StreamExt;
-use iced::widget::{button, column, container, horizontal_space, row, scrollable, text, text_input, vertical_space};
+use iced::widget::{container, row};
use iced::{Element, Length, Subscription, Task, Theme};
+use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
-use tsclientlib::sync::{SyncConnection, SyncConnectionHandle, SyncStreamItem};
-use tsclientlib::{ChannelId, ClientId, Connection, DisconnectOptions, Identity, MessageTarget};
+use tsclientlib::sync::{SyncConnection, SyncConnectionHandle};
+use tsclientlib::{ClientId, Connection, DisconnectOptions, Identity, MessageTarget};
use tsclientlib::events::{Event, PropertyId};
use tsclientlib::prelude::*;
-#[cfg(feature = "audio")]
-use tsproto_packets::packets::{AudioData, CodecType, OutAudio};
mod theme;
-
-#[cfg(feature = "audio")]
+mod icons;
+mod identity;
+mod persistence;
+mod runtime;
+mod types;
+mod view;
mod audio;
-
-#[cfg(feature = "audio")]
mod noise_cancel;
-#[cfg(feature = "rusqlite")]
-mod identity;
+use crate::runtime::{run_connection, start_transmission, stop_transmission};
+use crate::types::{
+ AppSettings, BookmarkInfo, ChannelEntry, ChatMessage, ClientEntry, LocalClientAudio, Message,
+ Page, TsEvent,
+};
+use crate::persistence::{load_bookmarks, load_settings, persist_bookmarks, persist_settings};
fn main() -> iced::Result {
iced::application("ReTeamSpeak", App::update, App::view)
@@ -31,114 +37,9 @@ fn main() -> iced::Result {
.run_with(App::new)
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Page {
- ServerList,
- ServerQuery,
- Settings,
-}
-
-#[derive(Debug, Clone)]
-#[allow(clippy::enum_variant_names)]
-enum Message {
- PageChanged(Page),
- SelectBookmark(Option),
- AddBookmark,
- DeleteBookmark(usize),
- BookmarkNameChanged(String),
- BookmarkAddressChanged(String),
- BookmarkPortChanged(String),
- NicknameChanged(String),
- PasswordChanged(String),
- Connect,
- JoinChannel(ChannelId),
- Disconnect,
- TsEvent(TsEvent),
- MessageInputChanged(String),
- SendChannelMessage,
- QueryAddressChanged(String),
- QueryPortChanged(String),
- QueryCommandChanged(String),
- RunServerQuery,
- QueryResponse(String),
- QueryError(String),
- #[cfg(feature = "audio")]
- PttPressed,
- #[cfg(feature = "audio")]
- PttReleased,
- #[cfg(feature = "audio")]
- ToggleTalkMode,
- #[cfg(feature = "audio")]
- ToggleNoiseCancel,
- #[cfg(feature = "audio")]
- SetNoiseCancelMethod(noise_cancel::NoiseCancelMethod),
- #[cfg(feature = "audio")]
- StartContinuous,
- #[cfg(feature = "audio")]
- StopContinuous,
- #[cfg(feature = "audio")]
- MicSamples(Vec),
- ToggleMicMute,
- ToggleSpeakerMute,
- ToggleHeadsetMute,
- ToggleAfk,
- ImportIdentity,
- IdentityImported(Result),
- #[cfg(feature = "audio")]
- SetOutputDevice(String),
- #[cfg(feature = "audio")]
- SetInputDevice(String),
- Noop,
-}
-
-#[derive(Debug, Clone)]
-enum TsEvent {
- Connected,
- BookEvents(Vec),
- MessageEvent(tsclientlib::InMessage),
- AudioChange(bool, bool),
- IdentityLevelIncreasing(u8),
- IdentityLevelIncreased,
- DisconnectedTemporarily,
- Disconnected,
- Error(String),
-}
-
-#[derive(Debug, Clone)]
-struct BookmarkInfo {
- name: String,
- address: String,
- port: u16,
- nickname: Option,
-}
-
-#[derive(Debug, Clone)]
-struct ChannelEntry {
- id: ChannelId,
- name: String,
- #[allow(dead_code)]
- parent: ChannelId,
- order: ChannelId,
-}
-
-#[derive(Debug, Clone)]
-struct ClientEntry {
- id: ClientId,
- name: String,
- channel: ChannelId,
- input_muted: bool,
- #[allow(dead_code)]
- output_muted: bool,
-}
-
-#[derive(Debug, Clone)]
-struct ChatMessage {
- invoker_name: String,
- message: String,
-}
-
struct App {
page: Page,
+ appearance_mode: theme::AppearanceMode,
bookmarks: Vec,
selected_bookmark: Option,
editing_bookmark: bool,
@@ -157,38 +58,25 @@ struct App {
own_client_id: Option,
channels: Vec,
clients: Vec,
+ collapsed_channels: HashSet,
+ local_client_audio: std::collections::HashMap,
+ channel_search: String,
messages: Vec,
message_input: String,
- query_address: String,
- query_port: u16,
- query_command: String,
- query_loading: bool,
- query_response: Option,
- query_error: Option,
error: Option,
identity_level: u8,
session_id: u64,
- #[cfg(feature = "audio")]
audio: Arc>,
- #[cfg(feature = "audio")]
mic: Arc>,
- #[cfg(feature = "audio")]
- encoder: Arc>>,
- #[cfg(feature = "audio")]
ptt_active: bool,
- #[cfg(feature = "audio")]
continuous_active: bool,
- #[cfg(feature = "audio")]
talk_mode: audio::TalkMode,
- #[cfg(feature = "audio")]
vad: Arc>,
- #[cfg(feature = "audio")]
noise_reducer: Arc>,
- #[cfg(feature = "audio")]
nc_method: noise_cancel::NoiseCancelMethod,
imported_identity: Option,
- #[cfg(feature = "audio")]
- audio_send_tx: Option>>,
+ identity_string_input: String,
+ identity_file_input: String,
mic_muted: bool,
speaker_muted: bool,
headset_muted: bool,
@@ -199,22 +87,18 @@ struct App {
impl App {
fn new() -> (Self, Task) {
+ let bookmarks = load_bookmarks();
+ let settings = load_settings();
+ let appearance_mode = settings.appearance_mode;
+ let talk_mode = settings.talk_mode;
+ let nc_method = settings.noise_cancel_method;
+ let selected_output_device = settings.selected_output_device;
+ let selected_input_device = settings.selected_input_device;
+
let app = Self {
- page: Page::ServerList,
- bookmarks: vec![
- BookmarkInfo {
- name: "Local Server".to_string(),
- address: "127.0.0.1".to_string(),
- port: 9987,
- nickname: Some("User".to_string()),
- },
- BookmarkInfo {
- name: "KR TeamSpeak".to_string(),
- address: "kr.teamspeak.app".to_string(),
- port: 9987,
- nickname: None,
- },
- ],
+ page: Page::Home,
+ appearance_mode,
+ bookmarks,
selected_bookmark: None,
editing_bookmark: false,
bm_name_input: String::new(),
@@ -232,51 +116,51 @@ impl App {
own_client_id: None,
channels: Vec::new(),
clients: Vec::new(),
+ collapsed_channels: HashSet::new(),
+ local_client_audio: std::collections::HashMap::new(),
+ channel_search: String::new(),
messages: Vec::new(),
message_input: String::new(),
- query_address: String::new(),
- query_port: 10011,
- query_command: "help".to_string(),
- query_loading: false,
- query_response: None,
- query_error: None,
error: None,
identity_level: 0,
session_id: 0,
- #[cfg(feature = "audio")]
audio: Arc::new(Mutex::new(audio::AudioPlayback::new())),
- #[cfg(feature = "audio")]
mic: Arc::new(Mutex::new(audio::Microphone::new())),
- #[cfg(feature = "audio")]
- encoder: Arc::new(Mutex::new(None)),
- #[cfg(feature = "audio")]
ptt_active: false,
- #[cfg(feature = "audio")]
continuous_active: false,
- #[cfg(feature = "audio")]
- talk_mode: audio::TalkMode::PushToTalk,
- #[cfg(feature = "audio")]
+ talk_mode,
vad: Arc::new(Mutex::new(audio::VoiceActivation::new(0.005))),
- #[cfg(feature = "audio")]
- noise_reducer: Arc::new(Mutex::new(noise_cancel::NoiseReducer::new(noise_cancel::NoiseCancelMethod::None))),
- #[cfg(feature = "audio")]
- nc_method: noise_cancel::NoiseCancelMethod::None,
+ noise_reducer: Arc::new(Mutex::new(noise_cancel::NoiseReducer::new(nc_method))),
+ nc_method,
imported_identity: None,
- #[cfg(feature = "audio")]
- audio_send_tx: None,
+ identity_string_input: String::new(),
+ identity_file_input: String::new(),
mic_muted: false,
speaker_muted: false,
headset_muted: false,
afk: false,
- selected_output_device: String::new(),
- selected_input_device: String::new(),
+ selected_output_device,
+ selected_input_device,
};
(app, Task::none())
}
fn theme(&self) -> Theme {
- theme::dark_theme()
+ match self.appearance_mode {
+ theme::AppearanceMode::Light => theme::light_theme(),
+ theme::AppearanceMode::Dark => theme::dark_theme(),
+ }
+ }
+
+ fn save_settings(&self) {
+ persist_settings(&AppSettings {
+ appearance_mode: self.appearance_mode,
+ selected_output_device: self.selected_output_device.clone(),
+ selected_input_device: self.selected_input_device.clone(),
+ talk_mode: self.talk_mode,
+ noise_cancel_method: self.nc_method,
+ });
}
fn update(&mut self, message: Message) -> Task {
@@ -302,9 +186,17 @@ impl App {
self.bm_port_input = "9987".to_string();
Task::none()
}
+ Message::CancelAddBookmark => {
+ self.editing_bookmark = false;
+ self.bm_name_input.clear();
+ self.bm_address_input.clear();
+ self.bm_port_input = "9987".to_string();
+ Task::none()
+ }
Message::DeleteBookmark(idx) => {
if idx < self.bookmarks.len() {
self.bookmarks.remove(idx);
+ persist_bookmarks(&self.bookmarks);
if self.selected_bookmark == Some(idx) {
self.selected_bookmark = None;
} else if self.selected_bookmark.is_some_and(|s| s > idx) {
@@ -327,6 +219,16 @@ impl App {
}
Message::NicknameChanged(n) => {
self.nickname = n;
+ if let Some(idx) = self.selected_bookmark {
+ if let Some(bookmark) = self.bookmarks.get_mut(idx) {
+ bookmark.nickname = if self.nickname.is_empty() {
+ None
+ } else {
+ Some(self.nickname.clone())
+ };
+ persist_bookmarks(&self.bookmarks);
+ }
+ }
Task::none()
}
Message::PasswordChanged(p) => {
@@ -340,8 +242,15 @@ impl App {
name: self.bm_name_input.clone(),
address: self.bm_address_input.clone(),
port: self.bm_port_input.parse().unwrap_or(9987),
- nickname: None,
+ nickname: if self.nickname.is_empty() {
+ None
+ } else {
+ Some(self.nickname.clone())
+ },
+ favorite: false,
+ last_used_at: None,
});
+ persist_bookmarks(&self.bookmarks);
self.selected_bookmark = Some(self.bookmarks.len() - 1);
self.editing_bookmark = false;
}
@@ -352,6 +261,12 @@ impl App {
Some(b) => b.clone(),
None => return Task::none(),
};
+ if let Some(idx) = self.selected_bookmark {
+ if let Some(bookmark) = self.bookmarks.get_mut(idx) {
+ bookmark.last_used_at = Some(chrono::Utc::now().timestamp());
+ persist_bookmarks(&self.bookmarks);
+ }
+ }
let nickname = if self.nickname.is_empty() {
"ReTeamSpeak".to_string()
} else {
@@ -364,9 +279,7 @@ impl App {
};
let handle_store = self.handle.clone();
let event_rx_store = self.event_rx.clone();
- #[cfg(feature = "audio")]
let audio = self.audio.clone();
- #[cfg(feature = "audio")]
let imported_identity = self.imported_identity.clone();
self.error = None;
@@ -387,7 +300,6 @@ impl App {
builder = builder.password(pwd);
}
- #[cfg(feature = "audio")]
if let Some(ref id) = imported_identity {
builder = builder.identity(id.clone());
}
@@ -398,10 +310,7 @@ impl App {
*handle_store.lock().await = Some(handle.clone());
- #[cfg(feature = "audio")]
tokio::spawn(run_connection(sync_con, event_tx, audio));
- #[cfg(not(feature = "audio"))]
- tokio::spawn(run_connection(sync_con, event_tx));
handle
.wait_until_connected()
@@ -437,6 +346,13 @@ impl App {
|_| Message::Noop,
)
}
+ Message::ToggleChannelCollapsed(channel_id) => {
+ let key = channel_id.0;
+ if !self.collapsed_channels.insert(key) {
+ self.collapsed_channels.remove(&key);
+ }
+ Task::none()
+ }
Message::Disconnect => {
let h = self.handle.clone();
let rx = self.event_rx.clone();
@@ -461,18 +377,15 @@ impl App {
Message::TsEvent(event) => match event {
TsEvent::Connected => {
self.connected = true;
- #[cfg(feature = "audio")]
- {
- let audio = self.audio.clone();
- let selected_device = self.selected_output_device.clone();
- tokio::spawn(async move {
- let mut playback = audio.lock().await;
- let device = if selected_device.is_empty() { None } else { Some(selected_device.as_str()) };
- if let Err(e) = playback.start(device) {
- tracing::warn!("Audio playback failed to start: {e}");
- }
- });
- }
+ let audio = self.audio.clone();
+ let selected_device = self.selected_output_device.clone();
+ tokio::spawn(async move {
+ let mut playback = audio.lock().await;
+ let device = if selected_device.is_empty() { None } else { Some(selected_device.as_str()) };
+ if let Err(e) = playback.start(device) {
+ tracing::warn!("Audio playback failed to start: {e}");
+ }
+ });
Task::none()
}
TsEvent::BookEvents(events) => {
@@ -496,13 +409,10 @@ impl App {
Task::none()
}
TsEvent::Disconnected => {
- #[cfg(feature = "audio")]
- {
- let audio = self.audio.clone();
- tokio::spawn(async move {
- audio.lock().await.stop();
- });
- }
+ let audio = self.audio.clone();
+ tokio::spawn(async move {
+ audio.lock().await.stop();
+ });
self.connected = false;
self.server_name.clear();
self.channels.clear();
@@ -512,17 +422,57 @@ impl App {
Task::none()
}
TsEvent::Error(e) => {
- #[cfg(feature = "audio")]
- {
- let audio = self.audio.clone();
- tokio::spawn(async move {
- audio.lock().await.stop();
- });
- }
+ let audio = self.audio.clone();
+ tokio::spawn(async move {
+ audio.lock().await.stop();
+ });
self.error = Some(e);
Task::none()
}
},
+ Message::ChannelSearchChanged(input) => {
+ self.channel_search = input;
+ Task::none()
+ }
+ Message::ToggleLocalClientMute(client_id) => {
+ let entry = self.local_client_audio.entry(client_id).or_insert(LocalClientAudio {
+ muted: false,
+ volume: 1.0,
+ });
+ entry.muted = !entry.muted;
+ let audio = self.audio.clone();
+ let muted = entry.muted;
+ tokio::spawn(async move {
+ audio.lock().await.set_client_muted(client_id, muted);
+ });
+ Task::none()
+ }
+ Message::IncreaseClientVolume(client_id) => {
+ let entry = self.local_client_audio.entry(client_id).or_insert(LocalClientAudio {
+ muted: false,
+ volume: 1.0,
+ });
+ entry.volume = (entry.volume + 0.1).min(2.0);
+ let audio = self.audio.clone();
+ let volume = entry.volume;
+ tokio::spawn(async move {
+ audio.lock().await.set_client_volume(client_id, volume);
+ });
+ Task::none()
+ }
+ Message::DecreaseClientVolume(client_id) => {
+ let entry = self.local_client_audio.entry(client_id).or_insert(LocalClientAudio {
+ muted: false,
+ volume: 1.0,
+ });
+ entry.volume = (entry.volume - 0.1).max(0.0);
+ let audio = self.audio.clone();
+ let volume = entry.volume;
+ tokio::spawn(async move {
+ audio.lock().await.set_client_volume(client_id, volume);
+ });
+ Task::none()
+ }
Message::MessageInputChanged(input) => {
self.message_input = input;
Task::none()
@@ -561,48 +511,6 @@ impl App {
},
)
}
- Message::QueryAddressChanged(addr) => {
- self.query_address = addr;
- Task::none()
- }
- Message::QueryPortChanged(port) => {
- self.query_port = port.parse().unwrap_or(10011);
- Task::none()
- }
- Message::QueryCommandChanged(cmd) => {
- self.query_command = cmd;
- Task::none()
- }
- Message::RunServerQuery => {
- if self.query_address.is_empty() {
- self.query_error = Some("Address required".to_string());
- return Task::none();
- }
- self.query_loading = true;
- self.query_error = None;
- self.query_response = None;
- Task::perform(
- async move {
- // ServerQuery not yet implemented with tsclientlib
- Err::("ServerQuery not implemented".to_string())
- },
- |result| match result {
- Ok(raw) => Message::QueryResponse(raw),
- Err(e) => Message::QueryError(e),
- },
- )
- }
- Message::QueryResponse(raw) => {
- self.query_loading = false;
- self.query_response = Some(raw);
- Task::none()
- }
- Message::QueryError(e) => {
- self.query_loading = false;
- self.query_error = Some(e);
- Task::none()
- }
- #[cfg(feature = "audio")]
Message::PttPressed => {
if !self.ptt_active && self.connected && self.talk_mode == audio::TalkMode::PushToTalk {
self.ptt_active = true;
@@ -616,7 +524,6 @@ impl App {
}
Task::none()
}
- #[cfg(feature = "audio")]
Message::PttReleased => {
if self.ptt_active {
self.ptt_active = false;
@@ -624,19 +531,18 @@ impl App {
}
Task::none()
}
- #[cfg(feature = "audio")]
Message::ToggleTalkMode => {
self.talk_mode = match self.talk_mode {
audio::TalkMode::PushToTalk => audio::TalkMode::Continuous,
audio::TalkMode::Continuous => audio::TalkMode::PushToTalk,
};
+ self.save_settings();
if self.talk_mode == audio::TalkMode::PushToTalk && self.continuous_active {
self.continuous_active = false;
stop_transmission(self.mic.clone(), self.handle.clone());
}
Task::none()
}
- #[cfg(feature = "audio")]
Message::StartContinuous => {
if !self.continuous_active && self.connected && self.talk_mode == audio::TalkMode::Continuous {
self.continuous_active = true;
@@ -650,7 +556,6 @@ impl App {
}
Task::none()
}
- #[cfg(feature = "audio")]
Message::StopContinuous => {
if self.continuous_active {
self.continuous_active = false;
@@ -658,7 +563,6 @@ impl App {
}
Task::none()
}
- #[cfg(feature = "audio")]
Message::MicSamples(samples) => {
if self.talk_mode == audio::TalkMode::Continuous && self.connected {
let vad = self.vad.clone();
@@ -681,26 +585,13 @@ impl App {
Task::none()
}
}
- #[cfg(feature = "audio")]
- Message::ToggleNoiseCancel => {
- let methods = noise_cancel::NoiseCancelMethod::all();
- let current_idx = methods.iter().position(|m| *m == self.nc_method).unwrap_or(0);
- let next_idx = (current_idx + 1) % methods.len();
- self.nc_method = methods[next_idx];
- let nc = self.noise_reducer.clone();
- let method = self.nc_method;
- tokio::spawn(async move {
- nc.lock().await.set_method(method);
- });
- Task::none()
- }
- #[cfg(feature = "audio")]
Message::SetNoiseCancelMethod(method) => {
self.nc_method = method;
let nc = self.noise_reducer.clone();
tokio::spawn(async move {
nc.lock().await.set_method(method);
});
+ self.save_settings();
Task::none()
}
Message::ToggleMicMute => {
@@ -806,26 +697,37 @@ impl App {
|_| Message::Noop,
)
}
- #[cfg(feature = "rusqlite")]
- Message::ImportIdentity => {
+ Message::IdentityStringChanged(input) => {
+ self.identity_string_input = input;
+ Task::none()
+ }
+ Message::IdentityFilePathChanged(input) => {
+ self.identity_file_input = input;
+ Task::none()
+ }
+ Message::ImportIdentityFromString => {
+ let raw = self.identity_string_input.clone();
Task::perform(
async move {
- if let Some(path) = identity::find_ts3_config_dir() {
- match identity::import_identity_from_ts3(&path) {
- Ok(id) => Message::IdentityImported(Ok(id)),
- Err(e) => Message::IdentityImported(Err(e)),
- }
- } else {
- Message::IdentityImported(Err("No TeamSpeak config found".to_string()))
+ match identity::import_identity_from_string(&raw) {
+ Ok(id) => Message::IdentityImported(Ok(id)),
+ Err(e) => Message::IdentityImported(Err(e)),
}
},
|msg| msg,
)
}
- #[cfg(not(feature = "rusqlite"))]
- Message::ImportIdentity => {
- self.error = Some("Identity import requires the 'rusqlite' feature".to_string());
- Task::none()
+ Message::ImportIdentityFromFile => {
+ let path = self.identity_file_input.clone();
+ Task::perform(
+ async move {
+ match identity::import_identity_from_file(&path) {
+ Ok(id) => Message::IdentityImported(Ok(id)),
+ Err(e) => Message::IdentityImported(Err(e)),
+ }
+ },
+ |msg| msg,
+ )
}
Message::IdentityImported(result) => {
match result {
@@ -840,14 +742,19 @@ impl App {
}
Task::none()
}
- #[cfg(feature = "audio")]
Message::SetOutputDevice(device) => {
self.selected_output_device = device;
+ self.save_settings();
Task::none()
}
- #[cfg(feature = "audio")]
Message::SetInputDevice(device) => {
self.selected_input_device = device;
+ self.save_settings();
+ Task::none()
+ }
+ Message::SetAppearance(mode) => {
+ self.appearance_mode = mode;
+ self.save_settings();
Task::none()
}
Message::Noop => Task::none(),
@@ -1000,7 +907,6 @@ impl App {
let mut subs = Vec::new();
// Keyboard events for PTT
- #[cfg(feature = "audio")]
subs.push(iced::event::listen().map(|event| {
match event {
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { ref key, .. }) => {
@@ -1053,7 +959,6 @@ impl App {
));
// Continuous talk: monitor mic input when in continuous mode
- #[cfg(feature = "audio")]
if self.talk_mode == audio::TalkMode::Continuous {
let mic = self.mic.clone();
let session_id = self.session_id;
@@ -1091,707 +996,10 @@ impl App {
let sidebar = self.view_sidebar();
let content = self.view_content();
- row![sidebar, content]
+ row![sidebar, container(content).style(theme::main_panel_container)]
.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(2)
- .padding(12);
-
- let content = match self.page {
- Page::ServerList => self.view_server_list(),
- Page::ServerQuery => container(text("").size(12)).padding(8).into(),
- Page::Settings => container(text("").size(12)).padding(8).into(),
- };
-
- container(
- column![nav, theme::separator_line(), content]
- .width(280)
- .height(Length::Fill),
- )
- .style(theme::sidebar_container)
- .width(280)
- .height(Length::Fill)
- .into()
- }
-
- fn view_server_list(&self) -> Element<'_, Message> {
- let mut list = column![].spacing(4).padding(12);
-
- for (i, bookmark) in self.bookmarks.iter().enumerate() {
- let is_selected = self.selected_bookmark == Some(i);
-
- let btn = button(
- row![
- column![
- text(&bookmark.name).size(13),
- text(format!("{}:{}", bookmark.address, bookmark.port))
- .size(11)
- .style(text::secondary),
- ]
- .spacing(2)
- .width(Length::Fill),
- button(text("x").size(10))
- .padding(4)
- .style(theme::danger_button)
- .on_press(Message::DeleteBookmark(i)),
- ]
- .align_y(iced::Alignment::Center)
- .spacing(8),
- )
- .width(Length::Fill)
- .on_press(Message::SelectBookmark(Some(i)))
- .padding([10, 12])
- .style(if is_selected {
- theme::bookmark_button_selected
- } else {
- theme::bookmark_button
- });
-
- list = list.push(btn);
- }
-
- if self.editing_bookmark {
- let form = container(
- column![
- text_input("Server name", &self.bm_name_input)
- .on_input(Message::BookmarkNameChanged)
- .style(theme::input_style),
- text_input("Address", &self.bm_address_input)
- .on_input(Message::BookmarkAddressChanged)
- .style(theme::input_style),
- text_input("Port", &self.bm_port_input)
- .on_input(Message::BookmarkPortChanged)
- .style(theme::input_style),
- row![
- button(text("Save").size(12))
- .padding([6, 16])
- .style(theme::primary_button)
- .on_press(Message::Connect),
- button(text("Cancel").size(12))
- .padding([6, 16])
- .style(theme::secondary_button)
- .on_press(Message::AddBookmark),
- ]
- .spacing(8),
- ]
- .spacing(8)
- .padding(12),
- )
- .style(theme::card_container)
- .padding(8);
-
- list = list.push(form);
- }
-
- column![
- container(
- row![
- text("Servers").size(14),
- horizontal_space(),
- button(text("+").size(16))
- .padding([2, 8])
- .style(theme::secondary_button)
- .on_press(Message::AddBookmark),
- ]
- .align_y(iced::Alignment::Center),
- )
- .padding([0, 12]),
- vertical_space().height(8),
- scrollable(list).height(Length::Fill),
- ]
- .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 mut connect_form = column![
- text(bookmark.name.clone()).size(20),
- text(format!("{}:{}", bookmark.address, bookmark.port))
- .size(12)
- .style(text::secondary),
- vertical_space().height(16),
- text_input("Nickname", &self.nickname)
- .on_input(Message::NicknameChanged)
- .style(theme::input_style),
- vertical_space().height(8),
- text_input("Password", &self.password)
- .secure(true)
- .on_input(Message::PasswordChanged)
- .style(theme::input_style),
- vertical_space().height(16),
- row![
- horizontal_space(),
- if self.connected {
- button(text("Disconnect").size(13))
- .padding([10, 28])
- .style(theme::danger_button)
- .on_press(Message::Disconnect)
- } else {
- button(text("Connect").size(13))
- .padding([10, 28])
- .style(theme::primary_button)
- .on_press(Message::Connect)
- },
- ],
- ]
- .spacing(4)
- .padding(20);
-
- if let Some(err) = &self.error {
- connect_form = connect_form.push(
- container(text(err.clone()).size(12).style(text::danger))
- .padding(10)
- .style(theme::elevated_container),
- );
- }
- if self.identity_level > 0 {
- connect_form = connect_form.push(
- text(format!("Computing identity level {}...", self.identity_level))
- .size(12)
- .style(text::secondary),
- );
- }
-
- if self.connected {
- let mic_label = if self.mic_muted { "🔇 Mic" } else { "🎤 Mic" };
- let speaker_label = if self.speaker_muted { "🔇 Speaker" } else { "🔊 Speaker" };
- let headset_label = if self.headset_muted { "🔇 Headset" } else { "🎧 Headset" };
- let afk_label = if self.afk { "AFK ✓" } else { "AFK" };
-
- let control_bar = row![
- button(text(mic_label).size(11))
- .padding([6, 12])
- .style(if self.mic_muted { theme::danger_button } else { theme::secondary_button })
- .on_press(Message::ToggleMicMute),
- button(text(speaker_label).size(11))
- .padding([6, 12])
- .style(if self.speaker_muted { theme::danger_button } else { theme::secondary_button })
- .on_press(Message::ToggleSpeakerMute),
- button(text(headset_label).size(11))
- .padding([6, 12])
- .style(if self.headset_muted { theme::danger_button } else { theme::secondary_button })
- .on_press(Message::ToggleHeadsetMute),
- horizontal_space(),
- button(text(afk_label).size(11))
- .padding([6, 12])
- .style(if self.afk { theme::danger_button } else { theme::secondary_button })
- .on_press(Message::ToggleAfk),
- ]
- .spacing(8)
- .align_y(iced::Alignment::Center);
-
- let server_header = container(
- column![
- row![
- text(&self.server_name).size(18),
- horizontal_space(),
- ],
- text(format!("{} / {} — {}/{} online",
- self.server_platform, self.server_version,
- self.clients.len(), self.server_max_clients))
- .size(12)
- .style(text::secondary),
- vertical_space().height(8),
- control_bar,
- ]
- .spacing(4),
- )
- .padding(16)
- .style(theme::card_container);
-
- let mut channel_list = column![].spacing(2);
- let mut sorted: Vec<&ChannelEntry> = self.channels.iter().collect();
- sorted.sort_by_key(|ch| ch.order.0);
- for ch in &sorted {
- let client_count = self.clients.iter().filter(|c| c.channel == ch.id).count();
- let is_current = self.own_client_id.is_some_and(|oid| {
- self.clients.iter().any(|c| c.id == oid && c.channel == ch.id)
- });
- let ch_row = button(
- row![
- text(&ch.name).size(12).width(Length::Fill),
- text(format!("{}", client_count)).size(11).style(text::secondary),
- ]
- .spacing(8)
- .padding([4, 0]),
- )
- .width(Length::Fill)
- .padding([6, 10])
- .style(if is_current {
- theme::bookmark_button_selected
- } else {
- theme::bookmark_button
- })
- .on_press(Message::JoinChannel(ch.id));
- channel_list = channel_list.push(ch_row);
- }
-
- let channels_panel = container(
- column![
- text("Channels").size(13),
- vertical_space().height(8),
- scrollable(channel_list).height(Length::Fill),
- ]
- .spacing(4)
- .padding(12),
- )
- .style(theme::card_container)
- .height(Length::Fill);
-
- let mut client_list = column![].spacing(2);
- for c in &self.clients {
- let mute = if c.input_muted { " 🔇" } else { "" };
- client_list = client_list.push(
- text(format!("{}{}", c.name, mute)).size(12),
- );
- }
-
- let clients_panel = container(
- column![
- text("Clients").size(13),
- vertical_space().height(8),
- scrollable(client_list).height(Length::Fill),
- ]
- .spacing(4)
- .padding(12),
- )
- .style(theme::card_container)
- .height(Length::Fill);
-
- let mut messages_col = column![].spacing(6);
- for msg in &self.messages {
- messages_col = messages_col.push(
- column![
- text(&msg.invoker_name).size(11).style(text::secondary),
- text(&msg.message).size(13),
- ]
- .spacing(2),
- );
- }
-
- let messages_panel = container(
- column![
- text("Chat").size(13),
- vertical_space().height(8),
- scrollable(messages_col).height(Length::Fill),
- vertical_space().height(8),
- row![
- text_input("Type a message...", &self.message_input)
- .width(Length::Fill)
- .on_input(Message::MessageInputChanged)
- .on_submit(Message::SendChannelMessage)
- .style(theme::input_style),
- button(text("Send").size(12))
- .padding([8, 16])
- .style(theme::primary_button)
- .on_press(Message::SendChannelMessage),
- ]
- .spacing(8),
- ]
- .spacing(4)
- .padding(12),
- )
- .style(theme::card_container)
- .height(Length::Fill);
-
- column![
- connect_form,
- vertical_space().height(12),
- server_header,
- vertical_space().height(12),
- row![channels_panel, clients_panel].spacing(12).height(250),
- vertical_space().height(12),
- messages_panel,
- ]
- .spacing(0)
- .padding(20)
- .into()
- } else {
- container(connect_form)
- .center_x(Length::Fill)
- .center_y(Length::Fill)
- .into()
- }
- } else {
- self.view_welcome()
- }
- } else {
- self.view_welcome()
- }
- }
-
- fn view_query_content(&self) -> Element<'_, Message> {
- let mut content = column![
- text("ServerQuery").size(20),
- vertical_space().height(16),
- text_input("Address", &self.query_address)
- .on_input(Message::QueryAddressChanged)
- .style(theme::input_style),
- vertical_space().height(8),
- row![
- text_input("Port", &self.query_port.to_string())
- .on_input(Message::QueryPortChanged)
- .style(theme::input_style),
- text_input("Command", &self.query_command)
- .on_input(Message::QueryCommandChanged)
- .style(theme::input_style),
- ]
- .spacing(8),
- vertical_space().height(12),
- row![
- horizontal_space(),
- button(text(if self.query_loading { "Running..." } else { "Execute" }).size(13))
- .padding([10, 24])
- .style(theme::primary_button)
- .on_press_maybe(if self.query_loading { None } else { Some(Message::RunServerQuery) }),
- ],
- ]
- .spacing(4)
- .padding(20);
-
- if let Some(resp) = &self.query_response {
- content = content.push(
- container(
- column![
- text("Response").size(13),
- vertical_space().height(8),
- scrollable(text(resp).size(12)).height(Length::Fill),
- ]
- .padding(12),
- )
- .style(theme::card_container),
- );
- }
- if let Some(err) = &self.query_error {
- content = content.push(
- text(err.clone()).size(12).style(text::danger),
- );
- }
-
- content.into()
- }
-
- fn view_settings_content(&self) -> Element<'_, Message> {
- let output_label = if self.selected_output_device.is_empty() {
- "System Default"
- } else {
- &self.selected_output_device
- };
- let input_label = if self.selected_input_device.is_empty() {
- "System Default"
- } else {
- &self.selected_input_device
- };
-
- let mut settings_col = column![
- text("Settings").size(20),
- vertical_space().height(16),
- ]
- .spacing(8)
- .padding(20);
-
- // Audio devices card
- #[allow(unused_mut)]
- let mut devices_col = column![
- text("Audio Output Device").size(13),
- text(output_label).size(12).style(text::secondary),
- text("Audio Input Device").size(13),
- text(input_label).size(12).style(text::secondary),
- ]
- .spacing(4);
-
- #[cfg(feature = "audio")]
- {
- let outputs = audio::AudioPlayback::list_output_devices();
- let inputs = audio::AudioPlayback::list_input_devices();
- let mut output_btns = row![].spacing(4);
- for dev in outputs {
- let is_selected = self.selected_output_device == dev || (self.selected_output_device.is_empty() && dev == "default");
- output_btns = output_btns.push(
- button(text(dev.as_str()).size(10))
- .padding([4, 8])
- .style(if is_selected { theme::primary_button } else { theme::secondary_button })
- .on_press(Message::SetOutputDevice(dev)),
- );
- }
- let mut input_btns = row![].spacing(4);
- for dev in inputs {
- let is_selected = self.selected_input_device == dev || (self.selected_input_device.is_empty() && dev == "default");
- input_btns = input_btns.push(
- button(text(dev.as_str()).size(10))
- .padding([4, 8])
- .style(if is_selected { theme::primary_button } else { theme::secondary_button })
- .on_press(Message::SetInputDevice(dev)),
- );
- }
- devices_col = devices_col
- .push(vertical_space().height(4))
- .push(text("Output devices:").size(11).style(text::secondary))
- .push(output_btns)
- .push(vertical_space().height(4))
- .push(text("Input devices:").size(11).style(text::secondary))
- .push(input_btns);
- }
-
- settings_col = settings_col.push(
- container(devices_col.spacing(4).padding(16))
- .style(theme::card_container)
- .width(500),
- );
-
- // Voice settings card
- #[cfg(feature = "audio")]
- let talk_mode_text = match self.talk_mode {
- audio::TalkMode::PushToTalk => "Push-to-Talk (hold V)",
- audio::TalkMode::Continuous => "Continuous (voice activation)",
- };
- #[cfg(not(feature = "audio"))]
- let talk_mode_text = "N/A";
-
- #[cfg(feature = "audio")]
- let nc_label = self.nc_method.label();
- #[cfg(not(feature = "audio"))]
- let nc_label = "N/A";
-
- let voice_col = column![
- text("Voice Activation Mode").size(13),
- text(talk_mode_text).size(12).style(text::secondary),
- vertical_space().height(8),
- text("Push-to-Talk Key").size(13),
- text("V (hold to talk)").size(12).style(text::secondary),
- vertical_space().height(8),
- text("Noise Cancellation").size(13),
- text(nc_label).size(12).style(text::secondary),
- ]
- .spacing(4)
- .padding(16);
-
- #[cfg(feature = "audio")]
- let voice_col = voice_col.push(
- button(text("Toggle Noise Cancellation").size(11))
- .padding([4, 12])
- .style(theme::secondary_button)
- .on_press(Message::ToggleNoiseCancel),
- );
-
- settings_col = settings_col.push(
- container(voice_col)
- .style(theme::card_container)
- .width(500),
- );
-
- // Identity card
- let identity_col = column![
- text("TeamSpeak Identity").size(13),
- text("Import from TS3 client").size(12).style(text::secondary),
- vertical_space().height(8),
- button(text("Import Identity").size(12))
- .padding([8, 16])
- .style(theme::primary_button)
- .on_press(Message::ImportIdentity),
- ]
- .spacing(4)
- .padding(16);
-
- settings_col = settings_col.push(
- container(identity_col)
- .style(theme::card_container)
- .width(500),
- );
-
- container(settings_col)
- .center_x(Length::Fill)
- .center_y(Length::Fill)
- .into()
- }
-
- fn view_welcome(&self) -> Element<'_, Message> {
- container(
- column![
- text("ReTeamSpeak").size(28),
- vertical_space().height(8),
- text("Select a server from the sidebar to get started.")
- .size(14)
- .style(text::secondary),
- ]
- .align_x(iced::Alignment::Center),
- )
- .center_x(Length::Fill)
- .center_y(Length::Fill)
- .into()
- }
-}
-
-#[cfg(feature = "audio")]
-async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender, audio: Arc>) {
- let mut stream = con;
- while let Some(item) = stream.next().await {
- let ts_event = match item {
- Ok(SyncStreamItem::BookEvents(events)) => TsEvent::BookEvents(events),
- Ok(SyncStreamItem::MessageEvent(msg)) => TsEvent::MessageEvent(msg),
- Ok(SyncStreamItem::AudioChange(change)) => match change {
- tsclientlib::AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true),
- tsclientlib::AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can),
- },
- Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => TsEvent::IdentityLevelIncreasing(level),
- Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased,
- Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily,
- Ok(SyncStreamItem::NetworkStatsUpdated) => continue,
- Ok(SyncStreamItem::Audio(audio_buf)) => {
- let playback = audio.lock().await;
- playback.send_packet(audio_buf);
- continue;
- }
- Err(e) => TsEvent::Error(e.to_string()),
- };
- if event_tx.send(ts_event).await.is_err() {
- break;
- }
- }
- let _ = event_tx.send(TsEvent::Disconnected).await;
-}
-
-#[cfg(not(feature = "audio"))]
-async fn run_connection(con: SyncConnection, event_tx: mpsc::Sender) {
- let mut stream = con;
- while let Some(item) = stream.next().await {
- let ts_event = match item {
- Ok(SyncStreamItem::BookEvents(events)) => TsEvent::BookEvents(events),
- Ok(SyncStreamItem::MessageEvent(msg)) => TsEvent::MessageEvent(msg),
- Ok(SyncStreamItem::AudioChange(change)) => match change {
- tsclientlib::AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true),
- tsclientlib::AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can),
- },
- Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => TsEvent::IdentityLevelIncreasing(level),
- Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased,
- Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily,
- Ok(SyncStreamItem::NetworkStatsUpdated) => continue,
- Err(e) => TsEvent::Error(e.to_string()),
- };
- if event_tx.send(ts_event).await.is_err() {
- break;
- }
- }
- let _ = event_tx.send(TsEvent::Disconnected).await;
-}
-
-#[cfg(feature = "audio")]
-fn start_transmission(
- mic: Arc>,
- handle: Arc>>,
- device_name: Option,
- noise_reducer: Arc>,
-) {
- let (sample_tx, sample_rx) = std::sync::mpsc::channel();
-
- tokio::spawn(async move {
- {
- let mut mic_guard = mic.lock().await;
- if mic_guard.start(device_name.as_deref(), sample_tx).is_err() {
- return;
- }
- }
-
- let mut enc = match audio::OpusEncoderState::new() {
- Ok(e) => e,
- Err(_) => return,
- };
-
- let (audio_tx, mut audio_rx) = tokio::sync::mpsc::channel::>(100);
-
- let send_handle = handle.clone();
- tokio::spawn(async move {
- while let Some(data) = audio_rx.recv().await {
- let mut guard = send_handle.lock().await;
- if let Some(ref mut h) = *guard {
- let _ = h
- .with_connection(move |con| {
- let packet = OutAudio::new(&AudioData::C2S {
- id: 0,
- codec: CodecType::OpusVoice,
- data: &data,
- });
- let _ = con.send_audio(packet);
- Ok::<(), ()>(())
- })
- .await;
- }
- }
- });
-
- loop {
- match sample_rx.recv() {
- Ok(samples) => {
- // Apply noise reduction
- {
- let mut nr = noise_reducer.lock().await;
- let mut samples_copy = samples.clone();
- nr.process(&mut samples_copy);
- enc.encode_and_send(&samples_copy, &audio_tx);
- }
- }
- Err(_) => break,
- }
- }
-
- let mut mic_guard = mic.lock().await;
- mic_guard.stop();
- });
-}
-
-#[cfg(feature = "audio")]
-fn stop_transmission(
- mic: Arc>,
- handle: Arc>>,
-) {
- tokio::spawn(async move {
- {
- let mut mic_guard = mic.lock().await;
- mic_guard.stop();
- }
- let mut guard = handle.lock().await;
- if let Some(ref mut h) = *guard {
- let _ = h
- .with_connection(move |con| {
- let packet = OutAudio::new(&AudioData::C2S {
- id: 0,
- codec: CodecType::OpusVoice,
- data: &[],
- });
- let _ = con.send_audio(packet);
- Ok::<(), ()>(())
- })
- .await;
- }
- });
-}
-
-fn nav_button(label: &str, page: Page, current: Page) -> Element<'static, Message> {
- button(text(label.to_string()).size(12))
- .padding([8, 16])
- .on_press(Message::PageChanged(page))
- .style(if current == page {
- theme::nav_button_active
- } else {
- theme::nav_button_inactive
- })
- .into()
}
diff --git a/src/iced-app/src/noise_cancel.rs b/src/iced-app/src/noise_cancel.rs
index 6806baa..e9e48d5 100644
--- a/src/iced-app/src/noise_cancel.rs
+++ b/src/iced-app/src/noise_cancel.rs
@@ -1,4 +1,10 @@
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+use serde::{Deserialize, Serialize};
+use sonora::config::{NoiseSuppression, NoiseSuppressionLevel};
+use sonora::{AudioProcessing, Config, StreamConfig};
+
+const SONORA_FRAME_SIZE: usize = 480;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NoiseCancelMethod {
None,
Nnnoiseless,
@@ -21,23 +27,57 @@ impl NoiseCancelMethod {
pub struct NoiseReducer {
method: NoiseCancelMethod,
- #[cfg(feature = "nnnoiseless")]
- nnnoiseless: Option>,
- #[cfg(feature = "sonora")]
- sonora: Option,
- #[cfg(feature = "nnnoiseless")]
+ nnnoiseless: Option>>,
+ sonora: Option,
residual_buf: Vec,
}
+struct SonoraNoiseReducer {
+ processor: AudioProcessing,
+ input: Vec,
+ output: Vec,
+}
+
+impl SonoraNoiseReducer {
+ fn new() -> Self {
+ let stream = StreamConfig::new(48_000, 1);
+ let config = Config {
+ noise_suppression: Some(NoiseSuppression {
+ level: NoiseSuppressionLevel::Moderate,
+ ..NoiseSuppression::default()
+ }),
+ ..Config::default()
+ };
+
+ Self {
+ processor: AudioProcessing::builder()
+ .config(config)
+ .capture_config(stream)
+ .render_config(stream)
+ .build(),
+ input: vec![0.0; SONORA_FRAME_SIZE],
+ output: vec![0.0; SONORA_FRAME_SIZE],
+ }
+ }
+
+ fn process(&mut self, frame: &mut [f32]) {
+ self.input.copy_from_slice(frame);
+ let src = [&self.input[..]];
+ let mut dest = [&mut self.output[..]];
+ if let Err(error) = self.processor.process_capture_f32(&src, &mut dest) {
+ tracing::debug!("Sonora noise suppression error: {error}");
+ return;
+ }
+ frame.copy_from_slice(&self.output);
+ }
+}
+
impl NoiseReducer {
pub fn new(method: NoiseCancelMethod) -> Self {
let mut this = Self {
method,
- #[cfg(feature = "nnnoiseless")]
nnnoiseless: None,
- #[cfg(feature = "sonora")]
sonora: None,
- #[cfg(feature = "nnnoiseless")]
residual_buf: Vec::new(),
};
this.init_method();
@@ -45,26 +85,15 @@ impl NoiseReducer {
}
fn init_method(&mut self) {
- #[cfg(feature = "nnnoiseless")]
- {
- self.nnnoiseless = match self.method {
- NoiseCancelMethod::Nnnoiseless => Some(nnnoiseless::DenoiseState::new()),
- _ => None,
- };
- }
- #[cfg(feature = "sonora")]
- {
- self.sonora = match self.method {
- NoiseCancelMethod::Sonora => {
- Some(sonora::NoiseSuppression::new(48000, 480).expect("Failed to create Sonora NS"))
- }
- _ => None,
- };
- }
- #[cfg(feature = "nnnoiseless")]
- {
- self.residual_buf.clear();
- }
+ self.nnnoiseless = match self.method {
+ NoiseCancelMethod::Nnnoiseless => Some(nnnoiseless::DenoiseState::new()),
+ _ => None,
+ };
+ self.sonora = match self.method {
+ NoiseCancelMethod::Sonora => Some(SonoraNoiseReducer::new()),
+ _ => None,
+ };
+ self.residual_buf.clear();
}
pub fn set_method(&mut self, method: NoiseCancelMethod) {
@@ -72,25 +101,14 @@ impl NoiseReducer {
self.init_method();
}
- pub fn method(&self) -> NoiseCancelMethod {
- self.method
- }
-
- pub fn process(&mut self, samples: &mut [f32]) {
+ pub fn process(&mut self, _samples: &mut [f32]) {
match self.method {
NoiseCancelMethod::None => {}
- #[cfg(feature = "nnnoiseless")]
- NoiseCancelMethod::Nnnoiseless => self.process_nnnoiseless(samples),
- #[cfg(not(feature = "nnnoiseless"))]
- NoiseCancelMethod::Nnnoiseless => {}
- #[cfg(feature = "sonora")]
- NoiseCancelMethod::Sonora => self.process_sonora(samples),
- #[cfg(not(feature = "sonora"))]
- NoiseCancelMethod::Sonora => {}
+ NoiseCancelMethod::Nnnoiseless => self.process_nnnoiseless(_samples),
+ NoiseCancelMethod::Sonora => self.process_sonora(_samples),
}
}
- #[cfg(feature = "nnnoiseless")]
fn process_nnnoiseless(&mut self, samples: &mut [f32]) {
let denoise = match &mut self.nnnoiseless {
Some(d) => d,
@@ -118,21 +136,20 @@ impl NoiseReducer {
}
}
- #[cfg(feature = "sonora")]
fn process_sonora(&mut self, samples: &mut [f32]) {
- let ns = match &mut self.sonora {
+ let reducer = match &mut self.sonora {
Some(s) => s,
None => return,
};
// Sonora processes 10ms frames (480 samples at 48kHz)
- let frame_size = 480;
+ let frame_size = SONORA_FRAME_SIZE;
let mut offset = 0;
while offset + frame_size <= samples.len() {
let frame = &mut samples[offset..offset + frame_size];
- ns.process(frame);
+ reducer.process(frame);
offset += frame_size;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/iced-app/src/persistence.rs b/src/iced-app/src/persistence.rs
new file mode 100644
index 0000000..9beb5c6
--- /dev/null
+++ b/src/iced-app/src/persistence.rs
@@ -0,0 +1,120 @@
+use std::fs;
+use std::path::PathBuf;
+
+use crate::types::{AppSettings, BookmarkInfo};
+
+pub(crate) fn load_bookmarks() -> Vec {
+ let Some(path) = bookmarks_config_path() else {
+ return default_bookmarks();
+ };
+
+ let Ok(raw) = fs::read_to_string(path) else {
+ return default_bookmarks();
+ };
+
+ match serde_json::from_str::>(&raw) {
+ Ok(bookmarks) if !bookmarks.is_empty() => bookmarks,
+ _ => default_bookmarks(),
+ }
+}
+
+pub(crate) fn persist_bookmarks(bookmarks: &[BookmarkInfo]) {
+ let Some(path) = bookmarks_config_path() else {
+ return;
+ };
+
+ let Some(parent) = path.parent() else {
+ return;
+ };
+
+ if fs::create_dir_all(parent).is_err() {
+ return;
+ }
+
+ let Ok(raw) = serde_json::to_string_pretty(bookmarks) else {
+ return;
+ };
+
+ if let Err(error) = fs::write(path, raw) {
+ tracing::warn!("Failed to persist bookmarks: {error}");
+ }
+}
+
+pub(crate) fn load_settings() -> AppSettings {
+ let Some(path) = settings_config_path() else {
+ return AppSettings::default();
+ };
+
+ let Ok(raw) = fs::read_to_string(path) else {
+ return AppSettings::default();
+ };
+
+ serde_json::from_str::(&raw).unwrap_or_default()
+}
+
+pub(crate) fn persist_settings(settings: &AppSettings) {
+ let Some(path) = settings_config_path() else {
+ return;
+ };
+
+ let Some(parent) = path.parent() else {
+ return;
+ };
+
+ if fs::create_dir_all(parent).is_err() {
+ return;
+ }
+
+ let Ok(raw) = serde_json::to_string_pretty(settings) else {
+ return;
+ };
+
+ if let Err(error) = fs::write(path, raw) {
+ tracing::warn!("Failed to persist settings: {error}");
+ }
+}
+
+fn default_bookmarks() -> Vec {
+ vec![
+ BookmarkInfo {
+ name: "Local Server".to_string(),
+ address: "127.0.0.1".to_string(),
+ port: 9987,
+ nickname: Some("User".to_string()),
+ favorite: true,
+ last_used_at: None,
+ },
+ BookmarkInfo {
+ name: "KR TeamSpeak".to_string(),
+ address: "kr.teamspeak.app".to_string(),
+ port: 9987,
+ nickname: None,
+ favorite: true,
+ last_used_at: None,
+ },
+ ]
+}
+
+fn config_dir() -> Option {
+ #[cfg(target_os = "windows")]
+ {
+ std::env::var_os("APPDATA").map(|base| PathBuf::from(base).join("re-teamspeak"))
+ }
+
+ #[cfg(not(target_os = "windows"))]
+ {
+ if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") {
+ return Some(PathBuf::from(base).join("re-teamspeak"));
+ }
+ std::env::var_os("HOME")
+ .map(|home| PathBuf::from(home).join(".config").join("re-teamspeak"))
+ }
+}
+
+fn bookmarks_config_path() -> Option {
+ config_dir().map(|path| path.join("bookmarks.json"))
+}
+
+fn settings_config_path() -> Option {
+ config_dir().map(|path| path.join("settings.json"))
+}
diff --git a/src/iced-app/src/runtime.rs b/src/iced-app/src/runtime.rs
new file mode 100644
index 0000000..7c88788
--- /dev/null
+++ b/src/iced-app/src/runtime.rs
@@ -0,0 +1,132 @@
+use iced::futures::StreamExt;
+
+use std::sync::Arc;
+use tokio::sync::{mpsc, Mutex};
+
+use tsclientlib::sync::{SyncConnection, SyncConnectionHandle, SyncStreamItem};
+use tsclientlib::AudioEvent;
+use tsproto_packets::packets::{AudioData, CodecType, OutAudio};
+
+use crate::audio;
+use crate::noise_cancel;
+use crate::types::TsEvent;
+
+pub async fn run_connection(
+ con: SyncConnection,
+ event_tx: mpsc::Sender,
+ audio: Arc>,
+) {
+ let mut stream = con;
+ while let Some(item) = stream.next().await {
+ let ts_event = match item {
+ Ok(SyncStreamItem::BookEvents(events)) => TsEvent::BookEvents(events),
+ Ok(SyncStreamItem::MessageEvent(msg)) => TsEvent::MessageEvent(msg),
+ Ok(SyncStreamItem::AudioChange(change)) => match change {
+ AudioEvent::CanSendAudio(can) => TsEvent::AudioChange(can, true),
+ AudioEvent::CanReceiveAudio(can) => TsEvent::AudioChange(false, can),
+ },
+ Ok(SyncStreamItem::IdentityLevelIncreasing(level)) => {
+ TsEvent::IdentityLevelIncreasing(level)
+ }
+ Ok(SyncStreamItem::IdentityLevelIncreased) => TsEvent::IdentityLevelIncreased,
+ Ok(SyncStreamItem::DisconnectedTemporarily(_)) => TsEvent::DisconnectedTemporarily,
+ Ok(SyncStreamItem::NetworkStatsUpdated) => continue,
+ Ok(SyncStreamItem::Audio(audio_buf)) => {
+ let playback = audio.lock().await;
+ playback.send_packet(audio_buf);
+ continue;
+ }
+ Err(e) => TsEvent::Error(e.to_string()),
+ };
+ if event_tx.send(ts_event).await.is_err() {
+ break;
+ }
+ }
+ let _ = event_tx.send(TsEvent::Disconnected).await;
+}
+
+pub fn start_transmission(
+ mic: Arc>,
+ handle: Arc>>,
+ device_name: Option,
+ noise_reducer: Arc>,
+) {
+ let (sample_tx, sample_rx) = std::sync::mpsc::channel();
+
+ tokio::spawn(async move {
+ {
+ let mut mic_guard = mic.lock().await;
+ if mic_guard.start(device_name.as_deref(), sample_tx).is_err() {
+ return;
+ }
+ }
+
+ let mut enc = match audio::OpusEncoderState::new() {
+ Ok(e) => e,
+ Err(_) => return,
+ };
+
+ let (audio_tx, mut audio_rx) = tokio::sync::mpsc::channel::>(100);
+
+ let send_handle = handle.clone();
+ tokio::spawn(async move {
+ while let Some(data) = audio_rx.recv().await {
+ let mut guard = send_handle.lock().await;
+ if let Some(ref mut h) = *guard {
+ let _ = h
+ .with_connection(move |con| {
+ let packet = OutAudio::new(&AudioData::C2S {
+ id: 0,
+ codec: CodecType::OpusVoice,
+ data: &data,
+ });
+ let _ = con.send_audio(packet);
+ Ok::<(), ()>(())
+ })
+ .await;
+ }
+ }
+ });
+
+ loop {
+ match sample_rx.recv() {
+ Ok(samples) => {
+ let mut nr = noise_reducer.lock().await;
+ let mut samples_copy = samples.clone();
+ nr.process(&mut samples_copy);
+ enc.encode_and_send(&samples_copy, &audio_tx);
+ }
+ Err(_) => break,
+ }
+ }
+
+ let mut mic_guard = mic.lock().await;
+ mic_guard.stop();
+ });
+}
+
+pub fn stop_transmission(
+ mic: Arc>,
+ handle: Arc>>,
+) {
+ tokio::spawn(async move {
+ {
+ let mut mic_guard = mic.lock().await;
+ mic_guard.stop();
+ }
+ let mut guard = handle.lock().await;
+ if let Some(ref mut h) = *guard {
+ let _ = h
+ .with_connection(move |con| {
+ let packet = OutAudio::new(&AudioData::C2S {
+ id: 0,
+ codec: CodecType::OpusVoice,
+ data: &[],
+ });
+ let _ = con.send_audio(packet);
+ Ok::<(), ()>(())
+ })
+ .await;
+ }
+ });
+}
diff --git a/src/iced-app/src/theme.rs b/src/iced-app/src/theme.rs
index c5a5e48..fd01d42 100644
--- a/src/iced-app/src/theme.rs
+++ b/src/iced-app/src/theme.rs
@@ -1,39 +1,113 @@
use iced::widget::{button, container, text_input};
use iced::{Background, Border, Color, Shadow, Theme};
-// Apple-inspired dark palette
-pub const BG_PRIMARY: Color = Color::from_rgb(0.11, 0.11, 0.118); // #1C1C1E
-pub const BG_SECONDARY: Color = Color::from_rgb(0.17, 0.17, 0.18); // #2C2C2E
-pub const BG_TERTIARY: Color = Color::from_rgb(0.22, 0.22, 0.235); // #38383A
-pub const BG_ELEVATED: Color = Color::from_rgb(0.29, 0.29, 0.305); // #48484A
-pub const TEXT_PRIMARY: Color = Color::from_rgb(0.95, 0.95, 0.97); // #F2F2F7
-pub const TEXT_SECONDARY: Color = Color::from_rgb(0.60, 0.60, 0.63); // #98989D
-pub const TEXT_TERTIARY: Color = Color::from_rgb(0.42, 0.42, 0.44); // #6B6B70
-pub const ACCENT: Color = Color::from_rgb(0.29, 0.56, 1.0); // #4A90D9
-pub const ACCENT_HOVER: Color = Color::from_rgb(0.36, 0.63, 1.0); // #5CA0FF
-pub const SUCCESS: Color = Color::from_rgb(0.29, 0.85, 0.56); // #4AD98F
-pub const DANGER: Color = Color::from_rgb(1.0, 0.27, 0.23); // #FF453A
-pub const SEPARATOR: Color = Color::from_rgb(0.33, 0.33, 0.35); // #545458
-pub const SIDEBAR_BG: Color = Color::from_rgb(0.09, 0.09, 0.098); // #171719
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub enum AppearanceMode {
+ Light,
+ Dark,
+}
-pub fn dark_theme() -> Theme {
+impl AppearanceMode {
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Light => "Light",
+ Self::Dark => "Dark",
+ }
+ }
+}
+
+const LIGHT_BG: Color = Color { r: 245.0 / 255.0, g: 245.0 / 255.0, b: 247.0 / 255.0, a: 1.0 };
+const LIGHT_SURFACE: Color = Color { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
+const LIGHT_ELEVATED: Color = Color { r: 251.0 / 255.0, g: 251.0 / 255.0, b: 253.0 / 255.0, a: 1.0 };
+const LIGHT_TEXT: Color = Color { r: 29.0 / 255.0, g: 29.0 / 255.0, b: 31.0 / 255.0, a: 1.0 };
+const LIGHT_TEXT_SECONDARY: Color = Color { r: 110.0 / 255.0, g: 110.0 / 255.0, b: 115.0 / 255.0, a: 1.0 };
+const LIGHT_BORDER: Color = Color { r: 210.0 / 255.0, g: 210.0 / 255.0, b: 215.0 / 255.0, a: 1.0 };
+const LIGHT_ACCENT: Color = Color { r: 0.0, g: 122.0 / 255.0, b: 1.0, a: 1.0 };
+
+const DARK_BG: Color = Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
+const DARK_SURFACE: Color = Color { r: 28.0 / 255.0, g: 28.0 / 255.0, b: 30.0 / 255.0, a: 1.0 };
+const DARK_ELEVATED: Color = Color { r: 44.0 / 255.0, g: 44.0 / 255.0, b: 46.0 / 255.0, a: 1.0 };
+const DARK_TEXT: Color = Color { r: 245.0 / 255.0, g: 245.0 / 255.0, b: 247.0 / 255.0, a: 1.0 };
+const DARK_TEXT_SECONDARY: Color = Color { r: 174.0 / 255.0, g: 174.0 / 255.0, b: 178.0 / 255.0, a: 1.0 };
+const DARK_BORDER: Color = Color { r: 56.0 / 255.0, g: 56.0 / 255.0, b: 58.0 / 255.0, a: 1.0 };
+const DARK_ACCENT: Color = Color { r: 10.0 / 255.0, g: 132.0 / 255.0, b: 1.0, a: 1.0 };
+
+const SUCCESS: Color = Color { r: 52.0 / 255.0, g: 199.0 / 255.0, b: 89.0 / 255.0, a: 1.0 };
+const DANGER: Color = Color { r: 1.0, g: 69.0 / 255.0, b: 58.0 / 255.0, a: 1.0 };
+
+pub fn light_theme() -> Theme {
Theme::custom(
- "ReTeamSpeak".to_string(),
+ "ReTeamSpeak Light".to_string(),
iced::theme::Palette {
- background: BG_PRIMARY,
- text: TEXT_PRIMARY,
- primary: ACCENT,
+ background: LIGHT_BG,
+ text: LIGHT_TEXT,
+ primary: LIGHT_ACCENT,
success: SUCCESS,
danger: DANGER,
},
)
}
-pub fn sidebar_container(_theme: &Theme) -> container::Style {
+pub fn dark_theme() -> Theme {
+ Theme::custom(
+ "ReTeamSpeak Dark".to_string(),
+ iced::theme::Palette {
+ background: DARK_BG,
+ text: DARK_TEXT,
+ primary: DARK_ACCENT,
+ success: SUCCESS,
+ danger: DANGER,
+ },
+ )
+}
+
+fn is_dark(theme: &Theme) -> bool {
+ theme.palette().background.r < 0.2
+}
+
+fn surface(theme: &Theme) -> Color {
+ if is_dark(theme) { DARK_SURFACE } else { LIGHT_SURFACE }
+}
+
+fn elevated(theme: &Theme) -> Color {
+ if is_dark(theme) { DARK_ELEVATED } else { LIGHT_ELEVATED }
+}
+
+fn text_secondary(theme: &Theme) -> Color {
+ if is_dark(theme) { DARK_TEXT_SECONDARY } else { LIGHT_TEXT_SECONDARY }
+}
+
+pub fn icon_color(theme: &Theme) -> Color {
+ text_secondary(theme)
+}
+
+pub fn nav_icon_color(theme: &Theme) -> Color {
+ text_secondary(theme)
+}
+
+fn border(theme: &Theme) -> Color {
+ if is_dark(theme) { DARK_BORDER } else { LIGHT_BORDER }
+}
+
+fn accent(theme: &Theme) -> Color {
+ theme.palette().primary
+}
+
+fn accent_hover(theme: &Theme) -> Color {
+ let primary = accent(theme);
+ Color { a: primary.a, ..Color::from_rgba(
+ (primary.r + 0.05).min(1.0),
+ (primary.g + 0.05).min(1.0),
+ (primary.b + 0.05).min(1.0),
+ 1.0,
+ ) }
+}
+
+pub fn sidebar_container(theme: &Theme) -> container::Style {
container::Style {
- background: Some(Background::Color(SIDEBAR_BG)),
+ background: Some(Background::Color(elevated(theme))),
border: Border {
- color: SEPARATOR,
+ color: border(theme),
width: 0.0,
radius: 0.0.into(),
},
@@ -41,360 +115,238 @@ pub fn sidebar_container(_theme: &Theme) -> container::Style {
}
}
-pub fn card_container(_theme: &Theme) -> container::Style {
+pub fn main_panel_container(theme: &Theme) -> container::Style {
container::Style {
- background: Some(Background::Color(BG_SECONDARY)),
+ background: Some(Background::Color(theme.palette().background)),
+ ..container::Style::default()
+ }
+}
+
+pub fn sidebar_section_container(theme: &Theme) -> container::Style {
+ container::Style {
+ background: Some(Background::Color(surface(theme))),
+ border: Border {
+ color: border(theme),
+ width: 1.0,
+ radius: 16.0.into(),
+ },
+ shadow: Shadow {
+ color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.18 } else { 0.08 }),
+ offset: iced::Vector::new(0.0, 4.0),
+ blur_radius: 16.0,
+ },
+ ..container::Style::default()
+ }
+}
+
+pub fn hero_container(theme: &Theme) -> container::Style {
+ container::Style {
+ background: Some(Background::Color(surface(theme))),
+ border: Border {
+ color: border(theme),
+ width: 1.0,
+ radius: 20.0.into(),
+ },
+ shadow: Shadow {
+ color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.24 } else { 0.10 }),
+ offset: iced::Vector::new(0.0, 8.0),
+ blur_radius: 24.0,
+ },
+ ..container::Style::default()
+ }
+}
+
+pub fn card_container(theme: &Theme) -> container::Style {
+ container::Style {
+ background: Some(Background::Color(surface(theme))),
+ border: Border {
+ color: border(theme),
+ width: 1.0,
+ radius: 16.0.into(),
+ },
+ shadow: Shadow {
+ color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.16 } else { 0.07 }),
+ offset: iced::Vector::new(0.0, 4.0),
+ blur_radius: 16.0,
+ },
+ ..container::Style::default()
+ }
+}
+
+pub fn voice_capsule_container(theme: &Theme) -> container::Style {
+ container::Style {
+ background: Some(Background::Color(elevated(theme))),
+ border: Border {
+ color: border(theme),
+ width: 1.0,
+ radius: 18.0.into(),
+ },
+ shadow: Shadow {
+ color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.20 } else { 0.08 }),
+ offset: iced::Vector::new(0.0, 6.0),
+ blur_radius: 18.0,
+ },
+ ..container::Style::default()
+ }
+}
+
+pub fn elevated_container(theme: &Theme) -> container::Style {
+ container::Style {
+ background: Some(Background::Color(elevated(theme))),
+ border: Border {
+ color: border(theme),
+ width: 1.0,
+ radius: 12.0.into(),
+ },
+ ..container::Style::default()
+ }
+}
+
+pub fn input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
+ let base = match status {
+ text_input::Status::Focused => accent(theme),
+ text_input::Status::Hovered => text_secondary(theme),
+ _ => border(theme),
+ };
+
+ text_input::Style {
+ background: Background::Color(elevated(theme)),
+ border: Border {
+ color: base,
+ width: if matches!(status, text_input::Status::Focused) { 1.5 } else { 1.0 },
+ radius: 12.0.into(),
+ },
+ icon: text_secondary(theme),
+ placeholder: text_secondary(theme),
+ value: theme.palette().text,
+ selection: accent(theme),
+ }
+}
+
+pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style {
+ let background = match status {
+ button::Status::Hovered => accent_hover(theme),
+ button::Status::Pressed => accent_hover(theme),
+ button::Status::Disabled => elevated(theme),
+ _ => accent(theme),
+ };
+
+ button::Style {
+ background: Some(Background::Color(background)),
+ text_color: Color::WHITE,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 12.0.into(),
},
shadow: Shadow {
- color: Color::from_rgba(0.0, 0.0, 0.0, 0.3),
- offset: iced::Vector::new(0.0, 2.0),
- blur_radius: 8.0,
+ color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.20 } else { 0.10 }),
+ offset: iced::Vector::new(0.0, 3.0),
+ blur_radius: 10.0,
},
- ..container::Style::default()
}
}
-pub fn elevated_container(_theme: &Theme) -> container::Style {
- container::Style {
- background: Some(Background::Color(BG_TERTIARY)),
+pub fn secondary_button(theme: &Theme, status: button::Status) -> button::Style {
+ let background = match status {
+ button::Status::Hovered => elevated(theme),
+ button::Status::Pressed => elevated(theme),
+ _ => surface(theme),
+ };
+
+ button::Style {
+ background: Some(Background::Color(background)),
+ text_color: theme.palette().text,
+ border: Border {
+ color: border(theme),
+ width: 1.0,
+ radius: 12.0.into(),
+ },
+ shadow: Shadow::default(),
+ }
+}
+
+pub fn danger_button(_theme: &Theme, _status: button::Status) -> button::Style {
+ button::Style {
+ background: Some(Background::Color(DANGER)),
+ text_color: Color::WHITE,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
- radius: 10.0.into(),
+ radius: 12.0.into(),
},
- ..container::Style::default()
+ shadow: Shadow::default(),
}
}
-pub fn input_style(_theme: &Theme, status: text_input::Status) -> text_input::Style {
- match status {
- text_input::Status::Active => text_input::Style {
- background: Background::Color(BG_TERTIARY),
- border: Border {
- color: SEPARATOR,
- width: 1.0,
- radius: 8.0.into(),
- },
- icon: TEXT_SECONDARY,
- placeholder: TEXT_TERTIARY,
- value: TEXT_PRIMARY,
- selection: ACCENT,
- },
- text_input::Status::Focused => text_input::Style {
- background: Background::Color(BG_TERTIARY),
- border: Border {
- color: ACCENT,
- width: 1.5,
- radius: 8.0.into(),
- },
- icon: TEXT_SECONDARY,
- placeholder: TEXT_TERTIARY,
- value: TEXT_PRIMARY,
- selection: ACCENT,
- },
- text_input::Status::Hovered => text_input::Style {
- background: Background::Color(BG_TERTIARY),
- border: Border {
- color: TEXT_TERTIARY,
- width: 1.0,
- radius: 8.0.into(),
- },
- icon: TEXT_SECONDARY,
- placeholder: TEXT_TERTIARY,
- value: TEXT_PRIMARY,
- selection: ACCENT,
- },
- _ => text_input::Style {
- background: Background::Color(BG_TERTIARY),
- border: Border {
- color: SEPARATOR,
- width: 1.0,
- radius: 8.0.into(),
- },
- icon: TEXT_SECONDARY,
- placeholder: TEXT_TERTIARY,
- value: TEXT_PRIMARY,
- selection: ACCENT,
+pub fn nav_button_active(theme: &Theme, _status: button::Status) -> button::Style {
+ button::Style {
+ background: Some(Background::Color(accent(theme))),
+ text_color: Color::WHITE,
+ border: Border {
+ color: Color::TRANSPARENT,
+ width: 0.0,
+ radius: 12.0.into(),
},
+ shadow: Shadow::default(),
}
}
-pub fn primary_button(_theme: &Theme, status: button::Status) -> button::Style {
- match status {
- button::Status::Active => button::Style {
- background: Some(Background::Color(ACCENT)),
- text_color: Color::WHITE,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow {
- color: Color::from_rgba(0.29, 0.56, 1.0, 0.3),
- offset: iced::Vector::new(0.0, 1.0),
- blur_radius: 4.0,
- },
+pub fn nav_button_inactive(theme: &Theme, status: button::Status) -> button::Style {
+ let background = if matches!(status, button::Status::Hovered) {
+ surface(theme)
+ } else {
+ Color::TRANSPARENT
+ };
+
+ button::Style {
+ background: Some(Background::Color(background)),
+ text_color: if matches!(status, button::Status::Hovered) {
+ theme.palette().text
+ } else {
+ text_secondary(theme)
},
- button::Status::Hovered => button::Style {
- background: Some(Background::Color(ACCENT_HOVER)),
- text_color: Color::WHITE,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow {
- color: Color::from_rgba(0.29, 0.56, 1.0, 0.4),
- offset: iced::Vector::new(0.0, 2.0),
- blur_radius: 6.0,
- },
- },
- button::Status::Pressed => button::Style {
- background: Some(Background::Color(Color::from_rgb(0.22, 0.44, 0.80))),
- text_color: Color::WHITE,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- button::Status::Disabled => button::Style {
- background: Some(Background::Color(BG_ELEVATED)),
- text_color: TEXT_TERTIARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
+ border: Border {
+ color: Color::TRANSPARENT,
+ width: 0.0,
+ radius: 12.0.into(),
},
+ shadow: Shadow::default(),
}
}
-pub fn secondary_button(_theme: &Theme, status: button::Status) -> button::Style {
- match status {
- button::Status::Active => button::Style {
- background: Some(Background::Color(BG_TERTIARY)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: SEPARATOR,
- width: 1.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
+pub fn bookmark_button_selected(theme: &Theme, _status: button::Status) -> button::Style {
+ button::Style {
+ background: Some(Background::Color(elevated(theme))),
+ text_color: theme.palette().text,
+ border: Border {
+ color: accent(theme),
+ width: 1.0,
+ radius: 14.0.into(),
},
- button::Status::Hovered => button::Style {
- background: Some(Background::Color(BG_ELEVATED)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: TEXT_TERTIARY,
- width: 1.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- button::Status::Pressed => button::Style {
- background: Some(Background::Color(BG_SECONDARY)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: SEPARATOR,
- width: 1.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- _ => button::Style::default(),
+ shadow: Shadow::default(),
}
}
-pub fn danger_button(_theme: &Theme, status: button::Status) -> button::Style {
- match status {
- button::Status::Active => button::Style {
- background: Some(Background::Color(DANGER)),
- text_color: Color::WHITE,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
+pub fn bookmark_button(theme: &Theme, status: button::Status) -> button::Style {
+ let background = if matches!(status, button::Status::Hovered) {
+ elevated(theme)
+ } else {
+ Color::TRANSPARENT
+ };
+
+ button::Style {
+ background: Some(Background::Color(background)),
+ text_color: if matches!(status, button::Status::Hovered) {
+ theme.palette().text
+ } else {
+ text_secondary(theme)
},
- button::Status::Hovered => button::Style {
- background: Some(Background::Color(Color::from_rgb(1.0, 0.35, 0.30))),
- text_color: Color::WHITE,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- _ => button::Style {
- background: Some(Background::Color(DANGER)),
- text_color: Color::WHITE,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
+ border: Border {
+ color: Color::TRANSPARENT,
+ width: 0.0,
+ radius: 14.0.into(),
},
+ shadow: Shadow::default(),
}
}
-
-pub fn nav_button_active(_theme: &Theme, status: button::Status) -> button::Style {
- match status {
- button::Status::Active => button::Style {
- background: Some(Background::Color(BG_TERTIARY)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- button::Status::Hovered => button::Style {
- background: Some(Background::Color(BG_ELEVATED)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- _ => button::Style {
- background: Some(Background::Color(BG_TERTIARY)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- }
-}
-
-pub fn nav_button_inactive(_theme: &Theme, status: button::Status) -> button::Style {
- match status {
- button::Status::Active => button::Style {
- background: Some(Background::Color(Color::TRANSPARENT)),
- text_color: TEXT_SECONDARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- button::Status::Hovered => button::Style {
- background: Some(Background::Color(BG_SECONDARY)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- _ => button::Style {
- background: Some(Background::Color(Color::TRANSPARENT)),
- text_color: TEXT_SECONDARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 8.0.into(),
- },
- shadow: Shadow::default(),
- },
- }
-}
-
-pub fn bookmark_button_selected(_theme: &Theme, status: button::Status) -> button::Style {
- match status {
- button::Status::Active => button::Style {
- background: Some(Background::Color(BG_TERTIARY)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: ACCENT,
- width: 1.0,
- radius: 10.0.into(),
- },
- shadow: Shadow::default(),
- },
- button::Status::Hovered => button::Style {
- background: Some(Background::Color(BG_ELEVATED)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: ACCENT,
- width: 1.0,
- radius: 10.0.into(),
- },
- shadow: Shadow::default(),
- },
- _ => button::Style {
- background: Some(Background::Color(BG_TERTIARY)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: ACCENT,
- width: 1.0,
- radius: 10.0.into(),
- },
- shadow: Shadow::default(),
- },
- }
-}
-
-pub fn bookmark_button(_theme: &Theme, status: button::Status) -> button::Style {
- match status {
- button::Status::Active => button::Style {
- background: Some(Background::Color(Color::TRANSPARENT)),
- text_color: TEXT_SECONDARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 10.0.into(),
- },
- shadow: Shadow::default(),
- },
- button::Status::Hovered => button::Style {
- background: Some(Background::Color(BG_SECONDARY)),
- text_color: TEXT_PRIMARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 10.0.into(),
- },
- shadow: Shadow::default(),
- },
- _ => button::Style {
- background: Some(Background::Color(Color::TRANSPARENT)),
- text_color: TEXT_SECONDARY,
- border: Border {
- color: Color::TRANSPARENT,
- width: 0.0,
- radius: 10.0.into(),
- },
- shadow: Shadow::default(),
- },
- }
-}
-
-
-pub fn separator_line<'a>() -> iced::widget::Rule<'a, Theme> {
- iced::widget::horizontal_rule(1).style(|_theme: &Theme| {
- iced::widget::rule::Style {
- color: SEPARATOR,
- width: 1,
- radius: 0.0.into(),
- fill_mode: iced::widget::rule::FillMode::Full,
- }
- })
-}
diff --git a/src/iced-app/src/types.rs b/src/iced-app/src/types.rs
new file mode 100644
index 0000000..41035a3
--- /dev/null
+++ b/src/iced-app/src/types.rs
@@ -0,0 +1,150 @@
+use serde::{Deserialize, Serialize};
+use tsclientlib::events::Event;
+use tsclientlib::{ChannelId, ClientId, Identity};
+
+use crate::{audio, noise_cancel};
+use crate::theme;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Page {
+ Home,
+ Bookmarks,
+ Identities,
+ Settings,
+}
+
+#[derive(Debug, Clone)]
+#[allow(clippy::enum_variant_names)]
+pub(crate) enum Message {
+ PageChanged(Page),
+ SelectBookmark(Option),
+ AddBookmark,
+ CancelAddBookmark,
+ DeleteBookmark(usize),
+ BookmarkNameChanged(String),
+ BookmarkAddressChanged(String),
+ BookmarkPortChanged(String),
+ NicknameChanged(String),
+ PasswordChanged(String),
+ Connect,
+ JoinChannel(ChannelId),
+ ToggleChannelCollapsed(ChannelId),
+ Disconnect,
+ TsEvent(TsEvent),
+ ChannelSearchChanged(String),
+ ToggleLocalClientMute(ClientId),
+ IncreaseClientVolume(ClientId),
+ DecreaseClientVolume(ClientId),
+ MessageInputChanged(String),
+ SendChannelMessage,
+ PttPressed,
+ PttReleased,
+ ToggleTalkMode,
+ SetNoiseCancelMethod(noise_cancel::NoiseCancelMethod),
+ StartContinuous,
+ StopContinuous,
+ MicSamples(Vec),
+ ToggleMicMute,
+ ToggleSpeakerMute,
+ ToggleHeadsetMute,
+ ToggleAfk,
+ IdentityStringChanged(String),
+ IdentityFilePathChanged(String),
+ ImportIdentityFromString,
+ ImportIdentityFromFile,
+ IdentityImported(Result),
+ SetOutputDevice(String),
+ SetInputDevice(String),
+ SetAppearance(theme::AppearanceMode),
+ Noop,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) enum TsEvent {
+ Connected,
+ BookEvents(Vec),
+ MessageEvent(tsclientlib::InMessage),
+ AudioChange(bool, bool),
+ IdentityLevelIncreasing(u8),
+ IdentityLevelIncreased,
+ DisconnectedTemporarily,
+ Disconnected,
+ Error(String),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default)]
+pub(crate) struct BookmarkInfo {
+ pub(crate) name: String,
+ pub(crate) address: String,
+ pub(crate) port: u16,
+ pub(crate) nickname: Option,
+ pub(crate) favorite: bool,
+ pub(crate) last_used_at: Option,
+}
+
+impl Default for BookmarkInfo {
+ fn default() -> Self {
+ Self {
+ name: String::new(),
+ address: String::new(),
+ port: 9987,
+ nickname: None,
+ favorite: false,
+ last_used_at: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct ChannelEntry {
+ pub(crate) id: ChannelId,
+ pub(crate) name: String,
+ #[allow(dead_code)]
+ pub(crate) parent: ChannelId,
+ pub(crate) order: ChannelId,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct ClientEntry {
+ pub(crate) id: ClientId,
+ pub(crate) name: String,
+ pub(crate) channel: ChannelId,
+ pub(crate) input_muted: bool,
+ #[allow(dead_code)]
+ pub(crate) output_muted: bool,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct ChatMessage {
+ pub(crate) invoker_name: String,
+ pub(crate) message: String,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct LocalClientAudio {
+ pub(crate) muted: bool,
+ pub(crate) volume: f32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default)]
+pub(crate) struct AppSettings {
+ pub(crate) appearance_mode: theme::AppearanceMode,
+ pub(crate) selected_output_device: String,
+ pub(crate) selected_input_device: String,
+ pub(crate) talk_mode: audio::TalkMode,
+ pub(crate) noise_cancel_method: noise_cancel::NoiseCancelMethod,
+}
+
+impl Default for AppSettings {
+ fn default() -> Self {
+ Self {
+ appearance_mode: theme::AppearanceMode::Dark,
+ selected_output_device: String::new(),
+ selected_input_device: String::new(),
+ talk_mode: audio::TalkMode::PushToTalk,
+ noise_cancel_method: noise_cancel::NoiseCancelMethod::None,
+ }
+ }
+}
diff --git a/src/iced-app/src/view.rs b/src/iced-app/src/view.rs
new file mode 100644
index 0000000..3ca65ef
--- /dev/null
+++ b/src/iced-app/src/view.rs
@@ -0,0 +1,1035 @@
+use iced::widget::{button, column, container, horizontal_space, row, scrollable, text, text_input, vertical_space};
+use iced::{Element, Length, Theme};
+use tsclientlib::ChannelId;
+
+use crate::audio;
+use crate::noise_cancel;
+use crate::{icons, theme, App, Message, Page};
+use crate::types::{BookmarkInfo, ChannelEntry, ClientEntry};
+
+impl App {
+ pub(crate) fn view_sidebar(&self) -> Element<'_, Message> {
+ let theme_value = self.theme();
+ let primary_nav = column![
+ nav_button(&theme_value, icons::Icon::Home, "Home", Page::Home, self.page),
+ nav_button(&theme_value, icons::Icon::Bookmark, "Bookmarks", Page::Bookmarks, self.page),
+ nav_button(&theme_value, icons::Icon::UserCircle, "Identities", Page::Identities, self.page),
+ nav_button(&theme_value, icons::Icon::Cog, "Settings", Page::Settings, self.page),
+ ]
+ .spacing(4);
+
+ let bookmarks = self.view_sidebar_bookmarks();
+ let recent = self.view_sidebar_recent();
+
+ let footer = container(
+ column![
+ text("Current Voice").size(11).style(text::secondary),
+ vertical_space().height(4),
+ text(self.voice_summary_line()).size(12),
+ ]
+ .spacing(2),
+ )
+ .style(theme::sidebar_section_container)
+ .padding(12);
+
+ container(
+ column![
+ text("TeamSpeak").size(24),
+ vertical_space().height(16),
+ primary_nav,
+ vertical_space().height(20),
+ bookmarks,
+ vertical_space().height(12),
+ recent,
+ vertical_space().height(Length::Fill),
+ footer,
+ ]
+ .width(220)
+ .height(Length::Fill)
+ .padding(16)
+ .spacing(0),
+ )
+ .style(theme::sidebar_container)
+ .width(220)
+ .height(Length::Fill)
+ .into()
+ }
+
+ pub(crate) fn view_sidebar_bookmarks(&self) -> Element<'_, Message> {
+ let mut list = column![].spacing(4).padding(12);
+
+ for (i, bookmark) in self.bookmarks.iter().enumerate() {
+ let is_selected = self.selected_bookmark == Some(i);
+
+ let btn = button(
+ row![
+ column![
+ text(&bookmark.name).size(13),
+ text(format!("{}:{}", bookmark.address, bookmark.port))
+ .size(11)
+ .style(text::secondary),
+ ]
+ .spacing(2)
+ .width(Length::Fill),
+ button(text("x").size(10))
+ .padding(4)
+ .style(theme::danger_button)
+ .on_press(Message::DeleteBookmark(i)),
+ ]
+ .align_y(iced::Alignment::Center)
+ .spacing(8),
+ )
+ .width(Length::Fill)
+ .on_press(Message::SelectBookmark(Some(i)))
+ .padding([10, 12])
+ .style(if is_selected {
+ theme::bookmark_button_selected
+ } else {
+ theme::bookmark_button
+ });
+
+ list = list.push(btn);
+ }
+
+ if self.editing_bookmark {
+ let form = container(
+ column![
+ text_input("Server name", &self.bm_name_input)
+ .on_input(Message::BookmarkNameChanged)
+ .style(theme::input_style),
+ text_input("Address", &self.bm_address_input)
+ .on_input(Message::BookmarkAddressChanged)
+ .style(theme::input_style),
+ text_input("Port", &self.bm_port_input)
+ .on_input(Message::BookmarkPortChanged)
+ .style(theme::input_style),
+ row![
+ button(text("Save").size(12))
+ .padding([6, 16])
+ .style(theme::primary_button)
+ .on_press(Message::Connect),
+ button(text("Cancel").size(12))
+ .padding([6, 16])
+ .style(theme::secondary_button)
+ .on_press(Message::CancelAddBookmark),
+ ]
+ .spacing(8),
+ ]
+ .spacing(8)
+ .padding(12),
+ )
+ .style(theme::card_container)
+ .padding(8);
+
+ list = list.push(form);
+ }
+
+ container(column![
+ container(
+ row![
+ text("Bookmarks").size(14),
+ horizontal_space(),
+ button(text("+").size(16))
+ .padding([2, 8])
+ .style(theme::secondary_button)
+ .on_press(Message::AddBookmark),
+ ]
+ .align_y(iced::Alignment::Center),
+ )
+ .padding([0, 12]),
+ vertical_space().height(8),
+ scrollable(list).height(Length::Fill),
+ ])
+ .style(theme::sidebar_section_container)
+ .into()
+ }
+
+ pub(crate) fn view_sidebar_recent(&self) -> Element<'_, Message> {
+ let recent_bookmarks = self.recent_bookmarks(2);
+ let recent = if recent_bookmarks.is_empty() {
+ column![text("No recent server").size(13).style(text::secondary)]
+ } else {
+ recent_bookmarks.into_iter().fold(column![].spacing(8), |column, bookmark| {
+ column.push(
+ column![
+ text(&bookmark.name).size(13),
+ text(format!("{}:{}", bookmark.address, bookmark.port))
+ .size(11)
+ .style(text::secondary),
+ ]
+ .spacing(2),
+ )
+ })
+ };
+
+ container(
+ column![
+ text("Recent").size(14),
+ vertical_space().height(8),
+ recent,
+ ]
+ .padding(12)
+ .spacing(0),
+ )
+ .style(theme::sidebar_section_container)
+ .into()
+ }
+
+ pub(crate) fn view_content(&self) -> Element<'_, Message> {
+ match self.page {
+ Page::Home => self.view_home_content(),
+ Page::Bookmarks => self.view_server_content(),
+ Page::Identities => self.view_identities_content(),
+ Page::Settings => self.view_settings_content(),
+ }
+ }
+
+ pub(crate) fn view_home_content(&self) -> Element<'_, Message> {
+ let continue_card = if let Some(idx) = self.selected_bookmark {
+ if let Some(bookmark) = self.bookmarks.get(idx) {
+ container(
+ column![
+ text("Continue").size(17),
+ vertical_space().height(8),
+ text(&bookmark.name).size(20),
+ text(format!("{}:{}", bookmark.address, bookmark.port))
+ .size(13)
+ .style(text::secondary),
+ vertical_space().height(12),
+ button(text(if self.connected { "Open Server" } else { "Connect" }).size(13))
+ .padding([10, 18])
+ .style(theme::primary_button)
+ .on_press(if self.connected { Message::PageChanged(Page::Bookmarks) } else { Message::Connect }),
+ ]
+ .spacing(0)
+ .padding(20),
+ )
+ .style(theme::hero_container)
+ } else {
+ container(text("Select a bookmark to continue").size(13).style(text::secondary))
+ .padding(20)
+ .style(theme::hero_container)
+ }
+ } else {
+ container(text("Select a bookmark to continue").size(13).style(text::secondary))
+ .padding(20)
+ .style(theme::hero_container)
+ };
+
+ let favorite_bookmarks = self.favorite_bookmarks(2);
+ let mut favorite_col = column![text("Favorites").size(17), vertical_space().height(8)].spacing(0);
+ if favorite_bookmarks.is_empty() {
+ favorite_col = favorite_col.push(
+ text("No favorites yet").size(13).style(text::secondary),
+ );
+ } else {
+ for bookmark in favorite_bookmarks {
+ favorite_col = favorite_col.push(text(bookmark.name.clone()).size(15));
+ favorite_col = favorite_col.push(
+ text(format!("{}:{}", bookmark.address, bookmark.port))
+ .size(13)
+ .style(text::secondary),
+ );
+ favorite_col = favorite_col.push(vertical_space().height(8));
+ }
+ }
+
+ let favorites = container(favorite_col.padding(20)).style(theme::card_container);
+
+ let audio_summary = container(
+ column![
+ text("Audio").size(17),
+ vertical_space().height(8),
+ text(format!("Mic: {}", self.input_label())).size(15),
+ text(format!("Output: {}", self.output_label()))
+ .size(13)
+ .style(text::secondary),
+ text(format!("Mode: {}", self.talk_mode_label()))
+ .size(13)
+ .style(text::secondary),
+ ]
+ .padding(20)
+ .spacing(0),
+ )
+ .style(theme::card_container);
+
+ column![
+ text("Good evening").size(32),
+ vertical_space().height(8),
+ text("Return to voice quickly with your recent server and audio setup.")
+ .size(15)
+ .style(text::secondary),
+ vertical_space().height(24),
+ continue_card,
+ vertical_space().height(16),
+ row![favorites, audio_summary].spacing(16),
+ vertical_space().height(16),
+ self.view_voice_capsule(),
+ ]
+ .padding(20)
+ .spacing(0)
+ .into()
+ }
+
+ pub(crate) fn view_identities_content(&self) -> Element<'_, Message> {
+ let identity_status = if let Some(identity) = &self.imported_identity {
+ column![
+ text("Selected Identity").size(17),
+ vertical_space().height(8),
+ text(identity.key().to_pub().get_uid()).size(13),
+ text(format!("Security level {}", identity.level()))
+ .size(12)
+ .style(text::secondary),
+ ]
+ } else {
+ column![
+ text("Selected Identity").size(17),
+ vertical_space().height(8),
+ text("Default client identity").size(13).style(text::secondary),
+ ]
+ };
+
+ column![
+ text("Identities").size(32),
+ vertical_space().height(8),
+ text("Manage the identity used for connecting to TeamSpeak servers.")
+ .size(15)
+ .style(text::secondary),
+ vertical_space().height(24),
+ container(
+ column![
+ identity_status.spacing(0),
+ vertical_space().height(16),
+ text("Paste TeamSpeak identity string").size(13),
+ vertical_space().height(8),
+ text_input("Enter exported identity string...", &self.identity_string_input)
+ .on_input(Message::IdentityStringChanged)
+ .style(theme::input_style),
+ vertical_space().height(8),
+ button(text("Import From String").size(13))
+ .padding([10, 18])
+ .style(theme::primary_button)
+ .on_press(Message::ImportIdentityFromString),
+ vertical_space().height(16),
+ text("Or import from file").size(13),
+ vertical_space().height(8),
+ text_input("Path to exported identity file...", &self.identity_file_input)
+ .on_input(Message::IdentityFilePathChanged)
+ .style(theme::input_style),
+ vertical_space().height(8),
+ button(text("Import From File").size(13))
+ .padding([10, 18])
+ .style(theme::secondary_button)
+ .on_press(Message::ImportIdentityFromFile),
+ ]
+ .padding(20)
+ .spacing(0),
+ )
+ .style(theme::card_container),
+ vertical_space().height(16),
+ self.view_voice_capsule(),
+ ]
+ .padding(20)
+ .spacing(0)
+ .into()
+ }
+
+ pub(crate) fn view_server_content(&self) -> Element<'_, Message> {
+ if let Some(idx) = self.selected_bookmark {
+ if let Some(bookmark) = self.bookmarks.get(idx) {
+ let mut connect_form = column![
+ text(bookmark.name.clone()).size(32),
+ text(format!("{}:{}", bookmark.address, bookmark.port))
+ .size(13)
+ .style(text::secondary),
+ vertical_space().height(8),
+ text(if self.connected {
+ "Connected voice workspace"
+ } else {
+ "Connect to join channels, chat, and voice"
+ })
+ .size(15)
+ .style(text::secondary),
+ vertical_space().height(16),
+ text_input("Nickname", &self.nickname)
+ .on_input(Message::NicknameChanged)
+ .style(theme::input_style),
+ vertical_space().height(8),
+ text_input("Password", &self.password)
+ .secure(true)
+ .on_input(Message::PasswordChanged)
+ .style(theme::input_style),
+ vertical_space().height(16),
+ row![
+ horizontal_space(),
+ if self.connected {
+ button(text("Disconnect").size(13))
+ .padding([10, 28])
+ .style(theme::danger_button)
+ .on_press(Message::Disconnect)
+ } else {
+ button(text("Connect").size(13))
+ .padding([10, 28])
+ .style(theme::primary_button)
+ .on_press(Message::Connect)
+ },
+ ],
+ ]
+ .spacing(4)
+ .padding(24);
+
+ if let Some(err) = &self.error {
+ connect_form = connect_form.push(
+ container(text(err.clone()).size(12).style(text::danger))
+ .padding(10)
+ .style(theme::elevated_container),
+ );
+ }
+ if self.identity_level > 0 {
+ connect_form = connect_form.push(
+ text(format!("Computing identity level {}...", self.identity_level))
+ .size(12)
+ .style(text::secondary),
+ );
+ }
+
+ if self.connected {
+ let mic_label = if self.mic_muted { "🔇 Mic" } else { "🎤 Mic" };
+ let speaker_label = if self.speaker_muted { "🔇 Speaker" } else { "🔊 Speaker" };
+ let headset_label = if self.headset_muted { "🔇 Headset" } else { "🎧 Headset" };
+ let afk_label = if self.afk { "AFK ✓" } else { "AFK" };
+
+ let control_bar = row![
+ button(text(mic_label).size(11))
+ .padding([6, 12])
+ .style(if self.mic_muted { theme::danger_button } else { theme::secondary_button })
+ .on_press(Message::ToggleMicMute),
+ button(text(speaker_label).size(11))
+ .padding([6, 12])
+ .style(if self.speaker_muted { theme::danger_button } else { theme::secondary_button })
+ .on_press(Message::ToggleSpeakerMute),
+ button(text(headset_label).size(11))
+ .padding([6, 12])
+ .style(if self.headset_muted { theme::danger_button } else { theme::secondary_button })
+ .on_press(Message::ToggleHeadsetMute),
+ horizontal_space(),
+ button(text(afk_label).size(11))
+ .padding([6, 12])
+ .style(if self.afk { theme::danger_button } else { theme::secondary_button })
+ .on_press(Message::ToggleAfk),
+ ]
+ .spacing(8)
+ .align_y(iced::Alignment::Center);
+
+ let server_header = container(
+ column![
+ row![
+ text(&self.server_name).size(24),
+ horizontal_space(),
+ ],
+ text(format!("{} / {} — {}/{} online",
+ self.server_platform, self.server_version,
+ self.clients.len(), self.server_max_clients))
+ .size(13)
+ .style(text::secondary),
+ vertical_space().height(8),
+ control_bar,
+ ]
+ .spacing(4),
+ )
+ .padding(16)
+ .style(theme::card_container);
+
+ let search = self.channel_search.trim().to_lowercase();
+ let mut browser_list = column![].spacing(8);
+ let mut match_count = 0usize;
+ for root in self.sorted_child_channels(ChannelId(0)) {
+ if let Some(section) = self.view_channel_tree(root, 0, &search, &mut match_count) {
+ browser_list = browser_list.push(section);
+ }
+ }
+
+ if self.channels.is_empty() {
+ browser_list = browser_list.push(
+ text("No channels available yet.").size(13).style(text::secondary),
+ );
+ } else if match_count == 0 {
+ browser_list = browser_list.push(
+ text("No channels or users match your search.").size(13).style(text::secondary),
+ );
+ }
+
+ let browser_panel = container(
+ column![
+ text("Channel Browser").size(17),
+ vertical_space().height(8),
+ text_input("Search channels or users...", &self.channel_search)
+ .on_input(Message::ChannelSearchChanged)
+ .style(theme::input_style),
+ vertical_space().height(12),
+ scrollable(browser_list).height(Length::Fill),
+ ]
+ .spacing(0)
+ .padding(16),
+ )
+ .style(theme::card_container)
+ .height(320);
+
+ let mut messages_col = column![].spacing(6);
+ for msg in &self.messages {
+ messages_col = messages_col.push(
+ column![
+ text(&msg.invoker_name).size(11).style(text::secondary),
+ text(&msg.message).size(13),
+ ]
+ .spacing(2),
+ );
+ }
+
+ let messages_panel = container(
+ column![
+ text("Chat").size(13),
+ vertical_space().height(8),
+ scrollable(messages_col).height(Length::Fill),
+ vertical_space().height(8),
+ row![
+ text_input("Type a message...", &self.message_input)
+ .width(Length::Fill)
+ .on_input(Message::MessageInputChanged)
+ .on_submit(Message::SendChannelMessage)
+ .style(theme::input_style),
+ button(text("Send").size(12))
+ .padding([8, 16])
+ .style(theme::primary_button)
+ .on_press(Message::SendChannelMessage),
+ ]
+ .spacing(8),
+ ]
+ .spacing(4)
+ .padding(12),
+ )
+ .style(theme::card_container)
+ .height(Length::Fill);
+
+ column![
+ container(connect_form).style(theme::hero_container),
+ vertical_space().height(16),
+ server_header,
+ vertical_space().height(16),
+ browser_panel,
+ vertical_space().height(16),
+ messages_panel,
+ vertical_space().height(16),
+ self.view_voice_capsule(),
+ ]
+ .spacing(0)
+ .padding(20)
+ .into()
+ } else {
+ column![
+ container(connect_form).style(theme::hero_container),
+ vertical_space().height(16),
+ self.view_voice_capsule(),
+ ]
+ .padding(20)
+ .spacing(0)
+ .into()
+ }
+ } else {
+ self.view_welcome()
+ }
+ } else {
+ self.view_welcome()
+ }
+ }
+
+ pub(crate) fn view_settings_content(&self) -> Element<'_, Message> {
+ let output_label = if self.selected_output_device.is_empty() {
+ "System Default"
+ } else {
+ &self.selected_output_device
+ };
+ let input_label = if self.selected_input_device.is_empty() {
+ "System Default"
+ } else {
+ &self.selected_input_device
+ };
+
+ let mut settings_col = column![
+ text("Settings").size(32),
+ vertical_space().height(8),
+ text("Tune appearance, audio devices, and identity behavior.")
+ .size(15)
+ .style(text::secondary),
+ vertical_space().height(24),
+ ]
+ .spacing(8)
+ .padding(20);
+
+ let appearance_col = column![
+ text("Appearance").size(17),
+ vertical_space().height(8),
+ text(format!("Theme: {}", self.appearance_mode.label()))
+ .size(13)
+ .style(text::secondary),
+ vertical_space().height(12),
+ row![
+ button(text("Light").size(12))
+ .padding([6, 14])
+ .style(if self.appearance_mode == theme::AppearanceMode::Light { theme::primary_button } else { theme::secondary_button })
+ .on_press(Message::SetAppearance(theme::AppearanceMode::Light)),
+ button(text("Dark").size(12))
+ .padding([6, 14])
+ .style(if self.appearance_mode == theme::AppearanceMode::Dark { theme::primary_button } else { theme::secondary_button })
+ .on_press(Message::SetAppearance(theme::AppearanceMode::Dark)),
+ ]
+ .spacing(8),
+ ]
+ .padding(20)
+ .spacing(0);
+
+ settings_col = settings_col.push(
+ container(appearance_col)
+ .style(theme::card_container)
+ .width(560),
+ );
+
+ settings_col = settings_col.push(vertical_space().height(16));
+
+ #[allow(unused_mut)]
+ let mut devices_col = column![
+ text("Audio Output Device").size(13),
+ text(output_label).size(12).style(text::secondary),
+ text("Audio Input Device").size(13),
+ text(input_label).size(12).style(text::secondary),
+ ]
+ .spacing(4);
+
+ let outputs = audio::AudioPlayback::list_output_devices();
+ let inputs = audio::AudioPlayback::list_input_devices();
+ let mut output_btns = row![].spacing(4);
+ for dev in outputs {
+ let label = dev.clone();
+ let is_selected = self.selected_output_device == dev || (self.selected_output_device.is_empty() && dev == "default");
+ output_btns = output_btns.push(
+ button(text(label).size(10))
+ .padding([4, 8])
+ .style(if is_selected { theme::primary_button } else { theme::secondary_button })
+ .on_press(Message::SetOutputDevice(dev)),
+ );
+ }
+ let mut input_btns = row![].spacing(4);
+ for dev in inputs {
+ let label = dev.clone();
+ let is_selected = self.selected_input_device == dev || (self.selected_input_device.is_empty() && dev == "default");
+ input_btns = input_btns.push(
+ button(text(label).size(10))
+ .padding([4, 8])
+ .style(if is_selected { theme::primary_button } else { theme::secondary_button })
+ .on_press(Message::SetInputDevice(dev)),
+ );
+ }
+ devices_col = devices_col
+ .push(vertical_space().height(4))
+ .push(text("Output devices:").size(11).style(text::secondary))
+ .push(output_btns)
+ .push(vertical_space().height(4))
+ .push(text("Input devices:").size(11).style(text::secondary))
+ .push(input_btns);
+
+ settings_col = settings_col.push(
+ container(devices_col.spacing(4).padding(16))
+ .style(theme::card_container)
+ .width(560),
+ );
+
+ let talk_mode_text = match self.talk_mode {
+ audio::TalkMode::PushToTalk => "Push-to-Talk (hold V)",
+ audio::TalkMode::Continuous => "Continuous (voice activation)",
+ };
+
+ let nc_label = self.nc_method.label();
+
+ let voice_col = column![
+ text("Voice Activation Mode").size(13),
+ text(talk_mode_text).size(12).style(text::secondary),
+ vertical_space().height(8),
+ text("Push-to-Talk Key").size(13),
+ text("V (hold to talk)").size(12).style(text::secondary),
+ vertical_space().height(8),
+ text("Noise Cancellation").size(13),
+ text(nc_label).size(12).style(text::secondary),
+ ]
+ .spacing(4)
+ .padding(16);
+
+ let voice_col = voice_col.push(
+ button(text("Toggle Voice Mode").size(11))
+ .padding([4, 12])
+ .style(theme::secondary_button)
+ .on_press(Message::ToggleTalkMode),
+ );
+
+ let voice_col = noise_cancel::NoiseCancelMethod::all().iter().copied().fold(
+ voice_col.push(text("Noise Cancel Backend").size(11).style(text::secondary)),
+ |column, method| {
+ let label = method.label().to_string();
+ column.push(
+ button(text(label).size(11))
+ .padding([4, 12])
+ .style(if self.nc_method == method {
+ theme::primary_button
+ } else {
+ theme::secondary_button
+ })
+ .on_press(Message::SetNoiseCancelMethod(method)),
+ )
+ },
+ );
+
+ settings_col = settings_col.push(
+ container(voice_col)
+ .style(theme::card_container)
+ .width(560),
+ );
+
+ let identity_col = column![
+ text("TeamSpeak Identity").size(13),
+ text("Import from an identity string or export file").size(12).style(text::secondary),
+ vertical_space().height(8),
+ text_input("Identity file path", &self.identity_file_input)
+ .on_input(Message::IdentityFilePathChanged)
+ .style(theme::input_style),
+ vertical_space().height(8),
+ button(text("Import From File").size(12))
+ .padding([8, 16])
+ .style(theme::primary_button)
+ .on_press(Message::ImportIdentityFromFile),
+ ]
+ .spacing(4)
+ .padding(16);
+
+ settings_col = settings_col.push(
+ container(identity_col)
+ .style(theme::card_container)
+ .width(560),
+ );
+
+ settings_col = settings_col.push(vertical_space().height(16));
+ settings_col = settings_col.push(self.view_voice_capsule());
+
+ container(settings_col).into()
+ }
+
+ pub(crate) fn view_voice_capsule(&self) -> Element<'_, Message> {
+ let mut actions = row![
+ button(
+ row![
+ icons::view(icons::Icon::Microphone, 14.0, theme::icon_color(&self.theme())),
+ text(if self.mic_muted { "Unmute Mic" } else { "Mute Mic" }).size(11),
+ ]
+ .spacing(6),
+ )
+ .padding([6, 12])
+ .style(if self.mic_muted { theme::danger_button } else { theme::secondary_button })
+ .on_press(Message::ToggleMicMute),
+ button(
+ row![
+ icons::view(
+ if self.headset_muted { icons::Icon::SpeakerXMark } else { icons::Icon::SpeakerWave },
+ 14.0,
+ theme::icon_color(&self.theme()),
+ ),
+ text(if self.headset_muted { "Undeafen" } else { "Deafen" }).size(11),
+ ]
+ .spacing(6),
+ )
+ .padding([6, 12])
+ .style(if self.headset_muted { theme::danger_button } else { theme::secondary_button })
+ .on_press(Message::ToggleHeadsetMute),
+ ]
+ .spacing(8);
+
+ if self.connected {
+ actions = actions.push(
+ button(text("Disconnect").size(11))
+ .padding([6, 12])
+ .style(theme::danger_button)
+ .on_press(Message::Disconnect),
+ );
+ }
+
+ container(
+ row![
+ column![
+ row![
+ icons::view(
+ if self.connected { icons::Icon::SpeakerWave } else { icons::Icon::SpeakerXMark },
+ 16.0,
+ theme::icon_color(&self.theme()),
+ ),
+ text(self.connection_line()).size(15),
+ ]
+ .spacing(8),
+ text(self.voice_summary_line()).size(12).style(text::secondary),
+ ]
+ .spacing(4)
+ .width(Length::Fill),
+ actions,
+ ]
+ .align_y(iced::Alignment::Center)
+ .spacing(16)
+ .padding(16),
+ )
+ .style(theme::voice_capsule_container)
+ .into()
+ }
+
+ pub(crate) fn view_welcome(&self) -> Element<'_, Message> {
+ container(
+ column![
+ text("ReTeamSpeak").size(28),
+ vertical_space().height(8),
+ text("Use Home to continue quickly or Bookmarks to connect manually.")
+ .size(14)
+ .style(text::secondary),
+ ]
+ .align_x(iced::Alignment::Center),
+ )
+ .center_x(Length::Fill)
+ .center_y(Length::Fill)
+ .into()
+ }
+
+ pub(crate) fn connection_line(&self) -> String {
+ let state = if self.connected { "Connected" } else { "Disconnected" };
+ let channel = self.current_channel_name().unwrap_or("No channel");
+ format!("{} · {}", state, channel)
+ }
+
+ pub(crate) fn voice_summary_line(&self) -> String {
+ let mic = if self.mic_muted { "Mic Off" } else { "Mic On" };
+ let output = if self.headset_muted { "Deafened" } else { "Audio On" };
+ format!("{} · {} · {}", mic, output, self.talk_mode_label())
+ }
+
+ pub(crate) fn current_channel_name(&self) -> Option<&str> {
+ let own_id = self.own_client_id?;
+ let channel_id = self.clients.iter().find(|c| c.id == own_id)?.channel;
+ self.channels
+ .iter()
+ .find(|channel| channel.id == channel_id)
+ .map(|channel| channel.name.as_str())
+ }
+
+ pub(crate) fn output_label(&self) -> &str {
+ if self.selected_output_device.is_empty() {
+ "System Default"
+ } else {
+ &self.selected_output_device
+ }
+ }
+
+ pub(crate) fn input_label(&self) -> &str {
+ if self.selected_input_device.is_empty() {
+ "System Default"
+ } else {
+ &self.selected_input_device
+ }
+ }
+
+ pub(crate) fn talk_mode_label(&self) -> &'static str {
+ match self.talk_mode {
+ audio::TalkMode::PushToTalk => "Push-to-Talk",
+ audio::TalkMode::Continuous => "Voice Activation",
+ }
+ }
+
+ pub(crate) fn favorite_bookmarks(&self, limit: usize) -> Vec<&BookmarkInfo> {
+ self.bookmarks
+ .iter()
+ .filter(|bookmark| bookmark.favorite)
+ .take(limit)
+ .collect()
+ }
+
+ pub(crate) fn recent_bookmarks(&self, limit: usize) -> Vec<&BookmarkInfo> {
+ let mut bookmarks: Vec<&BookmarkInfo> = self.bookmarks.iter().collect();
+ bookmarks.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
+ bookmarks
+ .into_iter()
+ .filter(|bookmark| bookmark.last_used_at.is_some())
+ .take(limit)
+ .collect()
+ }
+
+ pub(crate) fn sorted_child_channels(&self, parent: ChannelId) -> Vec<&ChannelEntry> {
+ let mut channels: Vec<&ChannelEntry> = self
+ .channels
+ .iter()
+ .filter(|channel| channel.parent == parent)
+ .collect();
+ channels.sort_by_key(|channel| channel.order.0);
+ channels
+ }
+
+ pub(crate) fn view_channel_tree<'a>(
+ &'a self,
+ channel: &'a ChannelEntry,
+ depth: usize,
+ search: &str,
+ match_count: &mut usize,
+ ) -> Option> {
+ let channel_matches = search.is_empty() || channel.name.to_lowercase().contains(search);
+ let matching_clients: Vec<&ClientEntry> = self
+ .clients
+ .iter()
+ .filter(|client| client.channel == channel.id)
+ .filter(|client| {
+ search.is_empty() || channel_matches || client.name.to_lowercase().contains(search)
+ })
+ .collect();
+
+ let child_sections: Vec> = self
+ .sorted_child_channels(channel.id)
+ .into_iter()
+ .filter_map(|child| self.view_channel_tree(child, depth + 1, search, match_count))
+ .collect();
+
+ if !channel_matches && matching_clients.is_empty() && child_sections.is_empty() {
+ return None;
+ }
+
+ *match_count += 1;
+
+ let is_current = self.own_client_id.is_some_and(|own_id| {
+ self.clients
+ .iter()
+ .any(|client| client.id == own_id && client.channel == channel.id)
+ });
+
+ let has_children = !child_sections.is_empty();
+ let force_expanded = !search.is_empty();
+ let is_collapsed = has_children
+ && !force_expanded
+ && self.collapsed_channels.contains(&channel.id.0);
+
+ let indent = 12 + (depth as u16 * 20);
+ let mut section = column![
+ button(
+ row![
+ if has_children {
+ Element::from(icons::view(
+ if is_collapsed { icons::Icon::ChevronRight } else { icons::Icon::ChevronDown },
+ 14.0,
+ theme::icon_color(&self.theme()),
+ ))
+ } else {
+ container(horizontal_space().width(14)).into()
+ },
+ text(channel.name.clone()).size(13).width(Length::Fill),
+ text(format!("{} users", matching_clients.len()))
+ .size(11)
+ .style(text::secondary),
+ ]
+ .spacing(8)
+ .padding([2, 0]),
+ )
+ .width(Length::Fill)
+ .padding([8, indent])
+ .style(if is_current {
+ theme::bookmark_button_selected
+ } else {
+ theme::bookmark_button
+ })
+ .on_press(if has_children {
+ Message::ToggleChannelCollapsed(channel.id)
+ } else {
+ Message::JoinChannel(channel.id)
+ })
+ ]
+ .spacing(4);
+
+ if !is_collapsed {
+ for client in matching_clients {
+ let is_self = self.own_client_id == Some(client.id);
+ let local_audio = self.local_client_audio.get(&client.id);
+ let locally_muted = local_audio.map(|state| state.muted).unwrap_or(false);
+ let volume = local_audio.map(|state| state.volume).unwrap_or(1.0);
+ let state = if locally_muted {
+ "Locally Muted"
+ } else if client.input_muted {
+ "Muted"
+ } else if is_self {
+ "You"
+ } else {
+ "Idle"
+ };
+ section = section.push(
+ container(
+ row![
+ icons::view(
+ if locally_muted {
+ icons::Icon::UserMinus
+ } else if client.input_muted {
+ icons::Icon::Microphone
+ } else {
+ icons::Icon::User
+ },
+ 14.0,
+ theme::icon_color(&self.theme()),
+ ),
+ text(client.name.clone()).size(12).width(Length::Fill),
+ text(format!("{:.0}%", volume * 100.0)).size(11).style(text::secondary),
+ button(text("-").size(10))
+ .padding([2, 6])
+ .style(theme::secondary_button)
+ .on_press(Message::DecreaseClientVolume(client.id)),
+ button(text(if locally_muted { "Unmute" } else { "Mute" }).size(10))
+ .padding([2, 6])
+ .style(if locally_muted { theme::danger_button } else { theme::secondary_button })
+ .on_press(Message::ToggleLocalClientMute(client.id)),
+ button(text("+").size(10))
+ .padding([2, 6])
+ .style(theme::secondary_button)
+ .on_press(Message::IncreaseClientVolume(client.id)),
+ text(state).size(11).style(text::secondary),
+ ]
+ .spacing(8),
+ )
+ .padding([2, indent + 20]),
+ );
+ }
+
+ for child in child_sections {
+ section = section.push(child);
+ }
+ }
+
+ Some(section.into())
+ }
+}
+
+fn nav_button(theme: &Theme, icon: icons::Icon, label: &str, page: Page, current: Page) -> Element<'static, Message> {
+ let color = if current == page {
+ iced::Color::WHITE
+ } else {
+ theme::nav_icon_color(theme)
+ };
+
+ button(
+ row![
+ icons::view(icon, 16.0, color),
+ text(label.to_string()).size(12),
+ ]
+ .spacing(8),
+ )
+ .padding([8, 16])
+ .on_press(Message::PageChanged(page))
+ .style(if current == page {
+ theme::nav_button_active
+ } else {
+ theme::nav_button_inactive
+ })
+ .into()
+}