From 57456a814763b40c5f143cecab92f8fcd43640e8 Mon Sep 17 00:00:00 2001 From: ReTeamSpeak Date: Wed, 13 May 2026 15:50:16 +0900 Subject: [PATCH] refactor: make audio mandatory and split iced app modules --- .github/workflows/ci.yml | 29 +- README.md | 80 +- docs/design_guidelines.md | 550 ++++++++ scripts/check-audio-podman.sh | 16 + scripts/run-audio-podman.sh | 122 ++ src/Cargo.toml | 2 +- src/iced-app/Cargo.toml | 17 +- src/iced-app/assets/icons/bookmark.svg | 3 + src/iced-app/assets/icons/chevron-down.svg | 3 + src/iced-app/assets/icons/chevron-right.svg | 3 + src/iced-app/assets/icons/cog-6-tooth.svg | 4 + src/iced-app/assets/icons/home.svg | 3 + src/iced-app/assets/icons/microphone.svg | 3 + src/iced-app/assets/icons/speaker-wave.svg | 3 + src/iced-app/assets/icons/speaker-x-mark.svg | 3 + src/iced-app/assets/icons/user-circle.svg | 3 + src/iced-app/assets/icons/user-minus.svg | 3 + src/iced-app/assets/icons/user.svg | 4 + src/iced-app/src/audio.rs | 83 +- src/iced-app/src/icons.rs | 54 + src/iced-app/src/identity.rs | 52 +- src/iced-app/src/main.rs | 1176 +++--------------- src/iced-app/src/noise_cancel.rs | 113 +- src/iced-app/src/persistence.rs | 120 ++ src/iced-app/src/runtime.rs | 132 ++ src/iced-app/src/theme.rs | 640 +++++----- src/iced-app/src/types.rs | 150 +++ src/iced-app/src/view.rs | 1035 +++++++++++++++ 28 files changed, 2902 insertions(+), 1504 deletions(-) create mode 100644 docs/design_guidelines.md create mode 100755 scripts/check-audio-podman.sh create mode 100755 scripts/run-audio-podman.sh create mode 100644 src/iced-app/assets/icons/bookmark.svg create mode 100644 src/iced-app/assets/icons/chevron-down.svg create mode 100644 src/iced-app/assets/icons/chevron-right.svg create mode 100644 src/iced-app/assets/icons/cog-6-tooth.svg create mode 100644 src/iced-app/assets/icons/home.svg create mode 100644 src/iced-app/assets/icons/microphone.svg create mode 100644 src/iced-app/assets/icons/speaker-wave.svg create mode 100644 src/iced-app/assets/icons/speaker-x-mark.svg create mode 100644 src/iced-app/assets/icons/user-circle.svg create mode 100644 src/iced-app/assets/icons/user-minus.svg create mode 100644 src/iced-app/assets/icons/user.svg create mode 100644 src/iced-app/src/icons.rs create mode 100644 src/iced-app/src/persistence.rs create mode 100644 src/iced-app/src/runtime.rs create mode 100644 src/iced-app/src/types.rs create mode 100644 src/iced-app/src/view.rs 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() +}