Initial commit: TS3J TeamSpeak 3 Java client
Swing desktop client (core/desktop/swing Maven modules) built on the ts3j protocol library, included as a submodule. Native Opus voice with voice-activation detection, push-to-talk, and audio pre-processing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
154
ts3-client/README.md
Normal file
154
ts3-client/README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# TS3J Client
|
||||
|
||||
A desktop TeamSpeak 3 client built on top of the [`ts3j`](../ts3j) reverse-engineered
|
||||
TS3 protocol library. It looks and behaves like the official TS3 client and focuses
|
||||
first on the features that matter most: **real-time voice with Opus encoding and
|
||||
voice-activation detection (VAD)**.
|
||||
|
||||
The project is split so the frontend can be replaced (e.g. a future web UI via
|
||||
TeaVM/CheerpJ) without touching the library:
|
||||
|
||||
| Module | Artifact | Responsibility |
|
||||
|--------|----------|----------------|
|
||||
| `core` | `ts3-client-core` | Frontend-agnostic library: protocol integration, server model, connection orchestration, audio **abstractions**. No UI, no platform audio, no JNA. |
|
||||
| `desktop` | `ts3-client-desktop` | Desktop audio backend: Java Sound capture/playback + native Opus via JNA, implementing the core audio interfaces. |
|
||||
| `swing` | `ts3-client-swing` | Swing desktop UI + entry point. Depends on `core` and `desktop`. |
|
||||
|
||||
The core exposes `AudioBackend` / `VoiceInput` / `VoiceOutput`; the frontend injects
|
||||
a concrete backend (`JavaSoundAudioBackend`) into `TeamspeakConnection`. A different
|
||||
frontend supplies its own UI and audio backend while reusing `core` unchanged.
|
||||
|
||||
## Features
|
||||
|
||||
### Voice (the priority)
|
||||
- **Native Opus codec** via a direct JNA binding to the system `libopus`
|
||||
(no bundled/native-jar dependency). Encoding at 48 kHz, 20 ms frames.
|
||||
- **Voice Activation Detection (VAD)** with the same three modes as the TS3 client:
|
||||
- **Volume Gate** — RMS/dBFS threshold with a live input meter and hangover.
|
||||
- **Automatic** — a dependency-free speech detector (short-term energy + spectral
|
||||
flatness + dominant frequency against an adaptive noise floor).
|
||||
- **Hybrid** — transmit only when loud enough **and** detected as speech.
|
||||
- Optional **VAD over Push-To-Talk** (keep detecting voice while PTT is available).
|
||||
- **Capture pre-processing** — a mini audio-processing chain in the same order as the
|
||||
TS3 client's WebRTC APM, applied before activation and encoding (and feeding the VAD):
|
||||
input gain → **high-pass filter** (80 Hz, always-on rumble/DC removal) → **noise
|
||||
suppression** → **typing attenuation** → **AGC**:
|
||||
- **Remove background noise** — spectral denoiser (decision-directed Wiener with
|
||||
minimum-statistics noise tracking), with an adjustable removal level.
|
||||
- **Typing attenuation** — detects impulsive keystroke transients (short, broadband,
|
||||
high-frequency bursts) and ducks them while leaving sustained speech intact.
|
||||
- **Automatic gain control (AGC)** — normalises mic loudness to a target level
|
||||
(fast attack / slow release, noise-gated so silence is never amplified).
|
||||
- Echo cancellation (WebRTC AEC3) is intentionally omitted — it needs the loudspeaker
|
||||
reference signal and matters mainly for open speakers, not the typical headset.
|
||||
- **Push-to-Talk** — bind any key; transmits only while held (while the app is focused).
|
||||
- **Continuous** transmission mode.
|
||||
- **Playback mixing** — each speaker gets its own Opus decoder and audio line, so
|
||||
multiple simultaneous talkers are mixed and one slow decode never blocks others.
|
||||
- **Whisper receive** — targeted voice is decoded and played like normal voice.
|
||||
- Per-client **mute**, master **deafen**, adjustable mic gain / playback volume.
|
||||
- **On-the-fly Opus tuning** — bitrate, complexity, VBR, FEC and voice/music codec
|
||||
can all be changed live from the options dialog and take effect on the running
|
||||
encoder immediately (no reconnect); a voice↔music switch transparently rebuilds
|
||||
the encoder because Opus fixes its application mode at creation.
|
||||
- Configurable capture/playback **devices**.
|
||||
|
||||
### Server interaction
|
||||
- Connect to any TS3 server (auto-generates and persists a TS3 identity).
|
||||
- **Channel/client tree** styled like TS3, updated live from protocol events
|
||||
(joins, leaves, moves, channel create/edit/delete, nickname/mute/away changes).
|
||||
- **Talk indicators** — speakers turn green live as they talk.
|
||||
- **Info panel** — selecting a channel shows its topic and **description** (fetched
|
||||
on demand); selecting a client shows its **server groups** and **channel group**
|
||||
(names resolved from the server's group lists), talk power, platform and version.
|
||||
- **Server-group badge** next to each client's nickname in the tree.
|
||||
- **Spacer channels** — TS3 `[spacer]`/`[*spacer]`/`[c/l/r spacer]` names render as
|
||||
non-interactive separators (fill, centred, aligned).
|
||||
- Double-click a channel to **join**; right-click a client to **poke**, open a
|
||||
**private chat**, or **locally mute** them.
|
||||
- **Chat** to the current channel or the whole server; receive channel/server/private
|
||||
messages and **pokes**.
|
||||
- **Server bookmarks** — quick-connect menu with add/edit/remove management.
|
||||
- **Self status** — Away (with message) and Channel Commander toggles.
|
||||
- **Status bar** shows the server name, user count and live ping.
|
||||
- Change your nickname, mute/deafen from the toolbar.
|
||||
|
||||
## Requirements
|
||||
- Java 17+ (developed/tested on Temurin 26)
|
||||
- The native Opus library on the system:
|
||||
- Debian/Ubuntu: `sudo apt install libopus0`
|
||||
- Arch: `sudo pacman -S opus`
|
||||
- macOS: `brew install opus`
|
||||
|
||||
## Building
|
||||
The client depends on `ts3j`, so install that into your local Maven repo first:
|
||||
|
||||
```bash
|
||||
cd ../ts3j && mvn -DskipTests install
|
||||
cd ../ts3-client && mvn -DskipTests package
|
||||
```
|
||||
|
||||
This produces a runnable fat-jar at `swing/target/ts3-client.jar`.
|
||||
|
||||
## Running
|
||||
```bash
|
||||
java -jar swing/target/ts3-client.jar
|
||||
# or, during development:
|
||||
mvn -pl swing exec:java
|
||||
```
|
||||
|
||||
Then use **Connections → Connect…**, enter a server address, port (default 9987)
|
||||
and nickname, and connect. Open **Tools → Options** to pick audio devices and tune
|
||||
voice activation while watching the live meter.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
core/ com.ts3client
|
||||
├── config.Settings persisted prefs (~/.ts3jclient/settings.properties)
|
||||
├── config.Bookmarks persisted server bookmarks
|
||||
├── audio abstractions + reusable DSP (no platform code)
|
||||
│ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput
|
||||
│ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls
|
||||
│ ├── VoiceOutput voice-packet playback sink
|
||||
│ ├── OpusParameters live-tunable encoder settings
|
||||
│ └── SpeechDetector feature-based speech-probability VAD (Automatic/Hybrid)
|
||||
└── net
|
||||
├── TeamspeakConnection ties socket + audio backend + model, translates events
|
||||
├── ServerModel thread-safe channel/client state
|
||||
├── ChannelNode/ClientEntry view models
|
||||
└── ConnectionListener frontend callbacks
|
||||
|
||||
desktop/ com.ts3client.audio.desktop
|
||||
├── Opus JNA binding to native libopus
|
||||
├── OpusEncoder/OpusDecoder thin codec wrappers
|
||||
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
|
||||
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
|
||||
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing
|
||||
└── JavaSoundAudioBackend wires the above into the core AudioBackend
|
||||
|
||||
swing/ com.ts3client
|
||||
├── Main entry point (look & feel, settings, backend injection)
|
||||
└── ui
|
||||
├── MainFrame window: menu, toolbar, tree | chat, status bar
|
||||
├── ServerTreePanel TS3-style channel/client tree (group badges, spacers)
|
||||
├── Spacers TS3 spacer-channel name parsing/rendering
|
||||
├── InfoPanel channel description / client group + details view
|
||||
├── ChatPanel chat log + input
|
||||
├── SettingsDialog audio + VAD options with live meter
|
||||
├── ConnectDialog connect form
|
||||
├── BookmarksDialog manage saved servers
|
||||
├── LevelMeter dBFS meter with threshold marker
|
||||
├── Icons programmatic vector icons (no image assets)
|
||||
└── Theme palette + fonts
|
||||
```
|
||||
|
||||
## Known limitations / next steps
|
||||
- The **Automatic/Hybrid** VAD uses a lightweight energy/spectral detector rather
|
||||
than the WebRTC GMM model the official client ships; it is intentionally
|
||||
dependency-free and reusable in the core library.
|
||||
- Group display shows names; **group icons** are not rendered.
|
||||
- Whisper is received/played but not yet **sendable** from the UI.
|
||||
- Playback decodes streams as mono; stereo music-bot audio is down-mixed.
|
||||
- Push-to-talk is captured via Swing key events, so it only works while the app
|
||||
window has focus (no global hotkey).
|
||||
- No file transfer, avatars, or server/channel administration UI yet.
|
||||
23
ts3-client/core/pom.xml
Normal file
23
ts3-client/core/pom.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-parent</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>ts3-client-core</artifactId>
|
||||
<name>TS3J Client Core</name>
|
||||
<description>Frontend-agnostic library: protocol integration, model, audio abstractions</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.manevolent</groupId>
|
||||
<artifactId>ts3j</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
/**
|
||||
* Factory for a platform's voice capture and playback. Injected into the
|
||||
* connection layer so the core stays independent of any concrete audio stack.
|
||||
*/
|
||||
public interface AudioBackend {
|
||||
|
||||
VoiceInput createInput(Settings settings);
|
||||
|
||||
VoiceOutput createOutput(Settings settings);
|
||||
|
||||
/** Human-readable codec/backend description, e.g. for an "about" line. */
|
||||
String description();
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Microphone pre-processing chain applied before voice activation and Opus encoding,
|
||||
* mirroring the capture-side denoise/typing filters of the TeamSpeak 3 client.
|
||||
*
|
||||
* <p>The stages run in the same order as the TeamSpeak client's WebRTC capture chain:
|
||||
* a {@link HighPassFilter} (always-on rumble/DC removal), then a streaming short-time
|
||||
* Fourier transform (square-root Hann window, 50% overlap-add) carrying a
|
||||
* {@link NoiseSuppressor} ("Remove background noise") and a {@link TypingAttenuator}
|
||||
* ("Typing attenuation") — sharing one FFT/IFFT per hop — and finally an
|
||||
* {@link AutomaticGainControl} ("AGC"). Echo cancellation (WebRTC AEC3) is omitted as
|
||||
* it requires the loudspeaker reference signal.
|
||||
*
|
||||
* <p>Input frames of any length are decoupled from the STFT hop by internal ring
|
||||
* buffers; when noise/typing suppression is active the output is delayed by one hop
|
||||
* (~5 ms). The high-pass filter and AGC are zero-latency. When every stage is
|
||||
* disabled the chain is fully bypassed and audio passes through untouched.
|
||||
*
|
||||
* <p>Pure DSP with no platform dependencies, so any frontend/backend can reuse it.
|
||||
* Not thread-safe: drive it from a single capture thread; the enable/level setters
|
||||
* are cheap volatiles safe to call from the UI thread.
|
||||
*/
|
||||
public final class AudioEnhancer {
|
||||
|
||||
private static final int FFT_SIZE = 512; // power of two -> 10.7 ms @ 48 kHz
|
||||
private static final int HOP = FFT_SIZE / 2; // 50% overlap
|
||||
private static final int BINS = FFT_SIZE / 2 + 1;
|
||||
|
||||
private final double[] window = new double[FFT_SIZE];
|
||||
private final double[] re = new double[FFT_SIZE];
|
||||
private final double[] im = new double[FFT_SIZE];
|
||||
private final double[] power = new double[BINS];
|
||||
private final double[] gain = new double[BINS];
|
||||
|
||||
private final double[] frame = new double[FFT_SIZE]; // sliding analysis frame
|
||||
private final double[] ola = new double[FFT_SIZE]; // overlap-add accumulator
|
||||
|
||||
private final FloatRing input = new FloatRing(FFT_SIZE * 4);
|
||||
private final FloatRing output = new FloatRing(FFT_SIZE * 4);
|
||||
private final float[] hopIn = new float[HOP];
|
||||
|
||||
private final NoiseSuppressor noiseSuppressor = new NoiseSuppressor(BINS);
|
||||
private final TypingAttenuator typingAttenuator;
|
||||
private final HighPassFilter highPass;
|
||||
private final AutomaticGainControl agc;
|
||||
|
||||
private volatile boolean noiseEnabled;
|
||||
private volatile boolean typingEnabled;
|
||||
private volatile boolean agcEnabled;
|
||||
private boolean active; // any stage on: HPF + AGC state is live
|
||||
private boolean stftRunning; // noise/typing on: STFT rings are live
|
||||
|
||||
public AudioEnhancer(int sampleRate) {
|
||||
for (int i = 0; i < FFT_SIZE; i++) {
|
||||
// sqrt(Hann): analysis*synthesis = Hann, which is COLA at 50% overlap.
|
||||
window[i] = Math.sqrt(0.5 * (1 - Math.cos(2 * Math.PI * i / FFT_SIZE)));
|
||||
}
|
||||
this.typingAttenuator = new TypingAttenuator(BINS, sampleRate, FFT_SIZE);
|
||||
this.highPass = new HighPassFilter(sampleRate);
|
||||
this.agc = new AutomaticGainControl(sampleRate);
|
||||
}
|
||||
|
||||
public void setNoiseSuppression(boolean enabled) {
|
||||
this.noiseEnabled = enabled;
|
||||
}
|
||||
|
||||
public void setDenoiserLevel(double level) {
|
||||
noiseSuppressor.setLevel(level);
|
||||
}
|
||||
|
||||
public void setTypingAttenuation(boolean enabled) {
|
||||
this.typingEnabled = enabled;
|
||||
}
|
||||
|
||||
public void setAgc(boolean enabled) {
|
||||
this.agcEnabled = enabled;
|
||||
}
|
||||
|
||||
/** Clears all filter state; call when (re)starting capture. */
|
||||
public void reset() {
|
||||
resetStft();
|
||||
highPass.reset();
|
||||
agc.reset();
|
||||
active = false;
|
||||
stftRunning = false;
|
||||
}
|
||||
|
||||
private void resetStft() {
|
||||
input.clear();
|
||||
output.clear();
|
||||
Arrays.fill(frame, 0);
|
||||
Arrays.fill(ola, 0);
|
||||
noiseSuppressor.reset();
|
||||
typingAttenuator.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhances one frame of mono PCM in place. {@code buf[0..len)} is overwritten with
|
||||
* the processed (one-hop-delayed when noise/typing suppression is on) signal.
|
||||
* Returns immediately if every stage is disabled.
|
||||
*/
|
||||
public void process(float[] buf, int len) {
|
||||
boolean stft = noiseEnabled || typingEnabled;
|
||||
boolean anyStage = stft || agcEnabled;
|
||||
if (!anyStage) {
|
||||
if (active) reset(); // drop stale filter/delay state on full disable
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
reset();
|
||||
active = true;
|
||||
}
|
||||
|
||||
// 1) High-pass filter (always-on part of the active chain).
|
||||
highPass.process(buf, len);
|
||||
|
||||
// 2) STFT noise + typing suppression (only when either is enabled).
|
||||
if (stft) {
|
||||
if (!stftRunning) {
|
||||
resetStft();
|
||||
stftRunning = true;
|
||||
}
|
||||
runStft(buf, len);
|
||||
} else if (stftRunning) {
|
||||
stftRunning = false;
|
||||
}
|
||||
|
||||
// 3) Automatic gain control (last, on the cleaned signal).
|
||||
if (agcEnabled) {
|
||||
agc.process(buf, len);
|
||||
}
|
||||
}
|
||||
|
||||
private void runStft(float[] buf, int len) {
|
||||
input.write(buf, len);
|
||||
while (input.available() >= HOP) {
|
||||
System.arraycopy(frame, HOP, frame, 0, FFT_SIZE - HOP);
|
||||
input.read(hopIn, HOP);
|
||||
for (int i = 0; i < HOP; i++) {
|
||||
frame[FFT_SIZE - HOP + i] = hopIn[i];
|
||||
}
|
||||
processBlock();
|
||||
}
|
||||
|
||||
// During the initial one-hop priming the output ring is short; pad with zeros.
|
||||
int ready = output.available();
|
||||
if (ready < len) {
|
||||
for (int i = 0; i < len - ready; i++) buf[i] = 0f;
|
||||
output.read(buf, len - ready, ready);
|
||||
} else {
|
||||
output.read(buf, 0, len);
|
||||
}
|
||||
}
|
||||
|
||||
private void processBlock() {
|
||||
for (int i = 0; i < FFT_SIZE; i++) {
|
||||
re[i] = frame[i] * window[i];
|
||||
im[i] = 0;
|
||||
}
|
||||
Fft.forward(re, im);
|
||||
|
||||
for (int k = 0; k < BINS; k++) {
|
||||
power[k] = re[k] * re[k] + im[k] * im[k];
|
||||
gain[k] = 1.0;
|
||||
}
|
||||
if (noiseEnabled) noiseSuppressor.apply(power, gain);
|
||||
if (typingEnabled) typingAttenuator.apply(power, gain);
|
||||
|
||||
// Apply the real-valued gain to each bin and its conjugate mirror.
|
||||
for (int k = 0; k < BINS; k++) {
|
||||
double g = gain[k];
|
||||
re[k] *= g;
|
||||
im[k] *= g;
|
||||
if (k > 0 && k < FFT_SIZE - k) {
|
||||
int m = FFT_SIZE - k;
|
||||
re[m] *= g;
|
||||
im[m] *= g;
|
||||
}
|
||||
}
|
||||
Fft.inverse(re, im);
|
||||
|
||||
for (int i = 0; i < FFT_SIZE; i++) {
|
||||
ola[i] += re[i] * window[i];
|
||||
}
|
||||
output.write(ola, HOP);
|
||||
System.arraycopy(ola, HOP, ola, 0, FFT_SIZE - HOP);
|
||||
Arrays.fill(ola, FFT_SIZE - HOP, FFT_SIZE, 0);
|
||||
}
|
||||
|
||||
/** Minimal single-producer/single-consumer float ring buffer. */
|
||||
private static final class FloatRing {
|
||||
private final float[] buf;
|
||||
private int head, tail, size;
|
||||
|
||||
FloatRing(int capacity) {
|
||||
this.buf = new float[capacity];
|
||||
}
|
||||
|
||||
int available() {
|
||||
return size;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
head = tail = size = 0;
|
||||
}
|
||||
|
||||
void write(float[] src, int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
buf[tail] = src[i];
|
||||
tail = (tail + 1) % buf.length;
|
||||
}
|
||||
size += len;
|
||||
}
|
||||
|
||||
void write(double[] src, int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
buf[tail] = (float) src[i];
|
||||
tail = (tail + 1) % buf.length;
|
||||
}
|
||||
size += len;
|
||||
}
|
||||
|
||||
void read(float[] dst, int len) {
|
||||
read(dst, 0, len);
|
||||
}
|
||||
|
||||
void read(float[] dst, int offset, int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
dst[offset + i] = buf[head];
|
||||
head = (head + 1) % buf.length;
|
||||
}
|
||||
size -= len;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* Automatic gain control — WebRTC APM's {@code gain_controller} (AGC2 adaptive
|
||||
* digital) / Speex {@code AGC} stage. It normalises voice loudness toward a target
|
||||
* level so quiet microphones are boosted and loud ones tamed, keeping perceived volume
|
||||
* consistent across speakers.
|
||||
*
|
||||
* <p>Placed last in the capture chain (after noise suppression), it tracks the frame
|
||||
* level and moves an applied gain toward {@code target / level}: it attenuates quickly
|
||||
* to head off clipping and boosts slowly to avoid pumping. A noise gate freezes the
|
||||
* gain while the input is near silence, so background noise between words is never
|
||||
* amplified; the per-sample gain ramp avoids zipper artefacts and a final clamp guards
|
||||
* against overshoot.
|
||||
*/
|
||||
final class AutomaticGainControl {
|
||||
|
||||
private static final double TARGET_RMS = 0.12; // ~ -18.4 dBFS
|
||||
private static final double MAX_GAIN = dbToGain(30); // up to +30 dB boost
|
||||
private static final double MIN_GAIN = dbToGain(-20); // down to -20 dB
|
||||
private static final double NOISE_GATE_RMS = dbToGain(-55); // freeze below this level
|
||||
|
||||
private final double attackCoeff; // gain decreasing (signal too loud): fast
|
||||
private final double releaseCoeff; // gain increasing (too quiet): slow
|
||||
|
||||
private double gain = 1.0;
|
||||
|
||||
AutomaticGainControl(int sampleRate) {
|
||||
this.attackCoeff = 1 - Math.exp(-1.0 / (0.005 * sampleRate)); // ~5 ms
|
||||
this.releaseCoeff = 1 - Math.exp(-1.0 / (0.300 * sampleRate)); // ~300 ms
|
||||
}
|
||||
|
||||
void reset() {
|
||||
gain = 1.0;
|
||||
}
|
||||
|
||||
/** Applies gain normalisation to one mono frame in place. */
|
||||
void process(float[] buf, int len) {
|
||||
double sumSq = 0;
|
||||
for (int i = 0; i < len; i++) {
|
||||
sumSq += (double) buf[i] * buf[i];
|
||||
}
|
||||
double rms = Math.sqrt(sumSq / len);
|
||||
|
||||
double desired = gain;
|
||||
if (rms >= NOISE_GATE_RMS) {
|
||||
desired = TARGET_RMS / rms;
|
||||
if (desired > MAX_GAIN) desired = MAX_GAIN;
|
||||
else if (desired < MIN_GAIN) desired = MIN_GAIN;
|
||||
}
|
||||
// Boost slowly, attenuate quickly.
|
||||
double coeff = desired < gain ? attackCoeff : releaseCoeff;
|
||||
|
||||
for (int i = 0; i < len; i++) {
|
||||
gain += (desired - gain) * coeff;
|
||||
double y = buf[i] * gain;
|
||||
if (y > 1.0) y = 1.0;
|
||||
else if (y < -1.0) y = -1.0;
|
||||
buf[i] = (float) y;
|
||||
}
|
||||
}
|
||||
|
||||
private static double dbToGain(double db) {
|
||||
return Math.pow(10.0, db / 20.0);
|
||||
}
|
||||
}
|
||||
68
ts3-client/core/src/main/java/com/ts3client/audio/Fft.java
Normal file
68
ts3-client/core/src/main/java/com/ts3client/audio/Fft.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* In-place iterative radix-2 Cooley–Tukey FFT shared by the voice DSP stages.
|
||||
* All arrays must have a power-of-two length. Pure math, no platform dependencies.
|
||||
*/
|
||||
final class Fft {
|
||||
|
||||
private Fft() {
|
||||
}
|
||||
|
||||
/** Forward transform (unnormalised). */
|
||||
static void forward(double[] re, double[] im) {
|
||||
transform(re, im, false);
|
||||
}
|
||||
|
||||
/** Inverse transform, normalised by {@code 1/n} so it inverts {@link #forward}. */
|
||||
static void inverse(double[] re, double[] im) {
|
||||
transform(re, im, true);
|
||||
int n = re.length;
|
||||
double scale = 1.0 / n;
|
||||
for (int i = 0; i < n; i++) {
|
||||
re[i] *= scale;
|
||||
im[i] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
private static void transform(double[] re, double[] im, boolean inverse) {
|
||||
int n = re.length;
|
||||
for (int i = 1, j = 0; i < n; i++) {
|
||||
int bit = n >> 1;
|
||||
for (; (j & bit) != 0; bit >>= 1) {
|
||||
j ^= bit;
|
||||
}
|
||||
j ^= bit;
|
||||
if (i < j) {
|
||||
double tr = re[i];
|
||||
re[i] = re[j];
|
||||
re[j] = tr;
|
||||
double ti = im[i];
|
||||
im[i] = im[j];
|
||||
im[j] = ti;
|
||||
}
|
||||
}
|
||||
double sign = inverse ? 2 * Math.PI : -2 * Math.PI;
|
||||
for (int len = 2; len <= n; len <<= 1) {
|
||||
double ang = sign / len;
|
||||
double wr = Math.cos(ang);
|
||||
double wi = Math.sin(ang);
|
||||
for (int i = 0; i < n; i += len) {
|
||||
double curR = 1, curI = 0;
|
||||
for (int k = 0; k < len / 2; k++) {
|
||||
int a = i + k;
|
||||
int b = i + k + len / 2;
|
||||
double vr = re[b] * curR - im[b] * curI;
|
||||
double vi = re[b] * curI + im[b] * curR;
|
||||
re[b] = re[a] - vr;
|
||||
im[b] = im[a] - vi;
|
||||
re[a] += vr;
|
||||
im[a] += vi;
|
||||
double nr = curR * wr - curI * wi;
|
||||
curI = curR * wi + curI * wr;
|
||||
curR = nr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* Second-order Butterworth high-pass filter (RBJ biquad, transposed direct form II).
|
||||
* Mirrors WebRTC APM's {@code high_pass_filter} stage — an always-on part of the
|
||||
* capture chain that removes DC offset, mains hum and low-frequency rumble below the
|
||||
* speech band before noise suppression sees the signal.
|
||||
*/
|
||||
final class HighPassFilter {
|
||||
|
||||
private static final double CUTOFF_HZ = 80.0; // WebRTC APM high-pass cutoff
|
||||
|
||||
private final double b0, b1, b2, a1, a2;
|
||||
private double z1, z2;
|
||||
|
||||
HighPassFilter(int sampleRate) {
|
||||
double w0 = 2 * Math.PI * CUTOFF_HZ / sampleRate;
|
||||
double cos = Math.cos(w0);
|
||||
double alpha = Math.sin(w0) / Math.sqrt(2.0); // Q = 1/sqrt(2) (Butterworth)
|
||||
double a0 = 1 + alpha;
|
||||
this.b0 = (1 + cos) / 2 / a0;
|
||||
this.b1 = -(1 + cos) / a0;
|
||||
this.b2 = (1 + cos) / 2 / a0;
|
||||
this.a1 = -2 * cos / a0;
|
||||
this.a2 = (1 - alpha) / a0;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
z1 = 0;
|
||||
z2 = 0;
|
||||
}
|
||||
|
||||
/** Filters one mono frame in place. */
|
||||
void process(float[] buf, int len) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
double x = buf[i];
|
||||
double y = b0 * x + z1;
|
||||
z1 = b1 * x - a1 * y + z2;
|
||||
z2 = b2 * x - a2 * y;
|
||||
buf[i] = (float) y;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Single-channel spectral noise suppressor — the "Remove background noise"
|
||||
* (denoise) stage. The TeamSpeak 3 client filters steady background noise with
|
||||
* WebRTC's {@code noise_suppression} module (and a Speex denoiser fallback); this is
|
||||
* a self-contained equivalent that operates on the STFT bins produced by
|
||||
* {@link AudioEnhancer}.
|
||||
*
|
||||
* <p>The noise floor is tracked per bin by continuous minimum statistics (Doblinger's
|
||||
* recursive minimum tracker): the estimate follows the valleys of the smoothed power
|
||||
* spectrum, so modulated speech — which dips between syllables — is
|
||||
* preserved while only near-stationary background energy is learned as noise. From
|
||||
* that floor a Wiener gain is formed with a decision-directed a priori SNR
|
||||
* (Ephraim–Malah smoothing, which keeps musical noise low). A configurable
|
||||
* aggressiveness ({@code denoiser_level}, 0–1) sets both the over-subtraction
|
||||
* factor and the gain floor, i.e. how deeply steady noise is cut.
|
||||
*/
|
||||
final class NoiseSuppressor {
|
||||
|
||||
/** Power-spectrum smoothing feeding the minimum tracker. */
|
||||
private static final double POWER_SMOOTH = 0.7;
|
||||
/** Doblinger minimum-tracker constants. */
|
||||
private static final double MIN_GAMMA = 0.998;
|
||||
private static final double MIN_BETA = 0.96;
|
||||
/** Over-estimation applied to the tracked minimum to get the noise power. */
|
||||
private static final double NOISE_OVEREST = 1.5;
|
||||
/** Decision-directed smoothing of the a priori SNR (higher = less musical noise). */
|
||||
private static final double DD_ALPHA = 0.98;
|
||||
/** Floor on the a priori SNR (~ -25 dB) to bound the deepest Wiener gain. */
|
||||
private static final double XI_MIN = 0.003;
|
||||
|
||||
private final int bins;
|
||||
private final double[] smoothed;
|
||||
private final double[] prevSmoothed;
|
||||
private final double[] minTrack;
|
||||
private final double[] priorClean; // previous enhanced power, for the DD estimate
|
||||
|
||||
private double overSubtraction = 1.5;
|
||||
private double gainFloor = dbToGain(-18);
|
||||
private boolean initialised;
|
||||
|
||||
NoiseSuppressor(int bins) {
|
||||
this.bins = bins;
|
||||
this.smoothed = new double[bins];
|
||||
this.prevSmoothed = new double[bins];
|
||||
this.minTrack = new double[bins];
|
||||
this.priorClean = new double[bins];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets aggressiveness in [0,1]. 0 is a light touch (~6 dB max cut), 1 is
|
||||
* heavy (~30 dB) with stronger over-subtraction.
|
||||
*/
|
||||
void setLevel(double level) {
|
||||
double l = Math.max(0, Math.min(1, level));
|
||||
this.gainFloor = dbToGain(-(6 + 24 * l));
|
||||
this.overSubtraction = 1.0 + 1.5 * l;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
initialised = false;
|
||||
Arrays.fill(smoothed, 0);
|
||||
Arrays.fill(prevSmoothed, 0);
|
||||
Arrays.fill(minTrack, 0);
|
||||
Arrays.fill(priorClean, 0);
|
||||
}
|
||||
|
||||
/** Multiplies the running per-bin gain by this stage's Wiener gain. */
|
||||
void apply(double[] power, double[] gain) {
|
||||
if (!initialised) {
|
||||
for (int k = 0; k < bins; k++) {
|
||||
smoothed[k] = prevSmoothed[k] = minTrack[k] = power[k];
|
||||
priorClean[k] = power[k];
|
||||
}
|
||||
initialised = true;
|
||||
}
|
||||
for (int k = 0; k < bins; k++) {
|
||||
double p = power[k] + 1e-12;
|
||||
|
||||
double s = POWER_SMOOTH * smoothed[k] + (1 - POWER_SMOOTH) * p;
|
||||
// Doblinger continuous minimum tracking of the smoothed power.
|
||||
double mt;
|
||||
if (minTrack[k] < s) {
|
||||
mt = MIN_GAMMA * minTrack[k]
|
||||
+ ((1 - MIN_GAMMA) / (1 - MIN_BETA)) * (s - MIN_BETA * smoothed[k]);
|
||||
} else {
|
||||
mt = s;
|
||||
}
|
||||
minTrack[k] = mt;
|
||||
smoothed[k] = s;
|
||||
double noiseK = NOISE_OVEREST * mt + 1e-12;
|
||||
|
||||
double gamma = p / (noiseK * overSubtraction); // a posteriori SNR
|
||||
double xi = DD_ALPHA * (priorClean[k] / noiseK)
|
||||
+ (1 - DD_ALPHA) * Math.max(gamma - 1, 0); // a priori SNR
|
||||
if (xi < XI_MIN) xi = XI_MIN;
|
||||
|
||||
double g = xi / (1 + xi); // Wiener gain
|
||||
if (g < gainFloor) g = gainFloor;
|
||||
|
||||
priorClean[k] = g * g * p;
|
||||
gain[k] *= g;
|
||||
}
|
||||
}
|
||||
|
||||
private static double dbToGain(double db) {
|
||||
return Math.pow(10.0, db / 20.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
/**
|
||||
* Immutable set of Opus encoder settings that a {@link VoiceInput} can apply,
|
||||
* including while capturing. Frontend-agnostic: carries no native handles.
|
||||
*/
|
||||
public final class OpusParameters {
|
||||
|
||||
public static OpusParameters from(Settings s) {
|
||||
return new OpusParameters(s.bitrate, s.complexity, s.vbr, s.fec, s.packetLoss, s.music);
|
||||
}
|
||||
|
||||
/** Target bitrate in bits per second. */
|
||||
public final int bitrate;
|
||||
/** Encoder complexity, 0 (fast) .. 10 (best). */
|
||||
public final int complexity;
|
||||
/** Variable bitrate. */
|
||||
public final boolean vbr;
|
||||
/** In-band forward error correction. */
|
||||
public final boolean fec;
|
||||
/** Expected packet loss, 0..100 percent (drives FEC redundancy). */
|
||||
public final int expectedPacketLoss;
|
||||
/** {@code true} to encode as music (OPUS_MUSIC, stereo-friendly); {@code false} for voice. */
|
||||
public final boolean music;
|
||||
|
||||
public OpusParameters(int bitrate, int complexity, boolean vbr, boolean fec,
|
||||
int expectedPacketLoss, boolean music) {
|
||||
this.bitrate = bitrate;
|
||||
this.complexity = Math.max(0, Math.min(10, complexity));
|
||||
this.vbr = vbr;
|
||||
this.fec = fec;
|
||||
this.expectedPacketLoss = Math.max(0, Math.min(100, expectedPacketLoss));
|
||||
this.music = music;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* Lightweight speech-presence detector used by the "Automatic" and "Hybrid" voice
|
||||
* activation modes (the TeamSpeak 3 client uses a WebRTC GMM detector for the same
|
||||
* purpose). It classifies each 20 ms frame from three features — short-term
|
||||
* energy, spectral flatness and dominant frequency — against an adaptive noise
|
||||
* floor, following Moattar & Homayounpour's real-time VAD.
|
||||
*
|
||||
* <p>Pure DSP with no platform dependencies, so any frontend/backend can reuse it.
|
||||
* A frame votes for speech when at least two of the three features exceed the
|
||||
* baseline; the smoothed vote fraction is exposed as a [0,1] probability.
|
||||
*/
|
||||
public final class SpeechDetector {
|
||||
|
||||
private static final int FFT_SIZE = 1024;
|
||||
|
||||
// Primary thresholds from the reference algorithm (16-bit sample scale).
|
||||
private static final double ENERGY_PRIM = 40.0;
|
||||
private static final double DOMINANT_FREQ_PRIM = 185.0;
|
||||
private static final double FLATNESS_PRIM = 5.0;
|
||||
|
||||
private final int sampleRate;
|
||||
private final double[] re = new double[FFT_SIZE];
|
||||
private final double[] im = new double[FFT_SIZE];
|
||||
|
||||
private double minEnergy;
|
||||
private double minDominantFreq;
|
||||
private double minFlatness;
|
||||
private long silenceFrames;
|
||||
private long frameCount;
|
||||
private double probability;
|
||||
|
||||
public SpeechDetector(int sampleRate) {
|
||||
this.sampleRate = sampleRate;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
frameCount = 0;
|
||||
silenceFrames = 0;
|
||||
probability = 0;
|
||||
}
|
||||
|
||||
public double getProbability() {
|
||||
return probability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one frame of mono PCM in [-1,1] and returns the smoothed speech
|
||||
* probability in [0,1].
|
||||
*/
|
||||
public double process(float[] frame) {
|
||||
double energy = 0;
|
||||
int n = Math.min(frame.length, FFT_SIZE);
|
||||
for (int i = 0; i < n; i++) {
|
||||
double s = frame[i] * 32768.0; // emulate 16-bit scale
|
||||
energy += s * s;
|
||||
re[i] = frame[i];
|
||||
im[i] = 0;
|
||||
}
|
||||
for (int i = n; i < FFT_SIZE; i++) {
|
||||
re[i] = 0;
|
||||
im[i] = 0;
|
||||
}
|
||||
|
||||
Fft.forward(re, im);
|
||||
|
||||
int half = FFT_SIZE / 2;
|
||||
double geoLogSum = 0;
|
||||
double arithSum = 0;
|
||||
double maxMag = 0;
|
||||
int maxBin = 0;
|
||||
for (int k = 1; k < half; k++) {
|
||||
double mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]) + 1e-12;
|
||||
geoLogSum += Math.log(mag);
|
||||
arithSum += mag;
|
||||
if (mag > maxMag) {
|
||||
maxMag = mag;
|
||||
maxBin = k;
|
||||
}
|
||||
}
|
||||
int bins = half - 1;
|
||||
double geoMean = Math.exp(geoLogSum / bins);
|
||||
double arithMean = arithSum / bins;
|
||||
double flatness = -10.0 * Math.log10(geoMean / arithMean); // high = tonal (speech)
|
||||
double dominantFreq = (double) maxBin * sampleRate / FFT_SIZE;
|
||||
|
||||
if (frameCount == 0) {
|
||||
minEnergy = Math.max(energy, 1.0);
|
||||
minDominantFreq = dominantFreq;
|
||||
minFlatness = flatness;
|
||||
}
|
||||
|
||||
double energyThresh = ENERGY_PRIM * Math.log10(Math.max(minEnergy, 1.0));
|
||||
int votes = 0;
|
||||
if (energy - minEnergy >= energyThresh) votes++;
|
||||
if (dominantFreq - minDominantFreq >= DOMINANT_FREQ_PRIM) votes++;
|
||||
if (flatness - minFlatness >= FLATNESS_PRIM) votes++;
|
||||
|
||||
boolean speech = votes >= 2;
|
||||
if (!speech) {
|
||||
// Adapt the noise floor towards the current (silent) energy.
|
||||
silenceFrames++;
|
||||
minEnergy = ((silenceFrames * minEnergy) + energy) / (silenceFrames + 1);
|
||||
}
|
||||
|
||||
double instant = votes / 3.0;
|
||||
probability = 0.6 * probability + 0.4 * instant;
|
||||
frameCount++;
|
||||
return probability;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
/**
|
||||
* Transient (keystroke) suppressor — the "Typing attenuation" stage, which per
|
||||
* the TeamSpeak 3 client "tries to detect and reduce the sounds made by typing"
|
||||
* (WebRTC's {@code transient_suppression} module). Key clicks are short, impulsive,
|
||||
* broadband bursts with a strong high-frequency component, unlike voiced speech which
|
||||
* is sustained and low-frequency dominant.
|
||||
*
|
||||
* <p>Each STFT block is scored for a keystroke signature: a sudden jump in total
|
||||
* power (both against the previous block and a slow running floor) together with an
|
||||
* elevated high-frequency energy ratio. Matching blocks are ducked broadband with an
|
||||
* immediate attack and a short release. A hold cap ensures only genuinely brief
|
||||
* events are cut — a sustained sound such as a fricative outlasts the cap and is
|
||||
* released, so speech is preserved.
|
||||
*/
|
||||
final class TypingAttenuator {
|
||||
|
||||
private static final double HF_HZ = 4000.0; // high-frequency band start
|
||||
private static final double ONSET_FACTOR = 2.5; // total power vs slow floor
|
||||
private static final double FLUX_FACTOR = 3.0; // total power vs previous block
|
||||
private static final double HF_RATIO = 0.30; // fraction of energy above HF_HZ
|
||||
private static final double SUPPRESS = 0.12; // ducking gain on a detected click (~ -18 dB)
|
||||
private static final double RELEASE = 0.25; // recovery fraction per block after a click
|
||||
private static final double FLOOR_SMOOTH = 0.98; // slow power-floor tracking
|
||||
private static final int MAX_HOLD = 4; // max consecutive ducked blocks (~clicks only)
|
||||
|
||||
private final int bins;
|
||||
private final int hfBin;
|
||||
|
||||
private double slowPower;
|
||||
private double prevPower;
|
||||
private double envGain = 1.0;
|
||||
private int heldBlocks;
|
||||
|
||||
TypingAttenuator(int bins, int sampleRate, int fftSize) {
|
||||
this.bins = bins;
|
||||
this.hfBin = (int) Math.round(HF_HZ * fftSize / sampleRate);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
slowPower = 0;
|
||||
prevPower = 0;
|
||||
envGain = 1.0;
|
||||
heldBlocks = 0;
|
||||
}
|
||||
|
||||
/** Multiplies the running per-bin gain by the current broadband ducking gain. */
|
||||
void apply(double[] power, double[] gain) {
|
||||
double total = 0, high = 0;
|
||||
for (int k = 0; k < bins; k++) {
|
||||
total += power[k];
|
||||
if (k >= hfBin) high += power[k];
|
||||
}
|
||||
double hfRatio = high / (total + 1e-12);
|
||||
|
||||
boolean signature = total > slowPower * ONSET_FACTOR
|
||||
&& total > prevPower * FLUX_FACTOR
|
||||
&& hfRatio > HF_RATIO;
|
||||
|
||||
if (signature && heldBlocks < MAX_HOLD) {
|
||||
envGain = SUPPRESS; // fast attack: duck immediately
|
||||
heldBlocks++;
|
||||
} else {
|
||||
envGain += (1 - envGain) * RELEASE;
|
||||
if (!signature) {
|
||||
heldBlocks = 0;
|
||||
// Only let the floor track when we're not inside a transient.
|
||||
slowPower = slowPower == 0 ? total : FLOOR_SMOOTH * slowPower + (1 - FLOOR_SMOOTH) * total;
|
||||
}
|
||||
}
|
||||
prevPower = total;
|
||||
|
||||
for (int k = 0; k < bins; k++) {
|
||||
gain[k] *= envGain;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import com.github.manevolent.ts3j.audio.Microphone;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Voice capture source feeding the TS3 socket. Extends ts3j's {@link Microphone}
|
||||
* (the encoded-packet supplier) with capture lifecycle and voice-gating controls
|
||||
* so the connection layer can drive it without knowing the platform backend.
|
||||
*/
|
||||
public interface VoiceInput extends Microphone {
|
||||
|
||||
void start();
|
||||
|
||||
void stop();
|
||||
|
||||
void setMuted(boolean muted);
|
||||
|
||||
void setMode(Settings.InputMode mode);
|
||||
|
||||
void setVadMode(Settings.VadMode mode);
|
||||
|
||||
/** Voice-activation volume-gate threshold in dBFS. */
|
||||
void setThresholdDb(double db);
|
||||
|
||||
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
|
||||
void setSpeechThreshold(double threshold);
|
||||
|
||||
/** Keep voice activation active while in push-to-talk mode. */
|
||||
void setVadOverPtt(boolean enabled);
|
||||
|
||||
void setInputGain(double gain);
|
||||
|
||||
/** Enables removal of steady background noise (spectral denoise). */
|
||||
void setNoiseSuppression(boolean enabled);
|
||||
|
||||
/** Background-noise removal aggressiveness, 0 (light) .. 1 (heavy). */
|
||||
void setDenoiserLevel(double level);
|
||||
|
||||
/** Enables detection and attenuation of keyboard typing sounds. */
|
||||
void setTypingAttenuation(boolean enabled);
|
||||
|
||||
/** Enables automatic gain control (normalise microphone loudness). */
|
||||
void setAgc(boolean enabled);
|
||||
|
||||
/** Applies Opus encoder parameters, taking effect immediately if capturing. */
|
||||
void setOpusParameters(OpusParameters parameters);
|
||||
|
||||
void setPushToTalk(boolean pressed);
|
||||
|
||||
/** Receives the live input level in dBFS, once per captured frame. */
|
||||
void setLevelListener(Consumer<Double> listener);
|
||||
|
||||
/** Receives local transmit-state transitions (talking / silent). */
|
||||
void setTalkListener(Consumer<Boolean> listener);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ts3client.audio;
|
||||
|
||||
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
|
||||
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
|
||||
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/**
|
||||
* Playback sink for incoming voice packets. The connection layer registers
|
||||
* {@link #handleVoice} as the socket's voice handler; the backend decodes and
|
||||
* renders per speaker.
|
||||
*/
|
||||
public interface VoiceOutput {
|
||||
|
||||
void handleVoice(PacketBody0Voice voice);
|
||||
|
||||
/** Handles a whisper (targeted voice) packet; decoded and played like normal voice. */
|
||||
void handleWhisper(PacketBody1VoiceWhisper whisper);
|
||||
|
||||
void setMasterVolume(double volume);
|
||||
|
||||
void setDeafened(boolean deafened);
|
||||
|
||||
boolean isDeafened();
|
||||
|
||||
void setClientMuted(int clientId, boolean muted);
|
||||
|
||||
boolean isClientMuted(int clientId);
|
||||
|
||||
void setOutputDevice(String device);
|
||||
|
||||
void removeClient(int clientId);
|
||||
|
||||
void shutdown();
|
||||
|
||||
/** Receives remote speaker talk-state transitions as (clientId, talking). */
|
||||
void setTalkListener(BiConsumer<Integer, Boolean> listener);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ts3client.config;
|
||||
|
||||
/** A saved server for quick connect. */
|
||||
public final class Bookmark {
|
||||
public String label;
|
||||
public String address;
|
||||
public int port = 9987;
|
||||
public String nickname;
|
||||
public String password = "";
|
||||
|
||||
public Bookmark() {
|
||||
}
|
||||
|
||||
public Bookmark(String label, String address, int port, String nickname, String password) {
|
||||
this.label = label;
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
this.nickname = nickname;
|
||||
this.password = password == null ? "" : password;
|
||||
}
|
||||
|
||||
/** Display name, falling back to "address:port" when no label is set. */
|
||||
public String displayName() {
|
||||
if (label != null && !label.isBlank()) return label;
|
||||
return address + ":" + port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return displayName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ts3client.config;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Persistent list of {@link Bookmark}s, stored alongside the settings file.
|
||||
* Frontend-agnostic: no UI dependencies.
|
||||
*/
|
||||
public final class Bookmarks {
|
||||
|
||||
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
|
||||
private static final File FILE = new File(DIR, "bookmarks.properties");
|
||||
|
||||
private final List<Bookmark> entries = new ArrayList<>();
|
||||
|
||||
public List<Bookmark> all() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
public void add(Bookmark b) {
|
||||
entries.add(b);
|
||||
}
|
||||
|
||||
public void remove(int index) {
|
||||
if (index >= 0 && index < entries.size()) entries.remove(index);
|
||||
}
|
||||
|
||||
public static Bookmarks load() {
|
||||
Bookmarks b = new Bookmarks();
|
||||
if (!FILE.isFile()) return b;
|
||||
Properties p = new Properties();
|
||||
try (FileInputStream in = new FileInputStream(FILE)) {
|
||||
p.load(in);
|
||||
} catch (Exception e) {
|
||||
return b;
|
||||
}
|
||||
int count = parseInt(p.getProperty("count"), 0);
|
||||
for (int i = 0; i < count; i++) {
|
||||
String prefix = "bookmark." + i + ".";
|
||||
Bookmark bm = new Bookmark();
|
||||
bm.label = p.getProperty(prefix + "label", "");
|
||||
bm.address = p.getProperty(prefix + "address", "");
|
||||
bm.port = parseInt(p.getProperty(prefix + "port"), 9987);
|
||||
bm.nickname = p.getProperty(prefix + "nickname", "");
|
||||
bm.password = p.getProperty(prefix + "password", "");
|
||||
if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
Properties p = new Properties();
|
||||
p.setProperty("count", Integer.toString(entries.size()));
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
Bookmark bm = entries.get(i);
|
||||
String prefix = "bookmark." + i + ".";
|
||||
p.setProperty(prefix + "label", nullToEmpty(bm.label));
|
||||
p.setProperty(prefix + "address", nullToEmpty(bm.address));
|
||||
p.setProperty(prefix + "port", Integer.toString(bm.port));
|
||||
p.setProperty(prefix + "nickname", nullToEmpty(bm.nickname));
|
||||
p.setProperty(prefix + "password", nullToEmpty(bm.password));
|
||||
}
|
||||
try {
|
||||
if (!DIR.isDirectory()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
DIR.mkdirs();
|
||||
}
|
||||
try (FileOutputStream out = new FileOutputStream(FILE)) {
|
||||
p.store(out, "TS3J client bookmarks");
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseInt(String v, int def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return Integer.parseInt(v.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
220
ts3-client/core/src/main/java/com/ts3client/config/Settings.java
Normal file
220
ts3-client/core/src/main/java/com/ts3client/config/Settings.java
Normal file
@@ -0,0 +1,220 @@
|
||||
package com.ts3client.config;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Simple persistent settings backed by a properties file in the user's home
|
||||
* directory (~/.ts3jclient/settings.properties).
|
||||
*
|
||||
* <p>Holds connection defaults, audio device selection and voice-activation
|
||||
* parameters. Loaded once at startup and saved whenever the user changes
|
||||
* something in the options dialog.
|
||||
*/
|
||||
public final class Settings {
|
||||
|
||||
/** How the microphone decides when to transmit. */
|
||||
public enum InputMode {
|
||||
/** Transmit whenever voice activation detects speech. */
|
||||
VOICE_ACTIVATION,
|
||||
/** Transmit only while the push-to-talk key is held. */
|
||||
PUSH_TO_TALK,
|
||||
/** Always transmit (continuous). */
|
||||
CONTINUOUS
|
||||
}
|
||||
|
||||
/** Voice-activation strategy, mirroring the TS3 client's VAD modes. */
|
||||
public enum VadMode {
|
||||
/** Speech-probability detector only. */
|
||||
AUTOMATIC,
|
||||
/** Volume gate (RMS threshold) only. */
|
||||
VOLUME_GATE,
|
||||
/** Volume gate combined with speech probability. */
|
||||
HYBRID
|
||||
}
|
||||
|
||||
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
|
||||
private static final File FILE = new File(DIR, "settings.properties");
|
||||
|
||||
private final Properties props = new Properties();
|
||||
|
||||
// ---- connection ----
|
||||
public String lastAddress = "localhost";
|
||||
public String nickname = System.getProperty("user.name", "TS3J User");
|
||||
public String serverPassword = "";
|
||||
public String identityFile = new File(DIR, "identity.ini").getAbsolutePath();
|
||||
|
||||
// ---- audio devices (mixer names; empty = system default) ----
|
||||
public String inputDevice = "";
|
||||
public String outputDevice = "";
|
||||
|
||||
// ---- voice ----
|
||||
public InputMode inputMode = InputMode.VOICE_ACTIVATION;
|
||||
/** Voice-activation strategy used when {@link #inputMode} is VOICE_ACTIVATION. */
|
||||
public VadMode vadMode = VadMode.HYBRID;
|
||||
/** VAD threshold in dBFS (RMS). Typical range -60 (sensitive) .. -10 (loud). */
|
||||
public double vadThresholdDb = -45.0;
|
||||
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
|
||||
public double speechThreshold = 0.5;
|
||||
/** Keep voice activation running while in push-to-talk mode. */
|
||||
public boolean vadOverPtt = false;
|
||||
/** Push-to-talk key as an AWT virtual-key code; the frontend interprets it. Default: Ctrl (VK_CONTROL). */
|
||||
public int pushToTalkKey = 17;
|
||||
/** Opus target bitrate in bits/sec. */
|
||||
public int bitrate = 48000;
|
||||
/** Opus complexity 0..10. */
|
||||
public int complexity = 10;
|
||||
/** Opus variable bitrate. */
|
||||
public boolean vbr = true;
|
||||
/** Opus in-band forward error correction. */
|
||||
public boolean fec = true;
|
||||
/** Expected packet loss percent (drives FEC). */
|
||||
public int packetLoss = 5;
|
||||
/** Encode as music (OPUS_MUSIC) rather than voice (OPUS_VOICE). */
|
||||
public boolean music = false;
|
||||
/** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */
|
||||
public double outputVolume = 1.0;
|
||||
/** Microphone input gain multiplier applied before VAD/encode. */
|
||||
public double inputVolume = 1.0;
|
||||
/** Remove steady background noise from the microphone (spectral denoise). */
|
||||
public boolean denoise = true;
|
||||
/** Background-noise removal aggressiveness, 0 (light) .. 1 (heavy). */
|
||||
public double denoiserLevel = 0.5;
|
||||
/** Detect and attenuate keyboard typing sounds in the microphone. */
|
||||
public boolean typingAttenuation = true;
|
||||
/** Automatic gain control: normalise microphone loudness to a target level. */
|
||||
public boolean agc = true;
|
||||
|
||||
public static Settings load() {
|
||||
Settings s = new Settings();
|
||||
try {
|
||||
if (FILE.isFile()) {
|
||||
try (FileInputStream in = new FileInputStream(FILE)) {
|
||||
s.props.load(in);
|
||||
}
|
||||
s.applyFromProps();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Corrupt/unreadable settings -> fall back to defaults.
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
try {
|
||||
if (!DIR.isDirectory()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
DIR.mkdirs();
|
||||
}
|
||||
writeToProps();
|
||||
try (FileOutputStream out = new FileOutputStream(FILE)) {
|
||||
props.store(out, "TS3J Swing Client settings");
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public File identityFile() {
|
||||
return new File(identityFile);
|
||||
}
|
||||
|
||||
public File configDir() {
|
||||
return DIR;
|
||||
}
|
||||
|
||||
private void applyFromProps() {
|
||||
lastAddress = props.getProperty("lastAddress", lastAddress);
|
||||
nickname = props.getProperty("nickname", nickname);
|
||||
serverPassword = props.getProperty("serverPassword", serverPassword);
|
||||
identityFile = props.getProperty("identityFile", identityFile);
|
||||
inputDevice = props.getProperty("inputDevice", inputDevice);
|
||||
outputDevice = props.getProperty("outputDevice", outputDevice);
|
||||
inputMode = parseMode(props.getProperty("inputMode"), inputMode);
|
||||
vadMode = parseVadMode(props.getProperty("vadMode"), vadMode);
|
||||
vadThresholdDb = parseD(props.getProperty("vadThresholdDb"), vadThresholdDb);
|
||||
speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold);
|
||||
vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt);
|
||||
pushToTalkKey = parseI(props.getProperty("pushToTalkKey"), pushToTalkKey);
|
||||
bitrate = parseI(props.getProperty("bitrate"), bitrate);
|
||||
complexity = parseI(props.getProperty("complexity"), complexity);
|
||||
vbr = parseB(props.getProperty("vbr"), vbr);
|
||||
fec = parseB(props.getProperty("fec"), fec);
|
||||
packetLoss = parseI(props.getProperty("packetLoss"), packetLoss);
|
||||
music = parseB(props.getProperty("music"), music);
|
||||
outputVolume = parseD(props.getProperty("outputVolume"), outputVolume);
|
||||
inputVolume = parseD(props.getProperty("inputVolume"), inputVolume);
|
||||
denoise = parseB(props.getProperty("denoise"), denoise);
|
||||
denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel);
|
||||
typingAttenuation = parseB(props.getProperty("typingAttenuation"), typingAttenuation);
|
||||
agc = parseB(props.getProperty("agc"), agc);
|
||||
}
|
||||
|
||||
private void writeToProps() {
|
||||
props.setProperty("lastAddress", lastAddress);
|
||||
props.setProperty("nickname", nickname);
|
||||
props.setProperty("serverPassword", serverPassword);
|
||||
props.setProperty("identityFile", identityFile);
|
||||
props.setProperty("inputDevice", inputDevice);
|
||||
props.setProperty("outputDevice", outputDevice);
|
||||
props.setProperty("inputMode", inputMode.name());
|
||||
props.setProperty("vadMode", vadMode.name());
|
||||
props.setProperty("vadThresholdDb", Double.toString(vadThresholdDb));
|
||||
props.setProperty("speechThreshold", Double.toString(speechThreshold));
|
||||
props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt));
|
||||
props.setProperty("pushToTalkKey", Integer.toString(pushToTalkKey));
|
||||
props.setProperty("bitrate", Integer.toString(bitrate));
|
||||
props.setProperty("complexity", Integer.toString(complexity));
|
||||
props.setProperty("vbr", Boolean.toString(vbr));
|
||||
props.setProperty("fec", Boolean.toString(fec));
|
||||
props.setProperty("packetLoss", Integer.toString(packetLoss));
|
||||
props.setProperty("music", Boolean.toString(music));
|
||||
props.setProperty("outputVolume", Double.toString(outputVolume));
|
||||
props.setProperty("inputVolume", Double.toString(inputVolume));
|
||||
props.setProperty("denoise", Boolean.toString(denoise));
|
||||
props.setProperty("denoiserLevel", Double.toString(denoiserLevel));
|
||||
props.setProperty("typingAttenuation", Boolean.toString(typingAttenuation));
|
||||
props.setProperty("agc", Boolean.toString(agc));
|
||||
}
|
||||
|
||||
private static InputMode parseMode(String v, InputMode def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return InputMode.valueOf(v);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static VadMode parseVadMode(String v, VadMode def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return VadMode.valueOf(v);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static double parseD(String v, double def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return Double.parseDouble(v);
|
||||
} catch (NumberFormatException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseI(String v, int def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return Integer.parseInt(v);
|
||||
} catch (NumberFormatException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean parseB(String v, boolean def) {
|
||||
return v == null ? def : Boolean.parseBoolean(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Mutable view-model of a TeamSpeak channel. */
|
||||
public final class ChannelNode {
|
||||
public final int id;
|
||||
public int parentId;
|
||||
public int order;
|
||||
public String name;
|
||||
public String topic = "";
|
||||
public String description = "";
|
||||
public boolean descriptionLoaded;
|
||||
public boolean hasPassword;
|
||||
public boolean permanent;
|
||||
public int maxClients = -1;
|
||||
|
||||
/** Populated when the tree is rebuilt. */
|
||||
public final List<ChannelNode> children = new ArrayList<>();
|
||||
public final List<ClientEntry> clients = new ArrayList<>();
|
||||
|
||||
public ChannelNode(int id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
/** Mutable view-model of a connected client. */
|
||||
public final class ClientEntry {
|
||||
public final int id;
|
||||
public int channelId;
|
||||
public String nickname;
|
||||
public String uniqueId = "";
|
||||
public int type; // 0 = normal voice client, 1 = server-query
|
||||
public int talkPower;
|
||||
|
||||
public int[] serverGroupIds = new int[0];
|
||||
public int channelGroupId;
|
||||
|
||||
// Filled on demand from clientinfo.
|
||||
public String platform = "";
|
||||
public String version = "";
|
||||
public long idleTimeMs;
|
||||
public String description = "";
|
||||
|
||||
public boolean talking;
|
||||
public boolean inputMuted; // microphone muted (client_input_muted)
|
||||
public boolean outputMuted; // speakers muted / deafened (client_output_muted)
|
||||
public boolean away;
|
||||
public boolean channelCommander;
|
||||
public boolean self;
|
||||
|
||||
public ClientEntry(int id, String nickname) {
|
||||
this.id = id;
|
||||
this.nickname = nickname;
|
||||
}
|
||||
|
||||
public boolean isQuery() {
|
||||
return type == 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
/**
|
||||
* UI-facing callbacks fired by {@link TeamspeakConnection}. Implementations are
|
||||
* responsible for marshalling to the Swing EDT.
|
||||
*/
|
||||
public interface ConnectionListener {
|
||||
|
||||
/** Message scope for chat display. */
|
||||
enum ChatScope {SERVER, CHANNEL, PRIVATE}
|
||||
|
||||
void onStatus(String status);
|
||||
|
||||
void onConnected();
|
||||
|
||||
void onDisconnected(String reason);
|
||||
|
||||
/** The channel/client model changed and any tree view should be rebuilt. */
|
||||
void onModelChanged();
|
||||
|
||||
/** On-demand channel/client details finished loading; refresh any info view. */
|
||||
void onInfoUpdated();
|
||||
|
||||
void onChat(ChatScope scope, int fromClientId, String fromName, String message);
|
||||
|
||||
/** A client (possibly the local one) started or stopped talking. */
|
||||
void onTalkStateChanged(int clientId, boolean talking);
|
||||
|
||||
void onError(String message);
|
||||
|
||||
/** Someone poked the local client. */
|
||||
void onPoke(String fromName, String message);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A snapshot of a client's connection statistics, as shown in the Connection
|
||||
* Info window. Values default to {@code -1} meaning "unknown / not reported",
|
||||
* which callers should render as a dash.
|
||||
*
|
||||
* <p>For the local client the figures are read live from the socket's own
|
||||
* packet counters and are fully populated, including the per-{@link Kind}
|
||||
* breakdown. For a remote client the figures come from the server's
|
||||
* {@code notifyconnectioninfo} report and only the aggregate totals are
|
||||
* available; {@link #perKind} is then empty.
|
||||
*/
|
||||
public final class ConnectionStats {
|
||||
|
||||
/** The three traffic categories TeamSpeak accounts separately. */
|
||||
public enum Kind {KEEPALIVE, CONTROL, SPEECH}
|
||||
|
||||
public int clientId;
|
||||
public String nickname = "";
|
||||
public boolean self;
|
||||
|
||||
/** True when derived from local live counters (self); false for a server snapshot. */
|
||||
public boolean live;
|
||||
|
||||
public String ip = "";
|
||||
public String version = "";
|
||||
public String platform = "";
|
||||
|
||||
public double pingMs = -1;
|
||||
public double pingDeviationMs = -1;
|
||||
/** Total packet loss as a fraction 0..1, or -1 if unknown. */
|
||||
public double packetLoss = -1;
|
||||
public long connectedTimeMs = -1;
|
||||
public long idleTimeMs = -1;
|
||||
|
||||
public long filetransferBandwidthSent = -1;
|
||||
public long filetransferBandwidthReceived = -1;
|
||||
|
||||
// Aggregate totals across all traffic kinds.
|
||||
public long packetsSentTotal = -1;
|
||||
public long packetsReceivedTotal = -1;
|
||||
public long bytesSentTotal = -1;
|
||||
public long bytesReceivedTotal = -1;
|
||||
public long bandwidthSentLastSecond = -1;
|
||||
public long bandwidthReceivedLastSecond = -1;
|
||||
public long bandwidthSentLastMinute = -1;
|
||||
public long bandwidthReceivedLastMinute = -1;
|
||||
|
||||
/** Per-category breakdown (one row per {@link Kind}); empty when unavailable. */
|
||||
public final List<KindStats> perKind = new ArrayList<>();
|
||||
|
||||
public boolean hasBreakdown() {
|
||||
return !perKind.isEmpty();
|
||||
}
|
||||
|
||||
/** Returns the stats for {@code kind}, or {@code null} if not present. */
|
||||
public KindStats kind(Kind kind) {
|
||||
for (KindStats k : perKind) {
|
||||
if (k.kind == kind) return k;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns the stats for {@code kind}, creating and registering the row if needed. */
|
||||
public KindStats getOrCreateKind(Kind kind) {
|
||||
KindStats existing = kind(kind);
|
||||
if (existing != null) return existing;
|
||||
KindStats created = new KindStats(kind);
|
||||
perKind.add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** One category's counters. Values default to {@code -1} meaning "unknown". */
|
||||
public static final class KindStats {
|
||||
public final Kind kind;
|
||||
public double packetLoss = -1; // fraction 0..1
|
||||
public long packetsSent = -1;
|
||||
public long packetsReceived = -1;
|
||||
public long bytesSent = -1;
|
||||
public long bytesReceived = -1;
|
||||
public long bandwidthSentLastSecond = -1;
|
||||
public long bandwidthReceivedLastSecond = -1;
|
||||
public long bandwidthSentLastMinute = -1;
|
||||
public long bandwidthReceivedLastMinute = -1;
|
||||
|
||||
public KindStats(Kind kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
158
ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
Normal file
158
ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java
Normal file
@@ -0,0 +1,158 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Thread-safe holder for the current server state (channels + clients).
|
||||
*
|
||||
* <p>Mutated from ts3j's event thread and read from the Swing EDT while building
|
||||
* the tree, so all access is synchronised on the instance.
|
||||
*/
|
||||
public final class ServerModel {
|
||||
|
||||
private final Map<Integer, ChannelNode> channels = new LinkedHashMap<>();
|
||||
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
|
||||
private final Map<Integer, String> serverGroups = new LinkedHashMap<>();
|
||||
private final Map<Integer, String> channelGroups = new LinkedHashMap<>();
|
||||
private String serverName = "TeamSpeak Server";
|
||||
|
||||
public synchronized void clear() {
|
||||
channels.clear();
|
||||
clients.clear();
|
||||
serverGroups.clear();
|
||||
channelGroups.clear();
|
||||
}
|
||||
|
||||
// ---- groups ----
|
||||
|
||||
public synchronized void putServerGroup(int id, String name) {
|
||||
if (name != null) serverGroups.put(id, name);
|
||||
}
|
||||
|
||||
public synchronized void putChannelGroup(int id, String name) {
|
||||
if (name != null) channelGroups.put(id, name);
|
||||
}
|
||||
|
||||
public synchronized String channelGroupName(int id) {
|
||||
return channelGroups.get(id);
|
||||
}
|
||||
|
||||
/** Resolves server-group ids to their names, keeping unknown ids as "#id". */
|
||||
public synchronized java.util.List<String> serverGroupNames(int[] ids) {
|
||||
java.util.List<String> names = new java.util.ArrayList<>();
|
||||
if (ids != null) {
|
||||
for (int id : ids) {
|
||||
String name = serverGroups.get(id);
|
||||
names.add(name != null ? name : "#" + id);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Primary (first) server-group name for compact display, or {@code null}. */
|
||||
public synchronized String primaryServerGroupName(int[] ids) {
|
||||
if (ids == null || ids.length == 0) return null;
|
||||
return serverGroups.get(ids[0]);
|
||||
}
|
||||
|
||||
public synchronized String getServerName() {
|
||||
return serverName;
|
||||
}
|
||||
|
||||
public synchronized void setServerName(String name) {
|
||||
if (name != null && !name.isEmpty()) this.serverName = name;
|
||||
}
|
||||
|
||||
// ---- channels ----
|
||||
|
||||
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {
|
||||
ChannelNode c = channels.computeIfAbsent(id, k -> new ChannelNode(id, name));
|
||||
if (name != null) c.name = name;
|
||||
c.parentId = parentId;
|
||||
c.order = order;
|
||||
return c;
|
||||
}
|
||||
|
||||
public synchronized ChannelNode getChannel(int id) {
|
||||
return channels.get(id);
|
||||
}
|
||||
|
||||
public synchronized void removeChannel(int id) {
|
||||
channels.remove(id);
|
||||
}
|
||||
|
||||
// ---- clients ----
|
||||
|
||||
public synchronized ClientEntry putClient(int id, String nickname, int channelId) {
|
||||
ClientEntry c = clients.computeIfAbsent(id, k -> new ClientEntry(id, nickname));
|
||||
if (nickname != null) c.nickname = nickname;
|
||||
c.channelId = channelId;
|
||||
return c;
|
||||
}
|
||||
|
||||
public synchronized ClientEntry getClient(int id) {
|
||||
return clients.get(id);
|
||||
}
|
||||
|
||||
public synchronized void removeClient(int id) {
|
||||
clients.remove(id);
|
||||
}
|
||||
|
||||
public synchronized ClientEntry findClientByName(String name) {
|
||||
for (ClientEntry c : clients.values()) {
|
||||
if (c.nickname != null && c.nickname.equals(name)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an ordered forest of channels (each with its clients attached), sorted
|
||||
* by TS3's channel order chain and then by client talk power / name.
|
||||
*
|
||||
* @return the list of root channels (parentId == 0)
|
||||
*/
|
||||
public synchronized List<ChannelNode> buildTree() {
|
||||
// Reset transient child/client lists.
|
||||
for (ChannelNode c : channels.values()) {
|
||||
c.children.clear();
|
||||
c.clients.clear();
|
||||
}
|
||||
List<ChannelNode> roots = new ArrayList<>();
|
||||
for (ChannelNode c : channels.values()) {
|
||||
ChannelNode parent = channels.get(c.parentId);
|
||||
if (c.parentId == 0 || parent == null) {
|
||||
roots.add(c);
|
||||
} else {
|
||||
parent.children.add(c);
|
||||
}
|
||||
}
|
||||
for (ClientEntry cl : clients.values()) {
|
||||
if (cl.isQuery()) continue; // hide server-query clients from the tree
|
||||
ChannelNode ch = channels.get(cl.channelId);
|
||||
if (ch != null) ch.clients.add(cl);
|
||||
}
|
||||
|
||||
Comparator<ChannelNode> byOrder = Comparator.comparingInt((ChannelNode c) -> c.order)
|
||||
.thenComparing(c -> c.name == null ? "" : c.name.toLowerCase());
|
||||
Comparator<ClientEntry> byClient = Comparator
|
||||
.comparingInt((ClientEntry c) -> -c.talkPower)
|
||||
.thenComparing(c -> c.nickname == null ? "" : c.nickname.toLowerCase());
|
||||
|
||||
roots.sort(byOrder);
|
||||
for (ChannelNode c : channels.values()) {
|
||||
c.children.sort(byOrder);
|
||||
c.clients.sort(byClient);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public synchronized int clientCount() {
|
||||
int n = 0;
|
||||
for (ClientEntry c : clients.values()) if (!c.isQuery()) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,860 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import com.github.manevolent.ts3j.api.Channel;
|
||||
import com.github.manevolent.ts3j.api.Client;
|
||||
import com.github.manevolent.ts3j.command.SingleCommand;
|
||||
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||
import com.github.manevolent.ts3j.event.*;
|
||||
import com.github.manevolent.ts3j.identity.LocalIdentity;
|
||||
import com.github.manevolent.ts3j.protocol.PacketKind;
|
||||
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
|
||||
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
|
||||
import com.github.manevolent.ts3j.util.Pair;
|
||||
import com.ts3client.audio.AudioBackend;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.audio.VoiceOutput;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* High-level facade that ties the ts3j protocol socket to the audio subsystem and
|
||||
* the {@link ServerModel}, translating protocol events into {@link ConnectionListener}
|
||||
* callbacks for the UI.
|
||||
*/
|
||||
public final class TeamspeakConnection implements TS3Listener {
|
||||
|
||||
private final Settings settings;
|
||||
private final AudioBackend audio;
|
||||
private final ServerModel model = new ServerModel();
|
||||
private final ConnectionListener ui;
|
||||
|
||||
private LocalTeamspeakClientSocket client;
|
||||
private VoiceInput microphone;
|
||||
private VoiceOutput playback;
|
||||
private LocalIdentity identity;
|
||||
|
||||
private volatile boolean connected;
|
||||
private volatile int selfClientId = -1;
|
||||
private volatile long connectedAtMs;
|
||||
|
||||
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
||||
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
|
||||
|
||||
public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui) {
|
||||
this.settings = settings;
|
||||
this.audio = audio;
|
||||
this.ui = ui;
|
||||
}
|
||||
|
||||
public ServerModel getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
public int getSelfClientId() {
|
||||
return selfClientId;
|
||||
}
|
||||
|
||||
public VoiceInput getMicrophone() {
|
||||
return microphone;
|
||||
}
|
||||
|
||||
public VoiceOutput getPlayback() {
|
||||
return playback;
|
||||
}
|
||||
|
||||
// ---- identity ----
|
||||
|
||||
/** Loads the persisted identity, or generates and saves a new one. */
|
||||
public LocalIdentity loadOrCreateIdentity() throws Exception {
|
||||
File file = settings.identityFile();
|
||||
if (file.isFile()) {
|
||||
try {
|
||||
return LocalIdentity.read(file);
|
||||
} catch (Exception e) {
|
||||
// fall through and regenerate
|
||||
}
|
||||
}
|
||||
LocalIdentity id = LocalIdentity.generateNew(10);
|
||||
if (!file.getParentFile().isDirectory()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
file.getParentFile().mkdirs();
|
||||
}
|
||||
id.save(file);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---- connection lifecycle ----
|
||||
|
||||
public void connect(String address, int port, String nickname, String password) {
|
||||
new Thread(() -> doConnect(address, port, nickname, password), "ts3j-connect").start();
|
||||
}
|
||||
|
||||
private void doConnect(String address, int port, String nickname, String password) {
|
||||
try {
|
||||
ui.onStatus("Loading identity…");
|
||||
identity = loadOrCreateIdentity();
|
||||
|
||||
model.clear();
|
||||
playback = audio.createOutput(settings);
|
||||
playback.setMasterVolume(settings.outputVolume);
|
||||
playback.setTalkListener((clientId, talking) -> {
|
||||
ClientEntry c = model.getClient(clientId);
|
||||
if (c != null) c.talking = talking;
|
||||
ui.onTalkStateChanged(clientId, talking);
|
||||
});
|
||||
|
||||
microphone = audio.createInput(settings);
|
||||
microphone.setTalkListener(talking -> {
|
||||
if (selfClientId >= 0) {
|
||||
ClientEntry c = model.getClient(selfClientId);
|
||||
if (c != null) c.talking = talking;
|
||||
ui.onTalkStateChanged(selfClientId, talking);
|
||||
}
|
||||
});
|
||||
|
||||
client = new LocalTeamspeakClientSocket();
|
||||
client.setIdentity(identity);
|
||||
client.setNickname(nickname);
|
||||
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
|
||||
client.addListener(this);
|
||||
client.setVoiceHandler(playback::handleVoice);
|
||||
client.setWhisperHandler(playback::handleWhisper);
|
||||
client.setExceptionHandler(t -> {
|
||||
// A late packet dispatched after close() rejects on the shut-down
|
||||
// command executor; harmless during teardown.
|
||||
if (t instanceof java.util.concurrent.RejectedExecutionException) return;
|
||||
ui.onError("Network error: " + rootMessage(t));
|
||||
});
|
||||
|
||||
ui.onStatus("Connecting to " + address + ":" + port + "…");
|
||||
client.connect(new InetSocketAddress(address, port),
|
||||
(password == null || password.isEmpty()) ? null : password,
|
||||
10_000L);
|
||||
|
||||
// Protocol connection established; anything past this point is best-effort.
|
||||
selfClientId = client.getClientId();
|
||||
client.setMicrophone(microphone);
|
||||
connected = true;
|
||||
connectedAtMs = System.currentTimeMillis();
|
||||
ui.onConnected();
|
||||
|
||||
ui.onStatus("Retrieving channels…");
|
||||
syncAll();
|
||||
ui.onModelChanged();
|
||||
ui.onStatus("Connected to " + model.getServerName());
|
||||
|
||||
try {
|
||||
microphone.start();
|
||||
} catch (Exception micError) {
|
||||
ui.onError("Microphone unavailable: " + rootMessage(micError));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
connected = false;
|
||||
ui.onError("Connection failed: " + rootMessage(e));
|
||||
ui.onStatus("Disconnected");
|
||||
safeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the model from the server. Each step is best-effort: a restricted
|
||||
* guest group may deny {@code channelsubscribeall} or the list commands without
|
||||
* that being a fatal connection error, and channel data already arrives via
|
||||
* events during {@code connect()}.
|
||||
*/
|
||||
private void syncAll() {
|
||||
try {
|
||||
client.subscribeAll();
|
||||
} catch (Exception e) {
|
||||
ui.onStatus("Limited subscription: " + rootMessage(e));
|
||||
}
|
||||
try {
|
||||
for (Channel ch : client.listChannels()) {
|
||||
model.putChannel(ch.getId(), ch.getName(), ch.getParentChannelId(), ch.getOrder());
|
||||
ChannelNode node = model.getChannel(ch.getId());
|
||||
if (node != null) {
|
||||
node.hasPassword = ch.hasPassword();
|
||||
node.permanent = ch.isPermanent();
|
||||
node.topic = ch.getTopic() == null ? "" : ch.getTopic();
|
||||
node.maxClients = ch.getMaxClients();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ui.onStatus("Channel list unavailable: " + rootMessage(e));
|
||||
}
|
||||
try {
|
||||
for (Client cl : client.listClients()) {
|
||||
ClientEntry e = model.putClient(cl.getId(), cl.getNickname(), cl.getChannelId());
|
||||
e.type = cl.getType();
|
||||
e.talkPower = cl.getTalkPower();
|
||||
e.inputMuted = cl.isInputMuted();
|
||||
e.outputMuted = cl.isOutputMuted();
|
||||
e.away = cl.isAway();
|
||||
e.uniqueId = cl.getUniqueIdentifier();
|
||||
e.serverGroupIds = cl.getServerGroups();
|
||||
e.channelGroupId = cl.getChannelGroupId();
|
||||
e.self = (cl.getId() == selfClientId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ui.onStatus("Client list unavailable: " + rootMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
new Thread(() -> disconnectBlocking("Leaving"), "ts3j-disconnect").start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect synchronously on the calling thread, notifying the UI. Used both
|
||||
* by the async {@link #disconnect()} and by shutdown paths (Ctrl+C, window
|
||||
* close) where the JVM must not exit before the leave notification is sent.
|
||||
*/
|
||||
public void disconnectBlocking(String reason) {
|
||||
try {
|
||||
if (client != null) client.disconnect(reason);
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
safeCleanup();
|
||||
connected = false;
|
||||
ui.onDisconnected("You disconnected");
|
||||
ui.onStatus("Disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void safeCleanup() {
|
||||
VoiceInput mic = microphone;
|
||||
VoiceOutput out = playback;
|
||||
LocalTeamspeakClientSocket sock = client;
|
||||
microphone = null;
|
||||
playback = null;
|
||||
client = null;
|
||||
selfClientId = -1;
|
||||
pendingConnInfo.clear();
|
||||
|
||||
if (mic != null) {
|
||||
try {
|
||||
mic.stop();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (out != null) {
|
||||
try {
|
||||
out.shutdown();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
if (sock != null) {
|
||||
try {
|
||||
sock.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- self actions ----
|
||||
|
||||
public void setMicMuted(boolean muted) {
|
||||
if (microphone != null) microphone.setMuted(muted);
|
||||
pushSelfFlags();
|
||||
}
|
||||
|
||||
public void setDeafened(boolean deaf) {
|
||||
if (playback != null) playback.setDeafened(deaf);
|
||||
if (microphone != null && deaf) microphone.setMuted(true);
|
||||
pushSelfFlags();
|
||||
}
|
||||
|
||||
private void pushSelfFlags() {
|
||||
// Best-effort: publish input/output muted flags to the server so others see them.
|
||||
if (client == null || !connected || microphone == null || playback == null) return;
|
||||
try {
|
||||
java.util.Map<String, String> props = new java.util.HashMap<>();
|
||||
props.put("client_input_muted", microphone.isMuted() ? "1" : "0");
|
||||
props.put("client_output_muted", playback.isDeafened() ? "1" : "0");
|
||||
client.editClient(selfClientId, props);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public void joinChannel(int channelId, String password) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.joinChannel(channelId, (password == null || password.isEmpty()) ? null : password);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Could not join channel: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-join").start();
|
||||
}
|
||||
|
||||
public void sendChannelMessage(String text) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
ClientEntry self = model.getClient(selfClientId);
|
||||
int cid = self != null ? self.channelId : 0;
|
||||
client.sendChannelMessage(cid, text);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Message failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-chan-msg").start();
|
||||
}
|
||||
|
||||
public void sendServerMessage(String text) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.sendServerMessage(text);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Message failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-srv-msg").start();
|
||||
}
|
||||
|
||||
public void sendPrivateMessage(int clientId, String text) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.sendPrivateMessage(clientId, text);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Message failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-pm").start();
|
||||
}
|
||||
|
||||
public void poke(int clientId, String message) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.clientPoke(clientId, message);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Poke failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-poke").start();
|
||||
}
|
||||
|
||||
public void setAway(boolean away, String message) {
|
||||
selfUpdate(cmd -> {
|
||||
cmd.add(new CommandSingleParameter("client_away", away ? "1" : "0"));
|
||||
cmd.add(new CommandSingleParameter("client_away_message", away && message != null ? message : ""));
|
||||
}, "Away update failed");
|
||||
}
|
||||
|
||||
public void setChannelCommander(boolean commander) {
|
||||
selfUpdate(cmd ->
|
||||
cmd.add(new CommandSingleParameter("client_is_channel_commander", commander ? "1" : "0")),
|
||||
"Channel commander update failed");
|
||||
}
|
||||
|
||||
/** Sends a {@code clientupdate} for the local client on a background thread. */
|
||||
private void selfUpdate(java.util.function.Consumer<SingleCommand> fill, String errorLabel) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
SingleCommand cmd = new SingleCommand("clientupdate", ProtocolRole.CLIENT);
|
||||
fill.accept(cmd);
|
||||
client.executeCommand(cmd).complete();
|
||||
} catch (Exception e) {
|
||||
ui.onError(errorLabel + ": " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-selfupdate").start();
|
||||
}
|
||||
|
||||
public double getPingMillis() {
|
||||
try {
|
||||
return client != null ? client.getPing().getKey() * 1000.0 : -1;
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public void setNickname(String nickname) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
client.setNickname(nickname);
|
||||
} catch (Exception e) {
|
||||
ui.onError("Rename failed: " + rootMessage(e));
|
||||
}
|
||||
}, "ts3j-rename").start();
|
||||
}
|
||||
|
||||
// ---- TS3Listener: keep model in sync and notify UI ----
|
||||
|
||||
@Override
|
||||
public void onClientJoin(ClientJoinEvent e) {
|
||||
ClientEntry c = model.putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId());
|
||||
c.type = safeInt(e, "client_type");
|
||||
c.talkPower = e.getClientTalkPower();
|
||||
c.inputMuted = e.isClientInputMuted();
|
||||
c.outputMuted = e.isClientOutputMuted();
|
||||
c.away = e.isClientAway();
|
||||
c.uniqueId = orEmpty(e.getUniqueClientIdentifier());
|
||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||
c.channelGroupId = e.getClientChannelGroupId();
|
||||
c.self = (e.getClientId() == selfClientId);
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientLeave(ClientLeaveEvent e) {
|
||||
model.removeClient(e.getClientId());
|
||||
if (playback != null) playback.removeClient(e.getClientId());
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientMoved(ClientMovedEvent e) {
|
||||
ClientEntry c = model.getClient(e.getClientId());
|
||||
if (c != null) {
|
||||
c.channelId = e.getTargetChannelId();
|
||||
ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientChanged(ClientUpdatedEvent e) {
|
||||
ClientEntry c = model.getClient(e.getClientId());
|
||||
if (c == null) return;
|
||||
String nick = e.get("client_nickname");
|
||||
if (nick != null) c.nickname = nick;
|
||||
if (e.get("client_input_muted") != null) c.inputMuted = e.getBoolean("client_input_muted");
|
||||
if (e.get("client_output_muted") != null) c.outputMuted = e.getBoolean("client_output_muted");
|
||||
if (e.get("client_away") != null) c.away = e.getBoolean("client_away");
|
||||
if (e.get("client_talk_power") != null) c.talkPower = e.getInt("client_talk_power");
|
||||
if (e.get("client_is_channel_commander") != null)
|
||||
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelCreate(ChannelCreateEvent e) {
|
||||
int cid = e.getChannelId();
|
||||
String name = e.get("channel_name");
|
||||
int pid = safeInt(e, "cpid");
|
||||
if (pid == 0) pid = safeInt(e, "pid");
|
||||
int order = safeInt(e, "channel_order");
|
||||
model.putChannel(cid, name, pid, order);
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelDeleted(ChannelDeletedEvent e) {
|
||||
model.removeChannel(e.getChannelId());
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelEdit(ChannelEditedEvent e) {
|
||||
ChannelNode ch = model.getChannel(safeInt(e, "cid"));
|
||||
if (ch != null) {
|
||||
String name = e.get("channel_name");
|
||||
if (name != null) ch.name = name;
|
||||
if (e.get("channel_order") != null) ch.order = e.getInt("channel_order");
|
||||
ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelMoved(ChannelMovedEvent e) {
|
||||
ChannelNode ch = model.getChannel(safeInt(e, "cid"));
|
||||
if (ch != null) {
|
||||
if (e.get("cpid") != null) ch.parentId = e.getInt("cpid");
|
||||
if (e.get("order") != null) ch.order = e.getInt("order");
|
||||
ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelList(ChannelListEvent e) {
|
||||
// Incremental channel arriving during connect.
|
||||
int cid = e.getChannelId();
|
||||
String name = e.get("channel_name");
|
||||
int pid = safeInt(e, "cpid");
|
||||
int order = safeInt(e, "channel_order");
|
||||
model.putChannel(cid, name, pid, order);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupList(ServerGroupListEvent e) {
|
||||
int id = safeInt(e, "sgid");
|
||||
if (id > 0) model.putServerGroup(id, e.get("name"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelGroupList(ChannelGroupListEvent e) {
|
||||
int id = safeInt(e, "cgid");
|
||||
if (id > 0) model.putChannelGroup(id, e.get("name"));
|
||||
}
|
||||
|
||||
/** Fetches a channel's description (and topic) on demand, then notifies the UI. */
|
||||
public void requestChannelInfo(int channelId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Channel ch = client.getChannelInfo(channelId);
|
||||
ChannelNode node = model.getChannel(channelId);
|
||||
if (ch != null && node != null) {
|
||||
node.description = orEmpty(ch.get("channel_description"));
|
||||
node.topic = orEmpty(ch.getTopic());
|
||||
node.descriptionLoaded = true;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// description may be permission-restricted; leave as-is
|
||||
} finally {
|
||||
ui.onInfoUpdated();
|
||||
}
|
||||
}, "ts3j-channelinfo").start();
|
||||
}
|
||||
|
||||
/** Fetches a client's extended info (groups, platform, version) on demand. */
|
||||
public void requestClientInfo(int clientId) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Client c = client.getClientInfo(clientId);
|
||||
ClientEntry e = model.getClient(clientId);
|
||||
if (c != null && e != null) {
|
||||
e.platform = orEmpty(c.getPlatform());
|
||||
e.version = orEmpty(c.getVersion());
|
||||
e.idleTimeMs = c.getIdleTime();
|
||||
e.description = orEmpty(c.get("client_description"));
|
||||
int[] groups = c.getServerGroups();
|
||||
if (groups != null && groups.length > 0) e.serverGroupIds = groups;
|
||||
e.channelGroupId = c.getChannelGroupId();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
ui.onInfoUpdated();
|
||||
}
|
||||
}, "ts3j-clientinfo").start();
|
||||
}
|
||||
|
||||
// ---- connection info ----
|
||||
|
||||
/**
|
||||
* Fetches connection statistics for a client and delivers them to
|
||||
* {@code callback} (invoked off the Swing EDT — the callback is responsible
|
||||
* for marshalling). For the local client the figures are read from the live
|
||||
* local packet counters; for a remote client they are requested from the
|
||||
* server via {@code getconnectioninfo}, enriched with {@code clientinfo} for
|
||||
* version, platform and idle time. May be called repeatedly to poll.
|
||||
*/
|
||||
public void requestConnectionInfo(int clientId, Consumer<ConnectionStats> callback) {
|
||||
if (client == null || !connected) return;
|
||||
if (clientId == selfClientId) {
|
||||
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
|
||||
} else {
|
||||
new Thread(() -> requestRemoteConnInfo(clientId, callback), "ts3j-conninfo").start();
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a live snapshot of the local client's connection from its own counters. */
|
||||
private ConnectionStats buildLocalStats() {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = selfClientId;
|
||||
s.self = true;
|
||||
s.live = true;
|
||||
s.packetLoss = 0; // the local client has no server->client loss figure
|
||||
|
||||
ClientEntry self = model.getClient(selfClientId);
|
||||
if (self != null) {
|
||||
s.nickname = self.nickname;
|
||||
s.version = self.version;
|
||||
s.platform = self.platform;
|
||||
s.idleTimeMs = self.idleTimeMs;
|
||||
}
|
||||
long connectedAt = connectedAtMs;
|
||||
if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt;
|
||||
|
||||
LocalTeamspeakClientSocket c = client;
|
||||
if (c == null) return s;
|
||||
|
||||
try {
|
||||
Pair<Double, Double> ping = c.getPing();
|
||||
s.pingMs = ping.getKey() * 1000.0;
|
||||
s.pingDeviationMs = ping.getValue() * 1000.0;
|
||||
} catch (Exception ignored) {
|
||||
// ping unavailable; leave as unknown
|
||||
}
|
||||
|
||||
long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0;
|
||||
long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0;
|
||||
for (PacketKind kind : PacketKind.values()) {
|
||||
PacketStatistics st = c.getStatistics(kind);
|
||||
ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind));
|
||||
ks.packetLoss = 0; // the local client cannot measure its own server->client loss
|
||||
ks.packetsSent = st.getSentPackets();
|
||||
ks.packetsReceived = st.getReceivedPackets();
|
||||
ks.bytesSent = st.getSentBytes();
|
||||
ks.bytesReceived = st.getReceivedBytes();
|
||||
ks.bandwidthSentLastSecond = st.getSentBytesLastSecond();
|
||||
ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond();
|
||||
ks.bandwidthSentLastMinute = st.getSentBytesLastMinute();
|
||||
ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute();
|
||||
s.perKind.add(ks);
|
||||
|
||||
pSent += ks.packetsSent;
|
||||
pRecv += ks.packetsReceived;
|
||||
bSent += ks.bytesSent;
|
||||
bRecv += ks.bytesReceived;
|
||||
bwSs += ks.bandwidthSentLastSecond;
|
||||
bwRs += ks.bandwidthReceivedLastSecond;
|
||||
bwSm += ks.bandwidthSentLastMinute;
|
||||
bwRm += ks.bandwidthReceivedLastMinute;
|
||||
}
|
||||
s.packetsSentTotal = pSent;
|
||||
s.packetsReceivedTotal = pRecv;
|
||||
s.bytesSentTotal = bSent;
|
||||
s.bytesReceivedTotal = bRecv;
|
||||
s.bandwidthSentLastSecond = bwSs;
|
||||
s.bandwidthReceivedLastSecond = bwRs;
|
||||
s.bandwidthSentLastMinute = bwSm;
|
||||
s.bandwidthReceivedLastMinute = bwRm;
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a remote client's connection info. First loads {@code clientinfo}
|
||||
* for the stable fields, then issues {@code getconnectioninfo} whose
|
||||
* {@code notifyconnectioninfo} report arrives asynchronously via
|
||||
* {@link #onUnknownEvent}. If the report does not arrive shortly (e.g. the
|
||||
* server withholds it), the clientinfo-only snapshot is delivered instead.
|
||||
*/
|
||||
private void requestRemoteConnInfo(int clientId, Consumer<ConnectionStats> callback) {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = clientId;
|
||||
ClientEntry entry = model.getClient(clientId);
|
||||
if (entry != null) s.nickname = entry.nickname;
|
||||
|
||||
try {
|
||||
Client c = client.getClientInfo(clientId);
|
||||
if (c != null) {
|
||||
s.version = orEmpty(c.getVersion());
|
||||
s.platform = orEmpty(c.getPlatform());
|
||||
s.ip = orEmpty(c.getIp());
|
||||
s.idleTimeMs = c.getIdleTime();
|
||||
if (entry == null) s.nickname = orEmpty(c.getNickname());
|
||||
applyConnectionFields(s, c.getMap());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// clientinfo may be permission-restricted; continue with what we have
|
||||
}
|
||||
|
||||
PendingConnInfo pending = new PendingConnInfo(callback, s);
|
||||
pendingConnInfo.put(clientId, pending);
|
||||
|
||||
boolean sent = false;
|
||||
try {
|
||||
SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("clid", Integer.toString(clientId)));
|
||||
client.executeCommand(cmd).complete();
|
||||
sent = true;
|
||||
} catch (Exception ignored) {
|
||||
// command failed; fall back to the clientinfo snapshot below
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
try {
|
||||
Thread.sleep(700); // give notifyconnectioninfo a chance to arrive
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
// If the report already arrived, onUnknownEvent removed and delivered it.
|
||||
if (pendingConnInfo.remove(clientId, pending)) {
|
||||
callback.accept(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */
|
||||
private static void applyConnectionFields(ConnectionStats s, Map<String, String> m) {
|
||||
double ping = parseDouble(m.get("connection_ping"));
|
||||
if (ping >= 0) s.pingMs = ping;
|
||||
double dev = parseDouble(m.get("connection_ping_deviation"));
|
||||
if (dev >= 0) s.pingDeviationMs = dev;
|
||||
double loss = parseDouble(m.get("connection_packetloss_total"));
|
||||
if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total"));
|
||||
if (loss >= 0) s.packetLoss = loss;
|
||||
|
||||
long connected = parseLong(m.get("connection_connected_time"));
|
||||
if (connected >= 0) s.connectedTimeMs = connected;
|
||||
String ip = m.get("connection_client_ip");
|
||||
if (ip != null && !ip.isEmpty()) s.ip = ip;
|
||||
|
||||
s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total"));
|
||||
s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total"));
|
||||
s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total"));
|
||||
s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total"));
|
||||
s.bandwidthSentLastSecond =
|
||||
pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total"));
|
||||
s.bandwidthReceivedLastSecond =
|
||||
pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total"));
|
||||
s.bandwidthSentLastMinute =
|
||||
pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total"));
|
||||
s.bandwidthReceivedLastMinute =
|
||||
pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total"));
|
||||
s.filetransferBandwidthSent =
|
||||
pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent"));
|
||||
s.filetransferBandwidthReceived =
|
||||
pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received"));
|
||||
|
||||
applyPerKindFields(s, m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the per-category {@code connection_*_<kind>} fields (as sent in a
|
||||
* {@code notifyconnectioninfo} report) into {@code s}, merging into any
|
||||
* existing rows. Categories absent from the map are left untouched.
|
||||
*/
|
||||
private static void applyPerKindFields(ConnectionStats s, Map<String, String> m) {
|
||||
for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) {
|
||||
String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech
|
||||
String probe = m.get("connection_packets_sent_" + suffix);
|
||||
String probe2 = m.get("connection_server2client_packetloss_" + suffix);
|
||||
if (probe == null && probe2 == null) continue; // this category not reported
|
||||
|
||||
ConnectionStats.KindStats ks = s.getOrCreateKind(kind);
|
||||
ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix));
|
||||
ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix));
|
||||
ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix));
|
||||
ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix));
|
||||
ks.bandwidthSentLastSecond =
|
||||
pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix));
|
||||
ks.bandwidthReceivedLastSecond =
|
||||
pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix));
|
||||
ks.bandwidthSentLastMinute =
|
||||
pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix));
|
||||
ks.bandwidthReceivedLastMinute =
|
||||
pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix));
|
||||
double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix));
|
||||
if (kloss >= 0) ks.packetLoss = kloss;
|
||||
}
|
||||
}
|
||||
|
||||
private static long pick(long current, String value) {
|
||||
long v = parseLong(value);
|
||||
return v >= 0 ? v : current;
|
||||
}
|
||||
|
||||
private static ConnectionStats.Kind mapKind(PacketKind kind) {
|
||||
switch (kind) {
|
||||
case KEEPALIVE:
|
||||
return ConnectionStats.Kind.KEEPALIVE;
|
||||
case SPEECH:
|
||||
return ConnectionStats.Kind.SPEECH;
|
||||
default:
|
||||
return ConnectionStats.Kind.CONTROL;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||
if (!"notifyconnectioninfo".equals(e.getCommand())) return;
|
||||
int clid = safeInt(e, "clid");
|
||||
PendingConnInfo pending = pendingConnInfo.remove(clid);
|
||||
if (pending == null) return;
|
||||
applyConnectionFields(pending.stats, e.getMap());
|
||||
pending.callback.accept(pending.stats);
|
||||
}
|
||||
|
||||
/** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */
|
||||
private static final class PendingConnInfo {
|
||||
final Consumer<ConnectionStats> callback;
|
||||
final ConnectionStats stats;
|
||||
|
||||
PendingConnInfo(Consumer<ConnectionStats> callback, ConnectionStats stats) {
|
||||
this.callback = callback;
|
||||
this.stats = stats;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextMessage(TextMessageEvent e) {
|
||||
if (e.getInvokerId() == selfClientId) return; // don't echo our own
|
||||
ConnectionListener.ChatScope scope;
|
||||
switch (e.getTargetMode()) {
|
||||
case CLIENT:
|
||||
scope = ConnectionListener.ChatScope.PRIVATE;
|
||||
break;
|
||||
case CHANNEL:
|
||||
scope = ConnectionListener.ChatScope.CHANNEL;
|
||||
break;
|
||||
default:
|
||||
scope = ConnectionListener.ChatScope.SERVER;
|
||||
break;
|
||||
}
|
||||
ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientPoke(ClientPokeEvent e) {
|
||||
ui.onPoke(orEmpty(e.getInvokerName()), orEmpty(e.get("msg")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(DisconnectedEvent e) {
|
||||
connected = false;
|
||||
safeCleanup();
|
||||
ui.onDisconnected(orEmpty(e.getReasonMessage()));
|
||||
ui.onStatus("Disconnected");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static int safeInt(BaseEvent e, String key) {
|
||||
try {
|
||||
String v = e.get(key);
|
||||
return v == null ? 0 : Integer.parseInt(v.trim());
|
||||
} catch (Exception ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static String orEmpty(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
/** Parses a long, returning -1 for null/blank/non-numeric input. */
|
||||
private static long parseLong(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Long.parseLong(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a double, returning -1 for null/blank/non-numeric input. */
|
||||
private static double parseDouble(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Double.parseDouble(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a comma-separated id list (e.g. server groups "6,12,15"). */
|
||||
private static int[] parseIntList(String csv) {
|
||||
if (csv == null || csv.isEmpty()) return new int[0];
|
||||
String[] parts = csv.split(",");
|
||||
int[] out = new int[parts.length];
|
||||
int n = 0;
|
||||
for (String p : parts) {
|
||||
try {
|
||||
out[n++] = Integer.parseInt(p.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
return n == parts.length ? out : java.util.Arrays.copyOf(out, n);
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable r = t;
|
||||
while (r.getCause() != null && r.getCause() != r) r = r.getCause();
|
||||
String m = r.getMessage();
|
||||
return m != null ? m : r.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
31
ts3-client/desktop/pom.xml
Normal file
31
ts3-client/desktop/pom.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-parent</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>ts3-client-desktop</artifactId>
|
||||
<name>TS3J Client Desktop Audio</name>
|
||||
<description>Desktop audio backend: Java Sound capture/playback and native Opus via JNA</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.manevolent</groupId>
|
||||
<artifactId>ts3j</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.java.dev.jna</groupId>
|
||||
<artifactId>jna</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ts3client.audio.desktop;
|
||||
|
||||
import javax.sound.sampled.AudioFormat;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
import javax.sound.sampled.DataLine;
|
||||
import javax.sound.sampled.Line;
|
||||
import javax.sound.sampled.Mixer;
|
||||
import javax.sound.sampled.SourceDataLine;
|
||||
import javax.sound.sampled.TargetDataLine;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Helpers for enumerating and opening capture/playback lines by mixer name.
|
||||
*
|
||||
* <p>The TS3 protocol uses 48 kHz, 16-bit, mono/stereo signed little-endian
|
||||
* PCM for Opus. We standardise on that format everywhere.
|
||||
*/
|
||||
public final class AudioDevices {
|
||||
|
||||
public static final int SAMPLE_RATE = 48_000;
|
||||
public static final int FRAME_SIZE = 960; // 20 ms @ 48 kHz
|
||||
public static final AudioFormat CAPTURE_FORMAT =
|
||||
new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono capture
|
||||
public static final AudioFormat PLAYBACK_FORMAT =
|
||||
new AudioFormat(SAMPLE_RATE, 16, 1, true, false); // mono playback
|
||||
|
||||
private AudioDevices() {
|
||||
}
|
||||
|
||||
/** Names of mixers that can provide microphone (capture) lines. */
|
||||
public static List<String> inputDeviceNames() {
|
||||
return deviceNames(new DataLine.Info(TargetDataLine.class, CAPTURE_FORMAT));
|
||||
}
|
||||
|
||||
/** Names of mixers that can provide speaker (playback) lines. */
|
||||
public static List<String> outputDeviceNames() {
|
||||
return deviceNames(new DataLine.Info(SourceDataLine.class, PLAYBACK_FORMAT));
|
||||
}
|
||||
|
||||
private static List<String> deviceNames(Line.Info lineInfo) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Mixer.Info mi : AudioSystem.getMixerInfo()) {
|
||||
Mixer mixer = AudioSystem.getMixer(mi);
|
||||
if (mixer.isLineSupported(lineInfo)) {
|
||||
String name = mi.getName();
|
||||
if (name != null && !names.contains(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
public static TargetDataLine openCapture(String deviceName) throws Exception {
|
||||
DataLine.Info info = new DataLine.Info(TargetDataLine.class, CAPTURE_FORMAT);
|
||||
Mixer.Info mixerInfo = findMixer(deviceName, info);
|
||||
TargetDataLine line = (mixerInfo != null)
|
||||
? (TargetDataLine) AudioSystem.getMixer(mixerInfo).getLine(info)
|
||||
: (TargetDataLine) AudioSystem.getLine(info);
|
||||
line.open(CAPTURE_FORMAT, FRAME_SIZE * 2 * 8); // ~8 frame buffer
|
||||
return line;
|
||||
}
|
||||
|
||||
public static SourceDataLine openPlayback(String deviceName) throws Exception {
|
||||
DataLine.Info info = new DataLine.Info(SourceDataLine.class, PLAYBACK_FORMAT);
|
||||
Mixer.Info mixerInfo = findMixer(deviceName, info);
|
||||
SourceDataLine line = (mixerInfo != null)
|
||||
? (SourceDataLine) AudioSystem.getMixer(mixerInfo).getLine(info)
|
||||
: (SourceDataLine) AudioSystem.getLine(info);
|
||||
line.open(PLAYBACK_FORMAT, FRAME_SIZE * 2 * 8);
|
||||
return line;
|
||||
}
|
||||
|
||||
private static Mixer.Info findMixer(String deviceName, Line.Info lineInfo) {
|
||||
if (deviceName == null || deviceName.isEmpty()) return null;
|
||||
for (Mixer.Info mi : AudioSystem.getMixerInfo()) {
|
||||
if (deviceName.equals(mi.getName()) && AudioSystem.getMixer(mi).isLineSupported(lineInfo)) {
|
||||
return mi;
|
||||
}
|
||||
}
|
||||
return null; // fall back to system default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ts3client.audio.desktop;
|
||||
|
||||
import com.ts3client.audio.AudioBackend;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.audio.VoiceOutput;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
/**
|
||||
* Desktop audio backend using Java Sound for capture/playback and native Opus
|
||||
* (via JNA) for the codec.
|
||||
*/
|
||||
public final class JavaSoundAudioBackend implements AudioBackend {
|
||||
|
||||
@Override
|
||||
public VoiceInput createInput(Settings settings) {
|
||||
return new JavaSoundVoiceInput(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public VoiceOutput createOutput(Settings settings) {
|
||||
return new JavaSoundVoiceOutput(settings.outputDevice);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
try {
|
||||
return "Opus " + Opus.INSTANCE.opus_get_version_string();
|
||||
} catch (Throwable t) {
|
||||
return "Opus (native library unavailable)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package com.ts3client.audio.desktop;
|
||||
|
||||
import com.github.manevolent.ts3j.enums.CodecType;
|
||||
import com.ts3client.audio.AudioEnhancer;
|
||||
import com.ts3client.audio.OpusParameters;
|
||||
import com.ts3client.audio.SpeechDetector;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import javax.sound.sampled.TargetDataLine;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Java Sound {@link VoiceInput}: captures the microphone, applies voice-activation
|
||||
* or push-to-talk gating, and Opus-encodes 20 ms frames.
|
||||
*
|
||||
* <p>A dedicated capture thread fills a small packet queue while ts3j polls
|
||||
* {@link #isReady()} / {@link #provide()} on its own timer, decoupling capture from
|
||||
* network sending. When the gate closes and the queue drains {@link #isReady()}
|
||||
* returns {@code false}, prompting ts3j to emit the terminating empty voice packet.
|
||||
*/
|
||||
public final class JavaSoundVoiceInput implements VoiceInput {
|
||||
|
||||
private static final int HANGOVER_FRAMES = 15; // ~300 ms of tail after level drops
|
||||
|
||||
private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>();
|
||||
private final AtomicBoolean muted = new AtomicBoolean(false);
|
||||
private final AtomicBoolean transmitting = new AtomicBoolean(false);
|
||||
private final AtomicBoolean pttDown = new AtomicBoolean(false);
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
private volatile Settings.InputMode mode;
|
||||
private volatile Settings.VadMode vadMode;
|
||||
private volatile double thresholdDb;
|
||||
private volatile double speechThreshold;
|
||||
private volatile boolean vadOverPtt;
|
||||
private volatile double inputGain;
|
||||
private volatile CodecType codec = CodecType.OPUS_VOICE;
|
||||
|
||||
private final SpeechDetector speechDetector = new SpeechDetector(AudioDevices.SAMPLE_RATE);
|
||||
private final AudioEnhancer enhancer = new AudioEnhancer(AudioDevices.SAMPLE_RATE);
|
||||
|
||||
private volatile Consumer<Double> levelListener; // input level in dBFS
|
||||
private volatile Consumer<Boolean> talkListener; // local talk-state changes
|
||||
|
||||
private final String deviceName;
|
||||
|
||||
private final Object encoderLock = new Object();
|
||||
private volatile OpusParameters params;
|
||||
private int encoderApplication = -1;
|
||||
|
||||
private Thread captureThread;
|
||||
private TargetDataLine line;
|
||||
private OpusEncoder encoder;
|
||||
|
||||
private int hangover;
|
||||
private boolean lastTransmitting;
|
||||
|
||||
public JavaSoundVoiceInput(Settings settings) {
|
||||
this.deviceName = settings.inputDevice;
|
||||
this.mode = settings.inputMode;
|
||||
this.vadMode = settings.vadMode;
|
||||
this.thresholdDb = settings.vadThresholdDb;
|
||||
this.speechThreshold = settings.speechThreshold;
|
||||
this.vadOverPtt = settings.vadOverPtt;
|
||||
this.inputGain = settings.inputVolume;
|
||||
this.params = OpusParameters.from(settings);
|
||||
this.codec = params.music ? CodecType.OPUS_MUSIC : CodecType.OPUS_VOICE;
|
||||
enhancer.setNoiseSuppression(settings.denoise);
|
||||
enhancer.setDenoiserLevel(settings.denoiserLevel);
|
||||
enhancer.setTypingAttenuation(settings.typingAttenuation);
|
||||
enhancer.setAgc(settings.agc);
|
||||
}
|
||||
|
||||
// ---- live configuration (safe to call from the UI thread) ----
|
||||
|
||||
public void setMode(Settings.InputMode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public void setVadMode(Settings.VadMode mode) {
|
||||
this.vadMode = mode;
|
||||
speechDetector.reset();
|
||||
}
|
||||
|
||||
public void setThresholdDb(double db) {
|
||||
this.thresholdDb = db;
|
||||
}
|
||||
|
||||
public void setSpeechThreshold(double threshold) {
|
||||
this.speechThreshold = threshold;
|
||||
}
|
||||
|
||||
public void setVadOverPtt(boolean enabled) {
|
||||
this.vadOverPtt = enabled;
|
||||
}
|
||||
|
||||
public void setInputGain(double gain) {
|
||||
this.inputGain = gain;
|
||||
}
|
||||
|
||||
public void setNoiseSuppression(boolean enabled) {
|
||||
enhancer.setNoiseSuppression(enabled);
|
||||
}
|
||||
|
||||
public void setDenoiserLevel(double level) {
|
||||
enhancer.setDenoiserLevel(level);
|
||||
}
|
||||
|
||||
public void setTypingAttenuation(boolean enabled) {
|
||||
enhancer.setTypingAttenuation(enabled);
|
||||
}
|
||||
|
||||
public void setAgc(boolean enabled) {
|
||||
enhancer.setAgc(enabled);
|
||||
}
|
||||
|
||||
public void setOpusParameters(OpusParameters p) {
|
||||
this.params = p;
|
||||
this.codec = p.music ? CodecType.OPUS_MUSIC : CodecType.OPUS_VOICE;
|
||||
synchronized (encoderLock) {
|
||||
if (encoder == null) return;
|
||||
int desiredApplication = applicationFor(p);
|
||||
if (desiredApplication != encoderApplication) {
|
||||
// The Opus application (VOIP vs AUDIO) can only be chosen at
|
||||
// creation, so switching voice<->music requires a new encoder.
|
||||
OpusEncoder replacement = new OpusEncoder(
|
||||
AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1, desiredApplication);
|
||||
configureEncoder(replacement, p);
|
||||
OpusEncoder previous = encoder;
|
||||
encoder = replacement;
|
||||
encoderApplication = desiredApplication;
|
||||
previous.close();
|
||||
} else {
|
||||
configureEncoder(encoder, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int applicationFor(OpusParameters p) {
|
||||
return p.music ? Opus.OPUS_APPLICATION_AUDIO : Opus.OPUS_APPLICATION_VOIP;
|
||||
}
|
||||
|
||||
private static void configureEncoder(OpusEncoder enc, OpusParameters p) {
|
||||
enc.setBitrate(p.bitrate);
|
||||
enc.setComplexity(p.complexity);
|
||||
enc.setVbr(p.vbr);
|
||||
enc.setInbandFec(p.fec);
|
||||
enc.setExpectedPacketLoss(p.expectedPacketLoss);
|
||||
enc.setSignal(p.music ? Opus.OPUS_SIGNAL_MUSIC : Opus.OPUS_SIGNAL_VOICE);
|
||||
}
|
||||
|
||||
public void setLevelListener(Consumer<Double> l) {
|
||||
this.levelListener = l;
|
||||
}
|
||||
|
||||
public void setTalkListener(Consumer<Boolean> l) {
|
||||
this.talkListener = l;
|
||||
}
|
||||
|
||||
public void setPushToTalk(boolean down) {
|
||||
this.pttDown.set(down);
|
||||
}
|
||||
|
||||
public void setMuted(boolean m) {
|
||||
this.muted.set(m);
|
||||
}
|
||||
|
||||
// ---- lifecycle ----
|
||||
|
||||
public synchronized void start() {
|
||||
if (running.get()) return;
|
||||
try {
|
||||
line = AudioDevices.openCapture(deviceName);
|
||||
OpusParameters p = params;
|
||||
synchronized (encoderLock) {
|
||||
encoderApplication = applicationFor(p);
|
||||
encoder = new OpusEncoder(
|
||||
AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1, encoderApplication);
|
||||
configureEncoder(encoder, p);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
cleanup();
|
||||
throw new RuntimeException("Could not start microphone: " + t.getMessage(), t);
|
||||
}
|
||||
speechDetector.reset();
|
||||
enhancer.reset();
|
||||
running.set(true);
|
||||
captureThread = new Thread(this::captureLoop, "ts3j-mic-capture");
|
||||
captureThread.setDaemon(true);
|
||||
captureThread.start();
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
running.set(false);
|
||||
if (captureThread != null) {
|
||||
captureThread.interrupt();
|
||||
captureThread = null;
|
||||
}
|
||||
cleanup();
|
||||
queue.clear();
|
||||
setTransmitting(false);
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
if (line != null) {
|
||||
try {
|
||||
line.stop();
|
||||
line.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
line = null;
|
||||
}
|
||||
synchronized (encoderLock) {
|
||||
if (encoder != null) {
|
||||
try {
|
||||
encoder.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
encoder = null;
|
||||
encoderApplication = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void captureLoop() {
|
||||
final int frameSamples = AudioDevices.FRAME_SIZE;
|
||||
final byte[] buf = new byte[frameSamples * 2];
|
||||
final float[] pcm = new float[frameSamples];
|
||||
|
||||
line.start();
|
||||
|
||||
while (running.get()) {
|
||||
int read = 0;
|
||||
try {
|
||||
while (read < buf.length) {
|
||||
int n = line.read(buf, read, buf.length - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
break;
|
||||
}
|
||||
if (read < buf.length) continue;
|
||||
|
||||
// 16-bit LE -> float, with input gain
|
||||
for (int i = 0; i < frameSamples; i++) {
|
||||
int lo = buf[2 * i] & 0xFF;
|
||||
int hi = buf[2 * i + 1];
|
||||
short s = (short) ((hi << 8) | lo);
|
||||
float f = (float) (s / 32768.0 * inputGain);
|
||||
if (f > 1f) f = 1f;
|
||||
else if (f < -1f) f = -1f;
|
||||
pcm[i] = f;
|
||||
}
|
||||
|
||||
// Denoise / typing attenuation feed the level meter, VAD and encoder alike.
|
||||
enhancer.process(pcm, frameSamples);
|
||||
|
||||
double sumSq = 0;
|
||||
for (int i = 0; i < frameSamples; i++) {
|
||||
sumSq += (double) pcm[i] * pcm[i];
|
||||
}
|
||||
double rms = Math.sqrt(sumSq / frameSamples);
|
||||
double db = (rms <= 1e-9) ? -100.0 : 20.0 * Math.log10(rms);
|
||||
Consumer<Double> ll = levelListener;
|
||||
if (ll != null) ll.accept(db);
|
||||
|
||||
boolean open = decideGate(db, pcm);
|
||||
setTransmitting(open);
|
||||
|
||||
if (open && !muted.get()) {
|
||||
try {
|
||||
byte[] packet;
|
||||
synchronized (encoderLock) {
|
||||
packet = encoder != null ? encoder.encode(pcm) : null;
|
||||
}
|
||||
if (packet != null && packet.length > 0) {
|
||||
queue.offer(packet);
|
||||
// Guard against unbounded growth if the network stalls.
|
||||
while (queue.size() > 10) queue.poll();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean decideGate(double db, float[] pcm) {
|
||||
if (muted.get()) {
|
||||
hangover = 0;
|
||||
return false;
|
||||
}
|
||||
switch (mode) {
|
||||
case CONTINUOUS:
|
||||
return true;
|
||||
case PUSH_TO_TALK:
|
||||
if (pttDown.get()) return true;
|
||||
return vadOverPtt && voiceActivated(db, pcm);
|
||||
case VOICE_ACTIVATION:
|
||||
default:
|
||||
return voiceActivated(db, pcm);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the selected VAD mode (volume gate, speech probability, or both) with
|
||||
* a deactivation-delay hangover so trailing syllables aren't clipped.
|
||||
*/
|
||||
private boolean voiceActivated(double db, float[] pcm) {
|
||||
boolean detected;
|
||||
switch (vadMode) {
|
||||
case VOLUME_GATE:
|
||||
detected = db >= thresholdDb;
|
||||
break;
|
||||
case AUTOMATIC:
|
||||
detected = speechDetector.process(pcm) >= speechThreshold;
|
||||
break;
|
||||
case HYBRID:
|
||||
default:
|
||||
double probability = speechDetector.process(pcm);
|
||||
detected = db >= thresholdDb && probability >= speechThreshold;
|
||||
break;
|
||||
}
|
||||
if (detected) {
|
||||
hangover = HANGOVER_FRAMES;
|
||||
return true;
|
||||
}
|
||||
if (hangover > 0) {
|
||||
hangover--;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void setTransmitting(boolean t) {
|
||||
transmitting.set(t);
|
||||
if (t != lastTransmitting) {
|
||||
lastTransmitting = t;
|
||||
Consumer<Boolean> tl = talkListener;
|
||||
if (tl != null) tl.accept(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ts3j Microphone contract ----
|
||||
|
||||
@Override
|
||||
public boolean isMuted() {
|
||||
return muted.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
// Keep the ts3j sender "active" while we're transmitting or still have
|
||||
// buffered packets. When both are false ts3j sends the terminating packet.
|
||||
return transmitting.get() || !queue.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CodecType getCodec() {
|
||||
return codec;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] provide() {
|
||||
byte[] p = queue.poll();
|
||||
return p != null ? p : new byte[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.ts3client.audio.desktop;
|
||||
|
||||
import com.github.manevolent.ts3j.protocol.packet.PacketBody0Voice;
|
||||
import com.github.manevolent.ts3j.protocol.packet.PacketBody1VoiceWhisper;
|
||||
import com.ts3client.audio.VoiceOutput;
|
||||
|
||||
import javax.sound.sampled.SourceDataLine;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/**
|
||||
* Java Sound {@link VoiceOutput}: decodes and plays incoming voice per speaker.
|
||||
* Each client gets its own Opus decoder, playback line and worker thread, so
|
||||
* simultaneous speakers are mixed by the OS and one slow decode never blocks
|
||||
* another (or the network thread).
|
||||
*/
|
||||
public final class JavaSoundVoiceOutput implements VoiceOutput {
|
||||
|
||||
/** One speaker's decode + playback pipeline. */
|
||||
private final class ClientStream {
|
||||
final int clientId;
|
||||
final OpusDecoder decoder;
|
||||
final SourceDataLine line;
|
||||
final ExecutorService worker;
|
||||
volatile boolean talking;
|
||||
volatile long lastPacketNanos;
|
||||
|
||||
ClientStream(int clientId) throws Exception {
|
||||
this.clientId = clientId;
|
||||
this.decoder = new OpusDecoder(AudioDevices.SAMPLE_RATE, AudioDevices.FRAME_SIZE, 1);
|
||||
this.line = AudioDevices.openPlayback(outputDevice);
|
||||
this.line.start();
|
||||
this.worker = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "ts3j-play-" + clientId);
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
void close() {
|
||||
worker.shutdownNow();
|
||||
try {
|
||||
line.stop();
|
||||
line.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
decoder.close();
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<Integer, ClientStream> streams = new ConcurrentHashMap<>();
|
||||
private final Set<Integer> mutedClients = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private volatile String outputDevice;
|
||||
private volatile double masterVolume = 1.0;
|
||||
private volatile boolean deafened = false;
|
||||
|
||||
/** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */
|
||||
private volatile BiConsumer<Integer, Boolean> talkListener;
|
||||
|
||||
public JavaSoundVoiceOutput(String outputDevice) {
|
||||
this.outputDevice = outputDevice;
|
||||
}
|
||||
|
||||
public void setTalkListener(BiConsumer<Integer, Boolean> l) {
|
||||
this.talkListener = l;
|
||||
}
|
||||
|
||||
public void setMasterVolume(double v) {
|
||||
this.masterVolume = Math.max(0, Math.min(2.0, v));
|
||||
}
|
||||
|
||||
public void setDeafened(boolean d) {
|
||||
this.deafened = d;
|
||||
if (d) {
|
||||
// Stop everyone talking immediately.
|
||||
for (ClientStream s : streams.values()) {
|
||||
markTalking(s, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isDeafened() {
|
||||
return deafened;
|
||||
}
|
||||
|
||||
public void setClientMuted(int clientId, boolean muted) {
|
||||
if (muted) mutedClients.add(clientId);
|
||||
else mutedClients.remove(clientId);
|
||||
}
|
||||
|
||||
public boolean isClientMuted(int clientId) {
|
||||
return mutedClients.contains(clientId);
|
||||
}
|
||||
|
||||
public void setOutputDevice(String device) {
|
||||
this.outputDevice = device;
|
||||
}
|
||||
|
||||
/** Entry point wired into {@code client.setVoiceHandler(...)}. */
|
||||
public void handleVoice(PacketBody0Voice voice) {
|
||||
route(voice.getClientId(), voice.getCodecData());
|
||||
}
|
||||
|
||||
/** Entry point wired into {@code client.setWhisperHandler(...)}. */
|
||||
public void handleWhisper(PacketBody1VoiceWhisper whisper) {
|
||||
route(whisper.getClientId(), whisper.getCodecData());
|
||||
}
|
||||
|
||||
private void route(int clientId, byte[] data) {
|
||||
if (deafened) return;
|
||||
if (mutedClients.contains(clientId)) return;
|
||||
|
||||
ClientStream stream = streams.get(clientId);
|
||||
if (stream == null) {
|
||||
try {
|
||||
stream = new ClientStream(clientId);
|
||||
ClientStream existing = streams.putIfAbsent(clientId, stream);
|
||||
if (existing != null) {
|
||||
stream.close();
|
||||
stream = existing;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return; // couldn't open a line; drop
|
||||
}
|
||||
}
|
||||
|
||||
final ClientStream target = stream;
|
||||
target.lastPacketNanos = System.nanoTime();
|
||||
|
||||
if (data == null || data.length == 0) {
|
||||
// End of a talk burst: flush and reset the decoder, mark silent.
|
||||
target.worker.submit(() -> {
|
||||
try {
|
||||
target.line.drain();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
target.decoder.reset();
|
||||
markTalking(target, false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
markTalking(target, true);
|
||||
target.worker.submit(() -> decodeAndPlay(target, data));
|
||||
}
|
||||
|
||||
private void decodeAndPlay(ClientStream stream, byte[] data) {
|
||||
try {
|
||||
float[] pcm = new float[AudioDevices.FRAME_SIZE];
|
||||
int samples = stream.decoder.decode(data, pcm);
|
||||
double vol = masterVolume;
|
||||
|
||||
byte[] out = new byte[samples * 2];
|
||||
for (int i = 0; i < samples; i++) {
|
||||
double v = pcm[i] * vol;
|
||||
if (v > 1.0) v = 1.0;
|
||||
else if (v < -1.0) v = -1.0;
|
||||
short s = (short) Math.round(v * 32767.0);
|
||||
out[2 * i] = (byte) (s & 0xFF);
|
||||
out[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
|
||||
}
|
||||
stream.line.write(out, 0, out.length);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void markTalking(ClientStream stream, boolean talking) {
|
||||
if (stream.talking == talking) return;
|
||||
stream.talking = talking;
|
||||
BiConsumer<Integer, Boolean> l = talkListener;
|
||||
if (l != null) l.accept(stream.clientId, talking);
|
||||
}
|
||||
|
||||
/** Drop a speaker's pipeline entirely (e.g. they left the server). */
|
||||
public void removeClient(int clientId) {
|
||||
ClientStream s = streams.remove(clientId);
|
||||
if (s != null) s.close();
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
for (ClientStream s : streams.values()) {
|
||||
s.close();
|
||||
}
|
||||
streams.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ts3client.audio.desktop;
|
||||
|
||||
import com.sun.jna.Library;
|
||||
import com.sun.jna.Native;
|
||||
import com.sun.jna.ptr.PointerByReference;
|
||||
|
||||
import java.nio.IntBuffer;
|
||||
|
||||
/**
|
||||
* Minimal JNA binding to the native Opus codec library (libopus).
|
||||
*
|
||||
* <p>We bind directly to the system-installed {@code libopus.so}/{@code opus.dll}
|
||||
* rather than relying on a bundled wrapper. Opus always operates internally at
|
||||
* 48 kHz which matches what the TeamSpeak 3 protocol uses on the wire.
|
||||
*/
|
||||
public interface Opus extends Library {
|
||||
|
||||
Opus INSTANCE = Native.load("opus", Opus.class);
|
||||
|
||||
// ---- application types (opus_defines.h) ----
|
||||
int OPUS_APPLICATION_VOIP = 2048;
|
||||
int OPUS_APPLICATION_AUDIO = 2049;
|
||||
int OPUS_APPLICATION_RESTRICTED_LOWDELAY = 2051;
|
||||
|
||||
// ---- CTL request codes ----
|
||||
int OPUS_SET_BITRATE_REQUEST = 4002;
|
||||
int OPUS_SET_VBR_REQUEST = 4006;
|
||||
int OPUS_SET_COMPLEXITY_REQUEST = 4010;
|
||||
int OPUS_SET_INBAND_FEC_REQUEST = 4012;
|
||||
int OPUS_SET_PACKET_LOSS_PERC_REQUEST = 4014;
|
||||
int OPUS_SET_SIGNAL_REQUEST = 4024;
|
||||
int OPUS_RESET_STATE = 4028;
|
||||
|
||||
// ---- signal hints ----
|
||||
int OPUS_AUTO = -1000;
|
||||
int OPUS_SIGNAL_VOICE = 3001;
|
||||
int OPUS_SIGNAL_MUSIC = 3002;
|
||||
|
||||
// ---- encoder ----
|
||||
PointerByReference opus_encoder_create(int fs, int channels, int application, IntBuffer error);
|
||||
|
||||
int opus_encode_float(PointerByReference st, float[] pcm, int frameSize, byte[] data, int maxDataBytes);
|
||||
|
||||
int opus_encoder_ctl(PointerByReference st, int request, Object... args);
|
||||
|
||||
void opus_encoder_destroy(PointerByReference st);
|
||||
|
||||
// ---- decoder ----
|
||||
PointerByReference opus_decoder_create(int fs, int channels, IntBuffer error);
|
||||
|
||||
int opus_decode_float(PointerByReference st, byte[] data, int len, float[] pcm, int frameSize, int decodeFec);
|
||||
|
||||
int opus_decoder_ctl(PointerByReference st, int request, Object... args);
|
||||
|
||||
void opus_decoder_destroy(PointerByReference st);
|
||||
|
||||
// ---- misc ----
|
||||
String opus_get_version_string();
|
||||
|
||||
String opus_strerror(int error);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.ts3client.audio.desktop;
|
||||
|
||||
import com.sun.jna.ptr.PointerByReference;
|
||||
|
||||
import java.nio.IntBuffer;
|
||||
|
||||
/**
|
||||
* Thin wrapper around a native Opus decoder.
|
||||
*
|
||||
* <p>Created as mono at 48 kHz; Opus transparently down-mixes stereo streams
|
||||
* (e.g. music-bot audio) to the requested channel count, so a single mono decoder
|
||||
* copes with both {@code OPUS_VOICE} and {@code OPUS_MUSIC} payloads.
|
||||
*/
|
||||
public final class OpusDecoder implements AutoCloseable {
|
||||
|
||||
private final PointerByReference handle;
|
||||
private final int frameSize;
|
||||
private final int channels;
|
||||
private boolean closed;
|
||||
|
||||
public OpusDecoder(int sampleRate, int frameSize, int channels) {
|
||||
this.frameSize = frameSize;
|
||||
this.channels = channels;
|
||||
|
||||
IntBuffer error = IntBuffer.allocate(1);
|
||||
handle = Opus.INSTANCE.opus_decoder_create(sampleRate, channels, error);
|
||||
if (handle == null || error.get(0) != 0) {
|
||||
throw new IllegalStateException("opus_decoder_create failed: "
|
||||
+ Opus.INSTANCE.opus_strerror(error.get(0)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an Opus packet to interleaved float PCM.
|
||||
*
|
||||
* @param packet the encoded packet, or {@code null} to request packet-loss
|
||||
* concealment (PLC) for a missing frame
|
||||
* @param out output buffer, at least {@code frameSize * channels} long
|
||||
* @return number of samples decoded per channel
|
||||
*/
|
||||
public int decode(byte[] packet, float[] out) {
|
||||
if (closed) throw new IllegalStateException("decoder closed");
|
||||
int samples = Opus.INSTANCE.opus_decode_float(
|
||||
handle,
|
||||
packet,
|
||||
packet == null ? 0 : packet.length,
|
||||
out,
|
||||
frameSize,
|
||||
0);
|
||||
if (samples < 0) {
|
||||
throw new IllegalStateException("opus_decode_float failed: "
|
||||
+ Opus.INSTANCE.opus_strerror(samples));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
if (closed) return;
|
||||
Opus.INSTANCE.opus_decoder_ctl(handle, Opus.OPUS_RESET_STATE);
|
||||
}
|
||||
|
||||
public int getChannels() {
|
||||
return channels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
Opus.INSTANCE.opus_decoder_destroy(handle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.ts3client.audio.desktop;
|
||||
|
||||
import com.sun.jna.ptr.PointerByReference;
|
||||
|
||||
import java.nio.IntBuffer;
|
||||
|
||||
/**
|
||||
* Thin wrapper around a native Opus encoder configured for TeamSpeak voice.
|
||||
*
|
||||
* <p>Fixed at 48 kHz. Frames are 20 ms (960 samples per channel), which
|
||||
* is the frame size the TS3 client uses.
|
||||
*/
|
||||
public final class OpusEncoder implements AutoCloseable {
|
||||
|
||||
private final PointerByReference handle;
|
||||
private final int frameSize;
|
||||
private final int channels;
|
||||
private final byte[] out = new byte[4096];
|
||||
private final Object lock = new Object();
|
||||
private boolean closed;
|
||||
|
||||
public OpusEncoder(int sampleRate, int frameSize, int channels, int application) {
|
||||
this.frameSize = frameSize;
|
||||
this.channels = channels;
|
||||
|
||||
IntBuffer error = IntBuffer.allocate(1);
|
||||
handle = Opus.INSTANCE.opus_encoder_create(sampleRate, channels, application, error);
|
||||
if (handle == null || error.get(0) != 0) {
|
||||
throw new IllegalStateException("opus_encoder_create failed: "
|
||||
+ Opus.INSTANCE.opus_strerror(error.get(0)));
|
||||
}
|
||||
}
|
||||
|
||||
public void setBitrate(int bitsPerSecond) {
|
||||
ctl(Opus.OPUS_SET_BITRATE_REQUEST, bitsPerSecond);
|
||||
}
|
||||
|
||||
public void setComplexity(int complexity) {
|
||||
ctl(Opus.OPUS_SET_COMPLEXITY_REQUEST, Math.max(0, Math.min(10, complexity)));
|
||||
}
|
||||
|
||||
public void setVbr(boolean vbr) {
|
||||
ctl(Opus.OPUS_SET_VBR_REQUEST, vbr ? 1 : 0);
|
||||
}
|
||||
|
||||
public void setInbandFec(boolean fec) {
|
||||
ctl(Opus.OPUS_SET_INBAND_FEC_REQUEST, fec ? 1 : 0);
|
||||
}
|
||||
|
||||
public void setExpectedPacketLoss(int percent) {
|
||||
ctl(Opus.OPUS_SET_PACKET_LOSS_PERC_REQUEST, Math.max(0, Math.min(100, percent)));
|
||||
}
|
||||
|
||||
public void setSignal(int signal) {
|
||||
ctl(Opus.OPUS_SET_SIGNAL_REQUEST, signal);
|
||||
}
|
||||
|
||||
private void ctl(int request, int value) {
|
||||
synchronized (lock) {
|
||||
if (closed) return;
|
||||
int r = Opus.INSTANCE.opus_encoder_ctl(handle, request, value);
|
||||
if (r < 0) {
|
||||
throw new IllegalStateException("opus_encoder_ctl(" + request + ") failed: "
|
||||
+ Opus.INSTANCE.opus_strerror(r));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes one frame of interleaved float PCM ({@code frameSize * channels} samples)
|
||||
* into an Opus packet.
|
||||
*
|
||||
* @return a newly allocated byte array holding the encoded packet
|
||||
*/
|
||||
public byte[] encode(float[] pcm) {
|
||||
if (pcm.length != frameSize * channels) {
|
||||
throw new IllegalArgumentException("expected " + (frameSize * channels)
|
||||
+ " samples, got " + pcm.length);
|
||||
}
|
||||
synchronized (lock) {
|
||||
if (closed) throw new IllegalStateException("encoder closed");
|
||||
int len = Opus.INSTANCE.opus_encode_float(handle, pcm, frameSize, out, out.length);
|
||||
if (len < 0) {
|
||||
throw new IllegalStateException("opus_encode_float failed: "
|
||||
+ Opus.INSTANCE.opus_strerror(len));
|
||||
}
|
||||
byte[] packet = new byte[len];
|
||||
System.arraycopy(out, 0, packet, 0, len);
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (lock) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
Opus.INSTANCE.opus_encoder_destroy(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
ts3-client/pom.xml
Normal file
52
ts3-client/pom.xml
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-parent</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>TS3J Client (parent)</name>
|
||||
<description>Desktop TeamSpeak 3 client built on the ts3j protocol library</description>
|
||||
|
||||
<modules>
|
||||
<module>core</module>
|
||||
<module>desktop</module>
|
||||
<module>swing</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<ts3j.version>1.0.3</ts3j.version>
|
||||
<jna.version>5.14.0</jna.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.manevolent</groupId>
|
||||
<artifactId>ts3j</artifactId>
|
||||
<version>${ts3j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.java.dev.jna</groupId>
|
||||
<artifactId>jna</artifactId>
|
||||
<version>${jna.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-desktop</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
84
ts3-client/swing/pom.xml
Normal file
84
ts3-client/swing/pom.xml
Normal file
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-parent</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>ts3-client-swing</artifactId>
|
||||
<name>TS3J Client Swing Frontend</name>
|
||||
<description>Swing desktop UI</description>
|
||||
|
||||
<properties>
|
||||
<exec.mainClass>com.ts3client.Main</exec.mainClass>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-desktop</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.manevolent</groupId>
|
||||
<artifactId>ts3j</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>ts3-client</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.5.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
<exclude>module-info.class</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
<transformers>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>com.ts3client.Main</mainClass>
|
||||
<manifestEntries>
|
||||
<Enable-Native-Access>ALL-UNNAMED</Enable-Native-Access>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<configuration>
|
||||
<mainClass>com.ts3client.Main</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
33
ts3-client/swing/src/main/java/com/ts3client/Main.java
Normal file
33
ts3-client/swing/src/main/java/com/ts3client/Main.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.ts3client;
|
||||
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.ui.MainFrame;
|
||||
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.UIManager;
|
||||
|
||||
/**
|
||||
* Application entry point. Loads settings, applies the system look-and-feel and
|
||||
* shows the main window on the Swing event dispatch thread.
|
||||
*/
|
||||
public final class Main {
|
||||
|
||||
public static void main(String[] args) {
|
||||
// ts3j is verbose by default; keep the console quiet unless debugging.
|
||||
try {
|
||||
com.github.manevolent.ts3j.util.Ts3Debugging.setEnabled(false);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
|
||||
final Settings settings = Settings.load();
|
||||
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
} catch (Exception ignored) {
|
||||
// fall back to cross-platform L&F
|
||||
}
|
||||
new MainFrame(settings).setVisible(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.config.Bookmark;
|
||||
import com.ts3client.config.Bookmarks;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPasswordField;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextField;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Frame;
|
||||
import java.awt.GridLayout;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Manage saved servers: add, edit, remove and quick-connect. */
|
||||
public final class BookmarksDialog extends JDialog {
|
||||
|
||||
private final Bookmarks bookmarks;
|
||||
private final Consumer<Bookmark> onConnect;
|
||||
private final Runnable onChanged;
|
||||
private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>();
|
||||
private final JList<Bookmark> list = new JList<>(listModel);
|
||||
|
||||
public BookmarksDialog(Frame owner, Bookmarks bookmarks, Consumer<Bookmark> onConnect, Runnable onChanged) {
|
||||
super(owner, "Manage Bookmarks", true);
|
||||
this.bookmarks = bookmarks;
|
||||
this.onConnect = onConnect;
|
||||
this.onChanged = onChanged;
|
||||
|
||||
reload();
|
||||
list.setVisibleRowCount(10);
|
||||
|
||||
JScrollPane scroll = new JScrollPane(list);
|
||||
scroll.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||
|
||||
JPanel buttons = new JPanel();
|
||||
buttons.setLayout(new BoxLayout(buttons, BoxLayout.Y_AXIS));
|
||||
buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8));
|
||||
addButton(buttons, "Connect", this::connectSelected);
|
||||
addButton(buttons, "Add…", this::addBookmark);
|
||||
addButton(buttons, "Edit…", this::editSelected);
|
||||
addButton(buttons, "Remove", this::removeSelected);
|
||||
buttons.add(Box.createVerticalGlue());
|
||||
addButton(buttons, "Close", this::dispose);
|
||||
|
||||
getContentPane().setLayout(new BorderLayout());
|
||||
getContentPane().add(scroll, BorderLayout.CENTER);
|
||||
getContentPane().add(buttons, BorderLayout.EAST);
|
||||
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
setSize(new Dimension(420, 300));
|
||||
setLocationRelativeTo(owner);
|
||||
}
|
||||
|
||||
private void addButton(JPanel panel, String text, Runnable action) {
|
||||
JButton b = new JButton(text);
|
||||
b.setAlignmentX(LEFT_ALIGNMENT);
|
||||
b.setMaximumSize(new Dimension(Integer.MAX_VALUE, b.getPreferredSize().height));
|
||||
b.addActionListener(e -> action.run());
|
||||
panel.add(b);
|
||||
panel.add(Box.createVerticalStrut(4));
|
||||
}
|
||||
|
||||
private void reload() {
|
||||
listModel.clear();
|
||||
for (Bookmark b : bookmarks.all()) listModel.addElement(b);
|
||||
}
|
||||
|
||||
private void connectSelected() {
|
||||
Bookmark b = list.getSelectedValue();
|
||||
if (b != null) {
|
||||
onConnect.accept(b);
|
||||
dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void addBookmark() {
|
||||
Bookmark b = new Bookmark("", "", 9987, System.getProperty("user.name", "TS3J User"), "");
|
||||
if (promptBookmark(b)) {
|
||||
bookmarks.add(b);
|
||||
persistAndRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void editSelected() {
|
||||
Bookmark b = list.getSelectedValue();
|
||||
if (b != null && promptBookmark(b)) {
|
||||
persistAndRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void removeSelected() {
|
||||
int idx = list.getSelectedIndex();
|
||||
if (idx >= 0) {
|
||||
bookmarks.remove(idx);
|
||||
persistAndRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void persistAndRefresh() {
|
||||
bookmarks.save();
|
||||
reload();
|
||||
if (onChanged != null) onChanged.run();
|
||||
}
|
||||
|
||||
/** Modal add/edit form. Mutates {@code b} and returns whether the user confirmed. */
|
||||
private boolean promptBookmark(Bookmark b) {
|
||||
JTextField label = new JTextField(b.label == null ? "" : b.label);
|
||||
JTextField address = new JTextField(b.address == null ? "" : b.address);
|
||||
JTextField port = new JTextField(Integer.toString(b.port));
|
||||
JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname);
|
||||
JPasswordField password = new JPasswordField(b.password == null ? "" : b.password);
|
||||
|
||||
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
|
||||
form.add(new JLabel("Label:"));
|
||||
form.add(label);
|
||||
form.add(new JLabel("Address:"));
|
||||
form.add(address);
|
||||
form.add(new JLabel("Port:"));
|
||||
form.add(port);
|
||||
form.add(new JLabel("Nickname:"));
|
||||
form.add(nick);
|
||||
form.add(new JLabel("Password (optional):"));
|
||||
form.add(password);
|
||||
|
||||
int result = JOptionPane.showConfirmDialog(this, form,
|
||||
"Bookmark", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
|
||||
if (result != JOptionPane.OK_OPTION) return false;
|
||||
|
||||
if (address.getText().trim().isEmpty()) {
|
||||
JOptionPane.showMessageDialog(this, "Address is required.");
|
||||
return false;
|
||||
}
|
||||
b.label = label.getText().trim();
|
||||
b.address = address.getText().trim();
|
||||
try {
|
||||
b.port = Integer.parseInt(port.getText().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
b.port = 9987;
|
||||
}
|
||||
b.nickname = nick.getText().trim();
|
||||
b.password = new String(password.getPassword());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
127
ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java
Normal file
127
ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java
Normal file
@@ -0,0 +1,127 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JTextPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.text.BadLocationException;
|
||||
import javax.swing.text.SimpleAttributeSet;
|
||||
import javax.swing.text.StyleConstants;
|
||||
import javax.swing.text.StyledDocument;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Chat log with an input line and a target selector (current channel or whole
|
||||
* server). Coloured styling mimics the TS3 chat pane.
|
||||
*/
|
||||
public final class ChatPanel extends JPanel {
|
||||
|
||||
/** Where an outgoing message should go. */
|
||||
public enum Target {CHANNEL, SERVER}
|
||||
|
||||
public interface SendHandler {
|
||||
void send(Target target, String text);
|
||||
}
|
||||
|
||||
private final JTextPane log = new JTextPane();
|
||||
private final JTextField input = new JTextField();
|
||||
private final JComboBox<String> targetBox = new JComboBox<>(new String[]{"Channel", "Server"});
|
||||
private final SimpleDateFormat time = new SimpleDateFormat("HH:mm:ss");
|
||||
private SendHandler sendHandler;
|
||||
|
||||
public ChatPanel() {
|
||||
super(new BorderLayout());
|
||||
log.setEditable(false);
|
||||
log.setBackground(Theme.CHAT_BG);
|
||||
log.setFont(Theme.UI_FONT);
|
||||
log.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
|
||||
|
||||
JScrollPane scroll = new JScrollPane(log);
|
||||
scroll.setBorder(BorderFactory.createLineBorder(new Color(0xD0D0D0)));
|
||||
add(scroll, BorderLayout.CENTER);
|
||||
|
||||
JPanel bottom = new JPanel(new BorderLayout(4, 0));
|
||||
bottom.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0));
|
||||
targetBox.setPreferredSize(new Dimension(90, 24));
|
||||
bottom.add(targetBox, BorderLayout.WEST);
|
||||
bottom.add(input, BorderLayout.CENTER);
|
||||
JButton send = new JButton("Send");
|
||||
bottom.add(send, BorderLayout.EAST);
|
||||
add(bottom, BorderLayout.SOUTH);
|
||||
|
||||
Runnable doSend = this::fireSend;
|
||||
send.addActionListener(e -> doSend.run());
|
||||
input.addActionListener(e -> doSend.run());
|
||||
|
||||
setInputEnabled(false);
|
||||
}
|
||||
|
||||
public void setSendHandler(SendHandler h) {
|
||||
this.sendHandler = h;
|
||||
}
|
||||
|
||||
public void setInputEnabled(boolean enabled) {
|
||||
input.setEnabled(enabled);
|
||||
targetBox.setEnabled(enabled);
|
||||
}
|
||||
|
||||
private void fireSend() {
|
||||
String text = input.getText().trim();
|
||||
if (text.isEmpty() || sendHandler == null) return;
|
||||
Target t = targetBox.getSelectedIndex() == 1 ? Target.SERVER : Target.CHANNEL;
|
||||
sendHandler.send(t, text);
|
||||
input.setText("");
|
||||
}
|
||||
|
||||
// ---- append helpers (safe from any thread) ----
|
||||
|
||||
public void appendSystem(String text) {
|
||||
edt(() -> append("[" + time.format(new Date()) + "] ", Theme.CHAT_SYSTEM, false,
|
||||
text, Theme.CHAT_SYSTEM, false));
|
||||
}
|
||||
|
||||
public void appendMessage(String from, String text) {
|
||||
edt(() -> {
|
||||
append("[" + time.format(new Date()) + "] ", Theme.CHAT_SYSTEM, false, "", Theme.CHAT_SYSTEM, false);
|
||||
append(from + ": ", Theme.CHAT_NAME, true, text, Theme.CHAT_TEXT, false);
|
||||
});
|
||||
}
|
||||
|
||||
private void edt(Runnable r) {
|
||||
if (SwingUtilities.isEventDispatchThread()) r.run();
|
||||
else SwingUtilities.invokeLater(r);
|
||||
}
|
||||
|
||||
private void append(String prefix, Color prefixColor, boolean prefixBold,
|
||||
String body, Color bodyColor, boolean bodyBold) {
|
||||
StyledDocument doc = log.getStyledDocument();
|
||||
try {
|
||||
if (prefix != null && !prefix.isEmpty()) {
|
||||
doc.insertString(doc.getLength(), prefix, style(prefixColor, prefixBold));
|
||||
}
|
||||
if (body != null && !body.isEmpty()) {
|
||||
doc.insertString(doc.getLength(), body, style(bodyColor, bodyBold));
|
||||
}
|
||||
doc.insertString(doc.getLength(), "\n", style(bodyColor, false));
|
||||
log.setCaretPosition(doc.getLength());
|
||||
} catch (BadLocationException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static SimpleAttributeSet style(Color c, boolean bold) {
|
||||
SimpleAttributeSet a = new SimpleAttributeSet();
|
||||
StyleConstants.setForeground(a, c);
|
||||
StyleConstants.setBold(a, bold);
|
||||
StyleConstants.setFontFamily(a, "SansSerif");
|
||||
StyleConstants.setFontSize(a, 12);
|
||||
return a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPasswordField;
|
||||
import javax.swing.JTextField;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Frame;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
|
||||
/** Modal "Connect to Server" dialog. */
|
||||
public final class ConnectDialog extends JDialog {
|
||||
|
||||
private final JTextField addressField;
|
||||
private final JTextField portField;
|
||||
private final JTextField nickField;
|
||||
private final JPasswordField passwordField;
|
||||
|
||||
private boolean confirmed;
|
||||
|
||||
public ConnectDialog(Frame owner, Settings settings) {
|
||||
super(owner, "Connect to Server", true);
|
||||
|
||||
String addr = settings.lastAddress;
|
||||
int port = 9987;
|
||||
int colon = addr.lastIndexOf(':');
|
||||
if (colon > 0) {
|
||||
try {
|
||||
port = Integer.parseInt(addr.substring(colon + 1));
|
||||
addr = addr.substring(0, colon);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
addressField = new JTextField(addr, 18);
|
||||
portField = new JTextField(Integer.toString(port), 6);
|
||||
nickField = new JTextField(settings.nickname, 18);
|
||||
passwordField = new JPasswordField(settings.serverPassword, 18);
|
||||
|
||||
JPanel form = new JPanel(new GridBagLayout());
|
||||
form.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
|
||||
int row = 0;
|
||||
add(form, c, row++, "Server address:", addressField);
|
||||
add(form, c, row++, "Port:", portField);
|
||||
add(form, c, row++, "Nickname:", nickField);
|
||||
add(form, c, row++, "Password (optional):", passwordField);
|
||||
|
||||
JPanel buttons = new JPanel(new BorderLayout());
|
||||
JPanel right = new JPanel();
|
||||
JButton connect = new JButton("Connect");
|
||||
JButton cancel = new JButton("Cancel");
|
||||
connect.addActionListener(e -> {
|
||||
confirmed = true;
|
||||
dispose();
|
||||
});
|
||||
cancel.addActionListener(e -> dispose());
|
||||
right.add(connect);
|
||||
right.add(cancel);
|
||||
buttons.add(right, BorderLayout.EAST);
|
||||
getRootPane().setDefaultButton(connect);
|
||||
|
||||
getContentPane().setLayout(new BorderLayout());
|
||||
getContentPane().add(form, BorderLayout.CENTER);
|
||||
getContentPane().add(buttons, BorderLayout.SOUTH);
|
||||
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
pack();
|
||||
setMinimumSize(new Dimension(340, getHeight()));
|
||||
setLocationRelativeTo(owner);
|
||||
}
|
||||
|
||||
private void add(JPanel form, GridBagConstraints c, int row, String label, java.awt.Component field) {
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weightx = 0;
|
||||
form.add(new JLabel(label), c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
form.add(field, c);
|
||||
}
|
||||
|
||||
public boolean isConfirmed() {
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return addressField.getText().trim();
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
try {
|
||||
return Integer.parseInt(portField.getText().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return 9987;
|
||||
}
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
String n = nickField.getText().trim();
|
||||
return n.isEmpty() ? "TS3J User" : n;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return new String(passwordField.getPassword());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ConnectionStats;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JTabbedPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Frame;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.text.DecimalFormat;
|
||||
|
||||
/**
|
||||
* Live "Client Connection Info" window, reachable from a client's right-click
|
||||
* menu. Mirrors the TeamSpeak 3 dialog: a summary header (address, version,
|
||||
* platform, idle/connected time, ping, filetransfer) above a
|
||||
* <em>Total / Speech / Keep Alive / Control</em> tab strip, each tab showing
|
||||
* that category's packet loss, packet and byte totals and live bandwidth.
|
||||
*
|
||||
* <p>Polls the {@link TeamspeakConnection} once a second while open; for the
|
||||
* local client the figures update live from local counters, for a remote client
|
||||
* they refresh from the server's connection report.
|
||||
*/
|
||||
public final class ConnectionInfoDialog extends JDialog {
|
||||
|
||||
private static final int REFRESH_MS = 1000;
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
private final int clientId;
|
||||
private final javax.swing.Timer timer;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean inFlight =
|
||||
new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
private final JLabel addressValue = value();
|
||||
private final JLabel versionValue = value();
|
||||
private final JLabel platformValue = value();
|
||||
private final JLabel idleValue = value();
|
||||
private final JLabel connectedValue = value();
|
||||
private final JLabel pingValue = value();
|
||||
private final JLabel filetransferValue = value();
|
||||
|
||||
private final KindTab totalTab = new KindTab();
|
||||
private final KindTab speechTab = new KindTab();
|
||||
private final KindTab keepAliveTab = new KindTab();
|
||||
private final KindTab controlTab = new KindTab();
|
||||
|
||||
public ConnectionInfoDialog(Frame owner, TeamspeakConnection conn, int clientId, String nickname) {
|
||||
super(owner, "Connection Info — " + nickname, false);
|
||||
this.conn = conn;
|
||||
this.clientId = clientId;
|
||||
|
||||
JPanel content = new JPanel(new BorderLayout(0, 10));
|
||||
content.setBackground(Theme.WINDOW_BG);
|
||||
content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
|
||||
content.add(buildSummary(), BorderLayout.NORTH);
|
||||
content.add(buildTabs(), BorderLayout.CENTER);
|
||||
content.add(buildButtons(), BorderLayout.SOUTH);
|
||||
setContentPane(content);
|
||||
|
||||
pack();
|
||||
setLocationRelativeTo(owner);
|
||||
|
||||
addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(WindowEvent e) {
|
||||
timer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
timer = new javax.swing.Timer(REFRESH_MS, e -> refresh());
|
||||
timer.setInitialDelay(0);
|
||||
timer.start();
|
||||
}
|
||||
|
||||
// ---- construction ----
|
||||
|
||||
private JPanel buildSummary() {
|
||||
JPanel grid = new JPanel(new GridBagLayout());
|
||||
grid.setBackground(Theme.WINDOW_BG);
|
||||
int row = 0;
|
||||
addRow(grid, row++, "Address", addressValue);
|
||||
addRow(grid, row++, "Client version", versionValue);
|
||||
addRow(grid, row++, "Platform", platformValue);
|
||||
addRow(grid, row++, "Ping", pingValue);
|
||||
addRow(grid, row++, "Idle time", idleValue);
|
||||
addRow(grid, row++, "Connected", connectedValue);
|
||||
addRow(grid, row, "Filetransfer (↑/↓)", filetransferValue);
|
||||
return grid;
|
||||
}
|
||||
|
||||
private JTabbedPane buildTabs() {
|
||||
JTabbedPane tabs = new JTabbedPane();
|
||||
tabs.setFont(Theme.UI_FONT);
|
||||
tabs.setBackground(Theme.WINDOW_BG);
|
||||
tabs.addTab("Total", totalTab);
|
||||
tabs.addTab("Speech", speechTab);
|
||||
tabs.addTab("Keep Alive", keepAliveTab);
|
||||
tabs.addTab("Control", controlTab);
|
||||
return tabs;
|
||||
}
|
||||
|
||||
private JPanel buildButtons() {
|
||||
JPanel bar = new JPanel();
|
||||
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
|
||||
bar.setBackground(Theme.WINDOW_BG);
|
||||
bar.add(Box.createHorizontalGlue());
|
||||
JButton close = new JButton("Close");
|
||||
close.addActionListener(e -> dispose());
|
||||
bar.add(close);
|
||||
return bar;
|
||||
}
|
||||
|
||||
private static JLabel value() {
|
||||
JLabel l = new JLabel("—");
|
||||
l.setFont(Theme.UI_FONT);
|
||||
l.setForeground(Theme.TREE_TEXT);
|
||||
return l;
|
||||
}
|
||||
|
||||
private static void addRow(JPanel grid, int row, String label, JLabel valueLabel) {
|
||||
GridBagConstraints lc = new GridBagConstraints();
|
||||
lc.gridx = 0;
|
||||
lc.gridy = row;
|
||||
lc.anchor = GridBagConstraints.WEST;
|
||||
lc.insets = new Insets(2, 0, 2, 16);
|
||||
JLabel key = new JLabel(label);
|
||||
key.setFont(Theme.UI_FONT);
|
||||
key.setForeground(new Color(0x5A6B7B));
|
||||
grid.add(key, lc);
|
||||
|
||||
GridBagConstraints vc = new GridBagConstraints();
|
||||
vc.gridx = 1;
|
||||
vc.gridy = row;
|
||||
vc.weightx = 1;
|
||||
vc.anchor = GridBagConstraints.WEST;
|
||||
vc.fill = GridBagConstraints.HORIZONTAL;
|
||||
vc.insets = new Insets(2, 0, 2, 0);
|
||||
grid.add(valueLabel, vc);
|
||||
}
|
||||
|
||||
// ---- live update ----
|
||||
|
||||
private void refresh() {
|
||||
if (!conn.isConnected()) {
|
||||
timer.stop();
|
||||
return;
|
||||
}
|
||||
if (!inFlight.compareAndSet(false, true)) return; // a previous poll is still running
|
||||
conn.requestConnectionInfo(clientId, stats -> SwingUtilities.invokeLater(() -> {
|
||||
inFlight.set(false);
|
||||
apply(stats);
|
||||
}));
|
||||
}
|
||||
|
||||
private void apply(ConnectionStats s) {
|
||||
if (!isDisplayable()) return;
|
||||
|
||||
addressValue.setText(s.self ? "This computer" : orDash(s.ip));
|
||||
versionValue.setText(orDash(s.version));
|
||||
platformValue.setText(orDash(s.platform));
|
||||
pingValue.setText(formatPing(s.pingMs, s.pingDeviationMs));
|
||||
idleValue.setText(formatDuration(s.idleTimeMs));
|
||||
connectedValue.setText(formatDuration(s.connectedTimeMs));
|
||||
filetransferValue.setText(formatRate(s.filetransferBandwidthSent)
|
||||
+ " / " + formatRate(s.filetransferBandwidthReceived));
|
||||
|
||||
totalTab.update(totalSample(s));
|
||||
speechTab.update(kindSample(s, ConnectionStats.Kind.SPEECH));
|
||||
keepAliveTab.update(kindSample(s, ConnectionStats.Kind.KEEPALIVE));
|
||||
controlTab.update(kindSample(s, ConnectionStats.Kind.CONTROL));
|
||||
|
||||
growToFit();
|
||||
}
|
||||
|
||||
/**
|
||||
* The window is first packed around placeholder text; once real (longer)
|
||||
* values arrive it may need more room. Grow to fit, but never shrink, so the
|
||||
* window doesn't jitter as counters tick up each second.
|
||||
*/
|
||||
private void growToFit() {
|
||||
Dimension pref = getContentPane().getPreferredSize();
|
||||
Dimension have = getContentPane().getSize();
|
||||
if (pref.width > have.width || pref.height > have.height) {
|
||||
pack();
|
||||
}
|
||||
}
|
||||
|
||||
private static Sample totalSample(ConnectionStats s) {
|
||||
Sample sample = new Sample();
|
||||
sample.packetLoss = s.packetLoss;
|
||||
sample.packetsSent = s.packetsSentTotal;
|
||||
sample.packetsReceived = s.packetsReceivedTotal;
|
||||
sample.bytesSent = s.bytesSentTotal;
|
||||
sample.bytesReceived = s.bytesReceivedTotal;
|
||||
sample.bwSentSecond = s.bandwidthSentLastSecond;
|
||||
sample.bwReceivedSecond = s.bandwidthReceivedLastSecond;
|
||||
sample.bwSentMinute = s.bandwidthSentLastMinute;
|
||||
sample.bwReceivedMinute = s.bandwidthReceivedLastMinute;
|
||||
return sample;
|
||||
}
|
||||
|
||||
private static Sample kindSample(ConnectionStats s, ConnectionStats.Kind kind) {
|
||||
Sample sample = new Sample();
|
||||
ConnectionStats.KindStats k = s.kind(kind);
|
||||
if (k == null) return sample; // all fields stay unknown (-1)
|
||||
sample.packetLoss = k.packetLoss;
|
||||
sample.packetsSent = k.packetsSent;
|
||||
sample.packetsReceived = k.packetsReceived;
|
||||
sample.bytesSent = k.bytesSent;
|
||||
sample.bytesReceived = k.bytesReceived;
|
||||
sample.bwSentSecond = k.bandwidthSentLastSecond;
|
||||
sample.bwReceivedSecond = k.bandwidthReceivedLastSecond;
|
||||
sample.bwSentMinute = k.bandwidthSentLastMinute;
|
||||
sample.bwReceivedMinute = k.bandwidthReceivedLastMinute;
|
||||
return sample;
|
||||
}
|
||||
|
||||
// ---- one category tab ----
|
||||
|
||||
/** Numeric snapshot fed to a {@link KindTab}; -1 means "unknown". */
|
||||
private static final class Sample {
|
||||
double packetLoss = -1;
|
||||
long packetsSent = -1;
|
||||
long packetsReceived = -1;
|
||||
long bytesSent = -1;
|
||||
long bytesReceived = -1;
|
||||
long bwSentSecond = -1;
|
||||
long bwReceivedSecond = -1;
|
||||
long bwSentMinute = -1;
|
||||
long bwReceivedMinute = -1;
|
||||
}
|
||||
|
||||
private static final class KindTab extends JPanel {
|
||||
private final JLabel packetLoss = value();
|
||||
private final JLabel packets = value();
|
||||
private final JLabel bytes = value();
|
||||
private final JLabel bandwidthSecond = value();
|
||||
private final JLabel bandwidthMinute = value();
|
||||
|
||||
KindTab() {
|
||||
super(new GridBagLayout());
|
||||
setBackground(Theme.WINDOW_BG);
|
||||
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
|
||||
int row = 0;
|
||||
addRow(this, row++, "Packet loss", packetLoss);
|
||||
addRow(this, row++, "Packets (↑/↓)", packets);
|
||||
addRow(this, row++, "Transferred (↑/↓)", bytes);
|
||||
addRow(this, row++, "Bandwidth · last second (↑/↓)", bandwidthSecond);
|
||||
addRow(this, row, "Bandwidth · last minute (↑/↓)", bandwidthMinute);
|
||||
}
|
||||
|
||||
void update(Sample s) {
|
||||
packetLoss.setText(formatLoss(s.packetLoss));
|
||||
packets.setText(formatCount(s.packetsSent) + " / " + formatCount(s.packetsReceived));
|
||||
bytes.setText(formatBytes(s.bytesSent) + " / " + formatBytes(s.bytesReceived));
|
||||
bandwidthSecond.setText(formatRate(s.bwSentSecond) + " / " + formatRate(s.bwReceivedSecond));
|
||||
bandwidthMinute.setText(formatRate(s.bwSentMinute) + " / " + formatRate(s.bwReceivedMinute));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- formatting ----
|
||||
|
||||
private static final DecimalFormat GROUPED = new DecimalFormat("#,##0");
|
||||
|
||||
private static String orDash(String s) {
|
||||
return (s == null || s.isEmpty()) ? "—" : s;
|
||||
}
|
||||
|
||||
private static String formatPing(double ms, double deviationMs) {
|
||||
if (ms < 0) return "—";
|
||||
String base = Math.round(ms) + " ms";
|
||||
if (deviationMs > 0) base += " (± " + Math.round(deviationMs) + " ms)";
|
||||
return base;
|
||||
}
|
||||
|
||||
private static String formatLoss(double fraction) {
|
||||
if (fraction < 0) return "—";
|
||||
return new DecimalFormat("0.00").format(fraction * 100.0) + " %";
|
||||
}
|
||||
|
||||
private static String formatCount(long n) {
|
||||
return n < 0 ? "—" : GROUPED.format(n);
|
||||
}
|
||||
|
||||
private static String formatBytes(long bytes) {
|
||||
if (bytes < 0) return "—";
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
double kib = bytes / 1024.0;
|
||||
if (kib < 1024) return new DecimalFormat("0.0").format(kib) + " KiB";
|
||||
double mib = kib / 1024.0;
|
||||
if (mib < 1024) return new DecimalFormat("0.00").format(mib) + " MiB";
|
||||
return new DecimalFormat("0.00").format(mib / 1024.0) + " GiB";
|
||||
}
|
||||
|
||||
private static String formatRate(long bytesPerSecond) {
|
||||
return bytesPerSecond < 0 ? "—" : formatBytes(bytesPerSecond) + "/s";
|
||||
}
|
||||
|
||||
private static String formatDuration(long ms) {
|
||||
if (ms < 0) return "—";
|
||||
long totalSeconds = ms / 1000;
|
||||
long days = totalSeconds / 86400;
|
||||
long hours = (totalSeconds % 86400) / 3600;
|
||||
long minutes = (totalSeconds % 3600) / 60;
|
||||
long seconds = totalSeconds % 60;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (days > 0) sb.append(days).append("d ");
|
||||
if (days > 0 || hours > 0) sb.append(hours).append("h ");
|
||||
if (days > 0 || hours > 0 || minutes > 0) sb.append(minutes).append("m ");
|
||||
sb.append(seconds).append("s");
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
196
ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java
Normal file
196
ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java
Normal file
@@ -0,0 +1,196 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.swing.ImageIcon;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
|
||||
/**
|
||||
* Programmatically drawn vector icons (no external image assets), so the client
|
||||
* is fully self-contained. All icons are rendered at 16×16 with a small
|
||||
* cache.
|
||||
*/
|
||||
public final class Icons {
|
||||
|
||||
private static final int SZ = 16;
|
||||
|
||||
private Icons() {
|
||||
}
|
||||
|
||||
private interface Painter {
|
||||
void paint(Graphics2D g);
|
||||
}
|
||||
|
||||
private static ImageIcon make(Painter p) {
|
||||
BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
|
||||
p.paint(g);
|
||||
g.dispose();
|
||||
return new ImageIcon(img);
|
||||
}
|
||||
|
||||
// ---- tree icons ----
|
||||
|
||||
public static ImageIcon server() {
|
||||
return make(g -> {
|
||||
g.setColor(new Color(0x2C6EA5));
|
||||
g.fillRoundRect(2, 3, 12, 4, 2, 2);
|
||||
g.fillRoundRect(2, 9, 12, 4, 2, 2);
|
||||
g.setColor(new Color(0x9FD0F0));
|
||||
g.fillOval(4, 4, 2, 2);
|
||||
g.fillOval(4, 10, 2, 2);
|
||||
});
|
||||
}
|
||||
|
||||
public static ImageIcon channel() {
|
||||
return channelPainted(new Color(0x3E7CB1), false);
|
||||
}
|
||||
|
||||
public static ImageIcon channelLocked() {
|
||||
return channelPainted(new Color(0x8A6D3B), true);
|
||||
}
|
||||
|
||||
private static ImageIcon channelPainted(Color c, boolean lock) {
|
||||
return make(g -> {
|
||||
g.setColor(c);
|
||||
g.setStroke(new BasicStroke(1.6f));
|
||||
// simple speaker-cone glyph
|
||||
g.fillRect(2, 6, 3, 4);
|
||||
int[] xs = {5, 9, 9, 5};
|
||||
int[] ys = {6, 3, 13, 10};
|
||||
g.fillPolygon(xs, ys, 4);
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
g.drawArc(9, 5, 4, 6, -60, 120);
|
||||
if (lock) {
|
||||
g.setColor(new Color(0xB8860B));
|
||||
g.fillRoundRect(10, 9, 5, 5, 1, 1);
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(12, 10, 1, 2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- client status icons ----
|
||||
|
||||
public static ImageIcon clientIdle() {
|
||||
return person(Theme.IDLE_CLIENT);
|
||||
}
|
||||
|
||||
public static ImageIcon clientTalking() {
|
||||
return person(Theme.TALKING);
|
||||
}
|
||||
|
||||
public static ImageIcon clientAway() {
|
||||
return person(Theme.AWAY);
|
||||
}
|
||||
|
||||
private static ImageIcon person(Color c) {
|
||||
return make(g -> {
|
||||
g.setColor(c);
|
||||
g.fillOval(5, 2, 6, 6); // head
|
||||
g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders
|
||||
});
|
||||
}
|
||||
|
||||
public static ImageIcon micMuted() {
|
||||
return make(g -> {
|
||||
g.setColor(Theme.MUTED);
|
||||
g.fillRoundRect(6, 2, 4, 7, 2, 2);
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
g.drawArc(4, 6, 8, 6, 200, 140);
|
||||
g.drawLine(8, 12, 8, 14);
|
||||
g.setStroke(new BasicStroke(2f));
|
||||
g.drawLine(2, 2, 14, 14); // slash
|
||||
});
|
||||
}
|
||||
|
||||
public static ImageIcon speakerMuted() {
|
||||
return make(g -> {
|
||||
g.setColor(Theme.MUTED);
|
||||
g.fillRect(2, 6, 3, 4);
|
||||
int[] xs = {5, 9, 9, 5};
|
||||
int[] ys = {6, 3, 13, 10};
|
||||
g.fillPolygon(xs, ys, 4);
|
||||
g.setStroke(new BasicStroke(2f));
|
||||
g.drawLine(2, 2, 14, 14);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- toolbar / action icons ----
|
||||
|
||||
public static ImageIcon connect() {
|
||||
return make(g -> {
|
||||
g.setColor(new Color(0x2E8B57));
|
||||
g.setStroke(new BasicStroke(2f));
|
||||
g.drawLine(3, 8, 8, 8);
|
||||
g.fillRoundRect(8, 5, 5, 6, 2, 2);
|
||||
g.drawLine(10, 3, 10, 5);
|
||||
g.drawLine(12, 3, 12, 5);
|
||||
});
|
||||
}
|
||||
|
||||
public static ImageIcon disconnect() {
|
||||
return make(g -> {
|
||||
g.setColor(Theme.MUTED);
|
||||
g.setStroke(new BasicStroke(2f));
|
||||
g.drawLine(3, 8, 8, 8);
|
||||
g.fillRoundRect(8, 5, 5, 6, 2, 2);
|
||||
g.drawLine(2, 3, 6, 13);
|
||||
});
|
||||
}
|
||||
|
||||
public static ImageIcon mic() {
|
||||
return make(g -> {
|
||||
g.setColor(new Color(0x37474F));
|
||||
g.fillRoundRect(6, 2, 4, 7, 2, 2);
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
g.drawArc(4, 6, 8, 6, 200, 140);
|
||||
g.drawLine(8, 12, 8, 14);
|
||||
g.drawLine(6, 14, 10, 14);
|
||||
});
|
||||
}
|
||||
|
||||
public static ImageIcon speaker() {
|
||||
return make(g -> {
|
||||
g.setColor(new Color(0x37474F));
|
||||
g.fillRect(2, 6, 3, 4);
|
||||
int[] xs = {5, 9, 9, 5};
|
||||
int[] ys = {6, 3, 13, 10};
|
||||
g.fillPolygon(xs, ys, 4);
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
g.drawArc(9, 5, 4, 6, -60, 120);
|
||||
});
|
||||
}
|
||||
|
||||
public static ImageIcon settings() {
|
||||
return make(g -> {
|
||||
g.setColor(new Color(0x37474F));
|
||||
g.setStroke(new BasicStroke(2f));
|
||||
g.drawOval(5, 5, 6, 6);
|
||||
for (int a = 0; a < 360; a += 45) {
|
||||
double r = Math.toRadians(a);
|
||||
int x1 = (int) (8 + Math.cos(r) * 5);
|
||||
int y1 = (int) (8 + Math.sin(r) * 5);
|
||||
int x2 = (int) (8 + Math.cos(r) * 7);
|
||||
int y2 = (int) (8 + Math.sin(r) * 7);
|
||||
g.drawLine(x1, y1, x2, y2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static ImageIcon app() {
|
||||
return make(g -> {
|
||||
g.setColor(Theme.ACCENT);
|
||||
g.fillRoundRect(1, 1, 14, 14, 4, 4);
|
||||
g.setColor(Color.WHITE);
|
||||
g.setStroke(new BasicStroke(1.6f));
|
||||
g.drawArc(4, 5, 8, 8, 30, 120);
|
||||
g.drawArc(2, 3, 12, 12, 30, 120);
|
||||
g.fillOval(7, 9, 2, 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
100
ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java
Normal file
100
ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java
Normal file
@@ -0,0 +1,100 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.ServerModel;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JEditorPane;
|
||||
import javax.swing.JScrollPane;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Read-only detail view for the currently selected channel or client, mirroring
|
||||
* the TeamSpeak 3 info box: channel topic/description, or a client's server and
|
||||
* channel groups, platform and version.
|
||||
*/
|
||||
public final class InfoPanel extends JScrollPane {
|
||||
|
||||
private final JEditorPane pane = new JEditorPane();
|
||||
|
||||
public InfoPanel() {
|
||||
pane.setContentType("text/html");
|
||||
pane.setEditable(false);
|
||||
pane.setBackground(Theme.CHAT_BG);
|
||||
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||
setViewportView(pane);
|
||||
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
|
||||
clear();
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
setHtml("<i style='color:#8a8a8a'>Select a channel or client to see details.</i>");
|
||||
}
|
||||
|
||||
public void showChannel(ChannelNode ch) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(heading(esc(ch.name)));
|
||||
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
|
||||
if (ch.maxClients >= 0) row(sb, "Max clients", Integer.toString(ch.maxClients));
|
||||
if (ch.hasPassword) row(sb, "Password", "protected");
|
||||
if (!ch.topic.isEmpty()) row(sb, "Topic", esc(ch.topic));
|
||||
sb.append("<hr>");
|
||||
if (ch.description != null && !ch.description.isEmpty()) {
|
||||
sb.append("<div>").append(multiline(ch.description)).append("</div>");
|
||||
} else if (ch.descriptionLoaded) {
|
||||
sb.append("<i style='color:#8a8a8a'>No description.</i>");
|
||||
} else {
|
||||
sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
|
||||
}
|
||||
setHtml(sb.toString());
|
||||
}
|
||||
|
||||
public void showClient(ClientEntry cl, ServerModel model) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
|
||||
|
||||
List<String> serverGroups = model.serverGroupNames(cl.serverGroupIds);
|
||||
row(sb, "Server groups", serverGroups.isEmpty() ? "—" : esc(String.join(", ", serverGroups)));
|
||||
|
||||
String channelGroup = model.channelGroupName(cl.channelGroupId);
|
||||
row(sb, "Channel group", channelGroup != null ? esc(channelGroup) : "#" + cl.channelGroupId);
|
||||
|
||||
if (cl.talkPower != 0) row(sb, "Talk power", Integer.toString(cl.talkPower));
|
||||
if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
|
||||
if (!cl.version.isEmpty()) row(sb, "Version", esc(cl.version));
|
||||
if (cl.away) row(sb, "Status", "Away");
|
||||
if (cl.inputMuted) row(sb, "Microphone", "muted");
|
||||
if (cl.outputMuted) row(sb, "Speakers", "muted");
|
||||
if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId));
|
||||
|
||||
if (cl.description != null && !cl.description.isEmpty()) {
|
||||
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
|
||||
}
|
||||
setHtml(sb.toString());
|
||||
}
|
||||
|
||||
private void setHtml(String body) {
|
||||
pane.setText("<html><body style='font-family:sans-serif;font-size:11px;color:#202020'>"
|
||||
+ body + "</body></html>");
|
||||
pane.setCaretPosition(0);
|
||||
}
|
||||
|
||||
private static String heading(String text) {
|
||||
return "<div style='font-weight:bold;font-size:13px;margin-bottom:4px'>" + text + "</div>";
|
||||
}
|
||||
|
||||
private static void row(StringBuilder sb, String label, String value) {
|
||||
sb.append("<div style='margin:1px 0'><span style='color:#5a6b7b'>")
|
||||
.append(label).append(":</span> ").append(value).append("</div>");
|
||||
}
|
||||
|
||||
private static String multiline(String text) {
|
||||
return esc(text).replace("\n", "<br>");
|
||||
}
|
||||
|
||||
private static String esc(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
|
||||
/**
|
||||
* Horizontal audio level meter (dBFS) with an optional VAD threshold marker.
|
||||
* The filled portion turns green once the level crosses the threshold, giving
|
||||
* immediate visual feedback while tuning voice activation.
|
||||
*/
|
||||
public final class LevelMeter extends JComponent {
|
||||
|
||||
private static final double MIN_DB = -70.0;
|
||||
private static final double MAX_DB = 0.0;
|
||||
|
||||
private volatile double levelDb = MIN_DB;
|
||||
private volatile double thresholdDb = -45.0;
|
||||
private volatile boolean showThreshold = true;
|
||||
|
||||
public LevelMeter() {
|
||||
setPreferredSize(new Dimension(240, 18));
|
||||
}
|
||||
|
||||
public void setLevel(double db) {
|
||||
this.levelDb = db;
|
||||
repaint();
|
||||
}
|
||||
|
||||
public void setThreshold(double db) {
|
||||
this.thresholdDb = db;
|
||||
repaint();
|
||||
}
|
||||
|
||||
public void setShowThreshold(boolean show) {
|
||||
this.showThreshold = show;
|
||||
repaint();
|
||||
}
|
||||
|
||||
private int dbToX(double db, int w) {
|
||||
double clamped = Math.max(MIN_DB, Math.min(MAX_DB, db));
|
||||
return (int) ((clamped - MIN_DB) / (MAX_DB - MIN_DB) * w);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0) {
|
||||
Graphics2D g = (Graphics2D) g0;
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
int w = getWidth();
|
||||
int h = getHeight();
|
||||
|
||||
g.setColor(new Color(0x2B2B2B));
|
||||
g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
|
||||
|
||||
int level = dbToX(levelDb, w - 2);
|
||||
boolean over = levelDb >= thresholdDb;
|
||||
g.setColor(over ? Theme.TALKING : new Color(0x5A9BD4));
|
||||
g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
|
||||
|
||||
if (showThreshold) {
|
||||
int tx = dbToX(thresholdDb, w - 2);
|
||||
g.setColor(new Color(0xF0C419));
|
||||
g.fillRect(tx, 1, 2, h - 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
575
ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
Normal file
575
ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
Normal file
@@ -0,0 +1,575 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.audio.AudioBackend;
|
||||
import com.ts3client.audio.desktop.JavaSoundAudioBackend;
|
||||
import com.ts3client.config.Bookmark;
|
||||
import com.ts3client.config.Bookmarks;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.ConnectionListener;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBoxMenuItem;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JMenu;
|
||||
import javax.swing.JMenuBar;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.KeyEventDispatcher;
|
||||
import java.awt.KeyboardFocusManager;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* The main application window: toolbar, server tree, chat and status bar,
|
||||
* wired to a {@link TeamspeakConnection}. Resembles the TeamSpeak 3 client layout.
|
||||
*/
|
||||
public final class MainFrame extends JFrame implements ConnectionListener, ServerTreePanel.Actions {
|
||||
|
||||
private final Settings settings;
|
||||
private final Bookmarks bookmarks = Bookmarks.load();
|
||||
private final AudioBackend audio = new JavaSoundAudioBackend();
|
||||
private TeamspeakConnection conn;
|
||||
|
||||
private JMenu bookmarksMenu;
|
||||
private JCheckBoxMenuItem awayItem;
|
||||
private JCheckBoxMenuItem commanderItem;
|
||||
private javax.swing.Timer statusTimer;
|
||||
|
||||
private final ServerTreePanel treePanel;
|
||||
private final ChatPanel chatPanel;
|
||||
private final InfoPanel infoPanel = new InfoPanel();
|
||||
private Object currentSelection;
|
||||
|
||||
private final JLabel statusLabel = new JLabel("Not connected");
|
||||
private final JLabel codecLabel = new JLabel();
|
||||
|
||||
private JButton connectButton;
|
||||
private JButton disconnectButton;
|
||||
private JToggleButton micButton;
|
||||
private JToggleButton speakerButton;
|
||||
|
||||
private boolean pttPressed;
|
||||
|
||||
/** Guards {@link #shutdown()} so the window listener and JVM hook don't both run it. */
|
||||
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
|
||||
private final Thread shutdownHook = new Thread(this::shutdown, "ts3j-shutdown");
|
||||
|
||||
public MainFrame(Settings settings) {
|
||||
super("TS3J — TeamSpeak 3 Java Client");
|
||||
this.settings = settings;
|
||||
|
||||
setIconImage(Icons.app().getImage());
|
||||
// We tear the connection down ourselves on close, so don't let Swing kill the JVM.
|
||||
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
|
||||
addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosing(WindowEvent e) {
|
||||
shutdown();
|
||||
System.exit(0);
|
||||
}
|
||||
});
|
||||
// Catches Ctrl+C / SIGTERM so we still leave the server cleanly.
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
setMinimumSize(new Dimension(720, 480));
|
||||
|
||||
this.conn = new TeamspeakConnection(settings, audio, this);
|
||||
this.treePanel = new ServerTreePanel(conn.getModel(), this);
|
||||
this.chatPanel = new ChatPanel();
|
||||
chatPanel.setSendHandler(this::onSendChat);
|
||||
|
||||
setJMenuBar(buildMenuBar());
|
||||
add(buildToolbar(), BorderLayout.NORTH);
|
||||
|
||||
JSplitPane leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
|
||||
leftColumn.setResizeWeight(0.68);
|
||||
leftColumn.setContinuousLayout(true);
|
||||
|
||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
|
||||
split.setResizeWeight(0.55);
|
||||
split.setDividerLocation(400);
|
||||
split.setContinuousLayout(true);
|
||||
add(split, BorderLayout.CENTER);
|
||||
|
||||
add(buildStatusBar(), BorderLayout.SOUTH);
|
||||
|
||||
codecLabel.setText(audio.description());
|
||||
|
||||
chatPanel.appendSystem("Welcome to the TS3J Swing client.");
|
||||
chatPanel.appendSystem("Use Connections → Connect to join a server.");
|
||||
|
||||
installPushToTalk();
|
||||
updateButtons(false);
|
||||
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
||||
statusTimer.start();
|
||||
setSize(880, 560);
|
||||
setLocationRelativeTo(null);
|
||||
}
|
||||
|
||||
// ---- UI construction ----
|
||||
|
||||
private JMenuBar buildMenuBar() {
|
||||
JMenuBar bar = new JMenuBar();
|
||||
|
||||
JMenu connections = new JMenu("Connections");
|
||||
JMenuItem connect = new JMenuItem("Connect…");
|
||||
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
|
||||
connect.addActionListener(e -> showConnectDialog());
|
||||
JMenuItem disconnect = new JMenuItem("Disconnect");
|
||||
disconnect.addActionListener(e -> doDisconnect());
|
||||
JMenuItem quit = new JMenuItem("Quit");
|
||||
quit.addActionListener(e -> {
|
||||
shutdown();
|
||||
System.exit(0);
|
||||
});
|
||||
connections.add(connect);
|
||||
connections.add(disconnect);
|
||||
connections.addSeparator();
|
||||
connections.add(quit);
|
||||
|
||||
bookmarksMenu = new JMenu("Bookmarks");
|
||||
rebuildBookmarksMenu();
|
||||
|
||||
JMenu self = new JMenu("Self");
|
||||
JMenuItem mute = new JMenuItem("Toggle microphone");
|
||||
mute.addActionListener(e -> micButton.doClick());
|
||||
JMenuItem deaf = new JMenuItem("Toggle speakers");
|
||||
deaf.addActionListener(e -> speakerButton.doClick());
|
||||
awayItem = new JCheckBoxMenuItem("Away");
|
||||
awayItem.addActionListener(e -> toggleAway());
|
||||
commanderItem = new JCheckBoxMenuItem("Channel Commander");
|
||||
commanderItem.addActionListener(e -> conn.setChannelCommander(commanderItem.isSelected()));
|
||||
JMenuItem rename = new JMenuItem("Change nickname…");
|
||||
rename.addActionListener(e -> changeNickname());
|
||||
self.add(mute);
|
||||
self.add(deaf);
|
||||
self.addSeparator();
|
||||
self.add(awayItem);
|
||||
self.add(commanderItem);
|
||||
self.addSeparator();
|
||||
self.add(rename);
|
||||
|
||||
JMenu tools = new JMenu("Tools");
|
||||
JMenuItem options = new JMenuItem("Options…");
|
||||
options.addActionListener(e -> showSettings());
|
||||
tools.add(options);
|
||||
|
||||
JMenu help = new JMenu("Help");
|
||||
JMenuItem about = new JMenuItem("About");
|
||||
about.addActionListener(e -> showAbout());
|
||||
help.add(about);
|
||||
|
||||
bar.add(connections);
|
||||
bar.add(bookmarksMenu);
|
||||
bar.add(self);
|
||||
bar.add(tools);
|
||||
bar.add(help);
|
||||
return bar;
|
||||
}
|
||||
|
||||
private void rebuildBookmarksMenu() {
|
||||
bookmarksMenu.removeAll();
|
||||
for (Bookmark b : bookmarks.all()) {
|
||||
JMenuItem item = new JMenuItem(b.displayName());
|
||||
item.addActionListener(e -> connectToBookmark(b));
|
||||
bookmarksMenu.add(item);
|
||||
}
|
||||
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
|
||||
JMenuItem addCurrent = new JMenuItem("Add current server…");
|
||||
addCurrent.addActionListener(e -> addCurrentServerBookmark());
|
||||
JMenuItem manage = new JMenuItem("Manage bookmarks…");
|
||||
manage.addActionListener(e -> new BookmarksDialog(this, bookmarks,
|
||||
this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true));
|
||||
bookmarksMenu.add(addCurrent);
|
||||
bookmarksMenu.add(manage);
|
||||
}
|
||||
|
||||
private JToolBar buildToolbar() {
|
||||
JToolBar tb = new JToolBar();
|
||||
tb.setFloatable(false);
|
||||
tb.setBackground(Theme.TOOLBAR_BG);
|
||||
tb.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
|
||||
|
||||
connectButton = new JButton(Icons.connect());
|
||||
connectButton.setToolTipText("Connect to a server");
|
||||
connectButton.addActionListener(e -> showConnectDialog());
|
||||
|
||||
disconnectButton = new JButton(Icons.disconnect());
|
||||
disconnectButton.setToolTipText("Disconnect");
|
||||
disconnectButton.addActionListener(e -> doDisconnect());
|
||||
|
||||
micButton = new JToggleButton(Icons.mic());
|
||||
micButton.setToolTipText("Mute / unmute microphone");
|
||||
micButton.addActionListener(e -> {
|
||||
boolean muted = micButton.isSelected();
|
||||
micButton.setIcon(muted ? Icons.micMuted() : Icons.mic());
|
||||
conn.setMicMuted(muted);
|
||||
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
|
||||
});
|
||||
|
||||
speakerButton = new JToggleButton(Icons.speaker());
|
||||
speakerButton.setToolTipText("Deafen / undeafen (mute speakers)");
|
||||
speakerButton.addActionListener(e -> {
|
||||
boolean deaf = speakerButton.isSelected();
|
||||
speakerButton.setIcon(deaf ? Icons.speakerMuted() : Icons.speaker());
|
||||
conn.setDeafened(deaf);
|
||||
if (deaf) {
|
||||
micButton.setSelected(true);
|
||||
micButton.setIcon(Icons.micMuted());
|
||||
}
|
||||
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
|
||||
});
|
||||
|
||||
JButton settingsButton = new JButton(Icons.settings());
|
||||
settingsButton.setToolTipText("Options");
|
||||
settingsButton.addActionListener(e -> showSettings());
|
||||
|
||||
tb.add(connectButton);
|
||||
tb.add(disconnectButton);
|
||||
tb.addSeparator();
|
||||
tb.add(micButton);
|
||||
tb.add(speakerButton);
|
||||
tb.addSeparator();
|
||||
tb.add(settingsButton);
|
||||
return tb;
|
||||
}
|
||||
|
||||
private JPanel buildStatusBar() {
|
||||
JPanel bar = new JPanel(new BorderLayout());
|
||||
bar.setBackground(Theme.STATUS_BG);
|
||||
bar.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
|
||||
statusLabel.setFont(Theme.UI_FONT);
|
||||
codecLabel.setFont(Theme.UI_FONT);
|
||||
codecLabel.setForeground(Theme.CHAT_SYSTEM);
|
||||
bar.add(statusLabel, BorderLayout.WEST);
|
||||
bar.add(codecLabel, BorderLayout.EAST);
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ---- push to talk ----
|
||||
|
||||
private void installPushToTalk() {
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(new KeyEventDispatcher() {
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent e) {
|
||||
if (settings.inputMode != Settings.InputMode.PUSH_TO_TALK) return false;
|
||||
if (conn == null || !conn.isConnected() || conn.getMicrophone() == null) return false;
|
||||
if (e.getKeyCode() != settings.pushToTalkKey) return false;
|
||||
if (e.getID() == KeyEvent.KEY_PRESSED && !pttPressed) {
|
||||
pttPressed = true;
|
||||
conn.getMicrophone().setPushToTalk(true);
|
||||
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
|
||||
pttPressed = false;
|
||||
conn.getMicrophone().setPushToTalk(false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- actions ----
|
||||
|
||||
private void showConnectDialog() {
|
||||
if (conn.isConnected()) {
|
||||
JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.",
|
||||
"Connect", JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
ConnectDialog dlg = new ConnectDialog(this, settings);
|
||||
dlg.setVisible(true);
|
||||
if (!dlg.isConfirmed()) return;
|
||||
startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword());
|
||||
}
|
||||
|
||||
private void startConnection(String address, int port, String nickname, String password) {
|
||||
if (conn.isConnected()) {
|
||||
JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.",
|
||||
"Connect", JOptionPane.INFORMATION_MESSAGE);
|
||||
return;
|
||||
}
|
||||
settings.lastAddress = address + ":" + port;
|
||||
settings.nickname = nickname;
|
||||
settings.serverPassword = password;
|
||||
settings.save();
|
||||
chatPanel.appendSystem("Connecting to " + address + ":" + port + " …");
|
||||
conn.connect(address, port, nickname, password);
|
||||
}
|
||||
|
||||
private void connectToBookmark(Bookmark b) {
|
||||
String nick = (b.nickname != null && !b.nickname.isBlank()) ? b.nickname : settings.nickname;
|
||||
startConnection(b.address, b.port, nick, b.password);
|
||||
}
|
||||
|
||||
private void addCurrentServerBookmark() {
|
||||
String addr = settings.lastAddress;
|
||||
int port = 9987;
|
||||
int colon = addr.lastIndexOf(':');
|
||||
if (colon > 0) {
|
||||
try {
|
||||
port = Integer.parseInt(addr.substring(colon + 1));
|
||||
addr = addr.substring(0, colon);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
String label = JOptionPane.showInputDialog(this, "Bookmark label:", addr);
|
||||
if (label == null) return;
|
||||
bookmarks.add(new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword));
|
||||
bookmarks.save();
|
||||
rebuildBookmarksMenu();
|
||||
}
|
||||
|
||||
private void toggleAway() {
|
||||
boolean away = awayItem.isSelected();
|
||||
String message = null;
|
||||
if (away) {
|
||||
message = JOptionPane.showInputDialog(this, "Away message (optional):", "");
|
||||
if (message == null) { // cancelled
|
||||
awayItem.setSelected(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
conn.setAway(away, message);
|
||||
}
|
||||
|
||||
private void doDisconnect() {
|
||||
if (conn.isConnected()) {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear everything down before the process exits: disconnect from the server
|
||||
* synchronously (so the "Leaving" notification actually reaches it) and stop
|
||||
* background timers. Idempotent and safe to call from any thread — the window
|
||||
* close handler, the Quit menu and the JVM shutdown hook may all invoke it.
|
||||
*/
|
||||
private void shutdown() {
|
||||
if (!shuttingDown.compareAndSet(false, true)) return;
|
||||
if (statusTimer != null) statusTimer.stop();
|
||||
try {
|
||||
Runtime.getRuntime().removeShutdownHook(shutdownHook);
|
||||
} catch (IllegalStateException ignored) {
|
||||
// Already shutting down (hook itself is running); nothing to remove.
|
||||
}
|
||||
if (conn.isConnected()) {
|
||||
conn.disconnectBlocking("Leaving");
|
||||
}
|
||||
}
|
||||
|
||||
private void showSettings() {
|
||||
SettingsDialog dlg = new SettingsDialog(this, settings,
|
||||
conn.getMicrophone(), conn.getPlayback(), () -> {
|
||||
});
|
||||
dlg.setVisible(true);
|
||||
}
|
||||
|
||||
private void changeNickname() {
|
||||
String n = JOptionPane.showInputDialog(this, "New nickname:", settings.nickname);
|
||||
if (n != null && !n.trim().isEmpty()) {
|
||||
settings.nickname = n.trim();
|
||||
settings.save();
|
||||
if (conn.isConnected()) conn.setNickname(settings.nickname);
|
||||
}
|
||||
}
|
||||
|
||||
private void showAbout() {
|
||||
JOptionPane.showMessageDialog(this,
|
||||
"TS3J Swing Client\n\n" +
|
||||
"An open-source TeamSpeak 3 desktop client built on the ts3j\n" +
|
||||
"reverse-engineered protocol library, with native Opus voice,\n" +
|
||||
"voice-activation detection and push-to-talk.\n\n" +
|
||||
codecLabel.getText(),
|
||||
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
|
||||
}
|
||||
|
||||
private void onSendChat(ChatPanel.Target target, String text) {
|
||||
if (!conn.isConnected()) {
|
||||
chatPanel.appendSystem("Not connected.");
|
||||
return;
|
||||
}
|
||||
if (target == ChatPanel.Target.SERVER) {
|
||||
conn.sendServerMessage(text);
|
||||
} else {
|
||||
conn.sendChannelMessage(text);
|
||||
}
|
||||
chatPanel.appendMessage(settings.nickname + " (you)", text);
|
||||
}
|
||||
|
||||
private void updateButtons(boolean connected) {
|
||||
connectButton.setEnabled(!connected);
|
||||
disconnectButton.setEnabled(connected);
|
||||
micButton.setEnabled(connected);
|
||||
speakerButton.setEnabled(connected);
|
||||
awayItem.setEnabled(connected);
|
||||
commanderItem.setEnabled(connected);
|
||||
chatPanel.setInputEnabled(connected);
|
||||
}
|
||||
|
||||
private void updateConnectionStatus() {
|
||||
if (!conn.isConnected()) return;
|
||||
int users = conn.getModel().clientCount();
|
||||
StringBuilder s = new StringBuilder("Connected to ")
|
||||
.append(conn.getModel().getServerName())
|
||||
.append(" | ").append(users).append(users == 1 ? " user" : " users");
|
||||
double ping = conn.getPingMillis();
|
||||
if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms");
|
||||
statusLabel.setText(s.toString());
|
||||
}
|
||||
|
||||
// ---- ServerTreePanel.Actions ----
|
||||
|
||||
@Override
|
||||
public void joinChannel(int channelId) {
|
||||
if (conn.isConnected()) conn.joinChannel(channelId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openPrivateChat(ClientEntry client) {
|
||||
String msg = JOptionPane.showInputDialog(this, "Message to " + client.nickname + ":");
|
||||
if (msg != null && !msg.trim().isEmpty()) {
|
||||
conn.sendPrivateMessage(client.id, msg.trim());
|
||||
chatPanel.appendMessage("You → " + client.nickname, msg.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pokeClient(ClientEntry client) {
|
||||
String msg = JOptionPane.showInputDialog(this, "Poke message for " + client.nickname + ":", "Poke!");
|
||||
if (msg != null) {
|
||||
conn.poke(client.id, msg);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleClientMute(ClientEntry client) {
|
||||
if (conn.getPlayback() == null) return;
|
||||
boolean now = !conn.getPlayback().isClientMuted(client.id);
|
||||
conn.getPlayback().setClientMuted(client.id, now);
|
||||
chatPanel.appendSystem((now ? "Muted " : "Unmuted ") + client.nickname + ".");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showConnectionInfo(ClientEntry client) {
|
||||
if (!conn.isConnected()) return;
|
||||
new ConnectionInfoDialog(this, conn, client.id, client.nickname).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClientLocallyMuted(int clientId) {
|
||||
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged(Object userObject) {
|
||||
currentSelection = userObject;
|
||||
renderInfo();
|
||||
if (!conn.isConnected()) return;
|
||||
if (userObject instanceof ChannelNode) {
|
||||
ChannelNode ch = (ChannelNode) userObject;
|
||||
if (!ch.descriptionLoaded) conn.requestChannelInfo(ch.id);
|
||||
} else if (userObject instanceof ClientEntry) {
|
||||
conn.requestClientInfo(((ClientEntry) userObject).id);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderInfo() {
|
||||
Object sel = currentSelection;
|
||||
if (sel instanceof ChannelNode) {
|
||||
infoPanel.showChannel((ChannelNode) sel);
|
||||
} else if (sel instanceof ClientEntry) {
|
||||
infoPanel.showClient((ClientEntry) sel, conn.getModel());
|
||||
} else {
|
||||
infoPanel.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ConnectionListener (marshal to EDT) ----
|
||||
|
||||
@Override
|
||||
public void onStatus(String status) {
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText(status));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnected() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
updateButtons(true);
|
||||
treePanel.setSelfClientId(conn.getSelfClientId());
|
||||
micButton.setSelected(false);
|
||||
micButton.setIcon(Icons.mic());
|
||||
speakerButton.setSelected(false);
|
||||
speakerButton.setIcon(Icons.speaker());
|
||||
awayItem.setSelected(false);
|
||||
commanderItem.setSelected(false);
|
||||
chatPanel.appendSystem("Connected.");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(String reason) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
updateButtons(false);
|
||||
conn.getModel().clear();
|
||||
treePanel.showDisconnected();
|
||||
currentSelection = null;
|
||||
infoPanel.clear();
|
||||
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onModelChanged() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
treePanel.rebuild();
|
||||
renderInfo();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInfoUpdated() {
|
||||
SwingUtilities.invokeLater(this::renderInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
|
||||
String prefix = scope == ChatScope.PRIVATE ? "[PM] " : scope == ChatScope.SERVER ? "[Server] " : "";
|
||||
chatPanel.appendMessage(prefix + fromName, message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTalkStateChanged(int clientId, boolean talking) {
|
||||
SwingUtilities.invokeLater(treePanel::refreshVisual);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
chatPanel.appendSystem("Error: " + message);
|
||||
statusLabel.setText(message);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPoke(String fromName, String message) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
|
||||
JOptionPane.showMessageDialog(this, fromName + " poked you:\n\n" + message,
|
||||
"Poke", JOptionPane.INFORMATION_MESSAGE);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.ServerModel;
|
||||
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.DefaultTreeModel;
|
||||
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||
import javax.swing.tree.TreePath;
|
||||
import java.awt.Component;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The server view: a tree of channels each containing its clients, styled to
|
||||
* resemble the TeamSpeak 3 client. Talk state colours clients green live.
|
||||
*/
|
||||
public final class ServerTreePanel extends JScrollPane {
|
||||
|
||||
/** Actions the tree can request of the controller. */
|
||||
public interface Actions {
|
||||
void joinChannel(int channelId);
|
||||
|
||||
void openPrivateChat(ClientEntry client);
|
||||
|
||||
void pokeClient(ClientEntry client);
|
||||
|
||||
void toggleClientMute(ClientEntry client);
|
||||
|
||||
void showConnectionInfo(ClientEntry client);
|
||||
|
||||
boolean isClientLocallyMuted(int clientId);
|
||||
|
||||
/** A channel or client node was selected (or {@code null} when cleared). */
|
||||
void onSelectionChanged(Object userObject);
|
||||
}
|
||||
|
||||
private final JTree tree;
|
||||
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
|
||||
private final ServerModel model;
|
||||
private final Actions actions;
|
||||
private int selfClientId = -1;
|
||||
|
||||
public ServerTreePanel(ServerModel model, Actions actions) {
|
||||
this.model = model;
|
||||
this.actions = actions;
|
||||
root.setUserObject("Not connected");
|
||||
this.tree = new JTree(treeModel);
|
||||
tree.setRootVisible(true);
|
||||
tree.setShowsRootHandles(true);
|
||||
tree.setRowHeight(20);
|
||||
tree.setBackground(Theme.TREE_BG);
|
||||
tree.setFont(Theme.UI_FONT);
|
||||
tree.setCellRenderer(new Renderer());
|
||||
setViewportView(tree);
|
||||
getViewport().setBackground(Theme.TREE_BG);
|
||||
|
||||
tree.addTreeSelectionListener(e -> {
|
||||
TreePath path = tree.getSelectionPath();
|
||||
Object obj = null;
|
||||
if (path != null) {
|
||||
obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
}
|
||||
actions.onSelectionChanged(obj);
|
||||
});
|
||||
|
||||
tree.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
maybePopup(e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
maybePopup(e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent e) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
|
||||
Object obj = nodeAt(e);
|
||||
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
|
||||
actions.joinChannel(((ChannelNode) obj).id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setSelfClientId(int id) {
|
||||
this.selfClientId = id;
|
||||
}
|
||||
|
||||
private Object nodeAt(MouseEvent e) {
|
||||
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
|
||||
if (path == null) return null;
|
||||
DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent();
|
||||
return n.getUserObject();
|
||||
}
|
||||
|
||||
private void maybePopup(MouseEvent e) {
|
||||
if (!e.isPopupTrigger()) return;
|
||||
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
|
||||
if (path == null) return;
|
||||
tree.setSelectionPath(path);
|
||||
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
if (obj instanceof ClientEntry) {
|
||||
showClientMenu((ClientEntry) obj, e);
|
||||
} else if (obj instanceof ChannelNode) {
|
||||
showChannelMenu((ChannelNode) obj, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void showClientMenu(ClientEntry client, MouseEvent e) {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
if (client.id != selfClientId) {
|
||||
JMenuItem pm = new JMenuItem("Open text chat");
|
||||
pm.addActionListener(a -> actions.openPrivateChat(client));
|
||||
menu.add(pm);
|
||||
JMenuItem poke = new JMenuItem("Poke");
|
||||
poke.addActionListener(a -> actions.pokeClient(client));
|
||||
menu.add(poke);
|
||||
menu.addSeparator();
|
||||
boolean muted = actions.isClientLocallyMuted(client.id);
|
||||
JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client");
|
||||
mute.addActionListener(a -> actions.toggleClientMute(client));
|
||||
menu.add(mute);
|
||||
} else {
|
||||
JMenuItem self = new JMenuItem("This is you");
|
||||
self.setEnabled(false);
|
||||
menu.add(self);
|
||||
}
|
||||
menu.addSeparator();
|
||||
JMenuItem info = new JMenuItem("Connection Info");
|
||||
info.addActionListener(a -> actions.showConnectionInfo(client));
|
||||
menu.add(info);
|
||||
menu.show(tree, e.getX(), e.getY());
|
||||
}
|
||||
|
||||
private void showChannelMenu(ChannelNode channel, MouseEvent e) {
|
||||
if (Spacers.isSpacer(channel.name)) return; // spacers aren't interactive
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
JMenuItem join = new JMenuItem("Join channel");
|
||||
join.addActionListener(a -> actions.joinChannel(channel.id));
|
||||
menu.add(join);
|
||||
menu.show(tree, e.getX(), e.getY());
|
||||
}
|
||||
|
||||
/** Rebuilds the tree from the model, preserving full expansion. */
|
||||
public void rebuild() {
|
||||
root.setUserObject(model.getServerName());
|
||||
root.removeAllChildren();
|
||||
List<ChannelNode> roots = model.buildTree();
|
||||
for (ChannelNode c : roots) {
|
||||
root.add(buildChannel(c));
|
||||
}
|
||||
treeModel.reload();
|
||||
for (int i = 0; i < tree.getRowCount(); i++) {
|
||||
tree.expandRow(i);
|
||||
}
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode buildChannel(ChannelNode c) {
|
||||
DefaultMutableTreeNode node = new DefaultMutableTreeNode(c);
|
||||
for (ClientEntry client : c.clients) {
|
||||
node.add(new DefaultMutableTreeNode(client));
|
||||
}
|
||||
for (ChannelNode child : c.children) {
|
||||
node.add(buildChannel(child));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Clears the tree back to the disconnected placeholder state. */
|
||||
public void showDisconnected() {
|
||||
root.setUserObject("Not connected");
|
||||
root.removeAllChildren();
|
||||
treeModel.reload();
|
||||
}
|
||||
|
||||
/** Repaint only (e.g. talk-state changes) without rebuilding structure. */
|
||||
public void refreshVisual() {
|
||||
tree.repaint();
|
||||
}
|
||||
|
||||
private final class Renderer extends DefaultTreeCellRenderer {
|
||||
@Override
|
||||
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
|
||||
boolean expanded, boolean leaf, int row,
|
||||
boolean hasFocus) {
|
||||
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
|
||||
setBackgroundNonSelectionColor(Theme.TREE_BG);
|
||||
setBackgroundSelectionColor(Theme.TREE_SELECTION);
|
||||
setBorderSelectionColor(Theme.TREE_SELECTION);
|
||||
|
||||
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
|
||||
if (obj instanceof ChannelNode) {
|
||||
ChannelNode c = (ChannelNode) obj;
|
||||
Spacers.Spacer spacer = Spacers.parse(c.name);
|
||||
if (spacer != null) {
|
||||
setText(Spacers.render(spacer, 40));
|
||||
setIcon(null);
|
||||
setForeground(Theme.IDLE_CLIENT);
|
||||
setFont(Theme.UI_FONT);
|
||||
} else {
|
||||
setText(c.name);
|
||||
setIcon(c.hasPassword ? Icons.channelLocked() : Icons.channel());
|
||||
setForeground(Theme.CHANNEL_TEXT);
|
||||
setFont(Theme.UI_BOLD);
|
||||
}
|
||||
} else if (obj instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) obj;
|
||||
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
|
||||
String label = cl.nickname;
|
||||
if (primaryGroup != null) label += " [" + primaryGroup + "]";
|
||||
setText(label);
|
||||
setIcon(iconFor(cl));
|
||||
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
|
||||
setFont(cl.talking ? Theme.UI_BOLD : Theme.UI_FONT);
|
||||
} else {
|
||||
// root / server
|
||||
setText(String.valueOf(obj));
|
||||
setIcon(Icons.server());
|
||||
setForeground(Theme.SERVER_TEXT);
|
||||
setFont(Theme.UI_BOLD);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private ImageIcon iconFor(ClientEntry cl) {
|
||||
if (cl.outputMuted) return Icons.speakerMuted();
|
||||
if (cl.inputMuted) return Icons.micMuted();
|
||||
if (cl.away) return Icons.clientAway();
|
||||
if (cl.talking) return Icons.clientTalking();
|
||||
return Icons.clientIdle();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.audio.OpusParameters;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.audio.VoiceOutput;
|
||||
import com.ts3client.audio.desktop.AudioDevices;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import javax.sound.sampled.TargetDataLine;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.JTabbedPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Frame;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.event.KeyAdapter;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Options dialog: audio device selection plus voice-activation / push-to-talk
|
||||
* tuning with a live input meter. Changes are applied to the running audio
|
||||
* subsystem immediately and persisted to {@link Settings} on OK.
|
||||
*/
|
||||
public final class SettingsDialog extends JDialog {
|
||||
|
||||
private final Settings settings;
|
||||
private final VoiceInput liveMic;
|
||||
private final VoiceOutput livePlayback;
|
||||
private final Runnable onApply;
|
||||
|
||||
private JComboBox<String> inputCombo;
|
||||
private JComboBox<String> outputCombo;
|
||||
private JSlider inputGain;
|
||||
private JSlider outputVol;
|
||||
private JCheckBox denoiseCheck;
|
||||
private JSlider denoiseLevel;
|
||||
private JCheckBox typingCheck;
|
||||
private JCheckBox agcCheck;
|
||||
|
||||
private JRadioButton vadRadio;
|
||||
private JRadioButton pttRadio;
|
||||
private JRadioButton contRadio;
|
||||
private JComboBox<String> vadModeCombo;
|
||||
private JSlider thresholdSlider;
|
||||
private JSlider speechSlider;
|
||||
private JCheckBox vadOverPttCheck;
|
||||
private JLabel thresholdLabel;
|
||||
private JLabel speechLabel;
|
||||
private LevelMeter meter;
|
||||
private JButton pttKeyButton;
|
||||
private int pttKey;
|
||||
private JSlider bitrateSlider;
|
||||
private JLabel bitrateLabel;
|
||||
private JSlider complexitySlider;
|
||||
private JCheckBox vbrCheck;
|
||||
private JCheckBox fecCheck;
|
||||
private JCheckBox musicCheck;
|
||||
|
||||
private volatile boolean meterRunning;
|
||||
private Thread meterThread;
|
||||
|
||||
public SettingsDialog(Frame owner, Settings settings,
|
||||
VoiceInput liveMic, VoiceOutput livePlayback,
|
||||
Runnable onApply) {
|
||||
super(owner, "Options", true);
|
||||
this.settings = settings;
|
||||
this.liveMic = liveMic;
|
||||
this.livePlayback = livePlayback;
|
||||
this.onApply = onApply;
|
||||
this.pttKey = settings.pushToTalkKey;
|
||||
|
||||
JTabbedPane tabs = new JTabbedPane();
|
||||
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
|
||||
tabs.addTab("Voice Activation", scrollable(buildVoiceTab()));
|
||||
|
||||
JPanel buttons = new JPanel(new BorderLayout());
|
||||
JPanel right = new JPanel();
|
||||
JButton ok = new JButton("OK");
|
||||
JButton cancel = new JButton("Cancel");
|
||||
ok.addActionListener(e -> {
|
||||
apply();
|
||||
close();
|
||||
});
|
||||
cancel.addActionListener(e -> close());
|
||||
right.add(ok);
|
||||
right.add(cancel);
|
||||
buttons.add(right, BorderLayout.EAST);
|
||||
|
||||
getContentPane().setLayout(new BorderLayout());
|
||||
getContentPane().add(tabs, BorderLayout.CENTER);
|
||||
getContentPane().add(buttons, BorderLayout.SOUTH);
|
||||
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
addWindowListener(new java.awt.event.WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(java.awt.event.WindowEvent e) {
|
||||
stopMeter();
|
||||
}
|
||||
});
|
||||
|
||||
pack();
|
||||
setSize(new Dimension(480, 540));
|
||||
setLocationRelativeTo(owner);
|
||||
startMeter();
|
||||
}
|
||||
|
||||
private JPanel buildDevicesTab() {
|
||||
JPanel p = new JPanel(new GridBagLayout());
|
||||
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||
GridBagConstraints c = gbc();
|
||||
|
||||
List<String> ins = AudioDevices.inputDeviceNames();
|
||||
List<String> outs = AudioDevices.outputDeviceNames();
|
||||
ins.add(0, "(System default)");
|
||||
outs.add(0, "(System default)");
|
||||
|
||||
inputCombo = new JComboBox<>(ins.toArray(new String[0]));
|
||||
outputCombo = new JComboBox<>(outs.toArray(new String[0]));
|
||||
selectOrDefault(inputCombo, settings.inputDevice);
|
||||
selectOrDefault(outputCombo, settings.outputDevice);
|
||||
|
||||
int row = 0;
|
||||
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
|
||||
addRow(p, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
|
||||
|
||||
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
|
||||
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
|
||||
addRow(p, c, row++, new JLabel("Microphone gain:"), inputGain);
|
||||
addRow(p, c, row++, new JLabel("Playback volume:"), outputVol);
|
||||
|
||||
outputVol.addChangeListener(e -> {
|
||||
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
|
||||
});
|
||||
inputGain.addChangeListener(e -> {
|
||||
if (liveMic != null) liveMic.setInputGain(inputGain.getValue() / 100.0);
|
||||
});
|
||||
inputCombo.addActionListener(e -> restartMeter());
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
c.insets = new Insets(14, 4, 2, 4);
|
||||
p.add(new JLabel("Noise reduction"), c);
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.gridwidth = 1;
|
||||
|
||||
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
|
||||
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
|
||||
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
|
||||
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
|
||||
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
|
||||
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
|
||||
+ "reduce the sounds made by typing.</html>");
|
||||
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
|
||||
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
|
||||
+ "microphone loudness to a target level, boosting quiet mics and taming "
|
||||
+ "loud ones.</html>");
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(denoiseCheck, c);
|
||||
c.gridwidth = 1;
|
||||
addRow(p, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(typingCheck, c);
|
||||
c.gridy = row++;
|
||||
p.add(agcCheck, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
Runnable syncNoise = () -> {
|
||||
denoiseLevel.setEnabled(denoiseCheck.isSelected());
|
||||
if (liveMic != null) {
|
||||
liveMic.setNoiseSuppression(denoiseCheck.isSelected());
|
||||
liveMic.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
|
||||
liveMic.setTypingAttenuation(typingCheck.isSelected());
|
||||
liveMic.setAgc(agcCheck.isSelected());
|
||||
}
|
||||
};
|
||||
denoiseCheck.addActionListener(e -> syncNoise.run());
|
||||
typingCheck.addActionListener(e -> syncNoise.run());
|
||||
agcCheck.addActionListener(e -> syncNoise.run());
|
||||
denoiseLevel.addChangeListener(e -> {
|
||||
if (liveMic != null) liveMic.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
|
||||
});
|
||||
syncNoise.run();
|
||||
|
||||
// filler
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weighty = 1;
|
||||
p.add(Box.createGlue(), c);
|
||||
return p;
|
||||
}
|
||||
|
||||
private JPanel buildVoiceTab() {
|
||||
JPanel p = new JPanel(new GridBagLayout());
|
||||
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||
GridBagConstraints c = gbc();
|
||||
|
||||
vadRadio = new JRadioButton("Voice Activation Detection");
|
||||
pttRadio = new JRadioButton("Push-To-Talk");
|
||||
contRadio = new JRadioButton("Continuous");
|
||||
ButtonGroup group = new ButtonGroup();
|
||||
group.add(vadRadio);
|
||||
group.add(pttRadio);
|
||||
group.add(contRadio);
|
||||
switch (settings.inputMode) {
|
||||
case PUSH_TO_TALK:
|
||||
pttRadio.setSelected(true);
|
||||
break;
|
||||
case CONTINUOUS:
|
||||
contRadio.setSelected(true);
|
||||
break;
|
||||
default:
|
||||
vadRadio.setSelected(true);
|
||||
}
|
||||
|
||||
int row = 0;
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(vadRadio, c);
|
||||
c.gridy = row++;
|
||||
p.add(pttRadio, c);
|
||||
c.gridy = row++;
|
||||
p.add(contRadio, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
meter = new LevelMeter();
|
||||
meter.setThreshold(settings.vadThresholdDb);
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.gridwidth = 2;
|
||||
c.insets = new Insets(10, 4, 2, 4);
|
||||
p.add(new JLabel("Input level (speak to test):"), c);
|
||||
c.gridy = ++row;
|
||||
p.add(meter, c);
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.gridwidth = 1;
|
||||
row++;
|
||||
|
||||
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
|
||||
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
|
||||
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
|
||||
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
|
||||
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
|
||||
addRow(p, c, row++, new JLabel("Detection:"), vadModeCombo);
|
||||
|
||||
thresholdSlider = new JSlider(-70, 0, (int) Math.round(settings.vadThresholdDb));
|
||||
thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
|
||||
thresholdSlider.addChangeListener(e -> {
|
||||
meter.setThreshold(thresholdSlider.getValue());
|
||||
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
|
||||
if (liveMic != null) liveMic.setThresholdDb(thresholdSlider.getValue());
|
||||
});
|
||||
addRow(p, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
|
||||
|
||||
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
|
||||
speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
|
||||
speechSlider.addChangeListener(e -> {
|
||||
speechLabel.setText(speechSlider.getValue() + "%");
|
||||
if (liveMic != null) liveMic.setSpeechThreshold(speechSlider.getValue() / 100.0);
|
||||
});
|
||||
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
|
||||
|
||||
pttKeyButton = new JButton(keyName(pttKey));
|
||||
pttKeyButton.addActionListener(e -> capturePttKey());
|
||||
addRow(p, c, row++, new JLabel("Push-to-talk key:"), pttKeyButton);
|
||||
|
||||
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(vadOverPttCheck, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
bitrateSlider = new JSlider(8, 128, settings.bitrate / 1000);
|
||||
bitrateLabel = new JLabel(settings.bitrate / 1000 + " kbit/s");
|
||||
bitrateSlider.addChangeListener(e -> {
|
||||
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
|
||||
pushOpusLive();
|
||||
});
|
||||
JPanel brPanel = new JPanel(new BorderLayout(6, 0));
|
||||
brPanel.add(bitrateSlider, BorderLayout.CENTER);
|
||||
brPanel.add(bitrateLabel, BorderLayout.EAST);
|
||||
addRow(p, c, row++, new JLabel("Opus bitrate:"), brPanel);
|
||||
|
||||
complexitySlider = new JSlider(0, 10, settings.complexity);
|
||||
complexitySlider.addChangeListener(e -> pushOpusLive());
|
||||
addRow(p, c, row++, new JLabel("Opus complexity:"), complexitySlider);
|
||||
|
||||
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
|
||||
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
|
||||
musicCheck = new JCheckBox("Music codec (higher fidelity)", settings.music);
|
||||
vbrCheck.addActionListener(e -> pushOpusLive());
|
||||
fecCheck.addActionListener(e -> pushOpusLive());
|
||||
musicCheck.addActionListener(e -> pushOpusLive());
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(vbrCheck, c);
|
||||
c.gridy = row++;
|
||||
p.add(fecCheck, c);
|
||||
c.gridy = row++;
|
||||
p.add(musicCheck, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
Runnable syncEnabled = () -> {
|
||||
boolean vad = vadRadio.isSelected();
|
||||
boolean ptt = pttRadio.isSelected();
|
||||
boolean vadContext = vad || (ptt && vadOverPttCheck.isSelected());
|
||||
Settings.VadMode vm = currentVadMode();
|
||||
boolean usesGate = vm != Settings.VadMode.AUTOMATIC;
|
||||
boolean usesSpeech = vm != Settings.VadMode.VOLUME_GATE;
|
||||
|
||||
vadModeCombo.setEnabled(vadContext);
|
||||
thresholdSlider.setEnabled(vadContext && usesGate);
|
||||
speechSlider.setEnabled(vadContext && usesSpeech);
|
||||
pttKeyButton.setEnabled(ptt);
|
||||
vadOverPttCheck.setEnabled(ptt);
|
||||
meter.setShowThreshold(vadContext && usesGate);
|
||||
|
||||
if (liveMic != null) {
|
||||
liveMic.setMode(ptt ? Settings.InputMode.PUSH_TO_TALK
|
||||
: vad ? Settings.InputMode.VOICE_ACTIVATION
|
||||
: Settings.InputMode.CONTINUOUS);
|
||||
liveMic.setVadMode(vm);
|
||||
liveMic.setVadOverPtt(vadOverPttCheck.isSelected());
|
||||
}
|
||||
};
|
||||
vadRadio.addActionListener(e -> syncEnabled.run());
|
||||
pttRadio.addActionListener(e -> syncEnabled.run());
|
||||
contRadio.addActionListener(e -> syncEnabled.run());
|
||||
vadModeCombo.addActionListener(e -> syncEnabled.run());
|
||||
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
|
||||
syncEnabled.run();
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weighty = 1;
|
||||
p.add(Box.createGlue(), c);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static int vadModeIndex(Settings.VadMode m) {
|
||||
switch (m) {
|
||||
case AUTOMATIC:
|
||||
return 0;
|
||||
case VOLUME_GATE:
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private Settings.VadMode currentVadMode() {
|
||||
switch (vadModeCombo.getSelectedIndex()) {
|
||||
case 0:
|
||||
return Settings.VadMode.AUTOMATIC;
|
||||
case 1:
|
||||
return Settings.VadMode.VOLUME_GATE;
|
||||
default:
|
||||
return Settings.VadMode.HYBRID;
|
||||
}
|
||||
}
|
||||
|
||||
private static javax.swing.JScrollPane scrollable(JPanel content) {
|
||||
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
|
||||
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||
javax.swing.ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
sp.setBorder(null);
|
||||
sp.getVerticalScrollBar().setUnitIncrement(16);
|
||||
return sp;
|
||||
}
|
||||
|
||||
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
|
||||
JPanel panel = new JPanel(new BorderLayout(6, 0));
|
||||
panel.add(slider, BorderLayout.CENTER);
|
||||
valueLabel.setPreferredSize(new Dimension(48, valueLabel.getPreferredSize().height));
|
||||
panel.add(valueLabel, BorderLayout.EAST);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private OpusParameters currentOpusParameters() {
|
||||
return new OpusParameters(
|
||||
bitrateSlider.getValue() * 1000,
|
||||
complexitySlider.getValue(),
|
||||
vbrCheck.isSelected(),
|
||||
fecCheck.isSelected(),
|
||||
settings.packetLoss,
|
||||
musicCheck.isSelected());
|
||||
}
|
||||
|
||||
/** Applies the current Opus controls to the running encoder immediately. */
|
||||
private void pushOpusLive() {
|
||||
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
|
||||
}
|
||||
|
||||
private void capturePttKey() {
|
||||
pttKeyButton.setText("Press a key…");
|
||||
pttKeyButton.requestFocusInWindow();
|
||||
KeyAdapter ka = new KeyAdapter() {
|
||||
@Override
|
||||
public void keyPressed(KeyEvent e) {
|
||||
pttKey = e.getKeyCode();
|
||||
pttKeyButton.setText(keyName(pttKey));
|
||||
pttKeyButton.removeKeyListener(this);
|
||||
}
|
||||
};
|
||||
pttKeyButton.addKeyListener(ka);
|
||||
}
|
||||
|
||||
private static String keyName(int code) {
|
||||
String t = KeyEvent.getKeyText(code);
|
||||
return (t == null || t.isEmpty()) ? ("Key " + code) : t;
|
||||
}
|
||||
|
||||
private void apply() {
|
||||
settings.inputDevice = comboValue(inputCombo);
|
||||
settings.outputDevice = comboValue(outputCombo);
|
||||
settings.inputVolume = inputGain.getValue() / 100.0;
|
||||
settings.outputVolume = outputVol.getValue() / 100.0;
|
||||
settings.denoise = denoiseCheck.isSelected();
|
||||
settings.denoiserLevel = denoiseLevel.getValue() / 100.0;
|
||||
settings.typingAttenuation = typingCheck.isSelected();
|
||||
settings.agc = agcCheck.isSelected();
|
||||
settings.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
|
||||
: contRadio.isSelected() ? Settings.InputMode.CONTINUOUS
|
||||
: Settings.InputMode.VOICE_ACTIVATION;
|
||||
settings.vadMode = currentVadMode();
|
||||
settings.vadThresholdDb = thresholdSlider.getValue();
|
||||
settings.speechThreshold = speechSlider.getValue() / 100.0;
|
||||
settings.vadOverPtt = vadOverPttCheck.isSelected();
|
||||
settings.pushToTalkKey = pttKey;
|
||||
settings.bitrate = bitrateSlider.getValue() * 1000;
|
||||
settings.complexity = complexitySlider.getValue();
|
||||
settings.vbr = vbrCheck.isSelected();
|
||||
settings.fec = fecCheck.isSelected();
|
||||
settings.music = musicCheck.isSelected();
|
||||
settings.save();
|
||||
|
||||
if (liveMic != null) {
|
||||
liveMic.setMode(settings.inputMode);
|
||||
liveMic.setVadMode(settings.vadMode);
|
||||
liveMic.setThresholdDb(settings.vadThresholdDb);
|
||||
liveMic.setSpeechThreshold(settings.speechThreshold);
|
||||
liveMic.setVadOverPtt(settings.vadOverPtt);
|
||||
liveMic.setInputGain(settings.inputVolume);
|
||||
liveMic.setNoiseSuppression(settings.denoise);
|
||||
liveMic.setDenoiserLevel(settings.denoiserLevel);
|
||||
liveMic.setTypingAttenuation(settings.typingAttenuation);
|
||||
liveMic.setAgc(settings.agc);
|
||||
liveMic.setOpusParameters(OpusParameters.from(settings));
|
||||
}
|
||||
if (livePlayback != null) {
|
||||
livePlayback.setMasterVolume(settings.outputVolume);
|
||||
livePlayback.setOutputDevice(settings.outputDevice);
|
||||
}
|
||||
if (onApply != null) onApply.run();
|
||||
}
|
||||
|
||||
private void close() {
|
||||
stopMeter();
|
||||
dispose();
|
||||
}
|
||||
|
||||
// ---- live meter ----
|
||||
|
||||
private void startMeter() {
|
||||
meterRunning = true;
|
||||
meterThread = new Thread(this::meterLoop, "settings-meter");
|
||||
meterThread.setDaemon(true);
|
||||
meterThread.start();
|
||||
}
|
||||
|
||||
private void restartMeter() {
|
||||
stopMeter();
|
||||
startMeter();
|
||||
}
|
||||
|
||||
private void stopMeter() {
|
||||
meterRunning = false;
|
||||
if (meterThread != null) {
|
||||
meterThread.interrupt();
|
||||
meterThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void meterLoop() {
|
||||
String device = comboValue(inputCombo);
|
||||
TargetDataLine line = null;
|
||||
try {
|
||||
line = AudioDevices.openCapture(device);
|
||||
line.start();
|
||||
int frame = AudioDevices.FRAME_SIZE;
|
||||
byte[] buf = new byte[frame * 2];
|
||||
double gain = (inputGain != null ? inputGain.getValue() / 100.0 : 1.0);
|
||||
while (meterRunning) {
|
||||
int read = 0;
|
||||
while (read < buf.length) {
|
||||
int n = line.read(buf, read, buf.length - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
if (read < buf.length) break;
|
||||
double sumSq = 0;
|
||||
for (int i = 0; i < frame; i++) {
|
||||
short s = (short) ((buf[2 * i + 1] << 8) | (buf[2 * i] & 0xFF));
|
||||
double f = s / 32768.0 * gain;
|
||||
sumSq += f * f;
|
||||
}
|
||||
double rms = Math.sqrt(sumSq / frame);
|
||||
double db = rms <= 1e-9 ? -100 : 20 * Math.log10(rms);
|
||||
final double fdb = db;
|
||||
if (meter != null) SwingUtilities.invokeLater(() -> meter.setLevel(fdb));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
} finally {
|
||||
if (line != null) {
|
||||
try {
|
||||
line.stop();
|
||||
line.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- small helpers ----
|
||||
|
||||
private static GridBagConstraints gbc() {
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
return c;
|
||||
}
|
||||
|
||||
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, java.awt.Component field) {
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weightx = 0;
|
||||
c.gridwidth = 1;
|
||||
p.add(label, c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
p.add(field, c);
|
||||
}
|
||||
|
||||
private static void selectOrDefault(JComboBox<String> combo, String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
combo.setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < combo.getItemCount(); i++) {
|
||||
if (value.equals(combo.getItemAt(i))) {
|
||||
combo.setSelectedIndex(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
combo.setSelectedIndex(0);
|
||||
}
|
||||
|
||||
private static String comboValue(JComboBox<String> combo) {
|
||||
int idx = combo.getSelectedIndex();
|
||||
if (idx <= 0) return "";
|
||||
Object v = combo.getSelectedItem();
|
||||
return v == null ? "" : v.toString();
|
||||
}
|
||||
}
|
||||
63
ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java
Normal file
63
ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java
Normal file
@@ -0,0 +1,63 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Parses TeamSpeak 3 "spacer" channel names — cosmetic root channels used as
|
||||
* separators, e.g. {@code [spacer0]---}, {@code [*spacer1]=}, {@code [cspacer]Rules}.
|
||||
* The tag selects alignment ({@code l}/{@code c}/{@code r}) or fill ({@code *}).
|
||||
*/
|
||||
public final class Spacers {
|
||||
|
||||
/** Result of parsing a spacer name. */
|
||||
public static final class Spacer {
|
||||
public final char align; // 'l', 'c', 'r', or '*' (repeat/fill)
|
||||
public final String caption;
|
||||
|
||||
Spacer(char align, String caption) {
|
||||
this.align = align;
|
||||
this.caption = caption;
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern PATTERN =
|
||||
Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$");
|
||||
|
||||
private Spacers() {
|
||||
}
|
||||
|
||||
/** Returns spacer info if {@code channelName} is a spacer, else {@code null}. */
|
||||
public static Spacer parse(String channelName) {
|
||||
if (channelName == null) return null;
|
||||
Matcher m = PATTERN.matcher(channelName);
|
||||
if (!m.matches()) return null;
|
||||
String tag = m.group(1);
|
||||
char align = (tag == null || tag.isEmpty()) ? 'l' : tag.charAt(0);
|
||||
return new Spacer(align, m.group(2));
|
||||
}
|
||||
|
||||
public static boolean isSpacer(String channelName) {
|
||||
return parse(channelName) != null;
|
||||
}
|
||||
|
||||
/** Builds the visible label for a spacer at roughly the given character width. */
|
||||
public static String render(Spacer s, int width) {
|
||||
String caption = s.caption == null ? "" : s.caption;
|
||||
if (s.align == '*') {
|
||||
if (caption.isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (sb.length() < width) sb.append(caption);
|
||||
return sb.substring(0, Math.max(caption.length(), Math.min(sb.length(), width)));
|
||||
}
|
||||
if (s.align == 'c') {
|
||||
int pad = Math.max(0, (width - caption.length()) / 2);
|
||||
return " ".repeat(pad) + caption;
|
||||
}
|
||||
if (s.align == 'r') {
|
||||
int pad = Math.max(0, width - caption.length());
|
||||
return " ".repeat(pad) + caption;
|
||||
}
|
||||
return caption;
|
||||
}
|
||||
}
|
||||
35
ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java
Normal file
35
ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java
Normal file
@@ -0,0 +1,35 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
|
||||
/** Central palette + fonts approximating the light TeamSpeak 3 look. */
|
||||
public final class Theme {
|
||||
|
||||
public static final Color WINDOW_BG = new Color(0xF0F0F0);
|
||||
public static final Color TREE_BG = new Color(0xFFFFFF);
|
||||
public static final Color TREE_SELECTION = new Color(0xCFE3FB);
|
||||
public static final Color TREE_TEXT = new Color(0x1E1E1E);
|
||||
public static final Color CHANNEL_TEXT = new Color(0x21486B);
|
||||
public static final Color SERVER_TEXT = new Color(0x123456);
|
||||
|
||||
public static final Color TALKING = new Color(0x33B35A);
|
||||
public static final Color IDLE_CLIENT = new Color(0x6E7B87);
|
||||
public static final Color AWAY = new Color(0xC98A1B);
|
||||
public static final Color MUTED = new Color(0xC0392B);
|
||||
|
||||
public static final Color TOOLBAR_BG = new Color(0xE6E9ED);
|
||||
public static final Color STATUS_BG = new Color(0xE6E9ED);
|
||||
public static final Color ACCENT = new Color(0x2C7BE5);
|
||||
|
||||
public static final Color CHAT_BG = new Color(0xFAFAFA);
|
||||
public static final Color CHAT_SYSTEM = new Color(0x8A8A8A);
|
||||
public static final Color CHAT_NAME = new Color(0x2C7BE5);
|
||||
public static final Color CHAT_TEXT = new Color(0x202020);
|
||||
|
||||
public static final Font UI_FONT = new Font("SansSerif", Font.PLAIN, 12);
|
||||
public static final Font UI_BOLD = new Font("SansSerif", Font.BOLD, 12);
|
||||
|
||||
private Theme() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user