refactor: make audio mandatory and split iced app modules
@@ -10,7 +10,6 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Test job
|
# Test job
|
||||||
@@ -30,7 +29,9 @@ jobs:
|
|||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
submodules: false
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -42,12 +43,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
|
cmake \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
libopus-dev \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
libasound2-dev
|
libasound2-dev
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
@@ -61,17 +64,13 @@ jobs:
|
|||||||
working-directory: src
|
working-directory: src
|
||||||
run: cargo check -p re-teamspeak
|
run: cargo check -p re-teamspeak
|
||||||
|
|
||||||
- name: Check (rusqlite)
|
|
||||||
working-directory: src
|
|
||||||
run: cargo check -p re-teamspeak --features rusqlite
|
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: cargo test -p re-teamspeak
|
run: cargo test -p re-teamspeak
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
working-directory: src
|
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
|
continue-on-error: true
|
||||||
|
|
||||||
# Build desktop apps
|
# Build desktop apps
|
||||||
@@ -97,7 +96,9 @@ jobs:
|
|||||||
artifact: macos-amd64
|
artifact: macos-amd64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
submodules: false
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -109,12 +110,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
|
cmake \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
libopus-dev \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
libasound2-dev
|
libasound2-dev
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
@@ -150,7 +153,7 @@ jobs:
|
|||||||
tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist .
|
tar -czf re-teamspeak-${{ matrix.artifact }}.tar.gz -C dist .
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: re-teamspeak-${{ matrix.artifact }}
|
name: re-teamspeak-${{ matrix.artifact }}
|
||||||
path: re-teamspeak-${{ matrix.artifact }}.*
|
path: re-teamspeak-${{ matrix.artifact }}.*
|
||||||
@@ -166,12 +169,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: artifacts/
|
path: artifacts/
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: artifacts/**/*
|
files: artifacts/**/*
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
@@ -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
|
- Mute speaker/output state and sync it to TeamSpeak
|
||||||
- Toggle headset/output hardware state and sync it to TeamSpeak
|
- Toggle headset/output hardware state and sync it to TeamSpeak
|
||||||
- Toggle AFK 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
|
- Select input and output audio devices
|
||||||
- Push-to-talk and continuous voice modes
|
- Push-to-talk and continuous voice modes
|
||||||
- Noise cancellation selection in settings
|
- Noise cancellation selection in settings
|
||||||
|
- Local per-user mute and volume controls in the channel tree
|
||||||
|
- Persistent bookmarks, favorites, recents, and settings
|
||||||
|
|
||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- ServerQuery page is UI-only right now; execution is intentionally stubbed
|
- Remote audio playback still needs runtime validation against a real TeamSpeak server
|
||||||
- Bookmarks are currently in-memory only
|
- The legacy `src/README.md` and root `Dockerfile` still need to be reconciled with the current app layout
|
||||||
- Identity import requires the `rusqlite` feature
|
|
||||||
- Full audio build requires system audio/build dependencies
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -43,26 +43,62 @@ cd src
|
|||||||
cargo check -p re-teamspeak
|
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
|
```bash
|
||||||
cd src
|
cd src
|
||||||
cargo check -p re-teamspeak --features rusqlite
|
cargo check -p re-teamspeak
|
||||||
```
|
```
|
||||||
|
|
||||||
### With audio and identity import
|
Repeatable Podman build:
|
||||||
|
|
||||||
On systems missing packages, use Podman:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
podman run --rm -v $(pwd):/workspace:Z -w /workspace/src \
|
./scripts/check-audio-podman.sh
|
||||||
docker.io/library/debian:trixie bash -lc '
|
```
|
||||||
apt-get update -qq &&
|
|
||||||
apt-get install -y -qq \
|
### Run the app in Podman
|
||||||
build-essential curl cmake pkg-config libasound2-dev libssl-dev &&
|
|
||||||
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y &&
|
From a desktop shell with `WAYLAND_DISPLAY` or `DISPLAY` set:
|
||||||
. /root/.cargo/env &&
|
|
||||||
cargo check -p re-teamspeak --features audio,rusqlite
|
```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/
|
└── src/
|
||||||
├── main.rs
|
├── main.rs
|
||||||
├── audio.rs
|
├── audio.rs
|
||||||
├── noise_cancel.rs
|
|
||||||
├── identity.rs
|
├── identity.rs
|
||||||
└── theme.rs
|
├── icons.rs
|
||||||
|
├── noise_cancel.rs
|
||||||
|
├── persistence.rs
|
||||||
|
├── theme.rs
|
||||||
|
├── types.rs
|
||||||
|
└── view.rs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reference Material
|
## Reference Material
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -19,5 +19,5 @@ serde_json = "1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
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" }
|
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", branch = "master" }
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ name = "re-teamspeak"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iced = { version = "0.13", features = ["tokio", "debug"] }
|
iced = { version = "0.13", features = ["tokio", "debug", "svg"] }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -18,16 +18,9 @@ serde_json = { workspace = true }
|
|||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
cpal = { version = "0.15", optional = true }
|
cpal = { version = "0.15" }
|
||||||
audiopus = { version = "0.3.0-rc.0", optional = true }
|
audiopus = { version = "0.3.0-rc.0" }
|
||||||
nnnoiseless = { version = "0.5", optional = true }
|
nnnoiseless = { version = "0.5" }
|
||||||
sonora = { version = "0.1", optional = true }
|
sonora = { version = "0.1" }
|
||||||
rusqlite = { version = "0.31", optional = true }
|
|
||||||
|
|
||||||
tsclientlib = { workspace = true }
|
tsclientlib = { workspace = true }
|
||||||
tsproto-packets = { workspace = true }
|
tsproto-packets = { workspace = true }
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
audio = ["dep:cpal", "dep:audiopus", "dep:nnnoiseless", "dep:sonora"]
|
|
||||||
rusqlite = ["dep:rusqlite"]
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.5933 3.32241C18.6939 3.45014 19.5 4.399 19.5 5.50699V21L12 17.25L4.5 21V5.50699C4.5 4.399 5.30608 3.45014 6.40668 3.32241C8.24156 3.10947 10.108 3 12 3C13.892 3 15.7584 3.10947 17.5933 3.32241Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19.5 8.25L12 15.75L4.5 8.25" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 227 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.25 4.5L15.75 12L8.25 19.5" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 227 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.59356 3.94014C9.68397 3.39768 10.1533 3.00009 10.7033 3.00009H13.2972C13.8472 3.00009 14.3165 3.39768 14.4069 3.94014L14.6204 5.22119C14.6828 5.59523 14.9327 5.9068 15.2645 6.09045C15.3387 6.13151 15.412 6.17393 15.4844 6.21766C15.8095 6.41393 16.2048 6.47495 16.5604 6.34175L17.7772 5.88587C18.2922 5.69293 18.8712 5.9006 19.1462 6.37687L20.4432 8.6233C20.7181 9.09957 20.6085 9.70482 20.1839 10.0544L19.1795 10.8812C18.887 11.122 18.742 11.4938 18.7491 11.8726C18.7498 11.915 18.7502 11.9575 18.7502 12.0001C18.7502 12.0427 18.7498 12.0852 18.7491 12.1275C18.742 12.5064 18.887 12.8782 19.1795 13.119L20.1839 13.9458C20.6085 14.2953 20.7181 14.9006 20.4432 15.3769L19.1462 17.6233C18.8712 18.0996 18.2922 18.3072 17.7772 18.1143L16.5604 17.6584C16.2048 17.5252 15.8095 17.5862 15.4844 17.7825C15.412 17.8263 15.3387 17.8687 15.2645 17.9097C14.9327 18.0934 14.6828 18.4049 14.6204 18.779L14.4069 20.06C14.3165 20.6025 13.8472 21.0001 13.2972 21.0001H10.7033C10.1533 21.0001 9.68397 20.6025 9.59356 20.06L9.38005 18.779C9.31771 18.4049 9.06774 18.0934 8.73597 17.9097C8.66179 17.8687 8.58847 17.8263 8.51604 17.7825C8.19101 17.5863 7.79568 17.5252 7.44011 17.6584L6.22325 18.1143C5.70826 18.3072 5.12926 18.0996 4.85429 17.6233L3.55731 15.3769C3.28234 14.9006 3.39199 14.2954 3.81657 13.9458L4.82092 13.119C5.11343 12.8782 5.25843 12.5064 5.25141 12.1276C5.25063 12.0852 5.25023 12.0427 5.25023 12.0001C5.25023 11.9575 5.25063 11.915 5.25141 11.8726C5.25843 11.4938 5.11343 11.122 4.82092 10.8812L3.81657 10.0544C3.39199 9.70484 3.28234 9.09958 3.55731 8.62332L4.85429 6.37688C5.12926 5.90061 5.70825 5.69295 6.22325 5.88588L7.4401 6.34176C7.79566 6.47496 8.19099 6.41394 8.51603 6.21767C8.58846 6.17393 8.66179 6.13151 8.73597 6.09045C9.06774 5.9068 9.31771 5.59523 9.38005 5.22119L9.59356 3.94014Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3432 10.3431 9.00001 12 9.00001C13.6569 9.00001 15 10.3432 15 12Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.25 12L11.2045 3.04549C11.6438 2.60615 12.3562 2.60615 12.7955 3.04549L21.75 12M4.5 9.75V19.875C4.5 20.4963 5.00368 21 5.625 21H9.75V16.125C9.75 15.5037 10.2537 15 10.875 15H13.125C13.7463 15 14.25 15.5037 14.25 16.125V21H18.375C18.9963 21 19.5 20.4963 19.5 19.875V9.75M8.25 21H16.5" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 483 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 18.75C15.3137 18.75 18 16.0637 18 12.75V11.25M12 18.75C8.68629 18.75 6 16.0637 6 12.75V11.25M12 18.75V22.5M8.25 22.5H15.75M12 15.75C10.3431 15.75 9 14.4069 9 12.75V4.5C9 2.84315 10.3431 1.5 12 1.5C13.6569 1.5 15 2.84315 15 4.5V12.75C15 14.4069 13.6569 15.75 12 15.75Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 470 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19.114 5.63597C22.6287 9.15069 22.6287 14.8492 19.114 18.3639M16.4626 8.28771C18.5129 10.338 18.5129 13.6621 16.4626 15.7123M6.75 8.24999L11.4697 3.53032C11.9421 3.05784 12.75 3.39247 12.75 4.06065V19.9393C12.75 20.6075 11.9421 20.9421 11.4697 20.4697L6.75 15.75H4.50905C3.62971 15.75 2.8059 15.2435 2.57237 14.3957C2.36224 13.6329 2.25 12.8296 2.25 12C2.25 11.1704 2.36224 10.367 2.57237 9.60423C2.8059 8.75646 3.62971 8.24999 4.50905 8.24999H6.75Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 649 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.25 9.74999L19.5 12M19.5 12L21.75 14.25M19.5 12L21.75 9.74999M19.5 12L17.25 14.25M6.75 8.24999L11.4697 3.53032C11.9421 3.05784 12.75 3.39247 12.75 4.06065V19.9393C12.75 20.6075 11.9421 20.9421 11.4697 20.4697L6.75 15.75H4.50905C3.62971 15.75 2.8059 15.2435 2.57237 14.3957C2.36224 13.6329 2.25 12.8296 2.25 12C2.25 11.1704 2.36224 10.367 2.57237 9.60423C2.8059 8.75646 3.62971 8.24999 4.50905 8.24999H6.75Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 608 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.9815 18.7248C16.6121 16.9175 14.4424 15.75 12 15.75C9.55761 15.75 7.38789 16.9175 6.01846 18.7248M17.9815 18.7248C19.8335 17.0763 21 14.6744 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 14.6744 4.1665 17.0763 6.01846 18.7248M17.9815 18.7248C16.3915 20.1401 14.2962 21 12 21C9.70383 21 7.60851 20.1401 6.01846 18.7248M15 9.75C15 11.4069 13.6569 12.75 12 12.75C10.3431 12.75 9 11.4069 9 9.75C9 8.09315 10.3431 6.75 12 6.75C13.6569 6.75 15 8.09315 15 9.75Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 670 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 10.5H16M13.75 6.375C13.75 8.23896 12.239 9.75 10.375 9.75C8.51104 9.75 7 8.23896 7 6.375C7 4.51104 8.51104 3 10.375 3C12.239 3 13.75 4.51104 13.75 6.375ZM4.00092 19.2343C4.00031 19.198 4 19.1615 4 19.125C4 15.6042 6.85418 12.75 10.375 12.75C13.8958 12.75 16.75 15.6042 16.75 19.125V19.1276C16.75 19.1632 16.7497 19.1988 16.7491 19.2343C14.8874 20.3552 12.7065 21 10.375 21C8.04353 21 5.86264 20.3552 4.00092 19.2343Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 619 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15.75 6C15.75 8.07107 14.071 9.75 12 9.75C9.9289 9.75 8.24996 8.07107 8.24996 6C8.24996 3.92893 9.9289 2.25 12 2.25C14.071 2.25 15.75 3.92893 15.75 6Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.5011 20.1182C4.5714 16.0369 7.90184 12.75 12 12.75C16.0982 12.75 19.4287 16.0371 19.4988 20.1185C17.216 21.166 14.6764 21.75 12.0003 21.75C9.32396 21.75 6.78406 21.1659 4.5011 20.1182Z" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 633 B |
@@ -1,7 +1,10 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use tsclientlib::audio::AudioHandler;
|
use tsclientlib::audio::AudioHandler;
|
||||||
use tsclientlib::ClientId;
|
use tsclientlib::ClientId;
|
||||||
use tsproto_packets::packets::{AudioData, CodecType, InAudioBuf, OutAudio};
|
use tsproto_packets::packets::{AudioData, InAudioBuf};
|
||||||
|
|
||||||
const SAMPLE_RATE: u32 = 48000;
|
const SAMPLE_RATE: u32 = 48000;
|
||||||
const CHANNELS: u16 = 2;
|
const CHANNELS: u16 = 2;
|
||||||
@@ -12,7 +15,8 @@ type PacketSender = std::sync::mpsc::Sender<InAudioBuf>;
|
|||||||
|
|
||||||
struct PlaybackState {
|
struct PlaybackState {
|
||||||
sender: PacketSender,
|
sender: PacketSender,
|
||||||
output_device: String,
|
volumes: Arc<Mutex<HashMap<ClientId, f32>>>,
|
||||||
|
muted_clients: Arc<Mutex<HashMap<ClientId, bool>>>,
|
||||||
_stream: cpal::Stream,
|
_stream: cpal::Stream,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +69,10 @@ impl AudioPlayback {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<InAudioBuf>();
|
let (tx, rx) = std::sync::mpsc::channel::<InAudioBuf>();
|
||||||
|
let volumes = Arc::new(Mutex::new(HashMap::<ClientId, f32>::new()));
|
||||||
|
let muted_clients = Arc::new(Mutex::new(HashMap::<ClientId, bool>::new()));
|
||||||
|
let callback_volumes = Arc::clone(&volumes);
|
||||||
|
let callback_muted = Arc::clone(&muted_clients);
|
||||||
let mut handler = AudioHandler::<ClientId>::new();
|
let mut handler = AudioHandler::<ClientId>::new();
|
||||||
|
|
||||||
let stream = device
|
let stream = device
|
||||||
@@ -82,6 +90,19 @@ impl AudioPlayback {
|
|||||||
for sample in data.iter_mut() {
|
for sample in data.iter_mut() {
|
||||||
*sample = 0.0;
|
*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);
|
handler.fill_buffer(data);
|
||||||
},
|
},
|
||||||
|err| tracing::error!("Audio output error: {err}"),
|
|err| tracing::error!("Audio output error: {err}"),
|
||||||
@@ -95,7 +116,8 @@ impl AudioPlayback {
|
|||||||
|
|
||||||
self.state = Some(PlaybackState {
|
self.state = Some(PlaybackState {
|
||||||
sender: tx,
|
sender: tx,
|
||||||
output_device: device_name_str,
|
volumes,
|
||||||
|
muted_clients,
|
||||||
_stream: stream,
|
_stream: stream,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,21 +136,25 @@ impl AudioPlayback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn output_device(&self) -> &str {
|
pub fn set_client_volume(&self, client: ClientId, volume: f32) {
|
||||||
self.state
|
if let Some(ref state) = self.state {
|
||||||
.as_ref()
|
if let Ok(mut volumes) = state.volumes.lock() {
|
||||||
.map(|s| s.output_device.as_str())
|
volumes.insert(client, volume.clamp(0.0, 2.0));
|
||||||
.unwrap_or("")
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_active(&self) -> bool {
|
pub fn set_client_muted(&self, client: ClientId, muted: bool) {
|
||||||
self.state.is_some()
|
if let Some(ref state) = self.state {
|
||||||
|
if let Ok(mut muted_clients) = state.muted_clients.lock() {
|
||||||
|
muted_clients.insert(client, muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CaptureState {
|
struct CaptureState {
|
||||||
_stream: cpal::Stream,
|
_stream: cpal::Stream,
|
||||||
input_device: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe impl Send for CaptureState {}
|
unsafe impl Send for CaptureState {}
|
||||||
@@ -186,7 +212,6 @@ impl Microphone {
|
|||||||
|
|
||||||
self.state = Some(CaptureState {
|
self.state = Some(CaptureState {
|
||||||
_stream: stream,
|
_stream: stream,
|
||||||
input_device: device_name_str,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!("Microphone started");
|
tracing::info!("Microphone started");
|
||||||
@@ -198,16 +223,6 @@ impl Microphone {
|
|||||||
tracing::info!("Microphone stopped");
|
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 {
|
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 {
|
pub enum TalkMode {
|
||||||
PushToTalk,
|
PushToTalk,
|
||||||
Continuous,
|
Continuous,
|
||||||
@@ -339,21 +346,7 @@ impl VoiceActivation {
|
|||||||
self.state
|
self.state
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn state(&self) -> VadState {
|
|
||||||
self.state
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_speaking(&self) -> bool {
|
pub fn is_speaking(&self) -> bool {
|
||||||
matches!(self.state, VadState::Speaking | VadState::Hangover)
|
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/home.svg")));
|
||||||
|
static BOOKMARK: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/bookmark.svg")));
|
||||||
|
static USER_CIRCLE: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/user-circle.svg")));
|
||||||
|
static USER: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/user.svg")));
|
||||||
|
static USER_MINUS: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/user-minus.svg")));
|
||||||
|
static COG: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/cog-6-tooth.svg")));
|
||||||
|
static CHEVRON_RIGHT: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/chevron-right.svg")));
|
||||||
|
static CHEVRON_DOWN: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/chevron-down.svg")));
|
||||||
|
static MICROPHONE: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/microphone.svg")));
|
||||||
|
static SPEAKER_WAVE: LazyLock<Handle> = LazyLock::new(|| Handle::from_memory(include_bytes!("../assets/icons/speaker-wave.svg")));
|
||||||
|
static SPEAKER_X_MARK: LazyLock<Handle> = 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) })
|
||||||
|
}
|
||||||
@@ -1,45 +1,23 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use tsclientlib::Identity;
|
use tsclientlib::Identity;
|
||||||
|
|
||||||
#[cfg(feature = "rusqlite")]
|
pub fn import_identity_from_string(raw: &str) -> Result<Identity, String> {
|
||||||
use rusqlite::Connection;
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("Identity string is empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "rusqlite")]
|
Identity::new_from_ts_str(trimmed)
|
||||||
pub fn import_identity_from_ts3(path: &str) -> Result<Identity, String> {
|
.or_else(|_| Identity::new_from_str(trimmed))
|
||||||
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)
|
|
||||||
.map_err(|e| format!("Failed to parse identity: {e}"))
|
.map_err(|e| format!("Failed to parse identity: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "rusqlite"))]
|
pub fn import_identity_from_file(path: &str) -> Result<Identity, String> {
|
||||||
pub fn import_identity_from_ts3(_path: &str) -> Result<Identity, String> {
|
let path_ref = Path::new(path);
|
||||||
Err("rusqlite feature not enabled".to_string())
|
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<String> {
|
import_identity_from_string(&raw)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
pub enum NoiseCancelMethod {
|
||||||
None,
|
None,
|
||||||
Nnnoiseless,
|
Nnnoiseless,
|
||||||
@@ -21,23 +27,57 @@ impl NoiseCancelMethod {
|
|||||||
|
|
||||||
pub struct NoiseReducer {
|
pub struct NoiseReducer {
|
||||||
method: NoiseCancelMethod,
|
method: NoiseCancelMethod,
|
||||||
#[cfg(feature = "nnnoiseless")]
|
nnnoiseless: Option<Box<nnnoiseless::DenoiseState<'static>>>,
|
||||||
nnnoiseless: Option<nnnoiseless::DenoiseState<'static>>,
|
sonora: Option<SonoraNoiseReducer>,
|
||||||
#[cfg(feature = "sonora")]
|
|
||||||
sonora: Option<sonora::NoiseSuppression>,
|
|
||||||
#[cfg(feature = "nnnoiseless")]
|
|
||||||
residual_buf: Vec<f32>,
|
residual_buf: Vec<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SonoraNoiseReducer {
|
||||||
|
processor: AudioProcessing,
|
||||||
|
input: Vec<f32>,
|
||||||
|
output: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl NoiseReducer {
|
||||||
pub fn new(method: NoiseCancelMethod) -> Self {
|
pub fn new(method: NoiseCancelMethod) -> Self {
|
||||||
let mut this = Self {
|
let mut this = Self {
|
||||||
method,
|
method,
|
||||||
#[cfg(feature = "nnnoiseless")]
|
|
||||||
nnnoiseless: None,
|
nnnoiseless: None,
|
||||||
#[cfg(feature = "sonora")]
|
|
||||||
sonora: None,
|
sonora: None,
|
||||||
#[cfg(feature = "nnnoiseless")]
|
|
||||||
residual_buf: Vec::new(),
|
residual_buf: Vec::new(),
|
||||||
};
|
};
|
||||||
this.init_method();
|
this.init_method();
|
||||||
@@ -45,52 +85,30 @@ impl NoiseReducer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn init_method(&mut self) {
|
fn init_method(&mut self) {
|
||||||
#[cfg(feature = "nnnoiseless")]
|
|
||||||
{
|
|
||||||
self.nnnoiseless = match self.method {
|
self.nnnoiseless = match self.method {
|
||||||
NoiseCancelMethod::Nnnoiseless => Some(nnnoiseless::DenoiseState::new()),
|
NoiseCancelMethod::Nnnoiseless => Some(nnnoiseless::DenoiseState::new()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
#[cfg(feature = "sonora")]
|
|
||||||
{
|
|
||||||
self.sonora = match self.method {
|
self.sonora = match self.method {
|
||||||
NoiseCancelMethod::Sonora => {
|
NoiseCancelMethod::Sonora => Some(SonoraNoiseReducer::new()),
|
||||||
Some(sonora::NoiseSuppression::new(48000, 480).expect("Failed to create Sonora NS"))
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
#[cfg(feature = "nnnoiseless")]
|
|
||||||
{
|
|
||||||
self.residual_buf.clear();
|
self.residual_buf.clear();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_method(&mut self, method: NoiseCancelMethod) {
|
pub fn set_method(&mut self, method: NoiseCancelMethod) {
|
||||||
self.method = method;
|
self.method = method;
|
||||||
self.init_method();
|
self.init_method();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn method(&self) -> NoiseCancelMethod {
|
pub fn process(&mut self, _samples: &mut [f32]) {
|
||||||
self.method
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn process(&mut self, samples: &mut [f32]) {
|
|
||||||
match self.method {
|
match self.method {
|
||||||
NoiseCancelMethod::None => {}
|
NoiseCancelMethod::None => {}
|
||||||
#[cfg(feature = "nnnoiseless")]
|
NoiseCancelMethod::Nnnoiseless => self.process_nnnoiseless(_samples),
|
||||||
NoiseCancelMethod::Nnnoiseless => self.process_nnnoiseless(samples),
|
NoiseCancelMethod::Sonora => self.process_sonora(_samples),
|
||||||
#[cfg(not(feature = "nnnoiseless"))]
|
|
||||||
NoiseCancelMethod::Nnnoiseless => {}
|
|
||||||
#[cfg(feature = "sonora")]
|
|
||||||
NoiseCancelMethod::Sonora => self.process_sonora(samples),
|
|
||||||
#[cfg(not(feature = "sonora"))]
|
|
||||||
NoiseCancelMethod::Sonora => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "nnnoiseless")]
|
|
||||||
fn process_nnnoiseless(&mut self, samples: &mut [f32]) {
|
fn process_nnnoiseless(&mut self, samples: &mut [f32]) {
|
||||||
let denoise = match &mut self.nnnoiseless {
|
let denoise = match &mut self.nnnoiseless {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
@@ -118,20 +136,19 @@ impl NoiseReducer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "sonora")]
|
|
||||||
fn process_sonora(&mut self, samples: &mut [f32]) {
|
fn process_sonora(&mut self, samples: &mut [f32]) {
|
||||||
let ns = match &mut self.sonora {
|
let reducer = match &mut self.sonora {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sonora processes 10ms frames (480 samples at 48kHz)
|
// Sonora processes 10ms frames (480 samples at 48kHz)
|
||||||
let frame_size = 480;
|
let frame_size = SONORA_FRAME_SIZE;
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
|
|
||||||
while offset + frame_size <= samples.len() {
|
while offset + frame_size <= samples.len() {
|
||||||
let frame = &mut samples[offset..offset + frame_size];
|
let frame = &mut samples[offset..offset + frame_size];
|
||||||
ns.process(frame);
|
reducer.process(frame);
|
||||||
offset += frame_size;
|
offset += frame_size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::types::{AppSettings, BookmarkInfo};
|
||||||
|
|
||||||
|
pub(crate) fn load_bookmarks() -> Vec<BookmarkInfo> {
|
||||||
|
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::<Vec<BookmarkInfo>>(&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::<AppSettings>(&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<BookmarkInfo> {
|
||||||
|
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<PathBuf> {
|
||||||
|
#[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<PathBuf> {
|
||||||
|
config_dir().map(|path| path.join("bookmarks.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_config_path() -> Option<PathBuf> {
|
||||||
|
config_dir().map(|path| path.join("settings.json"))
|
||||||
|
}
|
||||||
@@ -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<TsEvent>,
|
||||||
|
audio: Arc<Mutex<audio::AudioPlayback>>,
|
||||||
|
) {
|
||||||
|
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<Mutex<audio::Microphone>>,
|
||||||
|
handle: Arc<Mutex<Option<SyncConnectionHandle>>>,
|
||||||
|
device_name: Option<String>,
|
||||||
|
noise_reducer: Arc<Mutex<noise_cancel::NoiseReducer>>,
|
||||||
|
) {
|
||||||
|
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::<Vec<u8>>(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<Mutex<audio::Microphone>>,
|
||||||
|
handle: Arc<Mutex<Option<SyncConnectionHandle>>>,
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,39 +1,113 @@
|
|||||||
use iced::widget::{button, container, text_input};
|
use iced::widget::{button, container, text_input};
|
||||||
use iced::{Background, Border, Color, Shadow, Theme};
|
use iced::{Background, Border, Color, Shadow, Theme};
|
||||||
|
|
||||||
// Apple-inspired dark palette
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub const BG_PRIMARY: Color = Color::from_rgb(0.11, 0.11, 0.118); // #1C1C1E
|
pub enum AppearanceMode {
|
||||||
pub const BG_SECONDARY: Color = Color::from_rgb(0.17, 0.17, 0.18); // #2C2C2E
|
Light,
|
||||||
pub const BG_TERTIARY: Color = Color::from_rgb(0.22, 0.22, 0.235); // #38383A
|
Dark,
|
||||||
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
|
|
||||||
|
|
||||||
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(
|
Theme::custom(
|
||||||
"ReTeamSpeak".to_string(),
|
"ReTeamSpeak Light".to_string(),
|
||||||
iced::theme::Palette {
|
iced::theme::Palette {
|
||||||
background: BG_PRIMARY,
|
background: LIGHT_BG,
|
||||||
text: TEXT_PRIMARY,
|
text: LIGHT_TEXT,
|
||||||
primary: ACCENT,
|
primary: LIGHT_ACCENT,
|
||||||
success: SUCCESS,
|
success: SUCCESS,
|
||||||
danger: DANGER,
|
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 {
|
container::Style {
|
||||||
background: Some(Background::Color(SIDEBAR_BG)),
|
background: Some(Background::Color(elevated(theme))),
|
||||||
border: Border {
|
border: Border {
|
||||||
color: SEPARATOR,
|
color: border(theme),
|
||||||
width: 0.0,
|
width: 0.0,
|
||||||
radius: 0.0.into(),
|
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 {
|
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 {
|
border: Border {
|
||||||
color: Color::TRANSPARENT,
|
color: Color::TRANSPARENT,
|
||||||
width: 0.0,
|
width: 0.0,
|
||||||
radius: 12.0.into(),
|
radius: 12.0.into(),
|
||||||
},
|
},
|
||||||
shadow: Shadow {
|
shadow: Shadow {
|
||||||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.3),
|
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, 2.0),
|
offset: iced::Vector::new(0.0, 3.0),
|
||||||
blur_radius: 8.0,
|
blur_radius: 10.0,
|
||||||
},
|
|
||||||
..container::Style::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn elevated_container(_theme: &Theme) -> container::Style {
|
|
||||||
container::Style {
|
|
||||||
background: Some(Background::Color(BG_TERTIARY)),
|
|
||||||
border: Border {
|
|
||||||
color: Color::TRANSPARENT,
|
|
||||||
width: 0.0,
|
|
||||||
radius: 10.0.into(),
|
|
||||||
},
|
|
||||||
..container::Style::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 primary_button(_theme: &Theme, status: button::Status) -> button::Style {
|
pub fn secondary_button(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
match status {
|
let background = match status {
|
||||||
button::Status::Active => button::Style {
|
button::Status::Hovered => elevated(theme),
|
||||||
background: Some(Background::Color(ACCENT)),
|
button::Status::Pressed => elevated(theme),
|
||||||
text_color: Color::WHITE,
|
_ => surface(theme),
|
||||||
|
};
|
||||||
|
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(background)),
|
||||||
|
text_color: theme.palette().text,
|
||||||
border: Border {
|
border: Border {
|
||||||
color: Color::TRANSPARENT,
|
color: border(theme),
|
||||||
width: 0.0,
|
width: 1.0,
|
||||||
radius: 8.0.into(),
|
radius: 12.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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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(),
|
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(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn secondary_button(_theme: &Theme, status: button::Status) -> button::Style {
|
pub fn danger_button(_theme: &Theme, _status: button::Status) -> button::Style {
|
||||||
match status {
|
button::Style {
|
||||||
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(),
|
|
||||||
},
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn danger_button(_theme: &Theme, status: button::Status) -> button::Style {
|
|
||||||
match status {
|
|
||||||
button::Status::Active => button::Style {
|
|
||||||
background: Some(Background::Color(DANGER)),
|
background: Some(Background::Color(DANGER)),
|
||||||
text_color: Color::WHITE,
|
text_color: Color::WHITE,
|
||||||
border: Border {
|
border: Border {
|
||||||
color: Color::TRANSPARENT,
|
color: Color::TRANSPARENT,
|
||||||
width: 0.0,
|
width: 0.0,
|
||||||
radius: 8.0.into(),
|
radius: 12.0.into(),
|
||||||
},
|
},
|
||||||
shadow: Shadow::default(),
|
shadow: Shadow::default(),
|
||||||
},
|
}
|
||||||
button::Status::Hovered => button::Style {
|
}
|
||||||
background: Some(Background::Color(Color::from_rgb(1.0, 0.35, 0.30))),
|
|
||||||
|
pub fn nav_button_active(theme: &Theme, _status: button::Status) -> button::Style {
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(accent(theme))),
|
||||||
text_color: Color::WHITE,
|
text_color: Color::WHITE,
|
||||||
border: Border {
|
border: Border {
|
||||||
color: Color::TRANSPARENT,
|
color: Color::TRANSPARENT,
|
||||||
width: 0.0,
|
width: 0.0,
|
||||||
radius: 8.0.into(),
|
radius: 12.0.into(),
|
||||||
},
|
},
|
||||||
shadow: Shadow::default(),
|
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(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn nav_button_active(_theme: &Theme, status: button::Status) -> button::Style {
|
pub fn nav_button_inactive(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
match status {
|
let background = if matches!(status, button::Status::Hovered) {
|
||||||
button::Status::Active => button::Style {
|
surface(theme)
|
||||||
background: Some(Background::Color(BG_TERTIARY)),
|
} else {
|
||||||
text_color: TEXT_PRIMARY,
|
Color::TRANSPARENT
|
||||||
|
};
|
||||||
|
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(background)),
|
||||||
|
text_color: if matches!(status, button::Status::Hovered) {
|
||||||
|
theme.palette().text
|
||||||
|
} else {
|
||||||
|
text_secondary(theme)
|
||||||
|
},
|
||||||
border: Border {
|
border: Border {
|
||||||
color: Color::TRANSPARENT,
|
color: Color::TRANSPARENT,
|
||||||
width: 0.0,
|
width: 0.0,
|
||||||
radius: 8.0.into(),
|
radius: 12.0.into(),
|
||||||
},
|
},
|
||||||
shadow: Shadow::default(),
|
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 {
|
pub fn bookmark_button_selected(theme: &Theme, _status: button::Status) -> button::Style {
|
||||||
match status {
|
button::Style {
|
||||||
button::Status::Active => button::Style {
|
background: Some(Background::Color(elevated(theme))),
|
||||||
background: Some(Background::Color(Color::TRANSPARENT)),
|
text_color: theme.palette().text,
|
||||||
text_color: TEXT_SECONDARY,
|
|
||||||
border: Border {
|
border: Border {
|
||||||
color: Color::TRANSPARENT,
|
color: accent(theme),
|
||||||
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,
|
width: 1.0,
|
||||||
radius: 10.0.into(),
|
radius: 14.0.into(),
|
||||||
},
|
},
|
||||||
shadow: Shadow::default(),
|
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 {
|
pub fn bookmark_button(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
match status {
|
let background = if matches!(status, button::Status::Hovered) {
|
||||||
button::Status::Active => button::Style {
|
elevated(theme)
|
||||||
background: Some(Background::Color(Color::TRANSPARENT)),
|
} else {
|
||||||
text_color: TEXT_SECONDARY,
|
Color::TRANSPARENT
|
||||||
|
};
|
||||||
|
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(background)),
|
||||||
|
text_color: if matches!(status, button::Status::Hovered) {
|
||||||
|
theme.palette().text
|
||||||
|
} else {
|
||||||
|
text_secondary(theme)
|
||||||
|
},
|
||||||
border: Border {
|
border: Border {
|
||||||
color: Color::TRANSPARENT,
|
color: Color::TRANSPARENT,
|
||||||
width: 0.0,
|
width: 0.0,
|
||||||
radius: 10.0.into(),
|
radius: 14.0.into(),
|
||||||
},
|
},
|
||||||
shadow: Shadow::default(),
|
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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<usize>),
|
||||||
|
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<f32>),
|
||||||
|
ToggleMicMute,
|
||||||
|
ToggleSpeakerMute,
|
||||||
|
ToggleHeadsetMute,
|
||||||
|
ToggleAfk,
|
||||||
|
IdentityStringChanged(String),
|
||||||
|
IdentityFilePathChanged(String),
|
||||||
|
ImportIdentityFromString,
|
||||||
|
ImportIdentityFromFile,
|
||||||
|
IdentityImported(Result<Identity, String>),
|
||||||
|
SetOutputDevice(String),
|
||||||
|
SetInputDevice(String),
|
||||||
|
SetAppearance(theme::AppearanceMode),
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) enum TsEvent {
|
||||||
|
Connected,
|
||||||
|
BookEvents(Vec<Event>),
|
||||||
|
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<String>,
|
||||||
|
pub(crate) favorite: bool,
|
||||||
|
pub(crate) last_used_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||