Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98719d717e | |||
| 35f10ed14c | |||
| d952ffd856 | |||
| acfe25db15 | |||
| 08e6c51e90 | |||
| acd438dd14 | |||
| 6e65797f25 | |||
| fa99cadc44 | |||
| 9ba0982f25 | |||
| 275de77c8b | |||
| 189620df7f | |||
| cb05b097a6 | |||
| bcc034e975 | |||
| f68c1e297c | |||
| 52bd180b56 | |||
| f2885d33ad | |||
| d8a56566d4 | |||
| b29f518d59 | |||
| acf0221837 | |||
| e5b607fc29 | |||
| d722377508 | |||
| b694845381 | |||
| cf5ba94092 | |||
| e5fb87a796 | |||
| de02f88f1c | |||
| 71084ea309 | |||
| 047c89404c | |||
| 75ae6b89b2 | |||
| e228dd4ad0 | |||
| 752676e863 | |||
| edc8a0ca3a | |||
| 7e4b9671fe | |||
| 3e2c06248f | |||
| 32e5ffb576 | |||
| ef95b6305d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ out/
|
|||||||
|
|
||||||
# The bundled proprietary TeamSpeak 3 client (reference binary, not our source)
|
# The bundled proprietary TeamSpeak 3 client (reference binary, not our source)
|
||||||
/TeamSpeak3-Client-linux_amd64/
|
/TeamSpeak3-Client-linux_amd64/
|
||||||
|
/re-android/
|
||||||
|
|
||||||
# Packages
|
# Packages
|
||||||
*.jar
|
*.jar
|
||||||
|
|||||||
@@ -80,6 +80,35 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
|
|||||||
- **Status bar** shows the server name, user count and live ping.
|
- **Status bar** shows the server name, user count and live ping.
|
||||||
- Change your nickname, mute/deafen from the toolbar.
|
- Change your nickname, mute/deafen from the toolbar.
|
||||||
|
|
||||||
|
### Hotkeys
|
||||||
|
- **Global hotkeys**, as in TeamSpeak 3: single keys or combinations (any key can act
|
||||||
|
as the modifier) and mouse buttons including *Mouse 4* / *Mouse 5*, working
|
||||||
|
system-wide rather than only while the window has focus. Configure them in
|
||||||
|
**Options → Hotkeys**.
|
||||||
|
- Per binding you choose whether it **triggers on key down or on key up**, and whether
|
||||||
|
it applies **on the active server** only or to every connected one (checkbox off).
|
||||||
|
Push-to-talk style actions ignore the trigger setting and simply last while held.
|
||||||
|
- The action list is TeamSpeak's own, reverse-engineered from its hotkey dialog:
|
||||||
|
three categories (*Server*, *Self*, *Misc*) and a **"Show advanced actions"**
|
||||||
|
checkbox that reveals the rest, exactly as the original hides all but the everyday
|
||||||
|
actions. Actions this client does not implement are listed but greyed out.
|
||||||
|
- Push-to-talk is one of those hotkeys; the button in **Options → Voice Activation**
|
||||||
|
edits that binding.
|
||||||
|
- On Linux, capture uses **XInput2 raw events**, as TeamSpeak's own client does: no
|
||||||
|
privileges needed and the keystroke is not swallowed. X11's RECORD extension is kept
|
||||||
|
as a fallback for servers without XInput2.
|
||||||
|
- On **Windows**, capture uses **raw input** (`RIDEV_INPUTSINK`) through a message-only
|
||||||
|
window: it observes rather than intercepts, so unlike a low-level keyboard hook it
|
||||||
|
cannot swallow a keystroke or stall the system input queue, and it reports the side
|
||||||
|
buttons and both edges of every key. Bindings are stored as scan codes, so they follow
|
||||||
|
the physical key rather than the layout.
|
||||||
|
- On Wayland the hooks run inside Xwayland, so they see every X11 application, and keys
|
||||||
|
aimed at native Wayland windows only where the compositor forwards them — under KWin
|
||||||
|
that is *System Settings → Window Management → Legacy X11 App Support*, which is why
|
||||||
|
TeamSpeak's hotkeys work there. Where it forwards nothing, the client reads
|
||||||
|
`/dev/input/event*` instead, for which your user has to be in the `input` group.
|
||||||
|
The Hotkeys tab says which backend is in use, or why none is.
|
||||||
|
|
||||||
### Notification sounds
|
### Notification sounds
|
||||||
- **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini`
|
- **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini`
|
||||||
mapping actions (`CONNECTION_CONNECTED`, `CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS`, …)
|
mapping actions (`CONNECTION_CONNECTED`, `CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS`, …)
|
||||||
@@ -151,6 +180,12 @@ core/ com.ts3client
|
|||||||
├── gfx
|
├── gfx
|
||||||
│ ├── IconPack an icon pack zip/folder + its settings.ini mapping
|
│ ├── IconPack an icon pack zip/folder + its settings.ini mapping
|
||||||
│ └── IconPacks discovery of installed packs
|
│ └── IconPacks discovery of installed packs
|
||||||
|
├── hotkey
|
||||||
|
│ ├── HotkeyAction catalogue of TeamSpeak's hotkey actions (RE'd from its binary)
|
||||||
|
│ ├── Hotkey/HotkeyCombo one binding: keys, trigger edge, server scope, argument
|
||||||
|
│ ├── Hotkeys persisted bindings (~/.ts3jclient/hotkeys.properties)
|
||||||
|
│ ├── GlobalInputHook platform hook interface: system-wide key/button events
|
||||||
|
│ └── HotkeyEngine held-key tracking, combination matching, recording
|
||||||
├── sound
|
├── sound
|
||||||
│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids)
|
│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids)
|
||||||
│ ├── SoundPack a pack folder + its settings.ini mapping
|
│ ├── SoundPack a pack folder + its settings.ini mapping
|
||||||
@@ -164,14 +199,22 @@ core/ com.ts3client
|
|||||||
├── ChannelNode/ClientEntry view models
|
├── ChannelNode/ClientEntry view models
|
||||||
└── ConnectionListener frontend callbacks
|
└── ConnectionListener frontend callbacks
|
||||||
|
|
||||||
desktop/ com.ts3client.audio.desktop
|
desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop
|
||||||
├── Opus Panama (FFM) binding to native libopus
|
├── Opus Panama (FFM) binding to native libopus
|
||||||
├── OpusEncoder/OpusDecoder thin codec wrappers
|
├── OpusEncoder/OpusDecoder thin codec wrappers
|
||||||
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
|
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
|
||||||
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
|
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
|
||||||
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing
|
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing
|
||||||
├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line
|
├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line
|
||||||
└── JavaSoundAudioBackend wires the above into the core AudioBackend
|
├── JavaSoundAudioBackend wires the above into the core AudioBackend
|
||||||
|
└── hotkey.desktop
|
||||||
|
├── XInput2InputHook global key/button capture via XInput2 raw events
|
||||||
|
├── XRecordInputHook the same via X11's RECORD extension, as a fallback
|
||||||
|
├── EvdevInputHook /dev/input fallback for Wayland sessions
|
||||||
|
├── WindowsInputHook the same on Windows: raw input into a message-only window
|
||||||
|
├── RawInput RAWINPUT layout and decoding, split out to be testable
|
||||||
|
├── X11KeyNamer/WindowsKeyNamer layout-aware key labels per platform
|
||||||
|
└── DesktopInputHooks picks the backend that suits the session
|
||||||
|
|
||||||
swing/ com.ts3client
|
swing/ com.ts3client
|
||||||
├── Main entry point (look & feel, settings, backend injection)
|
├── Main entry point (look & feel, settings, backend injection)
|
||||||
@@ -182,6 +225,10 @@ swing/ com.ts3client
|
|||||||
├── InfoPanel channel description / client group + details view
|
├── InfoPanel channel description / client group + details view
|
||||||
├── ChatPanel chat log + input
|
├── ChatPanel chat log + input
|
||||||
├── SettingsDialog audio + VAD options with live meter
|
├── SettingsDialog audio + VAD options with live meter
|
||||||
|
├── HotkeysPanel hotkey list (Options → Hotkeys)
|
||||||
|
├── HotkeyDialog add/edit one hotkey: action, combination, trigger, scope
|
||||||
|
├── HotkeyService bindings + engine + input hook, for the dialogs
|
||||||
|
├── HotkeyActions carries a fired hotkey out on the client
|
||||||
├── NotificationsPanel sound pack + per-action sound/important configuration
|
├── NotificationsPanel sound pack + per-action sound/important configuration
|
||||||
├── IconPackPanel icon pack chooser + icon viewer (Options → Design)
|
├── IconPackPanel icon pack chooser + icon viewer (Options → Design)
|
||||||
├── ConnectDialog connect form
|
├── ConnectDialog connect form
|
||||||
@@ -200,6 +247,15 @@ swing/ com.ts3client
|
|||||||
dependency-free and reusable in the core library.
|
dependency-free and reusable in the core library.
|
||||||
- Whisper is received/played but not yet **sendable** from the UI.
|
- Whisper is received/played but not yet **sendable** from the UI.
|
||||||
- Playback decodes streams as mono; stereo music-bot audio is down-mixed.
|
- 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
|
- Hotkey actions TeamSpeak offers but this client does not perform yet (listed but
|
||||||
window has focus (no global hotkey).
|
greyed out in the dialog): capture/playback/hotkey profiles and sound packs,
|
||||||
|
whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D
|
||||||
|
sound, hardware ("local") microphone mute and the channel-traversal variants
|
||||||
|
beyond "Switch to Channel".
|
||||||
|
- Global hotkeys cover Linux and Windows; **macOS has no backend**, so hotkeys there are
|
||||||
|
inert (a `CGEventTap`, which needs Accessibility permission, is the way in). The
|
||||||
|
Windows backend is written but has not been run on Windows — only its decoding is
|
||||||
|
covered by tests.
|
||||||
|
- When no backend can start, push-to-talk does not work at all, not even with the window
|
||||||
|
focused; a focus-scoped fallback hook would restore the pre-hotkey behaviour.
|
||||||
- No file transfer, avatars, or server/channel administration UI yet.
|
- No file transfer, avatars, or server/channel administration UI yet.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/** Receives frames of PCM audio as they pass through the capture or playback chain. */
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface AudioFrameListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called with one frame of interleaved samples in [-1, 1].
|
||||||
|
*
|
||||||
|
* <p>The array is reused between calls, so copy anything you need to keep. Called from
|
||||||
|
* the audio thread: do not block.
|
||||||
|
*
|
||||||
|
* @param interleaved samples, {@code channels} per sample position
|
||||||
|
* @param channels number of interleaved channels
|
||||||
|
*/
|
||||||
|
void onFrame(float[] interleaved, int channels);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture level on the scale the TeamSpeak 3 client puts on its voice-activation slider.
|
||||||
|
*
|
||||||
|
* <p>TS3 stores {@code voiceactivation_level} in decibels but compares powers: its setter
|
||||||
|
* converts the slider value with {@code 10^(dB/10) * 0.0095} and its getter reports a
|
||||||
|
* measured power back with {@code 10*log10(power / 0.0095)}. The same reference constant is
|
||||||
|
* used here so a threshold of −40 means the same thing it does in TeamSpeak.
|
||||||
|
*
|
||||||
|
* <p>The resulting scale runs from {@link #MIN_DB} to {@link #MAX_DB}, with 0 dB
|
||||||
|
* corresponding to roughly −20 dBFS and the default −40 to about
|
||||||
|
* −60 dBFS.
|
||||||
|
*/
|
||||||
|
public final class InputLevel {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mean-square level that reads as 0 dB, taken from the client's own dB conversion.
|
||||||
|
*/
|
||||||
|
private static final double REFERENCE_POWER = 0.0095;
|
||||||
|
|
||||||
|
/** Lower end of the slider range. */
|
||||||
|
public static final double MIN_DB = -50.0;
|
||||||
|
|
||||||
|
/** Upper end of the slider range. */
|
||||||
|
public static final double MAX_DB = 50.0;
|
||||||
|
|
||||||
|
/** Reported when the frame is digital silence. */
|
||||||
|
public static final double SILENCE_DB = MIN_DB;
|
||||||
|
|
||||||
|
private InputLevel() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Level of {@code length} mono samples in [-1, 1], in dB on the scale above. */
|
||||||
|
public static double toDb(float[] frame, int length) {
|
||||||
|
if (length <= 0) {
|
||||||
|
return SILENCE_DB;
|
||||||
|
}
|
||||||
|
double sumSq = 0;
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
sumSq += (double) frame[i] * frame[i];
|
||||||
|
}
|
||||||
|
return powerToDb(sumSq / length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts a mean-square power to the slider's dB scale. */
|
||||||
|
public static double powerToDb(double power) {
|
||||||
|
if (power <= 0) {
|
||||||
|
return SILENCE_DB;
|
||||||
|
}
|
||||||
|
return Math.max(SILENCE_DB, 10.0 * Math.log10(power / REFERENCE_POWER));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of {@link #powerToDb}, matching the client's threshold conversion. */
|
||||||
|
public static double dbToPower(double db) {
|
||||||
|
return Math.pow(10.0, db / 10.0) * REFERENCE_POWER;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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,27 @@
|
|||||||
|
package com.ts3client.audio;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates how likely it is that a frame of captured audio contains speech.
|
||||||
|
*
|
||||||
|
* <p>Used by the "Automatic" and "Hybrid" voice-activation modes. Implementations are
|
||||||
|
* stateful and expect frames in capture order; {@link #reset()} clears that state.
|
||||||
|
*
|
||||||
|
* <p>Pure DSP with no platform dependencies, so any frontend or backend can reuse it.
|
||||||
|
*/
|
||||||
|
public interface SpeechProbabilityDetector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes one frame of mono PCM in [-1, 1] and returns the current speech
|
||||||
|
* probability in [0, 1].
|
||||||
|
*
|
||||||
|
* @param frame samples in capture order
|
||||||
|
* @param length number of valid samples in {@code frame}
|
||||||
|
*/
|
||||||
|
double process(float[] frame, int length);
|
||||||
|
|
||||||
|
/** Speech probability from the most recent {@link #process} call. */
|
||||||
|
double probability();
|
||||||
|
|
||||||
|
/** Drops all history. Call on a capture discontinuity or a settings change. */
|
||||||
|
void reset();
|
||||||
|
}
|
||||||
@@ -18,11 +18,20 @@ public interface VoiceInput extends Microphone {
|
|||||||
|
|
||||||
void setMuted(boolean muted);
|
void setMuted(boolean muted);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silences capture the same way {@link #setMuted} does, but locally only: it does
|
||||||
|
* not touch {@link #isMuted}, so the mute status published to the server (and the
|
||||||
|
* mute/unmute sound) is unaffected. TS3's "Local Mic Mute".
|
||||||
|
*/
|
||||||
|
void setLocalMuted(boolean muted);
|
||||||
|
|
||||||
|
boolean isLocalMuted();
|
||||||
|
|
||||||
void setMode(Settings.InputMode mode);
|
void setMode(Settings.InputMode mode);
|
||||||
|
|
||||||
void setVadMode(Settings.VadMode mode);
|
void setVadMode(Settings.VadMode mode);
|
||||||
|
|
||||||
/** Voice-activation volume-gate threshold in dBFS. */
|
/** Volume-gate threshold on {@link InputLevel}'s scale. */
|
||||||
void setThresholdDb(double db);
|
void setThresholdDb(double db);
|
||||||
|
|
||||||
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
|
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
|
||||||
@@ -50,12 +59,19 @@ public interface VoiceInput extends Microphone {
|
|||||||
|
|
||||||
void setPushToTalk(boolean pressed);
|
void setPushToTalk(boolean pressed);
|
||||||
|
|
||||||
/** Receives the live input level in dBFS, once per captured frame. */
|
/** Receives the live input level on {@link InputLevel}'s scale, once per captured frame. */
|
||||||
void setLevelListener(Consumer<Double> listener);
|
void setLevelListener(Consumer<Double> listener);
|
||||||
|
|
||||||
/** Receives local transmit-state transitions (talking / silent). */
|
/** Receives local transmit-state transitions (talking / silent). */
|
||||||
void setTalkListener(Consumer<Boolean> listener);
|
void setTalkListener(Consumer<Boolean> listener);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives every frame that is actually transmitted, for local monitoring: the audio
|
||||||
|
* after pre-processing and gating, including the pre-roll replayed when the gate opens.
|
||||||
|
* Nothing is delivered while the gate is shut or the microphone is muted.
|
||||||
|
*/
|
||||||
|
void setMonitorListener(AudioFrameListener listener);
|
||||||
|
|
||||||
/** Called when speech is detected while the microphone is muted. */
|
/** Called when speech is detected while the microphone is muted. */
|
||||||
void setMutedTalkListener(Runnable listener);
|
void setMutedTalkListener(Runnable listener);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.NUM_LPC_COEFFICIENTS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linear-prediction analysis for the pitch search, ported from WebRTC's
|
||||||
|
* {@code rnn_vad/lp_residual.cc}.
|
||||||
|
*
|
||||||
|
* <p>Whitening the signal before searching for a pitch makes the auto-correlation peak at
|
||||||
|
* the true period rather than at whichever formant happens to dominate.
|
||||||
|
*/
|
||||||
|
final class LinearPrediction {
|
||||||
|
|
||||||
|
private final float[] autoCorr = new float[NUM_LPC_COEFFICIENTS];
|
||||||
|
private final float[] reflection = new float[NUM_LPC_COEFFICIENTS - 1];
|
||||||
|
|
||||||
|
/** Computes the 5 post-processed inverse-filter coefficients for {@code x}. */
|
||||||
|
void computeLpcCoefficients(float[] x, float[] lpcCoeffs) {
|
||||||
|
for (int lag = 0; lag < NUM_LPC_COEFFICIENTS; lag++) {
|
||||||
|
float sum = 0;
|
||||||
|
for (int i = 0; i < x.length - lag; i++) {
|
||||||
|
sum += x[i] * x[i + lag];
|
||||||
|
}
|
||||||
|
autoCorr[lag] = sum;
|
||||||
|
}
|
||||||
|
if (autoCorr[0] == 0.0f) { // Empty frame.
|
||||||
|
java.util.Arrays.fill(lpcCoeffs, 0.0f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume a -40 dB white noise floor.
|
||||||
|
autoCorr[0] *= 1.0001f;
|
||||||
|
autoCorr[1] -= autoCorr[1] * 0.000064f;
|
||||||
|
autoCorr[2] -= autoCorr[2] * 0.000256f;
|
||||||
|
autoCorr[3] -= autoCorr[3] * 0.000576f;
|
||||||
|
autoCorr[4] -= autoCorr[4] * 0.001024f;
|
||||||
|
|
||||||
|
java.util.Arrays.fill(reflection, 0.0f);
|
||||||
|
computeInitialInverseFilterCoefficients();
|
||||||
|
|
||||||
|
// Bandwidth expansion, then convolution with (1 + 0.8 z^-1).
|
||||||
|
reflection[0] *= 0.9f;
|
||||||
|
reflection[1] *= 0.9f * 0.9f;
|
||||||
|
reflection[2] *= 0.9f * 0.9f * 0.9f;
|
||||||
|
reflection[3] *= 0.9f * 0.9f * 0.9f * 0.9f;
|
||||||
|
final float c = 0.8f;
|
||||||
|
lpcCoeffs[0] = reflection[0] + c;
|
||||||
|
lpcCoeffs[1] = reflection[1] + c * reflection[0];
|
||||||
|
lpcCoeffs[2] = reflection[2] + c * reflection[1];
|
||||||
|
lpcCoeffs[3] = reflection[3] + c * reflection[2];
|
||||||
|
lpcCoeffs[4] = c * reflection[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Levinson–Durbin recursion over {@link #autoCorr}, writing {@link #reflection}. */
|
||||||
|
private void computeInitialInverseFilterCoefficients() {
|
||||||
|
float error = autoCorr[0];
|
||||||
|
for (int i = 0; i < NUM_LPC_COEFFICIENTS - 1; i++) {
|
||||||
|
float reflectionCoeff = 0;
|
||||||
|
for (int j = 0; j < i; j++) {
|
||||||
|
reflectionCoeff += reflection[j] * autoCorr[i - j];
|
||||||
|
}
|
||||||
|
reflectionCoeff += autoCorr[i + 1];
|
||||||
|
|
||||||
|
// Avoid dividing by numbers close to zero.
|
||||||
|
final float minErrorMagnitude = 1e-6f;
|
||||||
|
if (Math.abs(error) < minErrorMagnitude) {
|
||||||
|
error = Math.copySign(minErrorMagnitude, error);
|
||||||
|
}
|
||||||
|
reflectionCoeff /= -error;
|
||||||
|
|
||||||
|
reflection[i] = reflectionCoeff;
|
||||||
|
for (int j = 0; j < ((i + 1) >> 1); j++) {
|
||||||
|
float tmp1 = reflection[j];
|
||||||
|
float tmp2 = reflection[i - 1 - j];
|
||||||
|
reflection[j] = tmp1 + reflectionCoeff * tmp2;
|
||||||
|
reflection[i - 1 - j] = tmp2 + reflectionCoeff * tmp1;
|
||||||
|
}
|
||||||
|
error -= reflectionCoeff * reflectionCoeff * error;
|
||||||
|
if (error < 0.001f * autoCorr[0]) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the inverse filter: {@code y[i] = x[i] + sum_j lpc[j] * x[i - 1 - j]},
|
||||||
|
* with the sum truncated near the start of the buffer.
|
||||||
|
*/
|
||||||
|
static void computeLpResidual(float[] lpcCoeffs, float[] x, float[] y) {
|
||||||
|
for (int i = 0; i < x.length; i++) {
|
||||||
|
float sum = x[i];
|
||||||
|
int taps = Math.min(i, NUM_LPC_COEFFICIENTS);
|
||||||
|
for (int j = 0; j < taps; j++) {
|
||||||
|
sum += lpcCoeffs[j] * x[i - 1 - j];
|
||||||
|
}
|
||||||
|
y[i] = sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.BUF_SIZE_12K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.FRAME_20MS_12K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.FRAME_20MS_24K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.INITIAL_NUM_LAGS_24K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.MAX_PITCH_24K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.MAX_PITCH_48K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.MIN_PITCH_24K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.MIN_PITCH_48K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.NUM_LAGS_12K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.REFINE_NUM_LAGS_24K;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pitch period estimation for {@code rnn_vad}, ported from WebRTC's
|
||||||
|
* {@code rnn_vad/pitch_search.cc} and {@code pitch_search_internal.cc}.
|
||||||
|
*
|
||||||
|
* <p>Three stages: a coarse search over the 12 kHz decimated LP residual picks two
|
||||||
|
* candidates, those are refined against the 24 kHz buffer, and finally sub-harmonics
|
||||||
|
* of the winner are tested so that an octave error can be corrected. The result is a
|
||||||
|
* period expressed at 48 kHz, which is what the network was trained on.
|
||||||
|
*
|
||||||
|
* <p>Lags are stored inverted: index 0 corresponds to the maximum pitch period.
|
||||||
|
*/
|
||||||
|
final class PitchEstimator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each divisor k in [2, 15], the multiplier n such that n*T/k is a sub-harmonic
|
||||||
|
* worth checking alongside T/k, chosen so no multiple is visited twice.
|
||||||
|
*/
|
||||||
|
private static final int[] SUB_HARMONIC_MULTIPLIERS =
|
||||||
|
{3, 2, 3, 2, 5, 2, 3, 2, 3, 2, 5, 2, 3, 2};
|
||||||
|
|
||||||
|
/** Thresholds on the initial period, computed as {@code [5*k*k for k in range(16)]}. */
|
||||||
|
private static final int[] INITIAL_PITCH_PERIOD_THRESHOLDS =
|
||||||
|
{20, 45, 80, 125, 180, 245, 320, 405, 500, 605, 720, 845, 980, 1125};
|
||||||
|
|
||||||
|
/** Number of pitch periods analysed either side of a candidate during refinement. */
|
||||||
|
private static final int PITCH_NEIGHBORHOOD_RADIUS = 2;
|
||||||
|
|
||||||
|
private final float[] pitchBuffer12k = new float[BUF_SIZE_12K];
|
||||||
|
private final float[] autoCorrelation12k = new float[NUM_LAGS_12K];
|
||||||
|
private final float[] yEnergy24k = new float[REFINE_NUM_LAGS_24K];
|
||||||
|
private final float[] autoCorrelation24k = new float[INITIAL_NUM_LAGS_24K];
|
||||||
|
private final int[] invertedLagsIndex = new int[2 * (2 * PITCH_NEIGHBORHOOD_RADIUS + 1)];
|
||||||
|
private int invertedLagsCount;
|
||||||
|
|
||||||
|
// Pitch tracking: the previous estimate biases the sub-harmonic decision.
|
||||||
|
private int lastPeriod48k;
|
||||||
|
private float lastStrength;
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
lastPeriod48k = 0;
|
||||||
|
lastStrength = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the pitch period at 48 kHz for the given 24 kHz LP residual buffer. */
|
||||||
|
int estimate(float[] pitchBuffer) {
|
||||||
|
// Coarse search at 12 kHz. WebRTC decimates without an anti-aliasing filter here;
|
||||||
|
// the refinement stage below is what recovers the accuracy.
|
||||||
|
for (int i = 0; i < BUF_SIZE_12K; i++) {
|
||||||
|
pitchBuffer12k[i] = pitchBuffer[2 * i];
|
||||||
|
}
|
||||||
|
computeAutoCorrelation12k();
|
||||||
|
long candidates = computePitchPeriod12kHz();
|
||||||
|
int best = (int) (candidates >> 32) * 2;
|
||||||
|
int secondBest = (int) candidates * 2;
|
||||||
|
|
||||||
|
computeSlidingFrameSquareEnergies24k(pitchBuffer);
|
||||||
|
int pitchLag48k = computePitchPeriod48kHz(pitchBuffer, best, secondBest);
|
||||||
|
computeExtendedPitchPeriod48kHz(pitchBuffer, MAX_PITCH_48K - pitchLag48k);
|
||||||
|
return lastPeriod48k;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code autoCorrelation12k[lag]} is the correlation of the most recent 20 ms with the
|
||||||
|
* frame starting {@code lag} samples into the buffer.
|
||||||
|
*/
|
||||||
|
private void computeAutoCorrelation12k() {
|
||||||
|
final int refOffset = BUF_SIZE_12K - FRAME_20MS_12K;
|
||||||
|
for (int lag = 0; lag < NUM_LAGS_12K; lag++) {
|
||||||
|
float sum = 0;
|
||||||
|
for (int i = 0; i < FRAME_20MS_12K; i++) {
|
||||||
|
sum += pitchBuffer12k[refOffset + i] * pitchBuffer12k[lag + i];
|
||||||
|
}
|
||||||
|
autoCorrelation12k[lag] = sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the best and second-best inverted lags packed into a long. */
|
||||||
|
private long computePitchPeriod12kHz() {
|
||||||
|
float denominator = 1.0f;
|
||||||
|
for (int i = 0; i < FRAME_20MS_12K + 1; i++) {
|
||||||
|
denominator += pitchBuffer12k[i] * pitchBuffer12k[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
int bestLag = 0;
|
||||||
|
float bestNumerator = -1.0f;
|
||||||
|
float bestDenominator = 0.0f;
|
||||||
|
int secondLag = 1;
|
||||||
|
float secondNumerator = -1.0f;
|
||||||
|
float secondDenominator = 0.0f;
|
||||||
|
|
||||||
|
for (int invertedLag = 0; invertedLag < NUM_LAGS_12K; invertedLag++) {
|
||||||
|
// A pitch candidate must have positive correlation.
|
||||||
|
if (autoCorrelation12k[invertedLag] > 0.0f) {
|
||||||
|
float numerator = autoCorrelation12k[invertedLag] * autoCorrelation12k[invertedLag];
|
||||||
|
// Compare numerator/denominator ratios without dividing.
|
||||||
|
if (numerator * secondDenominator > secondNumerator * denominator) {
|
||||||
|
if (numerator * bestDenominator > bestNumerator * denominator) {
|
||||||
|
secondLag = bestLag;
|
||||||
|
secondNumerator = bestNumerator;
|
||||||
|
secondDenominator = bestDenominator;
|
||||||
|
bestLag = invertedLag;
|
||||||
|
bestNumerator = numerator;
|
||||||
|
bestDenominator = denominator;
|
||||||
|
} else {
|
||||||
|
secondLag = invertedLag;
|
||||||
|
secondNumerator = numerator;
|
||||||
|
secondDenominator = denominator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Slide the energy window to the next inverted lag.
|
||||||
|
float yOld = pitchBuffer12k[invertedLag];
|
||||||
|
float yNew = pitchBuffer12k[invertedLag + FRAME_20MS_12K];
|
||||||
|
denominator -= yOld * yOld;
|
||||||
|
denominator += yNew * yNew;
|
||||||
|
denominator = Math.max(0.0f, denominator);
|
||||||
|
}
|
||||||
|
return ((long) bestLag << 32) | (secondLag & 0xffffffffL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Energy of the sliding 20 ms frame for every inverted lag. */
|
||||||
|
private void computeSlidingFrameSquareEnergies24k(float[] pitchBuffer) {
|
||||||
|
float yy = 0;
|
||||||
|
for (int i = 0; i < FRAME_20MS_24K; i++) {
|
||||||
|
yy += pitchBuffer[i] * pitchBuffer[i];
|
||||||
|
}
|
||||||
|
yEnergy24k[0] = yy;
|
||||||
|
for (int invertedLag = 0; invertedLag < MAX_PITCH_24K; invertedLag++) {
|
||||||
|
yy -= pitchBuffer[invertedLag] * pitchBuffer[invertedLag];
|
||||||
|
yy += pitchBuffer[invertedLag + FRAME_20MS_24K] * pitchBuffer[invertedLag + FRAME_20MS_24K];
|
||||||
|
yy = Math.max(1.0f, yy);
|
||||||
|
yEnergy24k[invertedLag + 1] = yy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Correlation of the most recent 20 ms with the frame at {@code invertedLag}. */
|
||||||
|
private static float autoCorrelation(int invertedLag, float[] pitchBuffer) {
|
||||||
|
float sum = 0;
|
||||||
|
for (int i = 0; i < FRAME_20MS_24K; i++) {
|
||||||
|
sum += pitchBuffer[MAX_PITCH_24K + i] * pitchBuffer[invertedLag + i];
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes the auto-correlation around both candidates at 24 kHz and returns the
|
||||||
|
* strongest inverted lag, pseudo-interpolated to 48 kHz.
|
||||||
|
*/
|
||||||
|
private int computePitchPeriod48kHz(float[] pitchBuffer, int bestCandidate, int secondCandidate) {
|
||||||
|
invertedLagsCount = 0;
|
||||||
|
// Order the two neighbourhoods so the first precedes the second.
|
||||||
|
boolean swap = bestCandidate > secondCandidate;
|
||||||
|
int firstCenter = swap ? secondCandidate : bestCandidate;
|
||||||
|
int secondCenter = swap ? bestCandidate : secondCandidate;
|
||||||
|
int r1min = Math.max(firstCenter - PITCH_NEIGHBORHOOD_RADIUS, 0);
|
||||||
|
int r1max = Math.min(firstCenter + PITCH_NEIGHBORHOOD_RADIUS, INITIAL_NUM_LAGS_24K - 1);
|
||||||
|
int r2min = Math.max(secondCenter - PITCH_NEIGHBORHOOD_RADIUS, 0);
|
||||||
|
int r2max = Math.min(secondCenter + PITCH_NEIGHBORHOOD_RADIUS, INITIAL_NUM_LAGS_24K - 1);
|
||||||
|
|
||||||
|
if (r1max + 1 >= r2min) { // Overlapping or adjacent ranges.
|
||||||
|
computeAutoCorrelationRange(pitchBuffer, r1min, r2max);
|
||||||
|
} else {
|
||||||
|
computeAutoCorrelationRange(pitchBuffer, r1min, r1max);
|
||||||
|
computeAutoCorrelationRange(pitchBuffer, r2min, r2max);
|
||||||
|
}
|
||||||
|
|
||||||
|
int bestInvertedLag = 0;
|
||||||
|
float bestNumerator = -1.0f;
|
||||||
|
float bestDenominator = 0.0f;
|
||||||
|
for (int i = 0; i < invertedLagsCount; i++) {
|
||||||
|
int invertedLag = invertedLagsIndex[i];
|
||||||
|
if (autoCorrelation24k[invertedLag] > 0.0f) {
|
||||||
|
float numerator = autoCorrelation24k[invertedLag] * autoCorrelation24k[invertedLag];
|
||||||
|
float denominator = yEnergy24k[invertedLag];
|
||||||
|
if (numerator * bestDenominator > bestNumerator * denominator) {
|
||||||
|
bestInvertedLag = invertedLag;
|
||||||
|
bestNumerator = numerator;
|
||||||
|
bestDenominator = denominator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestInvertedLag == 0 || bestInvertedLag >= INITIAL_NUM_LAGS_24K - 1) {
|
||||||
|
return bestInvertedLag * 2; // Cannot interpolate at the boundaries.
|
||||||
|
}
|
||||||
|
int offset = pseudoInterpolationOffset(
|
||||||
|
autoCorrelation24k[bestInvertedLag + 1],
|
||||||
|
autoCorrelation24k[bestInvertedLag],
|
||||||
|
autoCorrelation24k[bestInvertedLag - 1]);
|
||||||
|
return 2 * bestInvertedLag + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void computeAutoCorrelationRange(float[] pitchBuffer, int min, int max) {
|
||||||
|
// The pseudo-interpolation reads one lag either side, so zero those rather than
|
||||||
|
// clearing the whole array.
|
||||||
|
if (min > 0) {
|
||||||
|
autoCorrelation24k[min - 1] = 0.0f;
|
||||||
|
}
|
||||||
|
if (max < INITIAL_NUM_LAGS_24K - 1) {
|
||||||
|
autoCorrelation24k[max + 1] = 0.0f;
|
||||||
|
}
|
||||||
|
for (int invertedLag = min; invertedLag <= max; invertedLag++) {
|
||||||
|
autoCorrelation24k[invertedLag] = autoCorrelation(invertedLag, pitchBuffer);
|
||||||
|
invertedLagsIndex[invertedLagsCount++] = invertedLag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a lag correction in {-1, 0, +1} based on the neighbouring correlations. */
|
||||||
|
private static int pseudoInterpolationOffset(float prev, float curr, float next) {
|
||||||
|
if ((next - prev) > 0.7f * (curr - prev)) {
|
||||||
|
return 1;
|
||||||
|
} else if ((prev - next) > 0.7f * (curr - next)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refines {@code lag} (24 kHz) to a 48 kHz lag using pseudo-interpolation. */
|
||||||
|
private static int pseudoInterpolationLagPitchBuf(int lag, float[] pitchBuffer) {
|
||||||
|
int offset = 0;
|
||||||
|
if (lag > 0 && lag < MAX_PITCH_24K) {
|
||||||
|
int invertedLag = MAX_PITCH_24K - lag;
|
||||||
|
offset = pseudoInterpolationOffset(
|
||||||
|
autoCorrelation(invertedLag + 1, pitchBuffer),
|
||||||
|
autoCorrelation(invertedLag, pitchBuffer),
|
||||||
|
autoCorrelation(invertedLag - 1, pitchBuffer));
|
||||||
|
}
|
||||||
|
return 2 * lag + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as {@code round(multiplier * pitchPeriod / divisor)}. */
|
||||||
|
private static int alternativePitchPeriod(int pitchPeriod, int multiplier, int divisor) {
|
||||||
|
return (2 * multiplier * pitchPeriod + divisor) / (2 * divisor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests sub-harmonics of the refined period and keeps the strongest, then stores the
|
||||||
|
* winner in {@link #lastPeriod48k}/{@link #lastStrength}.
|
||||||
|
*/
|
||||||
|
private void computeExtendedPitchPeriod48kHz(float[] pitchBuffer, int initialPeriod48k) {
|
||||||
|
final float xEnergy = yEnergy24k[MAX_PITCH_24K];
|
||||||
|
|
||||||
|
int bestPeriod = Math.min(initialPeriod48k / 2, MAX_PITCH_24K - 1);
|
||||||
|
float bestXy = autoCorrelation(MAX_PITCH_24K - bestPeriod, pitchBuffer);
|
||||||
|
float bestYEnergy = yEnergy24k[MAX_PITCH_24K - bestPeriod];
|
||||||
|
float bestStrength = pitchStrength(bestXy, bestYEnergy, xEnergy);
|
||||||
|
|
||||||
|
final int initialPeriod = bestPeriod;
|
||||||
|
final float initialStrength = bestStrength;
|
||||||
|
// The tracked pitch is kept at 48 kHz but compared at 24 kHz.
|
||||||
|
final int lastPeriod = lastPeriod48k / 2;
|
||||||
|
|
||||||
|
// Largest divisor for which the alternative period is still above the minimum.
|
||||||
|
int maxPeriodDivisor = (2 * initialPeriod) / (2 * MIN_PITCH_24K - 1);
|
||||||
|
for (int periodDivisor = 2; periodDivisor <= maxPeriodDivisor; periodDivisor++) {
|
||||||
|
int alternativePeriod = alternativePitchPeriod(initialPeriod, 1, periodDivisor);
|
||||||
|
int dualPeriod = alternativePitchPeriod(
|
||||||
|
initialPeriod, SUB_HARMONIC_MULTIPLIERS[periodDivisor - 2], periodDivisor);
|
||||||
|
// Special case: for a divisor of 2 the sub-harmonic can exceed the pitch range.
|
||||||
|
if (periodDivisor == 2 && dualPeriod > MAX_PITCH_24K) {
|
||||||
|
dualPeriod = initialPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score the candidate together with its sub-harmonic.
|
||||||
|
float xyPrimary = autoCorrelation(MAX_PITCH_24K - alternativePeriod, pitchBuffer);
|
||||||
|
float xySecondary = autoCorrelation(MAX_PITCH_24K - dualPeriod, pitchBuffer);
|
||||||
|
float xy = 0.5f * (xyPrimary + xySecondary);
|
||||||
|
float yy = 0.5f * (yEnergy24k[MAX_PITCH_24K - alternativePeriod]
|
||||||
|
+ yEnergy24k[MAX_PITCH_24K - dualPeriod]);
|
||||||
|
float alternativeStrength = pitchStrength(xy, yy, xEnergy);
|
||||||
|
|
||||||
|
if (isAlternativeStronger(lastPeriod, initialPeriod, initialStrength,
|
||||||
|
alternativePeriod, alternativeStrength, periodDivisor)) {
|
||||||
|
bestPeriod = alternativePeriod;
|
||||||
|
bestStrength = alternativeStrength;
|
||||||
|
bestXy = xy;
|
||||||
|
bestYEnergy = yy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bestXy = Math.max(0.0f, bestXy);
|
||||||
|
float finalStrength = (bestYEnergy <= bestXy) ? 1.0f : bestXy / (bestYEnergy + 1.0f);
|
||||||
|
finalStrength = Math.min(bestStrength, finalStrength);
|
||||||
|
|
||||||
|
lastPeriod48k = Math.max(MIN_PITCH_48K, pseudoInterpolationLagPitchBuf(bestPeriod, pitchBuffer));
|
||||||
|
lastStrength = finalStrength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float pitchStrength(float xy, float yEnergy, float xEnergy) {
|
||||||
|
return xy / (float) Math.sqrt(1.0f + xEnergy * yEnergy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAlternativeStronger(int lastPeriod, int initialPeriod, float initialStrength,
|
||||||
|
int alternativePeriod, float alternativeStrength,
|
||||||
|
int periodDivisor) {
|
||||||
|
// Pitch tracking: a candidate close to the previous estimate clears a lower bar.
|
||||||
|
float lowerThresholdTerm = 0.0f;
|
||||||
|
int distance = Math.abs(alternativePeriod - lastPeriod);
|
||||||
|
if (distance <= 1) {
|
||||||
|
lowerThresholdTerm = lastStrength;
|
||||||
|
} else if (distance == 2
|
||||||
|
&& initialPeriod > INITIAL_PITCH_PERIOD_THRESHOLDS[periodDivisor - 2]) {
|
||||||
|
lowerThresholdTerm = 0.5f * lastStrength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Higher frequencies get a stricter threshold: short-term correlations bias
|
||||||
|
// the search towards them and cause false positives.
|
||||||
|
float threshold = Math.max(0.3f, 0.7f * initialStrength - lowerThresholdTerm);
|
||||||
|
if (alternativePeriod < 3 * MIN_PITCH_24K) {
|
||||||
|
threshold = Math.max(0.4f, 0.85f * initialStrength - lowerThresholdTerm);
|
||||||
|
} else if (alternativePeriod < 2 * MIN_PITCH_24K) {
|
||||||
|
threshold = Math.max(0.5f, 0.9f * initialStrength - lowerThresholdTerm);
|
||||||
|
}
|
||||||
|
return alternativeStrength > threshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-input FFT for the {@code rnn_vad} spectral analysis.
|
||||||
|
*
|
||||||
|
* <p>The transform length is 480 (a 20 ms frame at 24 kHz), which is not a
|
||||||
|
* power of two, so the shared radix-2 {@code Fft} cannot be used. This is a
|
||||||
|
* mixed-radix Cooley–Tukey complex FFT (radices 2, 3, 4 and 5, with a generic
|
||||||
|
* DFT for any other factor) wrapped in the standard real-input trick: an
|
||||||
|
* {@code n}-point real transform is computed as an {@code n/2}-point complex one
|
||||||
|
* followed by a split step.
|
||||||
|
*
|
||||||
|
* <p>Output uses the packed layout WebRTC's PFFFT produces, which the band
|
||||||
|
* correlator indexes directly:
|
||||||
|
* <pre>
|
||||||
|
* out[0] = Re(X[0]) (DC)
|
||||||
|
* out[1] = Re(X[n/2]) (Nyquist)
|
||||||
|
* out[2k] = Re(X[k])
|
||||||
|
* out[2k+1] = Im(X[k]) for k in [1, n/2)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
final class RealFft {
|
||||||
|
|
||||||
|
private final int size;
|
||||||
|
private final int half;
|
||||||
|
|
||||||
|
// Twiddles for the complex sub-transform and for the real split step.
|
||||||
|
private final float[] halfRe;
|
||||||
|
private final float[] halfIm;
|
||||||
|
private final float[] splitCos;
|
||||||
|
private final float[] splitSin;
|
||||||
|
|
||||||
|
private final ComplexFft complexFft;
|
||||||
|
|
||||||
|
RealFft(int size) {
|
||||||
|
if (size < 2 || (size & 1) != 0) {
|
||||||
|
throw new IllegalArgumentException("Real FFT length must be even: " + size);
|
||||||
|
}
|
||||||
|
this.size = size;
|
||||||
|
this.half = size / 2;
|
||||||
|
this.halfRe = new float[half];
|
||||||
|
this.halfIm = new float[half];
|
||||||
|
this.complexFft = new ComplexFft(half);
|
||||||
|
|
||||||
|
this.splitCos = new float[half / 2 + 1];
|
||||||
|
this.splitSin = new float[half / 2 + 1];
|
||||||
|
for (int k = 0; k <= half / 2; k++) {
|
||||||
|
double angle = -2.0 * Math.PI * k / size;
|
||||||
|
splitCos[k] = (float) Math.cos(angle);
|
||||||
|
splitSin[k] = (float) Math.sin(angle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int size() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms {@code in} ({@link #size} real samples) into {@code out}
|
||||||
|
* ({@link #size} floats in the packed layout described above).
|
||||||
|
*/
|
||||||
|
void forward(float[] in, float[] out) {
|
||||||
|
// Pack the real signal as half/2 complex samples: z[i] = x[2i] + j*x[2i+1].
|
||||||
|
for (int i = 0; i < half; i++) {
|
||||||
|
halfRe[i] = in[2 * i];
|
||||||
|
halfIm[i] = in[2 * i + 1];
|
||||||
|
}
|
||||||
|
complexFft.forward(halfRe, halfIm);
|
||||||
|
|
||||||
|
// Split Z into the transforms of the even and odd sample sequences, then
|
||||||
|
// recombine them into the spectrum of the original real signal.
|
||||||
|
out[0] = halfRe[0] + halfIm[0];
|
||||||
|
out[1] = halfRe[0] - halfIm[0];
|
||||||
|
for (int k = 1; k <= half / 2; k++) {
|
||||||
|
int mirror = half - k;
|
||||||
|
|
||||||
|
// Even part: hermitian-symmetric average; odd part: the difference.
|
||||||
|
float evenRe = 0.5f * (halfRe[k] + halfRe[mirror]);
|
||||||
|
float evenIm = 0.5f * (halfIm[k] - halfIm[mirror]);
|
||||||
|
float oddRe = 0.5f * (halfIm[k] + halfIm[mirror]);
|
||||||
|
float oddIm = -0.5f * (halfRe[k] - halfRe[mirror]);
|
||||||
|
|
||||||
|
float c = splitCos[k];
|
||||||
|
float s = splitSin[k];
|
||||||
|
float twRe = oddRe * c - oddIm * s;
|
||||||
|
float twIm = oddRe * s + oddIm * c;
|
||||||
|
|
||||||
|
float re = evenRe + twRe;
|
||||||
|
float im = evenIm + twIm;
|
||||||
|
out[2 * k] = re;
|
||||||
|
out[2 * k + 1] = im;
|
||||||
|
|
||||||
|
if (mirror != k) {
|
||||||
|
// X[n/2 - k] is the conjugate-mirrored companion of X[k].
|
||||||
|
out[2 * mirror] = evenRe - twRe;
|
||||||
|
out[2 * mirror + 1] = -(evenIm - twIm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mixed-radix complex FFT, decimation in time.
|
||||||
|
*
|
||||||
|
* <p>A transform of length {@code n = r * m} is computed by transforming the {@code r}
|
||||||
|
* interleaved subsequences of length {@code m} and combining them:
|
||||||
|
* {@code X[k1*m + k2] = sum_j (W_n^(j*k2) * X_j[k2]) * W_r^(j*k1)}.
|
||||||
|
*
|
||||||
|
* <p>The recursion is expressed directly rather than as an iterative digit-reversed
|
||||||
|
* loop, which keeps it correct for any factorisation. Scratch buffers are allocated
|
||||||
|
* once per recursion level so no transform allocates.
|
||||||
|
*/
|
||||||
|
static final class ComplexFft {
|
||||||
|
|
||||||
|
private final int n;
|
||||||
|
private final float[] cosTable;
|
||||||
|
private final float[] sinTable;
|
||||||
|
private final float[][] scratchRe;
|
||||||
|
private final float[][] scratchIm;
|
||||||
|
private final float[] dftRe;
|
||||||
|
private final float[] dftIm;
|
||||||
|
|
||||||
|
ComplexFft(int n) {
|
||||||
|
this.n = n;
|
||||||
|
this.cosTable = new float[n];
|
||||||
|
this.sinTable = new float[n];
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
double angle = -2.0 * Math.PI * i / n;
|
||||||
|
cosTable[i] = (float) Math.cos(angle);
|
||||||
|
sinTable[i] = (float) Math.sin(angle);
|
||||||
|
}
|
||||||
|
int depth = 0;
|
||||||
|
int maxRadix = 1;
|
||||||
|
for (int rest = n; rest > 1; depth++) {
|
||||||
|
int r = smallestFactor(rest);
|
||||||
|
maxRadix = Math.max(maxRadix, r);
|
||||||
|
rest /= r;
|
||||||
|
}
|
||||||
|
this.scratchRe = new float[depth + 1][n];
|
||||||
|
this.scratchIm = new float[depth + 1][n];
|
||||||
|
this.dftRe = new float[maxRadix];
|
||||||
|
this.dftIm = new float[maxRadix];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transforms {@code re}/{@code im} of length {@code n} in place. */
|
||||||
|
void forward(float[] re, float[] im) {
|
||||||
|
transform(re, im, 0, 1, n, scratchRe[0], scratchIm[0], 0, 1);
|
||||||
|
System.arraycopy(scratchRe[0], 0, re, 0, n);
|
||||||
|
System.arraycopy(scratchIm[0], 0, im, 0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms the {@code len} samples starting at {@code inOff} with the given
|
||||||
|
* {@code stride}, writing the spectrum to {@code outRe}/{@code outIm} at
|
||||||
|
* {@code outOff}.
|
||||||
|
*/
|
||||||
|
private void transform(float[] inRe, float[] inIm, int inOff, int stride, int len,
|
||||||
|
float[] outRe, float[] outIm, int outOff, int depth) {
|
||||||
|
if (len == 1) {
|
||||||
|
outRe[outOff] = inRe[inOff];
|
||||||
|
outIm[outOff] = inIm[inOff];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int r = smallestFactor(len);
|
||||||
|
int m = len / r;
|
||||||
|
|
||||||
|
// Sub-transforms of the r interleaved subsequences, laid out end to end.
|
||||||
|
float[] subRe = scratchRe[depth];
|
||||||
|
float[] subIm = scratchIm[depth];
|
||||||
|
for (int j = 0; j < r; j++) {
|
||||||
|
transform(inRe, inIm, inOff + j * stride, stride * r, m,
|
||||||
|
subRe, subIm, outOff + j * m, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twiddle step for this transform size, expressed in top-level table units.
|
||||||
|
int unit = n / len;
|
||||||
|
for (int k2 = 0; k2 < m; k2++) {
|
||||||
|
for (int j = 0; j < r; j++) {
|
||||||
|
int idx = outOff + j * m + k2;
|
||||||
|
int t = (j * k2 * unit) % n;
|
||||||
|
float c = cosTable[t];
|
||||||
|
float s = sinTable[t];
|
||||||
|
dftRe[j] = subRe[idx] * c - subIm[idx] * s;
|
||||||
|
dftIm[j] = subRe[idx] * s + subIm[idx] * c;
|
||||||
|
}
|
||||||
|
combine(outRe, outIm, outOff + k2, m, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Length-{@code r} DFT of {@code dftRe}/{@code dftIm}, written with stride {@code m}. */
|
||||||
|
private void combine(float[] outRe, float[] outIm, int outOff, int m, int r) {
|
||||||
|
switch (r) {
|
||||||
|
case 2 -> {
|
||||||
|
outRe[outOff] = dftRe[0] + dftRe[1];
|
||||||
|
outIm[outOff] = dftIm[0] + dftIm[1];
|
||||||
|
outRe[outOff + m] = dftRe[0] - dftRe[1];
|
||||||
|
outIm[outOff + m] = dftIm[0] - dftIm[1];
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
int unit = n / r;
|
||||||
|
for (int k1 = 0; k1 < r; k1++) {
|
||||||
|
float sumRe = 0;
|
||||||
|
float sumIm = 0;
|
||||||
|
for (int j = 0; j < r; j++) {
|
||||||
|
int t = (j * k1 % r) * unit;
|
||||||
|
float c = cosTable[t];
|
||||||
|
float s = sinTable[t];
|
||||||
|
sumRe += dftRe[j] * c - dftIm[j] * s;
|
||||||
|
sumIm += dftRe[j] * s + dftIm[j] * c;
|
||||||
|
}
|
||||||
|
outRe[outOff + k1 * m] = sumRe;
|
||||||
|
outIm[outOff + k1 * m] = sumIm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int smallestFactor(int v) {
|
||||||
|
for (int f = 2; f * f <= v; f++) {
|
||||||
|
if (v % f == 0) return f;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code rnn_vad} network: a 42→24 dense layer, a 24-unit GRU and a 24→1
|
||||||
|
* dense output. Ported from WebRTC's {@code rnn_vad/rnn.cc}, {@code rnn_fc.cc} and
|
||||||
|
* {@code rnn_gru.cc}, using the activation approximations from RNNoise.
|
||||||
|
*
|
||||||
|
* <p>The GRU carries state between frames, so the caller must feed frames in order and
|
||||||
|
* {@link #reset()} on a discontinuity. Silence resets the state, matching WebRTC.
|
||||||
|
*/
|
||||||
|
final class RnnNetwork {
|
||||||
|
|
||||||
|
private static final int INPUT_SIZE = 42;
|
||||||
|
private static final int FC1_SIZE = 24;
|
||||||
|
private static final int GRU_SIZE = 24;
|
||||||
|
private static final int NUM_GATES = 3; // update, reset, output
|
||||||
|
|
||||||
|
private final float[] fc1Weights; // [output][input]
|
||||||
|
private final float[] fc1Bias;
|
||||||
|
private final float[] fc2Weights;
|
||||||
|
private final float[] fc2Bias;
|
||||||
|
|
||||||
|
private final float[] gruWeights; // [gate][output][input]
|
||||||
|
private final float[] gruRecurrentWeights; // [gate][output][output]
|
||||||
|
private final float[] gruBias; // [gate][output]
|
||||||
|
|
||||||
|
private final float[] fc1Output = new float[FC1_SIZE];
|
||||||
|
private final float[] state = new float[GRU_SIZE];
|
||||||
|
private final float[] update = new float[GRU_SIZE];
|
||||||
|
private final float[] resetGate = new float[GRU_SIZE];
|
||||||
|
private final float[] resetTimesState = new float[GRU_SIZE];
|
||||||
|
|
||||||
|
RnnNetwork() {
|
||||||
|
fc1Weights = transposeAndScale(RnnVadWeights.INPUT_DENSE_WEIGHTS, INPUT_SIZE, FC1_SIZE);
|
||||||
|
fc1Bias = scale(RnnVadWeights.INPUT_DENSE_BIAS);
|
||||||
|
// The output layer is 24x1, so its stored layout already matches.
|
||||||
|
fc2Weights = scale(RnnVadWeights.OUTPUT_DENSE_WEIGHTS);
|
||||||
|
fc2Bias = scale(RnnVadWeights.OUTPUT_DENSE_BIAS);
|
||||||
|
gruWeights = transposeGruTensor(RnnVadWeights.HIDDEN_GRU_WEIGHTS, FC1_SIZE, GRU_SIZE);
|
||||||
|
gruRecurrentWeights =
|
||||||
|
transposeGruTensor(RnnVadWeights.HIDDEN_GRU_RECURRENT_WEIGHTS, GRU_SIZE, GRU_SIZE);
|
||||||
|
gruBias = transposeGruTensor(RnnVadWeights.HIDDEN_GRU_BIAS, 1, GRU_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
java.util.Arrays.fill(state, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the speech probability for a feature vector. When {@code silence} is set the
|
||||||
|
* state is cleared and zero is returned without running the network.
|
||||||
|
*/
|
||||||
|
float computeVadProbability(float[] featureVector, boolean silence) {
|
||||||
|
if (silence) {
|
||||||
|
reset();
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
for (int o = 0; o < FC1_SIZE; o++) {
|
||||||
|
float x = fc1Bias[o] + dot(featureVector, fc1Weights, o * INPUT_SIZE, INPUT_SIZE);
|
||||||
|
fc1Output[o] = tansigApproximated(x);
|
||||||
|
}
|
||||||
|
computeGruOutput(fc1Output);
|
||||||
|
float x = fc2Bias[0] + dot(state, fc2Weights, 0, GRU_SIZE);
|
||||||
|
return sigmoidApproximated(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void computeGruOutput(float[] input) {
|
||||||
|
final int strideWeights = FC1_SIZE * GRU_SIZE;
|
||||||
|
final int strideRecurrent = GRU_SIZE * GRU_SIZE;
|
||||||
|
|
||||||
|
// u = sigmoid(W_u.i + R_u.s + b_u)
|
||||||
|
computeGate(input, 0, 0, 0, update);
|
||||||
|
// r = sigmoid(W_r.i + R_r.s + b_r)
|
||||||
|
computeGate(input, GRU_SIZE, strideWeights, strideRecurrent, resetGate);
|
||||||
|
|
||||||
|
// s' = u.s + (1 - u).ReLU(W_s.i + R_s.(s.r) + b_s)
|
||||||
|
for (int o = 0; o < GRU_SIZE; o++) {
|
||||||
|
resetTimesState[o] = state[o] * resetGate[o];
|
||||||
|
}
|
||||||
|
for (int o = 0; o < GRU_SIZE; o++) {
|
||||||
|
float x = gruBias[2 * GRU_SIZE + o];
|
||||||
|
x += dot(input, gruWeights, 2 * strideWeights + o * FC1_SIZE, FC1_SIZE);
|
||||||
|
x += dot(resetTimesState, gruRecurrentWeights,
|
||||||
|
2 * strideRecurrent + o * GRU_SIZE, GRU_SIZE);
|
||||||
|
state[o] = update[o] * state[o] + (1.0f - update[o]) * Math.max(0.0f, x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void computeGate(float[] input, int biasOffset, int weightsOffset,
|
||||||
|
int recurrentOffset, float[] gate) {
|
||||||
|
for (int o = 0; o < GRU_SIZE; o++) {
|
||||||
|
float x = gruBias[biasOffset + o];
|
||||||
|
x += dot(input, gruWeights, weightsOffset + o * FC1_SIZE, FC1_SIZE);
|
||||||
|
x += dot(state, gruRecurrentWeights, recurrentOffset + o * GRU_SIZE, GRU_SIZE);
|
||||||
|
gate[o] = sigmoidApproximated(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float dot(float[] a, float[] weights, int weightsOffset, int len) {
|
||||||
|
float sum = 0;
|
||||||
|
for (int i = 0; i < len; i++) {
|
||||||
|
sum += a[i] * weights[weightsOffset + i];
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- weight layout ----
|
||||||
|
|
||||||
|
private static float[] scale(byte[] src) {
|
||||||
|
float[] out = new float[src.length];
|
||||||
|
for (int i = 0; i < src.length; i++) {
|
||||||
|
out[i] = RnnVadWeights.SCALE * src[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored as {@code [input][output]}; the dot products want {@code [output][input]}. */
|
||||||
|
private static float[] transposeAndScale(byte[] src, int inputSize, int outputSize) {
|
||||||
|
float[] out = new float[src.length];
|
||||||
|
for (int o = 0; o < outputSize; o++) {
|
||||||
|
for (int i = 0; i < inputSize; i++) {
|
||||||
|
out[o * inputSize + i] = RnnVadWeights.SCALE * src[i * outputSize + o];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GRU tensors are stored as {@code [n][gate][output]} and are wanted as
|
||||||
|
* {@code [gate][output][n]}, where {@code n} is the input size (1 for the bias).
|
||||||
|
*/
|
||||||
|
private static float[] transposeGruTensor(byte[] src, int n, int outputSize) {
|
||||||
|
float[] out = new float[src.length];
|
||||||
|
int strideSrc = NUM_GATES * outputSize;
|
||||||
|
int strideDst = n * outputSize;
|
||||||
|
for (int g = 0; g < NUM_GATES; g++) {
|
||||||
|
for (int o = 0; o < outputSize; o++) {
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
out[g * strideDst + o * n + i] =
|
||||||
|
RnnVadWeights.SCALE * src[i * strideSrc + g * outputSize + o];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- activations (RNNoise approximations, bit-compatible with WebRTC) ----
|
||||||
|
|
||||||
|
/** tanh sampled every 0.04, copied verbatim from RNNoise's {@code rnn_activations.h}. */
|
||||||
|
private static final float[] TANSIG_TABLE = {
|
||||||
|
0.000000f, 0.039979f, 0.079830f, 0.119427f, 0.158649f, 0.197375f, 0.235496f, 0.272905f,
|
||||||
|
0.309507f, 0.345214f, 0.379949f, 0.413644f, 0.446244f, 0.477700f, 0.507977f, 0.537050f,
|
||||||
|
0.564900f, 0.591519f, 0.616909f, 0.641077f, 0.664037f, 0.685809f, 0.706419f, 0.725897f,
|
||||||
|
0.744277f, 0.761594f, 0.777888f, 0.793199f, 0.807569f, 0.821040f, 0.833655f, 0.845456f,
|
||||||
|
0.856485f, 0.866784f, 0.876393f, 0.885352f, 0.893698f, 0.901468f, 0.908698f, 0.915420f,
|
||||||
|
0.921669f, 0.927473f, 0.932862f, 0.937863f, 0.942503f, 0.946806f, 0.950795f, 0.954492f,
|
||||||
|
0.957917f, 0.961090f, 0.964028f, 0.966747f, 0.969265f, 0.971594f, 0.973749f, 0.975743f,
|
||||||
|
0.977587f, 0.979293f, 0.980869f, 0.982327f, 0.983675f, 0.984921f, 0.986072f, 0.987136f,
|
||||||
|
0.988119f, 0.989027f, 0.989867f, 0.990642f, 0.991359f, 0.992020f, 0.992631f, 0.993196f,
|
||||||
|
0.993718f, 0.994199f, 0.994644f, 0.995055f, 0.995434f, 0.995784f, 0.996108f, 0.996407f,
|
||||||
|
0.996682f, 0.996937f, 0.997172f, 0.997389f, 0.997590f, 0.997775f, 0.997946f, 0.998104f,
|
||||||
|
0.998249f, 0.998384f, 0.998508f, 0.998623f, 0.998728f, 0.998826f, 0.998916f, 0.999000f,
|
||||||
|
0.999076f, 0.999147f, 0.999213f, 0.999273f, 0.999329f, 0.999381f, 0.999428f, 0.999472f,
|
||||||
|
0.999513f, 0.999550f, 0.999585f, 0.999617f, 0.999646f, 0.999673f, 0.999699f, 0.999722f,
|
||||||
|
0.999743f, 0.999763f, 0.999781f, 0.999798f, 0.999813f, 0.999828f, 0.999841f, 0.999853f,
|
||||||
|
0.999865f, 0.999875f, 0.999885f, 0.999893f, 0.999902f, 0.999909f, 0.999916f, 0.999923f,
|
||||||
|
0.999929f, 0.999934f, 0.999939f, 0.999944f, 0.999948f, 0.999952f, 0.999956f, 0.999959f,
|
||||||
|
0.999962f, 0.999965f, 0.999968f, 0.999970f, 0.999973f, 0.999975f, 0.999977f, 0.999978f,
|
||||||
|
0.999980f, 0.999982f, 0.999983f, 0.999984f, 0.999986f, 0.999987f, 0.999988f, 0.999989f,
|
||||||
|
0.999990f, 0.999990f, 0.999991f, 0.999992f, 0.999992f, 0.999993f, 0.999994f, 0.999994f,
|
||||||
|
0.999994f, 0.999995f, 0.999995f, 0.999996f, 0.999996f, 0.999996f, 0.999997f, 0.999997f,
|
||||||
|
0.999997f, 0.999997f, 0.999997f, 0.999998f, 0.999998f, 0.999998f, 0.999998f, 0.999998f,
|
||||||
|
0.999998f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f,
|
||||||
|
0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 0.999999f, 1.000000f, 1.000000f,
|
||||||
|
1.000000f, 1.000000f, 1.000000f, 1.000000f, 1.000000f, 1.000000f, 1.000000f, 1.000000f,
|
||||||
|
1.000000f
|
||||||
|
};
|
||||||
|
|
||||||
|
private static float tansigApproximated(float x) {
|
||||||
|
// Tests are reversed to catch NaNs.
|
||||||
|
if (!(x < 8.0f)) {
|
||||||
|
return 1.0f;
|
||||||
|
}
|
||||||
|
if (!(x > -8.0f)) {
|
||||||
|
return -1.0f;
|
||||||
|
}
|
||||||
|
float sign = 1.0f;
|
||||||
|
if (x < 0.0f) {
|
||||||
|
x = -x;
|
||||||
|
sign = -1.0f;
|
||||||
|
}
|
||||||
|
int i = (int) Math.floor(0.5f + 25 * x);
|
||||||
|
float y = TANSIG_TABLE[i];
|
||||||
|
// Newton step back to x's own scale (undoing the table's factor of 25).
|
||||||
|
x -= 0.04f * i;
|
||||||
|
y = y + x * (1.0f - y * y) * (1.0f - y * x);
|
||||||
|
return sign * y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float sigmoidApproximated(float x) {
|
||||||
|
return 0.5f + 0.5f * tansigApproximated(0.5f * x);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
import com.ts3client.audio.SpeechProbabilityDetector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link SpeechProbabilityDetector} backed by {@link RnnVad}, adapting an arbitrary
|
||||||
|
* capture rate and frame size to the detector's fixed 10 ms frames at 24 kHz.
|
||||||
|
*
|
||||||
|
* <p>Capture frames rarely divide into 10 ms chunks exactly, so leftover samples are
|
||||||
|
* carried into the next call. The reported probability is that of the most recent complete
|
||||||
|
* 10 ms frame; a capture frame long enough to span several of them advances the
|
||||||
|
* detector several times, exactly as feeding it directly would.
|
||||||
|
*/
|
||||||
|
public final class RnnSpeechDetector implements SpeechProbabilityDetector {
|
||||||
|
|
||||||
|
/** Half-band FIR used when decimating; long enough to keep aliasing out of the passband. */
|
||||||
|
private static final int FILTER_TAPS = 32;
|
||||||
|
|
||||||
|
private final RnnVad vad = new RnnVad();
|
||||||
|
|
||||||
|
private final int sampleRate;
|
||||||
|
private final boolean decimateByTwo;
|
||||||
|
private final float[] filterCoeffs;
|
||||||
|
private final float[] filterState;
|
||||||
|
private int filterPos;
|
||||||
|
|
||||||
|
/** Resampled samples not yet consumed as a whole 10 ms frame. */
|
||||||
|
private final float[] pending = new float[RnnVad.FRAME_SIZE];
|
||||||
|
private int pendingCount;
|
||||||
|
|
||||||
|
/** Fractional read position, for capture rates that are not a multiple of 24 kHz. */
|
||||||
|
private double resamplePos;
|
||||||
|
|
||||||
|
public RnnSpeechDetector(int sampleRate) {
|
||||||
|
this.sampleRate = sampleRate;
|
||||||
|
this.decimateByTwo = sampleRate == 2 * RnnVad.SAMPLE_RATE;
|
||||||
|
if (decimateByTwo) {
|
||||||
|
this.filterCoeffs = lowPass(FILTER_TAPS, 0.25);
|
||||||
|
this.filterState = new float[FILTER_TAPS];
|
||||||
|
} else {
|
||||||
|
this.filterCoeffs = null;
|
||||||
|
this.filterState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double process(float[] frame, int length) {
|
||||||
|
if (sampleRate == RnnVad.SAMPLE_RATE) {
|
||||||
|
feed(frame, 0, length, 1);
|
||||||
|
} else if (decimateByTwo) {
|
||||||
|
decimateAndFeed(frame, length);
|
||||||
|
} else {
|
||||||
|
resampleAndFeed(frame, length);
|
||||||
|
}
|
||||||
|
return vad.probability();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double probability() {
|
||||||
|
return vad.probability();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reset() {
|
||||||
|
vad.reset();
|
||||||
|
pendingCount = 0;
|
||||||
|
resamplePos = 0;
|
||||||
|
filterPos = 0;
|
||||||
|
if (filterState != null) {
|
||||||
|
java.util.Arrays.fill(filterState, 0.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends samples (taking every {@code step}-th) and runs the detector per full frame. */
|
||||||
|
private void feed(float[] src, int offset, int length, int step) {
|
||||||
|
for (int i = offset; i < offset + length; i += step) {
|
||||||
|
push(src[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 48 kHz (the common case): low-pass, then keep every other sample. */
|
||||||
|
private void decimateAndFeed(float[] frame, int length) {
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
filterState[filterPos] = frame[i];
|
||||||
|
filterPos = (filterPos + 1) % FILTER_TAPS;
|
||||||
|
if ((i & 1) == 1) {
|
||||||
|
float acc = 0;
|
||||||
|
int idx = filterPos;
|
||||||
|
for (int t = 0; t < FILTER_TAPS; t++) {
|
||||||
|
acc += filterCoeffs[t] * filterState[idx];
|
||||||
|
idx = (idx + 1) % FILTER_TAPS;
|
||||||
|
}
|
||||||
|
push(acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any other capture rate. Linear interpolation is enough here: the detector's own
|
||||||
|
* analysis is far coarser than the resampling error.
|
||||||
|
*/
|
||||||
|
private void resampleAndFeed(float[] frame, int length) {
|
||||||
|
double ratio = (double) sampleRate / RnnVad.SAMPLE_RATE;
|
||||||
|
while (resamplePos < length) {
|
||||||
|
int i = (int) resamplePos;
|
||||||
|
double frac = resamplePos - i;
|
||||||
|
float a = frame[i];
|
||||||
|
float b = frame[Math.min(i + 1, length - 1)];
|
||||||
|
push((float) ((1 - frac) * a + frac * b));
|
||||||
|
resamplePos += ratio;
|
||||||
|
}
|
||||||
|
resamplePos -= length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void push(float sample) {
|
||||||
|
pending[pendingCount++] = sample;
|
||||||
|
if (pendingCount == RnnVad.FRAME_SIZE) {
|
||||||
|
vad.process(pending, 0);
|
||||||
|
pendingCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Windowed-sinc low-pass with the given normalised cutoff, scaled to unit DC gain. */
|
||||||
|
private static float[] lowPass(int taps, double cutoff) {
|
||||||
|
float[] h = new float[taps];
|
||||||
|
double centre = (taps - 1) / 2.0;
|
||||||
|
double sum = 0;
|
||||||
|
for (int i = 0; i < taps; i++) {
|
||||||
|
double t = i - centre;
|
||||||
|
double sinc = (t == 0) ? 2 * cutoff : Math.sin(2 * Math.PI * cutoff * t) / (Math.PI * t);
|
||||||
|
double window = 0.54 - 0.46 * Math.cos(2 * Math.PI * i / (taps - 1)); // Hamming
|
||||||
|
h[i] = (float) (sinc * window);
|
||||||
|
sum += h[i];
|
||||||
|
}
|
||||||
|
for (int i = 0; i < taps; i++) {
|
||||||
|
h[i] /= (float) sum;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.BUF_SIZE_24K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.FEATURE_VECTOR_SIZE;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.FRAME_10MS_24K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.FRAME_20MS_24K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.MAX_PITCH_24K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.NUM_LPC_COEFFICIENTS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebRTC's {@code rnn_vad} speech-probability detector, ported to Java.
|
||||||
|
*
|
||||||
|
* <p>This is the detector the TeamSpeak 3 client uses for its "Automatic" and "Hybrid"
|
||||||
|
* voice-activation modes: TS3 links the same network with byte-identical weights, so at a
|
||||||
|
* given threshold this gates on the same frames theirs does.
|
||||||
|
*
|
||||||
|
* <p>Feed exactly {@link VadConstants#FRAME_10MS_24K} samples of 24 kHz mono audio in
|
||||||
|
* [-1, 1] per call, in order. Combines {@code features_extraction.cc} and {@code rnn.cc}.
|
||||||
|
*/
|
||||||
|
public final class RnnVad {
|
||||||
|
|
||||||
|
/** Samples per call: 10 ms at 24 kHz. */
|
||||||
|
public static final int FRAME_SIZE = FRAME_10MS_24K;
|
||||||
|
|
||||||
|
/** Sample rate this detector expects. */
|
||||||
|
public static final int SAMPLE_RATE = VadConstants.SAMPLE_RATE_24K;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebRTC's audio processing carries float samples on the 16-bit integer scale, and the
|
||||||
|
* network's thresholds (the silence cut-off, the pitch energy floor) are calibrated for
|
||||||
|
* it. Callers pass [-1, 1], so scale on the way in.
|
||||||
|
*/
|
||||||
|
private static final float INT16_SCALE = 32768.0f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolling window of 24 kHz samples: one maximum pitch period of history followed by
|
||||||
|
* the most recent 20 ms.
|
||||||
|
*/
|
||||||
|
private final float[] pitchBuffer = new float[BUF_SIZE_24K];
|
||||||
|
private final float[] lpResidual = new float[BUF_SIZE_24K];
|
||||||
|
private final float[] lpcCoeffs = new float[NUM_LPC_COEFFICIENTS];
|
||||||
|
private final float[] featureVector = new float[FEATURE_VECTOR_SIZE];
|
||||||
|
|
||||||
|
private final LinearPrediction linearPrediction = new LinearPrediction();
|
||||||
|
private final PitchEstimator pitchEstimator = new PitchEstimator();
|
||||||
|
private final SpectralFeatures spectralFeatures = new SpectralFeatures();
|
||||||
|
private final RnnNetwork network = new RnnNetwork();
|
||||||
|
|
||||||
|
private float probability;
|
||||||
|
private int pitchPeriod48k;
|
||||||
|
|
||||||
|
/** Clears all history. Call on a capture discontinuity. */
|
||||||
|
public void reset() {
|
||||||
|
java.util.Arrays.fill(pitchBuffer, 0.0f);
|
||||||
|
pitchEstimator.reset();
|
||||||
|
pitchPeriod48k = 0;
|
||||||
|
spectralFeatures.reset();
|
||||||
|
network.reset();
|
||||||
|
probability = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speech probability in [0, 1] from the most recent {@link #process} call. */
|
||||||
|
public float probability() {
|
||||||
|
return probability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pitch period of the most recent frame, expressed at 48 kHz as the network's
|
||||||
|
* feature is. Exposed for diagnostics and tests.
|
||||||
|
*/
|
||||||
|
public int pitchPeriod48kHz() {
|
||||||
|
return pitchPeriod48k;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyses one 10 ms frame and returns the speech probability in [0, 1].
|
||||||
|
*
|
||||||
|
* @param frame mono samples in [-1, 1]
|
||||||
|
* @param offset index of the first sample
|
||||||
|
*/
|
||||||
|
public float process(float[] frame, int offset) {
|
||||||
|
System.arraycopy(pitchBuffer, FRAME_10MS_24K, pitchBuffer, 0, BUF_SIZE_24K - FRAME_10MS_24K);
|
||||||
|
int start = BUF_SIZE_24K - FRAME_10MS_24K;
|
||||||
|
for (int i = 0; i < FRAME_10MS_24K; i++) {
|
||||||
|
pitchBuffer[start + i] = frame[offset + i] * INT16_SCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search the pitch on the whitened signal so formants don't dominate the peak.
|
||||||
|
linearPrediction.computeLpcCoefficients(pitchBuffer, lpcCoeffs);
|
||||||
|
LinearPrediction.computeLpResidual(lpcCoeffs, pitchBuffer, lpResidual);
|
||||||
|
pitchPeriod48k = pitchEstimator.estimate(lpResidual);
|
||||||
|
// Normalisation based on the training-set statistics.
|
||||||
|
featureVector[FEATURE_VECTOR_SIZE - 2] = 0.01f * (pitchPeriod48k - 300);
|
||||||
|
|
||||||
|
// Compare the most recent 20 ms against the 20 ms one pitch period earlier.
|
||||||
|
int laggedOffset = MAX_PITCH_24K - pitchPeriod48k / 2;
|
||||||
|
boolean silence = spectralFeatures.checkSilenceComputeFeatures(
|
||||||
|
pitchBuffer, BUF_SIZE_24K - FRAME_20MS_24K, pitchBuffer, laggedOffset, featureVector);
|
||||||
|
|
||||||
|
probability = network.computeVadProbability(featureVector, silence);
|
||||||
|
return probability;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quantised weights of WebRTC's {@code rnn_vad} network, copied verbatim from
|
||||||
|
* {@code third_party/rnnoise/src/rnn_vad_weights.cc} (BSD, Xiph/Jean-Marc Valin).
|
||||||
|
*
|
||||||
|
* <p>These are the same 4585 bytes the TeamSpeak 3 client links in, so our detector
|
||||||
|
* produces the same speech probabilities theirs does.
|
||||||
|
*
|
||||||
|
* <p>Values are stored quantised; {@link #SCALE} converts them to float.
|
||||||
|
*/
|
||||||
|
final class RnnVadWeights {
|
||||||
|
|
||||||
|
/** Dequantisation factor applied to every table below. */
|
||||||
|
static final float SCALE = 1.0f / 256.0f;
|
||||||
|
|
||||||
|
/** FC1 weights, laid out as [input][output] (42 x 24). */
|
||||||
|
static final byte[] INPUT_DENSE_WEIGHTS = {
|
||||||
|
-10, 0, -3, 1, -8, -6, 3, -13, 1, 0, -3, -7, -5, -3, 6, -1, -6, 0, -6, -4, -1, -2, 1, 1, -7, 2,
|
||||||
|
21, 10, -5, -20, 24, 23, 37, 8, -2, 33, -6, 22, 13, -2, 50, 8, 13, 1, -15, 30, -10, 30, 0, 3, 5,
|
||||||
|
27, 1, 4, -3, 41, 56, 35, -2, 49, -13, 11, 13, -2, -47, 5, -16, -60, -15, 77, -17, 26, -3, 14,
|
||||||
|
-21, 19, -5, -19, -13, 0, 10, 14, 9, 31, -13, -41, -10, 4, 22, 18, -48, -6, -10, 62, -3, -18,
|
||||||
|
-14, 12, 26, -28, 3, 14, 25, -13, -19, 6, 5, 36, -3, -65, -12, 0, 31, -7, -9, 101, -4, 26, 16,
|
||||||
|
17, -12, -12, 14, -36, -3, 5, -15, 21, 2, 30, -3, 38, -4, 1, -6, 7, -7, 14, 38, -22, -30, -3,
|
||||||
|
-7, 3, -39, -70, -126, 25, 34, 94, -67, -22, -33, 83, -47, -118, 4, 70, 33, 25, 62, -128, -76,
|
||||||
|
-118, -113, 49, -12, -100, -18, -114, -33, 43, 32, 61, 40, -9, -106, 2, 36, -100, -40, -5, 20,
|
||||||
|
-75, 61, -51, -9, 126, -27, -52, 5, -24, -21, -126, -114, -12, 15, 106, -2, 73, -125, 50, 13,
|
||||||
|
-120, 35, 35, 4, -61, 29, -124, 6, -53, -69, -125, 64, -89, 36, -107, -103, -7, 27, 121, 69, 77,
|
||||||
|
-35, 35, 95, -125, -49, 97, -45, -43, -23, 23, -28, -65, -118, 2, 8, -126, 27, -97, 92, 5, 55,
|
||||||
|
82, 17, -57, -115, 37, 8, -106, -46, 41, -2, 21, -44, 8, -73, -58, -39, 34, 89, -95, 95, -117,
|
||||||
|
120, -58, 31, 123, 1, -32, -109, -110, 60, -120, -43, -74, 5, 91, 26, 21, 114, 82, -83, -126,
|
||||||
|
123, 22, -16, -67, 25, -83, 46, 48, -34, -121, -124, -63, -35, -9, 31, 82, 123, 6, -3, 117, 93,
|
||||||
|
-2, -13, -36, 124, -112, -6, -102, -5, -33, -15, 44, -69, -127, -23, -40, -34, -85, 68, 83, -1,
|
||||||
|
40, 8, 84, 118, -58, -55, -102, 123, -55, -14, -123, 44, -63, -14, 21, 35, 16, 24, -126, -13,
|
||||||
|
-114, 35, 20, -36, 61, -9, 97, 34, 19, -32, -109, 76, -104, 99, -119, 45, -125, -51, -28, -8,
|
||||||
|
-69, -8, 125, -45, -93, 113, 103, -41, -82, 52, 7, 126, 0, -40, 104, 55, -58, 17, -124, -93,
|
||||||
|
-58, 8, -45, 1, 56, -123, 108, -47, -23, 115, 127, 17, -68, -13, 116, -82, -44, 45, 67, -120,
|
||||||
|
-101, -15, -125, 120, -113, 17, -48, -73, 126, -64, -86, -118, -19, 112, -1, -66, -27, -62, 121,
|
||||||
|
-86, -58, 50, 89, -38, -75, 95, -111, 12, -113, 2, -68, 2, -94, -121, 91, -5, 0, 79, 43, -7,
|
||||||
|
-18, 79, 35, -38, 47, 1, -45, 83, -50, 102, 32, 55, -96, 15, -122, -69, 45, -27, 91, -62, -30,
|
||||||
|
46, -95, 22, -72, -97, -1, 14, -122, 28, 127, 61, -126, 121, 9, 68, -120, 49, -60, 90, 3, 43,
|
||||||
|
68, 54, 34, -10, 28, 21, -24, -54, 22, -113, -12, 82, -2, -17, -9, 127, 8, 116, -92, 0, -70,
|
||||||
|
-33, 123, 66, 116, -74, -4, 74, -72, -22, -47, 1, -83, -60, -124, 1, 122, -57, -43, 49, 40,
|
||||||
|
-126, -128, -8, -29, 28, -24, -123, -121, -70, -93, -37, -126, 11, -125, -37, 11, -31, -51,
|
||||||
|
-124, 116, -128, 8, -25, 109, 75, -12, 7, 8, 10, 117, 124, -128, -128, 29, -26, 101, 21, -128,
|
||||||
|
87, 8, -39, 23, -128, 127, -127, 74, -55, 74, 112, 127, 4, 55, 44, -92, 123, 34, -93, 47, -21,
|
||||||
|
-92, 17, 49, -121, 92, 7, -126, -125, 124, -74, 3, -59, 18, -91, 3, -9, 9, 56, 116, 7, -29, 33,
|
||||||
|
87, -21, -128, -13, 57, 74, 9, -29, -61, -97, -21, -95, -12, -114, 16, 82, 125, -7, 10, -24, 9,
|
||||||
|
77, -128, -102, -25, 3, -126, 10, 13, -18, 51, 26, 127, -79, 35, 51, 12, -50, -24, 1, -7, 22,
|
||||||
|
81, 65, 120, -30, -38, 85, 122, -4, -106, -11, 27, 53, 41, 8, -104, -66, -38, -124, 10, 12, 76,
|
||||||
|
117, -109, 9, 11, 2, -18, 3, 113, -16, -79, -39, -123, -20, -128, 2, 13, -33, -58, 10, 84, -104,
|
||||||
|
13, 64, 109, 1, 54, -12, 28, 24, 63, -126, 118, -82, 46, -12, -15, 14, -43, 60, 22, -32, -19,
|
||||||
|
-46, 91, -107, 24, -94, 26, -47, 125, 6, 58, -15, -75, -26, -38, -35, 103, -16, -17, -13, 63,
|
||||||
|
-2, 45, -45, -73, -23, 70, -87, 51, -17, 53, 76, 14, -18, -31, -14, 103, 8, 21, -28, -33, -20,
|
||||||
|
-47, 6, 39, 40, -30, 7, -76, 55, 31, -20, -21, -59, 1, 25, -11, 17, 5, -13, -39, 0, -76, 50,
|
||||||
|
-33, -29, -50, -16, -11, -12, -1, -46, 40, -10, 65, -19, 21, -41, -32, -83, -19, -4, 49, -60,
|
||||||
|
118, -24, -46, 9, 102, -20, 8, -19, 25, 31, -3, -37, 0, 25, 7, 29, 2, -39, 127, -64, -20, 64,
|
||||||
|
115, -30, 36, 100, 35, 122, 127, 127, -127, 127, -127, 19, 127, -89, -79, -32, 39, -127, 125,
|
||||||
|
-80, 126, -127, 26, 8, 98, -8, -57, -90, -50, 126, 61, 127, -126, 40, -106, -68, 104, -125,
|
||||||
|
-119, 11, 10, -127, 66, -56, -12, -126, -104, 27, 75, 38, -124, -126, -125, 84, -123, -45, -114,
|
||||||
|
-128, 127, 103, -101, -124, 127, -11, -23, -123, 92, -123, 24, 126, 41, -2, -39, -27, -94, 40,
|
||||||
|
-112, -48, 127, 58, 14, 38, -75, -64, 73, 117, 100, -119, -11, 6, 32, -126, -14, 35, 121, -10,
|
||||||
|
54, -60, 89, -3, 69, -25, -20, 43, -86, -34, 24, 27, 7, -81, -99, -23, -16, -26, 13, 35, -97,
|
||||||
|
80, -29, -13, -121, -12, -65, -94, 70, -89, -126, -95, 88, 33, 96, 29, -90, 69, 114, -78, 65,
|
||||||
|
90, -47, -47, 89, 1, -12, 3, 8, 30, 5, 2, -30, -1, 6, -7, 10, -4, 46, -27, -40, 22, -6, -17, 45,
|
||||||
|
24, -9, 23, -14, -63, -26, -12, -57, 27, 25, 55, -76, -47, 21, 34, 33, 26, 17, 14, 6, 9, 26, 25,
|
||||||
|
-25, -25, -18
|
||||||
|
};
|
||||||
|
|
||||||
|
/** FC1 bias (24). */
|
||||||
|
static final byte[] INPUT_DENSE_BIAS = {
|
||||||
|
38, -6, 127, 127, 127, -43, -127, 78, 127, 5, 127, 123, 127, 127, -128, -76, -126, 28, 127, 125,
|
||||||
|
-30, 127, -89, -20
|
||||||
|
};
|
||||||
|
|
||||||
|
/** GRU1 input weights, [input][gate][output] (24 x 3 x 24). */
|
||||||
|
static final byte[] HIDDEN_GRU_WEIGHTS = {
|
||||||
|
-124, 23, -123, -33, -95, -4, 8, -84, 4, 101, -119, 116, -4, 123, 103, -51, 29, -124, -114, -49,
|
||||||
|
31, 9, 75, -128, 0, -49, 37, -50, 46, -21, -63, -104, 54, 82, 33, 21, 70, 127, -9, -79, -39,
|
||||||
|
-23, -127, 107, 122, -96, -46, -18, -39, 13, -28, -48, 14, 56, -52, 49, -1, -121, 25, -18, -36,
|
||||||
|
-52, -57, -30, 54, -124, -26, -47, 10, 39, 12, 2, 9, -127, -128, 102, 21, 11, -64, -71, 89,
|
||||||
|
-113, -111, 54, 31, 94, 121, -40, 30, 40, -109, 73, -9, 108, -92, 2, -127, 116, 127, 127, -122,
|
||||||
|
95, 127, -37, -127, 28, 89, 10, 24, -104, -62, -67, -14, 38, 14, -71, 22, -41, 20, -50, 39, 63,
|
||||||
|
86, 127, -18, 79, 4, -51, 2, 33, 117, -113, -78, 56, -91, 37, 34, -45, -44, -22, 21, -16, 56,
|
||||||
|
30, -84, -79, 38, -74, 127, 9, -25, 2, 82, 61, 25, -26, 26, 11, 117, -65, 12, -58, 42, -62, -93,
|
||||||
|
11, 11, 124, -123, 80, -125, 11, -90, 42, 94, 4, -109, -1, 85, -52, 45, -26, -27, 77, -5, 30,
|
||||||
|
90, 0, 95, -7, 53, 29, -82, 22, -9, 74, 2, -12, -73, 114, 97, -64, 122, -77, 43, 91, 86, 126,
|
||||||
|
106, 72, 90, -43, 46, 96, -51, 21, 22, 68, 22, 41, 79, 75, -46, -105, 23, -116, 127, -123, 102,
|
||||||
|
57, 85, 10, -29, 34, 125, 126, 124, 81, -15, 54, 96, -128, 39, -124, 103, 74, 126, 127, -50,
|
||||||
|
-71, -122, -64, 93, -75, 71, 105, 122, 123, 126, 122, -127, 33, -63, -74, 124, -71, 33, 41, -56,
|
||||||
|
19, 6, 65, 41, 90, -116, -3, -46, 75, -13, 98, -74, -42, 74, -95, -96, 81, 24, 32, -19, -123,
|
||||||
|
74, 55, 109, 115, 0, 32, 33, 12, -20, 9, 127, 127, -61, 79, -48, -54, -49, 101, -9, 27, -106,
|
||||||
|
74, 119, 77, 87, -126, -24, 127, 124, 31, 34, 127, 40, 3, -90, 127, 23, 57, -53, 127, -69, -88,
|
||||||
|
-33, 127, 19, -46, -9, -125, 13, -126, -113, 127, -41, 46, 106, -62, 3, -10, 111, 49, -34, -24,
|
||||||
|
-20, -112, 11, 101, -50, -34, 50, 65, -64, -106, 70, -48, 60, 9, -122, -45, 15, -112, -26, -4,
|
||||||
|
1, 39, 23, 58, -45, -80, 127, 82, 58, 30, -94, -119, 51, -89, 95, -107, 30, 127, 125, 58, -52,
|
||||||
|
-42, -38, -20, -122, 115, 39, -26, 5, 73, 13, -39, 43, -23, -20, -125, 23, 35, 53, -61, -66, 72,
|
||||||
|
-20, 33, 8, 35, 4, 7, 18, 19, 16, -45, -50, -71, 31, -29, -41, -27, 10, 14, 27, 9, -23, 98, 6,
|
||||||
|
-94, 92, 127, -114, 59, -26, -100, -62, -127, -17, -85, -60, 126, -42, -6, 33, -120, -26, -126,
|
||||||
|
-127, -35, -114, -31, 25, -126, -100, -126, -64, -46, -31, 30, 25, -74, -111, -97, -81, -104,
|
||||||
|
-114, -19, -9, -116, -69, 22, 30, 59, 8, -51, 16, -97, 18, -4, -89, 80, -50, 3, 36, -67, 56, 69,
|
||||||
|
-26, 107, -10, 58, -28, -4, -57, -72, -111, 0, -75, -119, 14, -75, -49, -66, -49, 8, -121, 22,
|
||||||
|
-54, 121, 30, 54, -26, -126, -123, 56, 5, 48, 21, -127, -11, 23, 25, -82, 6, -25, 119, 78, 4,
|
||||||
|
-104, 27, 61, -48, 37, -13, -52, 50, -50, 44, -1, -22, -43, -59, -78, -67, -32, -26, 9, -3, 40,
|
||||||
|
16, 19, 3, -9, 20, -6, -37, 28, 39, 17, -19, -10, 1, 6, -59, 74, 47, 3, -119, 0, -128, -107,
|
||||||
|
-25, -22, -69, -23, -111, -42, -93, -120, 90, -85, -54, -118, 76, -79, 124, 101, -77, -75, -17,
|
||||||
|
-71, -114, 68, 55, 79, -1, -123, -20, 127, -65, -123, -128, -87, 123, 9, -115, -14, 7, -4, 127,
|
||||||
|
-79, -115, 125, -28, 89, -83, 49, 89, 119, -69, -5, 12, -49, 60, 57, -24, -99, -110, 76, -83,
|
||||||
|
125, 73, 81, 11, 8, -45, 1, 83, 13, -70, -2, 97, 112, -97, 53, -9, -94, 124, 44, -49, -24, 52,
|
||||||
|
76, -110, -70, -114, -12, 72, -4, -114, 43, -43, 81, 102, -84, -27, 62, -40, 52, 58, 124, -35,
|
||||||
|
-51, -123, -43, 56, -75, -34, -35, -106, 93, -43, 14, -16, 46, 62, -97, 21, 30, -53, 21, -11,
|
||||||
|
-33, -20, -95, 4, -126, 12, 45, 20, 108, 85, 11, 20, -40, 99, 4, -25, -18, -23, -12, -126, -55,
|
||||||
|
-20, -44, -51, 91, -127, 127, -44, 7, 127, 78, 38, 125, -6, -94, -103, 73, 126, -126, 18, 59,
|
||||||
|
-46, 106, 76, 116, -31, 75, -4, 92, 102, 32, -31, 73, 42, -21, -28, 57, 127, -8, -107, 115, 124,
|
||||||
|
-94, -4, -128, 29, -57, 70, -82, 50, -13, -44, 38, 67, -93, 6, -39, -46, 56, 68, 27, 61, 26, 18,
|
||||||
|
-72, 127, 22, 18, -31, 127, 61, -65, -38, 1, -67, -1, 8, -73, 46, -116, -94, 58, -49, 71, -40,
|
||||||
|
-63, -82, -20, -60, 93, 76, 69, -106, 34, -31, 4, -25, 107, -18, 45, 4, -61, 126, 54, -126,
|
||||||
|
-125, 41, 19, 44, 32, -98, 125, -24, 125, -96, -125, 15, 87, -4, -90, 18, -40, 28, -69, 67, 22,
|
||||||
|
41, 39, 7, -48, -44, 12, 69, -13, 2, 44, -38, 111, -7, -126, -22, -9, 74, -128, -36, -7, -123,
|
||||||
|
-15, -79, -91, -37, -127, -122, 104, 30, 7, 98, -37, 111, -116, -47, 127, -45, 118, -111, -123,
|
||||||
|
-120, -77, -64, -125, 124, 77, 111, 77, 18, -113, 117, -9, 67, -77, 126, 49, -20, -124, 39, 41,
|
||||||
|
-124, -34, 114, -87, -126, 98, -20, 59, -17, -24, 125, 107, 54, 35, 33, -44, 12, -29, 125, -71,
|
||||||
|
-28, -63, -114, 28, -17, 121, -36, 127, 89, -122, -49, -18, -48, 17, 24, 19, -64, -128, 13, 86,
|
||||||
|
45, 13, -49, 55, 84, 48, 80, -39, 99, -127, 70, -33, 30, 50, 126, -65, -117, -13, -20, -24, 127,
|
||||||
|
115, -72, -104, 63, 126, -42, 57, 17, 46, 21, 119, 110, -100, -60, -112, 62, -33, 28, 26, -22,
|
||||||
|
-60, -33, -54, 78, 25, 32, -114, 86, 44, 26, 43, 76, 121, 19, 97, -2, -3, -73, -68, 6, -116, 6,
|
||||||
|
-43, -97, 46, -128, -120, -31, -119, -29, 16, 16, -126, -128, -126, -46, -9, -3, 92, -31, -76,
|
||||||
|
-126, -3, -107, -12, -23, -69, 5, 51, 27, -42, 23, -70, -128, -29, 22, 29, -126, -55, 50, -71,
|
||||||
|
-3, 127, 44, -27, -70, -63, -66, -70, 104, 86, 115, 29, -92, 41, -90, 44, -11, -28, 20, -11,
|
||||||
|
-63, -16, 43, 31, 17, -73, -31, -1, -17, -11, -39, 56, 18, 124, 72, -14, 28, 69, -121, -125, 34,
|
||||||
|
127, 63, 86, -80, -126, -125, -124, -47, 124, 77, 124, -19, 23, -7, -50, 96, -128, -93, 102,
|
||||||
|
-53, -36, -87, 119, -125, 92, -126, 118, 102, 72, -2, 125, 10, 97, 124, -125, 125, 71, -20, -47,
|
||||||
|
-116, -121, -4, -9, -32, 79, -124, -36, 33, -128, -74, 125, 23, 127, -29, -115, -32, 124, -89,
|
||||||
|
32, -107, 43, -17, 24, 24, 18, 29, -13, -15, -36, 62, -91, 4, -41, 95, 28, -23, 6, 46, 84, 66,
|
||||||
|
77, 68, -70, -1, -23, -6, 65, 70, -21, 9, 77, -12, 2, -118, 4, 9, -108, 84, 52, 2, 52, 13, -10,
|
||||||
|
58, -110, 18, 66, -95, -23, 70, 31, -3, 56, 56, -3, -7, 1, -27, -48, -61, 41, -4, 10, -62, 32,
|
||||||
|
-7, -24, 9, -48, -60, -4, 79, -20, -38, -76, 68, -49, -97, 0, -15, 5, -100, -49, -95, -99, -115,
|
||||||
|
-9, -40, 10, 104, 13, 56, 127, -27, -109, -94, -118, -102, -44, -85, 52, 127, -4, 14, 62, 121,
|
||||||
|
-122, -26, -79, -42, -34, 1, 25, -38, -79, -58, -31, -31, -90, -30, -123, 32, -56, 125, 66, 124,
|
||||||
|
-1, 3, 91, -103, -7, 23, 78, -18, 9, 69, -69, 76, -38, -33, -2, -98, 18, 106, 84, 55, 87, -47,
|
||||||
|
35, -124, 64, 41, -14, 46, 25, -2, 120, -21, 82, 19, -79, -37, -3, -8, -16, 21, 19, -5, -28,
|
||||||
|
-112, 39, -6, -30, 53, -69, 53, 46, 127, 123, 78, 20, 28, -7, 73, 72, 17, -40, 41, 111, 57, 32,
|
||||||
|
-95, 29, 28, -39, -65, 54, -20, -63, 29, -67, 3, 44, -57, -47, 11, 61, -22, -44, 61, 48, -100,
|
||||||
|
20, 125, 96, -24, -16, 3, -69, -126, 74, -125, 9, 45, -67, -123, -59, -72, 118, 69, 45, 50, -57,
|
||||||
|
67, 13, -66, -106, 47, 62, 22, -1, -22, -25, -40, -125, 3, 125, 32, 102, -56, -25, -75, -30,
|
||||||
|
122, 60, -13, 36, -73, 7, -84, 124, 40, -118, 17, -87, -118, -8, 3, -27, 111, -40, 40, -51, 127,
|
||||||
|
125, -45, -30, -54, 46, 80, -1, -30, 101, -17, 18, 26, 54, 7, -12, 1, -127, 123, -122, -27, -75,
|
||||||
|
64, 10, 25, -15, -44, 127, -127, 5, -84, -81, -7, 19, -26, 126, 15, 116, -126, 14, -76, 44, 62,
|
||||||
|
-110, -124, 125, -29, -87, -3, -69, 82, 90, 57, -123, 123, 100, -19, -51, -32, 69, 37, -57,
|
||||||
|
-128, -124, -72, -13, 51, -7, -45, -73, 5, 99, -26, -117, -96, -109, 4, -31, -12, 0, 31, -42,
|
||||||
|
-27, 12, -81, 118, 39, 83, 14, 41, -126, 107, -82, 94, -116, -122, -47, -109, -84, -128, -35,
|
||||||
|
-56, 66, 8, -65, 19, 42, -46, -72, -109, 41, 43, -127, -113, 58, 127, 42, -75, -1, 65, 117, -55,
|
||||||
|
-113, -123, 124, 43, -96, -115, -19, 68, 15, 94, 3, 75, 0, 34, 9, 42, 110, -48, 92, -76, 99,
|
||||||
|
-17, 27, 32, 13, 125, 50, -17, 56, 4, 53, 34, -8, 99, 80, -126, -21, -65, -11, -46, 44, -81, -3,
|
||||||
|
-121, 123, 66, -81, -84, 119, 127, 84, 105, 45, -66, -42, -23, 32, -25, 12, 111, 127, 88, 125,
|
||||||
|
30, 24, -127, -9, -54, 127, -116, -119, 88, 70, 94, -120, 35, -93, 15, 22, -21, 25, -110, -123,
|
||||||
|
-45, 8, -109, 125, -122, -86, -126, 8, -14, -120, -45, -45, 69, -125, -122, 6, 81, 86, 125, 95,
|
||||||
|
54, 77, 54, -123, 126, -85, -117, 56, 11, 0, -61, -91, -12, -2, -113, -3, -15, -122, -63, -91,
|
||||||
|
10, 84, -111, 125, 93, 21, 62, -78, -116, 13, -57, 28, -124, 126, 110, 12, 15, 95, 15, -19,
|
||||||
|
-125, -97, 52, -7, 101, 9, 20, -125, -26, -56, 72, 77, 12, -126, 22, -29, 47, 62, 95, 112, 69,
|
||||||
|
32, 97, -83, -8, -5, 67, -63, -123, 79, 59, 0, -6, -17, 4, -111, -52, 27, 65, 0
|
||||||
|
};
|
||||||
|
|
||||||
|
/** GRU1 recurrent weights, same layout (24 x 3 x 24). */
|
||||||
|
static final byte[] HIDDEN_GRU_RECURRENT_WEIGHTS = {
|
||||||
|
65, 83, 35, 56, 24, -34, -28, -2, 125, 19, 42, -9, 124, -53, 24, -87, 11, 35, -81, -35, -125,
|
||||||
|
-31, 123, -21, 33, -91, 113, -93, 45, -6, 53, 38, -92, 8, -27, 87, 4, 43, 43, 10, -128, -128,
|
||||||
|
-46, 127, -38, -45, 25, -87, 19, 5, 52, -96, -23, -29, 121, -126, -24, -20, -2, 69, -50, 6, 71,
|
||||||
|
-81, -125, 90, -94, 1, -38, 36, 89, 17, -60, 71, -48, 18, -15, 44, -18, 59, 11, 114, -51, 32,
|
||||||
|
110, 1, 4, 109, -24, 127, 27, 60, 88, 24, 45, -59, 75, -36, 8, 57, -32, -25, 13, 126, -89, -61,
|
||||||
|
-76, 127, 18, -62, -68, 23, -113, 5, 126, 43, -88, 26, -78, 18, 75, 21, 9, -74, 20, 41, 126,
|
||||||
|
-118, -15, 9, 116, 126, -127, 34, -6, 126, -128, -53, -54, -55, -121, 70, 127, -12, -68, 82,
|
||||||
|
-25, 104, -126, 126, -21, -26, 124, -75, -127, -120, 13, 61, -64, -108, -63, -65, -44, -35, -61,
|
||||||
|
-39, 109, -74, 113, -3, 108, -30, 125, 120, 39, 125, -128, -95, -99, 111, 9, 25, 114, -75, -92,
|
||||||
|
-54, -12, -32, -38, 10, 31, 10, 63, 51, 40, -99, 74, 4, 50, -128, -36, -35, -11, -28, -126, -7,
|
||||||
|
66, -58, -126, -22, -83, -61, -127, 49, 126, -8, 7, 62, 36, -11, -32, -44, 63, 116, 41, 65,
|
||||||
|
-127, 126, 63, -30, -96, 74, -92, 127, 38, -18, -128, 68, -5, 101, -4, 85, 58, 79, 0, -58, 8,
|
||||||
|
119, -70, -1, -79, -68, 114, -28, -90, -6, -112, 2, 127, -8, 10, 55, -59, -126, 127, 125, 80,
|
||||||
|
72, 35, -54, 95, -124, -124, 79, 23, -46, -61, -127, -100, 99, -77, 8, -87, 5, -2, 49, 85, 7,
|
||||||
|
-71, 82, 53, -41, 22, -22, -93, -103, 6, 52, -56, 14, -8, -111, 85, 16, 54, 32, -118, -24, 61,
|
||||||
|
-53, 96, -70, -5, -17, -67, -84, -7, -82, -107, -96, 21, -83, -58, 50, 12, -126, -1, -28, 34,
|
||||||
|
-126, 115, 17, 91, 1, -127, 72, 11, 126, -81, 6, 96, -8, 77, 15, -6, 63, -27, 20, -123, -109,
|
||||||
|
85, -79, -17, 126, -92, 2, -61, 20, 14, 17, 121, 123, 30, 57, 120, 127, 57, 42, 117, 98, 67, 39,
|
||||||
|
-20, -70, 100, 7, 125, 122, 40, 16, -79, 125, 83, 41, -106, -57, 24, 55, 27, -66, -111, -44, -7,
|
||||||
|
-43, -66, 121, 42, -128, -45, 35, 15, -127, 34, -35, -34, -40, -18, -6, 63, 111, 31, 116, 127,
|
||||||
|
19, 24, -71, -39, 34, 11, 19, -40, 27, 12, 106, -10, 56, -82, -106, -2, -50, -52, 114, -126,
|
||||||
|
-34, -43, -68, 10, 76, 57, -118, -128, 37, -104, 76, 125, 3, -76, 127, -29, 84, -94, -15, 55,
|
||||||
|
125, 79, 127, -57, -125, 104, -68, 126, 126, -77, 51, 45, 33, -109, 115, -11, 1, 95, -121, -5,
|
||||||
|
-9, -126, -114, 39, 68, -126, -107, -51, -42, 24, -8, 51, -27, -43, 66, -45, 62, -98, -109, 69,
|
||||||
|
67, 0, -125, -128, 49, 31, 126, -122, 2, -55, -67, -126, -70, -128, -125, -77, 25, 16, -8, -102,
|
||||||
|
11, -75, 82, 38, -5, 5, 19, 34, 47, -127, -93, 21, 24, -97, -18, 31, 39, 34, -20, 22, 123, 7,
|
||||||
|
-77, -81, -46, -9, 1, 23, 39, -127, -43, -8, -50, 10, -21, 59, -9, -4, -13, -27, 44, 127, 52,
|
||||||
|
-47, 70, -43, 52, 101, -49, 27, 45, 49, 33, -125, 55, 114, 20, -1, 76, -24, -96, 105, 24, 126,
|
||||||
|
75, -21, -105, 13, -42, 40, 126, -30, -39, -95, 125, -63, 11, 6, 125, 125, -14, 5, 42, -61, -4,
|
||||||
|
49, 88, 6, -107, -28, 19, -29, 47, 126, 6, -46, -89, -18, 91, -20, -6, 118, -21, -22, 39, 115,
|
||||||
|
11, -42, 54, 73, -55, -77, 62, -27, -59, -99, -12, -127, -40, 56, -3, -124, -91, 71, -111, 6,
|
||||||
|
-19, 82, -24, -35, 102, -42, 7, -126, -126, -125, 18, 98, -52, 127, 105, -52, 40, -83, 126,
|
||||||
|
-122, 109, 5, 127, 48, 6, 5, -125, 100, -16, 29, 85, -89, 8, 4, 41, 62, -127, 62, 122, 85, 122,
|
||||||
|
-107, 8, -125, 93, -127, 127, 102, 19, 19, -66, 41, -42, 114, 127, -48, -117, -29, -6, -73,
|
||||||
|
-102, -3, -19, 0, 88, 42, 87, -117, -20, 2, 122, 28, 63, 71, 66, 120, 93, 124, -43, 49, 103, 31,
|
||||||
|
90, -91, -22, -126, 26, -24, -21, 51, -126, 87, -103, -69, -10, -66, -23, 20, 97, 36, 25, -127,
|
||||||
|
30, -20, -63, 30, 51, -116, 23, 40, -39, 36, -83, -77, -25, -50, 110, 14, 13, -109, 125, -65,
|
||||||
|
-55, -87, 124, -126, -32, -72, -108, 127, 127, -125, -124, 61, 121, 102, -128, -127, 16, 100,
|
||||||
|
127, -124, -68, 72, -93, -128, 43, -93, -19, -125, -97, -113, -33, 83, 127, -44, 127, -75, 127,
|
||||||
|
16, 44, 50, -122, 23, 118, 46, 19, 26, -128, 10, 4, 99, -14, -82, -13, 30, 125, 57, 65, 60, -71,
|
||||||
|
35, 98, 28, 7, 1, 43, 89, 70, 75, 121, -59, 82, -126, -53, -16, -116, -65, 52, -52, 0, 80, 35,
|
||||||
|
45, -61, 46, 8, 107, 27, -26, -118, 90, 57, -10, 7, -15, 0, -39, -4, 12, 29, -1, 116, 84, 79,
|
||||||
|
119, 125, -59, 28, -6, -25, -43, 2, 90, 79, 67, 103, -82, 2, -6, 125, 19, 73, 0, -105, 112, -17,
|
||||||
|
104, 107, 124, 106, 19, 56, -44, 55, -112, 6, -39, -83, 126, -93, -98, 57, -120, -23, -38, 2,
|
||||||
|
-31, -48, 106, 127, 127, 69, 16, 110, 71, 104, 62, -12, -22, 42, -37, -94, 34, -1, -32, -12,
|
||||||
|
-124, -47, -13, 60, -75, -66, 58, -127, -2, 64, 76, -106, 73, -49, -31, 127, 126, 31, 16, 127,
|
||||||
|
-110, 107, -16, -53, 20, 69, -14, -125, 59, -44, 15, 120, 125, 125, 43, 6, 19, -58, 127, 127,
|
||||||
|
43, 16, 82, 97, -127, 127, -93, -41, 88, 0, 77, -15, 116, 16, -124, -31, -3, 95, -40, -126, -54,
|
||||||
|
-126, -83, -8, -59, 6, 67, -29, 4, 124, -10, 112, -28, -8, 85, -21, 45, 84, 6, -8, 11, 72, 32,
|
||||||
|
84, -62, 77, 2, -36, 75, 31, -50, 116, 126, 119, -88, -55, -14, -37, 126, 40, -108, -6, -6, 57,
|
||||||
|
64, -28, -76, 30, -117, -93, 31, -92, -44, -64, 94, 58, 65, 114, 41, 47, 71, 42, -26, 99, -126,
|
||||||
|
57, -5, 74, -19, -113, -1, 67, -21, 126, 1, -3, 33, 60, -82, 37, -48, 89, 114, -38, 127, -114,
|
||||||
|
35, 58, -5, 21, -46, 121, -123, -43, 127, 115, 123, 122, -101, 126, 127, 81, 52, 89, -127, 102,
|
||||||
|
42, 117, -9, -2, 125, 127, 110, 96, 120, 66, 70, 124, 55, 84, -38, -58, 119, -127, -16, -79,
|
||||||
|
123, 18, -127, -50, -38, 120, -85, 1, 7, -56, 108, -77, -2, 21, 37, 1, 13, -105, -69, 28, -87,
|
||||||
|
33, -104, -51, 126, 41, 3, -121, 28, 71, 58, 86, -8, 127, 94, -55, 125, 40, -19, 127, -33, -87,
|
||||||
|
-23, 7, -111, -68, 9, 84, -119, 55, -82, 78, -37, -20, -9, -23, 53, -13, 15, -46, 116, 126,
|
||||||
|
-127, 56, -126, 125, -7, -1, 45, 26, 125, 121, 29, 47, -86, 30, 10, 76, -125, -7, 23, 92, -12,
|
||||||
|
-39, -18, 92, -97, -8, -85, -41, 49, -50, 123, -37, -126, -30, 14, 79, -49, -65, 9, -36, -38,
|
||||||
|
-96, 85, -24, -13, 37, -25, -5, -64, -127, 55, -60, -18, -61, -63, 127, 56, 67, 15, 124, 72,
|
||||||
|
120, 127, 40, -10, 114, 24, -23, 46, 78, -53, 125, 86, 124, 86, 0, 38, 93, 21, 127, 123, 75,
|
||||||
|
-72, 13, 48, 33, 83, -51, 15, -32, -49, -33, 120, 64, 7, 9, 65, 60, 21, -21, -61, -53, -113, 84,
|
||||||
|
-97, 101, 37, -114, -27, 41, 73, 126, -10, 59, 61, -15, 70, -13, 82, -4, 69, 56, 94, -91, -50,
|
||||||
|
92, -74, -48, 53, -7, -107, 127, 28, 30, -26, -21, -61, 77, 82, 64, -91, -125, 122, -104, 127,
|
||||||
|
123, 122, 123, 76, -126, 127, -6, -80, 7, 40, -66, -65, 54, -2, 23, 96, -64, 74, 2, -53, -12,
|
||||||
|
-123, 39, 60, -20, 16, -17, -97, 23, -4, -53, -122, 32, -16, -54, -95, 43, 71, -1, -67, -33, 41,
|
||||||
|
18, 72, 28, -83, 31, -100, -91, -27, 10, -128, -106, 2, 76, -13, 42, 34, 112, -19, 44, 40, -9,
|
||||||
|
-11, 65, 92, -43, -125, 2, 47, -32, 25, 122, -29, 12, 101, -8, -126, -23, 43, 7, 125, -20, -124,
|
||||||
|
82, -2, 13, -73, -106, 115, 31, 116, -23, -44, -71, 84, 3, 47, 91, 127, 127, -15, 95, 7, 93, 5,
|
||||||
|
113, -50, 54, 11, 13, -127, 17, 72, 43, -23, 5, -70, 20, 15, -27, 99, 69, -109, -122, -94, 16,
|
||||||
|
127, 0, 116, 104, 45, 108, -34, 87, 72, -14, 118, 46, 42, 109, -26, 95, 93, 127, 60, 127, -93,
|
||||||
|
-54, -122, 34, -105, 56, 55, 103, 125, -71, -50, 95, -72, 127, 107, 21, 73, 126, 61, 127, 127,
|
||||||
|
24, -62, 90, 73, 90, -46, -78, -124, 72, 123, -42, 50, -107, 17, -32, -62, -89, 124, 1, 80, -2,
|
||||||
|
117, 119, -65, -127, -95, -121, -52, 103, 66, 75, -3, -62, -127, 127, -74, 124, 79, 49, 40, 105,
|
||||||
|
-67, -71, -70, 43, 127, 119, -4, 66, 43, 23, 91, -126, 15, 63, -119, 112, 103, 15, -99, 31,
|
||||||
|
-127, 69, 116, -46, -67, 2, -126, -29, 30, 30, -69, -98, -47, -87, -70, -127, 23, -73, 30, -7,
|
||||||
|
94, -52, -65, 98, -45, 97, 53, 23, -9, -22, -52, -47, 6, -1, -85, -15, -61, -14, 68, 110, -10,
|
||||||
|
-121, -25, -35, -15, -94, -123, 27, 75, 48, -66, -56, -44, 93, 109, 67, -36, 24, 70, -126, 8,
|
||||||
|
-127, 126, 52, 11, -32, 120, -13, -26, -28, -125, 127, 106, -50, 124, 36, -126, -12, 0, -23, 76,
|
||||||
|
-71, -126, -12, -17, -82, 12, 124, 57, 33, 4, 77, -46, 71, -34, 72, 125, -128, 124, -24, -128,
|
||||||
|
75, -120, 69, -45, 55, 33, 127, -33, 4, -105, -41, -59, -91, 123, 44, -127, 127, -67, 52, 25,
|
||||||
|
-125, -65, 100, -25, 123, 6, 11, -123, -92, -33, 126, -17, -4, 29, 33, 127, 96, 3, 87, -48, -18,
|
||||||
|
-70, 123, 58, -127, -3, -52, -1, -36, -41, 127, 51, -52, -27, 46, -83, 57, 9, 126, 127, 94, 79,
|
||||||
|
-37, -127, -40, 67, 52, 82, -66, 122, -13, -73, 127, -8, -80, 46, -48, 4, -54
|
||||||
|
};
|
||||||
|
|
||||||
|
/** GRU1 bias, [gate][output] (3 x 24). */
|
||||||
|
static final byte[] HIDDEN_GRU_BIAS = {
|
||||||
|
124, 125, -57, -126, 53, 123, 127, -75, 68, 102, -2, 116, 124, 127, 124, 125, 126, 123, -16, 48,
|
||||||
|
125, 126, 78, 85, 11, 126, -30, -30, -64, -3, -105, -29, -17, 69, 63, 2, -32, -10, -62, 113,
|
||||||
|
-52, 112, -109, 112, 7, -40, 73, 53, 62, 6, -2, 0, 0, 100, -16, 26, -24, 56, 26, -10, -33, 41,
|
||||||
|
70, 109, -29, 127, 34, -66, 49, 53, 27, 62
|
||||||
|
};
|
||||||
|
|
||||||
|
/** FC2 weights (24 x 1). */
|
||||||
|
static final byte[] OUTPUT_DENSE_WEIGHTS = {
|
||||||
|
127, 127, 127, 127, 127, 20, 127, -126, -126, -54, 14, 125, -126, -126, 127, -125, -126, 127,
|
||||||
|
-127, -127, -57, -30, 127, 80
|
||||||
|
};
|
||||||
|
|
||||||
|
/** FC2 bias (1). */
|
||||||
|
static final byte[] OUTPUT_DENSE_BIAS = {
|
||||||
|
-50
|
||||||
|
};
|
||||||
|
|
||||||
|
private RnnVadWeights() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.CEPSTRAL_HISTORY;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.FRAME_20MS_24K;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.NUM_BANDS;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.NUM_LOWER_BANDS;
|
||||||
|
import static com.ts3client.audio.vad.VadConstants.OPUS_BANDS_24K;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Band-wise spectral features for {@code rnn_vad}, ported from WebRTC's
|
||||||
|
* {@code rnn_vad/spectral_features.cc} and {@code spectral_features_internal.cc}.
|
||||||
|
*
|
||||||
|
* <p>A 20 ms Vorbis-windowed FFT is folded into 22 Opus-scale bands, log-compressed
|
||||||
|
* and DCT'd into a cepstrum. The network is fed the higher cepstral coefficients, the
|
||||||
|
* running average and first two derivatives of the lower ones, the cepstrum of the
|
||||||
|
* band-wise correlation between the current frame and the frame one pitch period earlier,
|
||||||
|
* and a measure of how much the cepstrum has been varying.
|
||||||
|
*/
|
||||||
|
final class SpectralFeatures {
|
||||||
|
|
||||||
|
/** Total band energy below which the frame is treated as silence. */
|
||||||
|
private static final float SILENCE_THRESHOLD = 0.04f;
|
||||||
|
|
||||||
|
/** FFT bins per Opus band at 24 kHz for a 20 ms frame. */
|
||||||
|
private static final int[] OPUS_BAND_SIZES =
|
||||||
|
{4, 4, 4, 4, 4, 4, 4, 4, 8, 8, 8, 8, 16, 16, 16, 24, 24, 32, 48};
|
||||||
|
|
||||||
|
/** sqrt(2 / NUM_BANDS), applied after the DCT. */
|
||||||
|
private static final float DCT_SCALING_FACTOR = 0.301511345f;
|
||||||
|
|
||||||
|
private final RealFft fft = new RealFft(FRAME_20MS_24K);
|
||||||
|
private final float[] halfWindow = new float[FRAME_20MS_24K / 2];
|
||||||
|
private final float[] bandWeights = new float[FRAME_20MS_24K / 2];
|
||||||
|
private final float[] dctTable = new float[NUM_BANDS * NUM_BANDS];
|
||||||
|
|
||||||
|
private final float[] windowed = new float[FRAME_20MS_24K];
|
||||||
|
private final float[] referenceFft = new float[FRAME_20MS_24K];
|
||||||
|
private final float[] laggedFft = new float[FRAME_20MS_24K];
|
||||||
|
private final float[] referenceBandsEnergy = new float[OPUS_BANDS_24K];
|
||||||
|
private final float[] laggedBandsEnergy = new float[OPUS_BANDS_24K];
|
||||||
|
private final float[] bandsCrossCorr = new float[OPUS_BANDS_24K];
|
||||||
|
private final float[] logBandsEnergy = new float[NUM_BANDS];
|
||||||
|
private final float[] cepstrum = new float[NUM_BANDS];
|
||||||
|
private final float[] crossCorrCepstrum = new float[NUM_LOWER_BANDS];
|
||||||
|
|
||||||
|
/** Ring buffer of the last {@link VadConstants#CEPSTRAL_HISTORY} cepstra. */
|
||||||
|
private final float[][] cepstralHistory = new float[CEPSTRAL_HISTORY][NUM_BANDS];
|
||||||
|
private int cepstralTail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper-right triangle of pairwise cepstral distances, stored as a square matrix so a
|
||||||
|
* new frame can be shifted in with a single array copy.
|
||||||
|
*/
|
||||||
|
private final float[] cepstralDiffs = new float[(CEPSTRAL_HISTORY - 1) * (CEPSTRAL_HISTORY - 1)];
|
||||||
|
private final float[] newDistances = new float[CEPSTRAL_HISTORY - 1];
|
||||||
|
|
||||||
|
SpectralFeatures() {
|
||||||
|
int halfSize = FRAME_20MS_24K / 2;
|
||||||
|
float scaling = 1.0f / FRAME_20MS_24K;
|
||||||
|
for (int i = 0; i < halfSize; i++) {
|
||||||
|
double inner = Math.sin(0.5 * Math.PI * (i + 0.5) / halfSize);
|
||||||
|
halfWindow[i] = (float) (scaling * Math.sin(0.5 * Math.PI * inner * inner));
|
||||||
|
}
|
||||||
|
// Each band overlaps its neighbour with a triangular response; the weight is the
|
||||||
|
// fractional position of the bin within its band.
|
||||||
|
int bin = 0;
|
||||||
|
for (int bandSize : OPUS_BAND_SIZES) {
|
||||||
|
for (int j = 0; j < bandSize; j++) {
|
||||||
|
bandWeights[bin++] = (float) j / bandSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double k = Math.sqrt(0.5);
|
||||||
|
for (int i = 0; i < NUM_BANDS; i++) {
|
||||||
|
for (int j = 0; j < NUM_BANDS; j++) {
|
||||||
|
dctTable[i * NUM_BANDS + j] = (float) Math.cos((i + 0.5) * j * Math.PI / NUM_BANDS);
|
||||||
|
}
|
||||||
|
dctTable[i * NUM_BANDS] *= (float) k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
for (float[] row : cepstralHistory) {
|
||||||
|
java.util.Arrays.fill(row, 0.0f);
|
||||||
|
}
|
||||||
|
cepstralTail = 0;
|
||||||
|
java.util.Arrays.fill(cepstralDiffs, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the spectral part of the feature vector and returns {@code true} if the
|
||||||
|
* reference frame is silence, in which case the vector is only partially written.
|
||||||
|
*
|
||||||
|
* @param reference buffer holding the most recent 20 ms at 24 kHz
|
||||||
|
* @param referenceOffset offset of that frame within {@code reference}
|
||||||
|
* @param lagged buffer holding the 20 ms one pitch period earlier
|
||||||
|
* @param laggedOffset offset of that frame within {@code lagged}
|
||||||
|
*/
|
||||||
|
boolean checkSilenceComputeFeatures(float[] reference, int referenceOffset,
|
||||||
|
float[] lagged, int laggedOffset,
|
||||||
|
float[] featureVector) {
|
||||||
|
forwardFft(reference, referenceOffset, referenceFft);
|
||||||
|
crossCorrelation(referenceFft, referenceFft, referenceBandsEnergy);
|
||||||
|
|
||||||
|
float totalEnergy = 0;
|
||||||
|
for (float e : referenceBandsEnergy) {
|
||||||
|
totalEnergy += e;
|
||||||
|
}
|
||||||
|
if (totalEnergy < SILENCE_THRESHOLD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardFft(lagged, laggedOffset, laggedFft);
|
||||||
|
crossCorrelation(laggedFft, laggedFft, laggedBandsEnergy);
|
||||||
|
|
||||||
|
smoothedLogMagnitudeSpectrum(referenceBandsEnergy, logBandsEnergy);
|
||||||
|
dct(logBandsEnergy, NUM_BANDS, cepstrum, NUM_BANDS);
|
||||||
|
// Ad-hoc correction terms for the first two cepstral coefficients.
|
||||||
|
cepstrum[0] -= 12.0f;
|
||||||
|
cepstrum[1] -= 4.0f;
|
||||||
|
|
||||||
|
pushCepstrum(cepstrum);
|
||||||
|
|
||||||
|
// Higher bands go in as-is; the lower ones are summarised over time instead.
|
||||||
|
System.arraycopy(cepstrum, NUM_LOWER_BANDS, featureVector, NUM_LOWER_BANDS,
|
||||||
|
NUM_BANDS - NUM_LOWER_BANDS);
|
||||||
|
computeAverageAndDerivatives(featureVector);
|
||||||
|
computeNormalizedCepstralCorrelation(featureVector);
|
||||||
|
featureVector[VadConstants.FEATURE_VECTOR_SIZE - 1] = computeVariability();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Windows a 20 ms frame and transforms it, zeroing the unused Nyquist coefficient. */
|
||||||
|
private void forwardFft(float[] frame, int offset, float[] out) {
|
||||||
|
int halfSize = FRAME_20MS_24K / 2;
|
||||||
|
for (int i = 0, j = FRAME_20MS_24K - 1; i < halfSize; i++, j--) {
|
||||||
|
windowed[i] = frame[offset + i] * halfWindow[i];
|
||||||
|
windowed[j] = frame[offset + j] * halfWindow[i];
|
||||||
|
}
|
||||||
|
fft.forward(windowed, out);
|
||||||
|
out[1] = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds the interleaved spectra into Opus bands. Each bin contributes to the band it
|
||||||
|
* sits in and to the next one, split by the triangular weight.
|
||||||
|
*/
|
||||||
|
private void crossCorrelation(float[] x, float[] y, float[] out) {
|
||||||
|
int k = 0;
|
||||||
|
out[0] = 0.0f;
|
||||||
|
for (int i = 0; i < OPUS_BANDS_24K - 1; i++) {
|
||||||
|
out[i + 1] = 0.0f;
|
||||||
|
for (int j = 0; j < OPUS_BAND_SIZES[i]; j++) {
|
||||||
|
float v = x[2 * k] * y[2 * k] + x[2 * k + 1] * y[2 * k + 1];
|
||||||
|
float tmp = bandWeights[k] * v;
|
||||||
|
out[i] += v - tmp;
|
||||||
|
out[i + 1] += tmp;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[0] *= 2.0f; // The first band only receives half a triangle.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log magnitude with smoothing over frequency and a decaying floor. */
|
||||||
|
private static void smoothedLogMagnitudeSpectrum(float[] bandsEnergy, float[] out) {
|
||||||
|
final float logOneByHundred = -2.0f;
|
||||||
|
float logMax = logOneByHundred;
|
||||||
|
float follow = logOneByHundred;
|
||||||
|
for (int i = 0; i < out.length; i++) {
|
||||||
|
float x = (i < bandsEnergy.length)
|
||||||
|
? (float) Math.log10(0.01f + bandsEnergy[i])
|
||||||
|
: logOneByHundred;
|
||||||
|
x = Math.max(logMax - 7.0f, Math.max(follow - 1.5f, x));
|
||||||
|
logMax = Math.max(logMax, x);
|
||||||
|
follow = Math.max(follow - 1.5f, x);
|
||||||
|
out[i] = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DCT-II of the first {@code inSize} values of {@code in}, truncated to {@code outSize}. */
|
||||||
|
private void dct(float[] in, int inSize, float[] out, int outSize) {
|
||||||
|
for (int i = 0; i < outSize; i++) {
|
||||||
|
float sum = 0;
|
||||||
|
for (int j = 0; j < inSize; j++) {
|
||||||
|
sum += in[j] * dctTable[j * NUM_BANDS + i];
|
||||||
|
}
|
||||||
|
out[i] = sum * DCT_SCALING_FACTOR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pushCepstrum(float[] newCepstrum) {
|
||||||
|
// Distance from the new cepstrum to each older one, nearest first.
|
||||||
|
for (int i = 0; i < CEPSTRAL_HISTORY - 1; i++) {
|
||||||
|
float[] old = cepstralHistoryAt(i);
|
||||||
|
float sum = 0;
|
||||||
|
for (int k = 0; k < NUM_BANDS; k++) {
|
||||||
|
float c = newCepstrum[k] - old[k];
|
||||||
|
sum += c * c;
|
||||||
|
}
|
||||||
|
newDistances[i] = sum;
|
||||||
|
}
|
||||||
|
System.arraycopy(newCepstrum, 0, cepstralHistory[cepstralTail], 0, NUM_BANDS);
|
||||||
|
cepstralTail = (cepstralTail + 1) % CEPSTRAL_HISTORY;
|
||||||
|
pushCepstralDistances();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the cepstrum pushed {@code delay} frames ago (0 = most recent). */
|
||||||
|
private float[] cepstralHistoryAt(int delay) {
|
||||||
|
int offset = cepstralTail - 1 - delay;
|
||||||
|
if (offset < 0) {
|
||||||
|
offset += CEPSTRAL_HISTORY;
|
||||||
|
}
|
||||||
|
return cepstralHistory[offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shifts the distance matrix up-left by one and writes the new column. */
|
||||||
|
private void pushCepstralDistances() {
|
||||||
|
final int s = CEPSTRAL_HISTORY;
|
||||||
|
System.arraycopy(cepstralDiffs, s, cepstralDiffs, 0, cepstralDiffs.length - s);
|
||||||
|
for (int i = 0; i < newDistances.length; i++) {
|
||||||
|
cepstralDiffs[(s - 1 - i) * (s - 1) - 1] = newDistances[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float cepstralDistance(int delay1, int delay2) {
|
||||||
|
int row = CEPSTRAL_HISTORY - 1 - delay1;
|
||||||
|
int col = CEPSTRAL_HISTORY - 1 - delay2;
|
||||||
|
if (row > col) { // Only the upper-right triangle is stored.
|
||||||
|
int tmp = row;
|
||||||
|
row = col;
|
||||||
|
col = tmp;
|
||||||
|
}
|
||||||
|
return cepstralDiffs[row * (CEPSTRAL_HISTORY - 1) + (col - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void computeAverageAndDerivatives(float[] featureVector) {
|
||||||
|
float[] curr = cepstralHistoryAt(0);
|
||||||
|
float[] prev1 = cepstralHistoryAt(1);
|
||||||
|
float[] prev2 = cepstralHistoryAt(2);
|
||||||
|
for (int i = 0; i < NUM_LOWER_BANDS; i++) {
|
||||||
|
featureVector[i] = curr[i] + prev1[i] + prev2[i]; // kernel [1, 1, 1]
|
||||||
|
featureVector[NUM_BANDS + i] = curr[i] - prev2[i]; // kernel [1, 0, -1]
|
||||||
|
featureVector[NUM_BANDS + NUM_LOWER_BANDS + i] =
|
||||||
|
curr[i] - 2 * prev1[i] + prev2[i]; // kernel [1, -2, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Band correlation between the current frame and the one a pitch period earlier. */
|
||||||
|
private void computeNormalizedCepstralCorrelation(float[] featureVector) {
|
||||||
|
crossCorrelation(referenceFft, laggedFft, bandsCrossCorr);
|
||||||
|
for (int i = 0; i < bandsCrossCorr.length; i++) {
|
||||||
|
bandsCrossCorr[i] /= (float) Math.sqrt(
|
||||||
|
0.001f + referenceBandsEnergy[i] * laggedBandsEnergy[i]);
|
||||||
|
}
|
||||||
|
dct(bandsCrossCorr, OPUS_BANDS_24K, crossCorrCepstrum, NUM_LOWER_BANDS);
|
||||||
|
// Ad-hoc correction terms, as for the frame cepstrum.
|
||||||
|
crossCorrCepstrum[0] -= 1.3f;
|
||||||
|
crossCorrCepstrum[1] -= 0.9f;
|
||||||
|
System.arraycopy(crossCorrCepstrum, 0, featureVector,
|
||||||
|
NUM_BANDS + 2 * NUM_LOWER_BANDS, NUM_LOWER_BANDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the current cepstrum sits from its nearest neighbour in the recent history,
|
||||||
|
* averaged over the history. Steady tones score low, speech scores high.
|
||||||
|
*/
|
||||||
|
private float computeVariability() {
|
||||||
|
float variability = 0;
|
||||||
|
for (int delay1 = 0; delay1 < CEPSTRAL_HISTORY; delay1++) {
|
||||||
|
float minDist = Float.MAX_VALUE;
|
||||||
|
for (int delay2 = 0; delay2 < CEPSTRAL_HISTORY; delay2++) {
|
||||||
|
if (delay1 == delay2) {
|
||||||
|
continue; // The distance would be 0.
|
||||||
|
}
|
||||||
|
minDist = Math.min(minDist, cepstralDistance(delay1, delay2));
|
||||||
|
}
|
||||||
|
variability += minDist;
|
||||||
|
}
|
||||||
|
// Normalised against training-set statistics.
|
||||||
|
return variability / CEPSTRAL_HISTORY - 2.1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sizes and pitch-search bounds of WebRTC's {@code rnn_vad}, mirroring
|
||||||
|
* {@code modules/audio_processing/agc2/rnn_vad/common.h}.
|
||||||
|
*
|
||||||
|
* <p>The detector analyses 10 ms frames at 24 kHz. Spectral analysis uses the
|
||||||
|
* most recent 20 ms; pitch search additionally needs one maximum pitch period of
|
||||||
|
* history, hence the 864-sample buffer.
|
||||||
|
*/
|
||||||
|
final class VadConstants {
|
||||||
|
|
||||||
|
static final int SAMPLE_RATE_24K = 24_000;
|
||||||
|
static final int FRAME_10MS_24K = SAMPLE_RATE_24K / 100; // 240
|
||||||
|
static final int FRAME_20MS_24K = FRAME_10MS_24K * 2; // 480
|
||||||
|
|
||||||
|
/** Pitch range at 24 kHz: 0.00125 s to 0.016 s. */
|
||||||
|
static final int MIN_PITCH_24K = SAMPLE_RATE_24K / 800; // 30
|
||||||
|
static final int MAX_PITCH_24K = 384; // 24000 / 62.5
|
||||||
|
static final int BUF_SIZE_24K = MAX_PITCH_24K + FRAME_20MS_24K; // 864
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The coarse search skips very short periods; a refinement step recovers them, so the
|
||||||
|
* initial pass starts three minimum periods in.
|
||||||
|
*/
|
||||||
|
static final int INITIAL_MIN_PITCH_24K = 3 * MIN_PITCH_24K; // 90
|
||||||
|
static final int INITIAL_NUM_LAGS_24K = MAX_PITCH_24K - INITIAL_MIN_PITCH_24K; // 294
|
||||||
|
static final int REFINE_NUM_LAGS_24K = MAX_PITCH_24K + 1; // 385
|
||||||
|
|
||||||
|
// 12 kHz analysis, used for the coarse pitch search.
|
||||||
|
static final int FRAME_20MS_12K = 240;
|
||||||
|
static final int BUF_SIZE_12K = BUF_SIZE_24K / 2; // 432
|
||||||
|
static final int INITIAL_MIN_PITCH_12K = INITIAL_MIN_PITCH_24K / 2; // 45
|
||||||
|
static final int MAX_PITCH_12K = MAX_PITCH_24K / 2; // 192
|
||||||
|
static final int NUM_LAGS_12K = MAX_PITCH_12K - INITIAL_MIN_PITCH_12K; // 147
|
||||||
|
|
||||||
|
// The pitch period fed to the network is expressed at 48 kHz.
|
||||||
|
static final int MIN_PITCH_48K = MIN_PITCH_24K * 2; // 60
|
||||||
|
static final int MAX_PITCH_48K = MAX_PITCH_24K * 2; // 768
|
||||||
|
|
||||||
|
// Spectral features.
|
||||||
|
static final int NUM_BANDS = 22;
|
||||||
|
static final int NUM_LOWER_BANDS = 6;
|
||||||
|
static final int CEPSTRAL_HISTORY = 8;
|
||||||
|
/** Opus bands that fall below the 24 kHz Nyquist frequency. */
|
||||||
|
static final int OPUS_BANDS_24K = 20;
|
||||||
|
|
||||||
|
static final int NUM_LPC_COEFFICIENTS = 5;
|
||||||
|
|
||||||
|
static final int FEATURE_VECTOR_SIZE = 42;
|
||||||
|
|
||||||
|
private VadConstants() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.ts3client.config;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version, platform and signature a client reports to the server in
|
||||||
|
* {@code clientinit}. Other clients see the version and platform in the info
|
||||||
|
* panel ("3.6.2 on Windows").
|
||||||
|
*
|
||||||
|
* <p>The signature is TeamSpeak's, over the version and platform strings
|
||||||
|
* together, so the three only work as a set. Servers answer "client is modified"
|
||||||
|
* for a version they do not accept — an invented one, or one older than their
|
||||||
|
* configured minimum — which is why {@link #PRESETS} holds genuine triples taken
|
||||||
|
* from released clients, newest first per platform.
|
||||||
|
*
|
||||||
|
* @param label human-readable name, e.g. "3.6.2 on Windows"
|
||||||
|
* @param version {@code client_version}, e.g. "3.6.2 [Build: 1695203293]"
|
||||||
|
* @param platform {@code client_platform}, e.g. "Windows"
|
||||||
|
* @param sign {@code client_version_sign}, base64
|
||||||
|
*/
|
||||||
|
public record ClientVersion(String label, String version, String platform, String sign) {
|
||||||
|
|
||||||
|
/** Platform names the official clients report. */
|
||||||
|
public static final List<String> PLATFORMS =
|
||||||
|
List.of("Windows", "Linux", "OS X", "Android", "iOS");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed triples from released TeamSpeak clients. "3.?.?" is the version the
|
||||||
|
* SDK-based clients report; it is signed for every platform and is what this
|
||||||
|
* client uses by default.
|
||||||
|
*/
|
||||||
|
public static final List<ClientVersion> PRESETS = List.of(
|
||||||
|
new ClientVersion("3.?.? on Windows", "3.?.? [Build: 5680278000]", "Windows",
|
||||||
|
"DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg=="),
|
||||||
|
new ClientVersion("3.6.3 on Windows", "3.6.3 [Build: 1701166057]", "Windows",
|
||||||
|
"ML3yE6pV0QGyhc8o7k1bgqDSj+5gZIQFMjBNhWJ6L0FtJwniBq8l1wivx3DzgVVCvyRbzfg8MsdV/rjl5sHNCw=="),
|
||||||
|
new ClientVersion("3.6.2 on Windows", "3.6.2 [Build: 1695203293]", "Windows",
|
||||||
|
"4BdaZpdgUSMCuIs8qcloJPNxNlJ4o7QKnxMCRO60mSOTtJZyKjOrGLAmeAEtLIJjcjmdSpycMbQOIV92K2vXAw=="),
|
||||||
|
new ClientVersion("3.6.1 on Windows", "3.6.1 [Build: 1690872913]", "Windows",
|
||||||
|
"+YmPaYUBL4F9+TgxyFhlT8qIi1Ym+So4NNMcuohla/u4kfJCGabZ1vDF4cIOAghIqWfO+bQ0OkORZrCBdpi2Cw=="),
|
||||||
|
new ClientVersion("3.6.0 on Windows", "3.6.0 [Build: 1686749764]", "Windows",
|
||||||
|
"gfzG6CfwNYeqrjT68s7ahPd24A/XS0P8LLAB7hrE54MnrlO12RiflD2w69VZ7EYlqnd9GAiVzdGJHCmCFWtlAQ=="),
|
||||||
|
|
||||||
|
new ClientVersion("3.?.? on Linux", "3.?.? [Build: 5680278000]", "Linux",
|
||||||
|
"Hjd+N58Gv3ENhoKmGYy2bNRBsNNgm5kpiaQWxOj5HN2DXttG6REjymSwJtpJ8muC2gSwRuZi0R+8Laan5ts5CQ=="),
|
||||||
|
new ClientVersion("3.6.2 on Linux", "3.6.2 [Build: 1695203293]", "Linux",
|
||||||
|
"p4iF1jZ3ZOz9MEkKSZ2bvnFtm9WmUcQy9mAP//erFE4PF1sB6K1CSANrr+3X4B0aZR0u+K2pjnv8kiKsWKQCBQ=="),
|
||||||
|
new ClientVersion("3.6.1 on Linux", "3.6.1 [Build: 1690193193]", "Linux",
|
||||||
|
"UAcE5h5FgPcdChWTtiO816HX8f2qdoDcDEw6crbJ96Yo7rp1Bt+qx/rkKK9bzlVWMhOd4fI7pr++1XaPkEBnAQ=="),
|
||||||
|
new ClientVersion("3.6.0 on Linux", "3.6.0 [Build: 1686749764]", "Linux",
|
||||||
|
"TccRBHwn5LSA0lfpa2B42QmfCOiG+MqqyhlOqy5RcuRQNUGPD0e47lUz+Z/8+Gsdc1TPG4+vYlRl8+v2v+26Aw=="),
|
||||||
|
new ClientVersion("3.5.8 on Linux", "3.5.8 [Build: 1644595825]", "Linux",
|
||||||
|
"CK+cWJQESZgUdw1AFHINMbwiLjFGSiupour0Dfj/j1/xwx4L3nW3G+alYtiQw/wQwlWm873HS6PvXUBarz/tCw=="),
|
||||||
|
|
||||||
|
new ClientVersion("3.?.? on OS X", "3.?.? [Build: 5680278000]", "OS X",
|
||||||
|
"SttEnjoWE8jqIM6BOHSfiZP9DGjW0EP/ajU4bdKqgGMV4aYq/kzwVA9gxbmdIzV4lbaokvXBqrRjfBHrTVh8Cg=="),
|
||||||
|
new ClientVersion("3.6.2 on OS X", "3.6.2 [Build: 1695203293]", "OS X",
|
||||||
|
"SOCkLcsxINIGVc+3OQ5Vrmv2BVNNFA9LX5Jj9tqbIiYv2HU6Z5E8AL5+v5G2LDN0F+U55+uW43ezUoYAr1WuBA=="),
|
||||||
|
new ClientVersion("3.6.1 on OS X", "3.6.1 [Build: 1690193193]", "OS X",
|
||||||
|
"P+KOw42V8K0nN5FbGItCaJzrDuWcRgfE3MRzrtbYAhRMTXHzRxvwLkoLM7+vKTW135NtWZlKbZuDAvxgwhSLBg=="),
|
||||||
|
new ClientVersion("3.6.0 on OS X", "3.6.0 [Build: 1686749764]", "OS X",
|
||||||
|
"g8H7Op8eCrn0wHutuD/SlmUcZPmkSlFUBhvZpSgm9kuQl4dapzKJq2anI5jDwRs/ooBm/wrIlSiWI6HXuOPZDA=="),
|
||||||
|
|
||||||
|
new ClientVersion("3.?.? on Android", "3.?.? [Build: 5680278000]", "Android",
|
||||||
|
"AWb948BY32Z7bpIyoAlQguSmxOGcmjESPceQe1DpW5IZ4+AW1KfTk2VUIYNfUPsxReDJMCtlhVKslzhR2lf0AA=="),
|
||||||
|
new ClientVersion("3.8.7 on Android", "3.8.7 [Build: 1785186501]", "Android",
|
||||||
|
"mbt0WPhXJ+YKJUix5b6t+ZOHTCfvRqhhhrpcS5koM9CUL0BAFQZRG5Di5rJrqipZdk+gEUtpAX9I5nkuL7V9Dw=="),
|
||||||
|
new ClientVersion("3.8.6 on Android", "3.8.6 [Build: 1774892004]", "Android",
|
||||||
|
"Hh0oEZupsq8f0BuUHhMKdkXsMmgZZ03dJL8qAwnlH2JAHuefaAZFS/buiH2MLwgdRkZ81ffYk5YOTfc/bJczDw=="),
|
||||||
|
|
||||||
|
new ClientVersion("3.?.? on iOS", "3.?.? [Build: 5680278000]", "iOS",
|
||||||
|
"XrAf+Buq6Eb0ehEW/niFp06YX+nGGOS0Ke4MoUBzn+cX9q6G5C0A/d5XtgcNMe8r9jJgV/adIYVpsGS3pVlSAA=="),
|
||||||
|
new ClientVersion("3.8.2 on iOS", "3.8.2 [Build: 1764028963]", "iOS",
|
||||||
|
"Bc57xjumaioWkouTRqoS6XbSIXysrPH+gxynXgL4/nYr38l4OocUTpQaMMHevmgk/FZxJWFNEhU3Hpg5+M+MAA=="),
|
||||||
|
new ClientVersion("3.8.1 on iOS", "3.8.1 [Build: 1763403218]", "iOS",
|
||||||
|
"GM/7isPK6LDKTN3Hdelenbr99dnPyWTccLCOCFKK41321XMsdG02putu+NskCLddlDPu2CPYJ4YABRS2pJ88Ag=="));
|
||||||
|
|
||||||
|
/** The triple used when the user has not chosen one; matches the library's built-in. */
|
||||||
|
public static final ClientVersion DEFAULT = PRESETS.get(0);
|
||||||
|
|
||||||
|
/** The preset carrying exactly these three values, or null when the user typed their own. */
|
||||||
|
public static ClientVersion match(String version, String platform, String sign) {
|
||||||
|
return PRESETS.stream()
|
||||||
|
.filter(v -> v.version.equals(version) && v.platform.equals(platform) && v.sign.equals(sign))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,12 @@ public final class Settings {
|
|||||||
CONTINUOUS
|
CONTINUOUS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which look-and-feel variant the window is painted with. */
|
||||||
|
public enum Appearance {
|
||||||
|
LIGHT,
|
||||||
|
DARK
|
||||||
|
}
|
||||||
|
|
||||||
/** Voice-activation strategy, mirroring the TS3 client's VAD modes. */
|
/** Voice-activation strategy, mirroring the TS3 client's VAD modes. */
|
||||||
public enum VadMode {
|
public enum VadMode {
|
||||||
/** Speech-probability detector only. */
|
/** Speech-probability detector only. */
|
||||||
@@ -51,6 +57,16 @@ public final class Settings {
|
|||||||
/** Id of the identity used when a server doesn't select one of its own. */
|
/** Id of the identity used when a server doesn't select one of its own. */
|
||||||
public String defaultIdentityId = "";
|
public String defaultIdentityId = "";
|
||||||
|
|
||||||
|
// ---- reported client version ----
|
||||||
|
/** Report {@link #clientVersion}/{@link #clientPlatform} instead of the built-in ones. */
|
||||||
|
public boolean customVersion = false;
|
||||||
|
/** Version string other clients see, e.g. "3.5.6 [Build: 1603963200]". */
|
||||||
|
public String clientVersion = ClientVersion.DEFAULT.version();
|
||||||
|
/** Operating system other clients see, e.g. "Windows". */
|
||||||
|
public String clientPlatform = ClientVersion.DEFAULT.platform();
|
||||||
|
/** TeamSpeak's signature over the version and platform; servers may verify it. */
|
||||||
|
public String clientVersionSign = ClientVersion.DEFAULT.sign();
|
||||||
|
|
||||||
// ---- audio devices (mixer names; empty = system default) ----
|
// ---- audio devices (mixer names; empty = system default) ----
|
||||||
public String inputDevice = "";
|
public String inputDevice = "";
|
||||||
public String outputDevice = "";
|
public String outputDevice = "";
|
||||||
@@ -59,14 +75,18 @@ public final class Settings {
|
|||||||
public InputMode inputMode = InputMode.VOICE_ACTIVATION;
|
public InputMode inputMode = InputMode.VOICE_ACTIVATION;
|
||||||
/** Voice-activation strategy used when {@link #inputMode} is VOICE_ACTIVATION. */
|
/** Voice-activation strategy used when {@link #inputMode} is VOICE_ACTIVATION. */
|
||||||
public VadMode vadMode = VadMode.HYBRID;
|
public VadMode vadMode = VadMode.HYBRID;
|
||||||
/** VAD threshold in dBFS (RMS). Typical range -60 (sensitive) .. -10 (loud). */
|
/**
|
||||||
public double vadThresholdDb = -45.0;
|
* Volume-gate threshold on {@link com.ts3client.audio.InputLevel}'s scale, the same one
|
||||||
/** Speech-probability threshold (0..1) for Automatic/Hybrid modes. */
|
* the TS3 client's slider uses: -50 (most sensitive) .. +50, defaulting to -40.
|
||||||
|
*/
|
||||||
|
public double vadThresholdDb = -40.0;
|
||||||
|
/**
|
||||||
|
* Speech-probability threshold (0..1) for Automatic/Hybrid modes. TS3 calls this
|
||||||
|
* {@code vad_likelihood} and also defaults it to 0.5.
|
||||||
|
*/
|
||||||
public double speechThreshold = 0.5;
|
public double speechThreshold = 0.5;
|
||||||
/** Keep voice activation running while in push-to-talk mode. */
|
/** Keep voice activation running while in push-to-talk mode. */
|
||||||
public boolean vadOverPtt = false;
|
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. */
|
/** Opus target bitrate in bits/sec. */
|
||||||
public int bitrate = 48000;
|
public int bitrate = 48000;
|
||||||
/** Opus complexity 0..10. */
|
/** Opus complexity 0..10. */
|
||||||
@@ -81,6 +101,12 @@ public final class Settings {
|
|||||||
public boolean music = false;
|
public boolean music = false;
|
||||||
/** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */
|
/** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */
|
||||||
public double outputVolume = 1.0;
|
public double outputVolume = 1.0;
|
||||||
|
/**
|
||||||
|
* Global multiplier on top of {@link #outputVolume} and {@link #soundVolume},
|
||||||
|
* controlled from the toolbar's Master Volume slider so both can be ridden
|
||||||
|
* with one control. 0..2, same boost headroom as the other volumes.
|
||||||
|
*/
|
||||||
|
public double masterVolume = 1.0;
|
||||||
/** Microphone input gain multiplier applied before VAD/encode. */
|
/** Microphone input gain multiplier applied before VAD/encode. */
|
||||||
public double inputVolume = 1.0;
|
public double inputVolume = 1.0;
|
||||||
/** Remove steady background noise from the microphone (spectral denoise). */
|
/** Remove steady background noise from the microphone (spectral denoise). */
|
||||||
@@ -105,6 +131,15 @@ public final class Settings {
|
|||||||
/** Extra folder to look for icon packs in, on top of the well-known locations. */
|
/** Extra folder to look for icon packs in, on top of the well-known locations. */
|
||||||
public String iconPackDir = "";
|
public String iconPackDir = "";
|
||||||
|
|
||||||
|
/** Look-and-feel variant; see {@link Appearance}. */
|
||||||
|
public Appearance appearance = Appearance.LIGHT;
|
||||||
|
|
||||||
|
// ---- window chrome ----
|
||||||
|
/** Whether the status bar at the bottom of the window is shown. */
|
||||||
|
public boolean showStatusBar = true;
|
||||||
|
/** Whether the Master Volume slider is shown on the toolbar. */
|
||||||
|
public boolean showMasterVolumeSlider = true;
|
||||||
|
|
||||||
/** Which actions make a sound, and which ones are important enough to survive muting. */
|
/** Which actions make a sound, and which ones are important enough to survive muting. */
|
||||||
public final NotificationSettings notifications = new NotificationSettings();
|
public final NotificationSettings notifications = new NotificationSettings();
|
||||||
|
|
||||||
@@ -141,6 +176,16 @@ public final class Settings {
|
|||||||
return new File(identityFile);
|
return new File(identityFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@link #outputVolume} scaled by the Master Volume slider. */
|
||||||
|
public double effectiveOutputVolume() {
|
||||||
|
return outputVolume * masterVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link #soundVolume} scaled by the Master Volume slider. */
|
||||||
|
public double effectiveSoundVolume() {
|
||||||
|
return soundVolume * masterVolume;
|
||||||
|
}
|
||||||
|
|
||||||
/** The directory holding all persistent client state (settings, identities, caches). */
|
/** The directory holding all persistent client state (settings, identities, caches). */
|
||||||
public static File configDir() {
|
public static File configDir() {
|
||||||
return DIR;
|
return DIR;
|
||||||
@@ -152,14 +197,18 @@ public final class Settings {
|
|||||||
serverPassword = props.getProperty("serverPassword", serverPassword);
|
serverPassword = props.getProperty("serverPassword", serverPassword);
|
||||||
identityFile = props.getProperty("identityFile", identityFile);
|
identityFile = props.getProperty("identityFile", identityFile);
|
||||||
defaultIdentityId = props.getProperty("defaultIdentityId", defaultIdentityId);
|
defaultIdentityId = props.getProperty("defaultIdentityId", defaultIdentityId);
|
||||||
|
customVersion = parseB(props.getProperty("customVersion"), customVersion);
|
||||||
|
clientVersion = props.getProperty("clientVersion", clientVersion);
|
||||||
|
clientPlatform = props.getProperty("clientPlatform", clientPlatform);
|
||||||
|
clientVersionSign = props.getProperty("clientVersionSign", clientVersionSign);
|
||||||
inputDevice = props.getProperty("inputDevice", inputDevice);
|
inputDevice = props.getProperty("inputDevice", inputDevice);
|
||||||
outputDevice = props.getProperty("outputDevice", outputDevice);
|
outputDevice = props.getProperty("outputDevice", outputDevice);
|
||||||
inputMode = parseMode(props.getProperty("inputMode"), inputMode);
|
inputMode = parseMode(props.getProperty("inputMode"), inputMode);
|
||||||
vadMode = parseVadMode(props.getProperty("vadMode"), vadMode);
|
vadMode = parseVadMode(props.getProperty("vadMode"), vadMode);
|
||||||
vadThresholdDb = parseD(props.getProperty("vadThresholdDb"), vadThresholdDb);
|
vadThresholdDb = parseD(props.getProperty("vadLevelDb"),
|
||||||
|
migrateThreshold(props.getProperty("vadThresholdDb"), vadThresholdDb));
|
||||||
speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold);
|
speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold);
|
||||||
vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt);
|
vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt);
|
||||||
pushToTalkKey = parseI(props.getProperty("pushToTalkKey"), pushToTalkKey);
|
|
||||||
bitrate = parseI(props.getProperty("bitrate"), bitrate);
|
bitrate = parseI(props.getProperty("bitrate"), bitrate);
|
||||||
complexity = parseI(props.getProperty("complexity"), complexity);
|
complexity = parseI(props.getProperty("complexity"), complexity);
|
||||||
vbr = parseB(props.getProperty("vbr"), vbr);
|
vbr = parseB(props.getProperty("vbr"), vbr);
|
||||||
@@ -167,6 +216,7 @@ public final class Settings {
|
|||||||
packetLoss = parseI(props.getProperty("packetLoss"), packetLoss);
|
packetLoss = parseI(props.getProperty("packetLoss"), packetLoss);
|
||||||
music = parseB(props.getProperty("music"), music);
|
music = parseB(props.getProperty("music"), music);
|
||||||
outputVolume = parseD(props.getProperty("outputVolume"), outputVolume);
|
outputVolume = parseD(props.getProperty("outputVolume"), outputVolume);
|
||||||
|
masterVolume = parseD(props.getProperty("masterVolume"), masterVolume);
|
||||||
inputVolume = parseD(props.getProperty("inputVolume"), inputVolume);
|
inputVolume = parseD(props.getProperty("inputVolume"), inputVolume);
|
||||||
denoise = parseB(props.getProperty("denoise"), denoise);
|
denoise = parseB(props.getProperty("denoise"), denoise);
|
||||||
denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel);
|
denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel);
|
||||||
@@ -177,6 +227,9 @@ public final class Settings {
|
|||||||
soundPackDir = props.getProperty("soundPackDir", soundPackDir);
|
soundPackDir = props.getProperty("soundPackDir", soundPackDir);
|
||||||
iconPack = props.getProperty("iconPack", iconPack);
|
iconPack = props.getProperty("iconPack", iconPack);
|
||||||
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
|
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
|
||||||
|
appearance = parseAppearance(props.getProperty("appearance"), appearance);
|
||||||
|
showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar);
|
||||||
|
showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider);
|
||||||
notifications.load(props);
|
notifications.load(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,14 +239,18 @@ public final class Settings {
|
|||||||
props.setProperty("serverPassword", serverPassword);
|
props.setProperty("serverPassword", serverPassword);
|
||||||
props.setProperty("identityFile", identityFile);
|
props.setProperty("identityFile", identityFile);
|
||||||
props.setProperty("defaultIdentityId", defaultIdentityId);
|
props.setProperty("defaultIdentityId", defaultIdentityId);
|
||||||
|
props.setProperty("customVersion", Boolean.toString(customVersion));
|
||||||
|
props.setProperty("clientVersion", clientVersion);
|
||||||
|
props.setProperty("clientPlatform", clientPlatform);
|
||||||
|
props.setProperty("clientVersionSign", clientVersionSign);
|
||||||
props.setProperty("inputDevice", inputDevice);
|
props.setProperty("inputDevice", inputDevice);
|
||||||
props.setProperty("outputDevice", outputDevice);
|
props.setProperty("outputDevice", outputDevice);
|
||||||
props.setProperty("inputMode", inputMode.name());
|
props.setProperty("inputMode", inputMode.name());
|
||||||
props.setProperty("vadMode", vadMode.name());
|
props.setProperty("vadMode", vadMode.name());
|
||||||
props.setProperty("vadThresholdDb", Double.toString(vadThresholdDb));
|
props.setProperty("vadLevelDb", Double.toString(vadThresholdDb));
|
||||||
|
props.remove("vadThresholdDb");
|
||||||
props.setProperty("speechThreshold", Double.toString(speechThreshold));
|
props.setProperty("speechThreshold", Double.toString(speechThreshold));
|
||||||
props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt));
|
props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt));
|
||||||
props.setProperty("pushToTalkKey", Integer.toString(pushToTalkKey));
|
|
||||||
props.setProperty("bitrate", Integer.toString(bitrate));
|
props.setProperty("bitrate", Integer.toString(bitrate));
|
||||||
props.setProperty("complexity", Integer.toString(complexity));
|
props.setProperty("complexity", Integer.toString(complexity));
|
||||||
props.setProperty("vbr", Boolean.toString(vbr));
|
props.setProperty("vbr", Boolean.toString(vbr));
|
||||||
@@ -201,6 +258,7 @@ public final class Settings {
|
|||||||
props.setProperty("packetLoss", Integer.toString(packetLoss));
|
props.setProperty("packetLoss", Integer.toString(packetLoss));
|
||||||
props.setProperty("music", Boolean.toString(music));
|
props.setProperty("music", Boolean.toString(music));
|
||||||
props.setProperty("outputVolume", Double.toString(outputVolume));
|
props.setProperty("outputVolume", Double.toString(outputVolume));
|
||||||
|
props.setProperty("masterVolume", Double.toString(masterVolume));
|
||||||
props.setProperty("inputVolume", Double.toString(inputVolume));
|
props.setProperty("inputVolume", Double.toString(inputVolume));
|
||||||
props.setProperty("denoise", Boolean.toString(denoise));
|
props.setProperty("denoise", Boolean.toString(denoise));
|
||||||
props.setProperty("denoiserLevel", Double.toString(denoiserLevel));
|
props.setProperty("denoiserLevel", Double.toString(denoiserLevel));
|
||||||
@@ -211,6 +269,9 @@ public final class Settings {
|
|||||||
props.setProperty("soundPackDir", soundPackDir);
|
props.setProperty("soundPackDir", soundPackDir);
|
||||||
props.setProperty("iconPack", iconPack);
|
props.setProperty("iconPack", iconPack);
|
||||||
props.setProperty("iconPackDir", iconPackDir);
|
props.setProperty("iconPackDir", iconPackDir);
|
||||||
|
props.setProperty("appearance", appearance.name());
|
||||||
|
props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
|
||||||
|
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
|
||||||
notifications.store(props);
|
notifications.store(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +284,15 @@ public final class Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Appearance parseAppearance(String v, Appearance def) {
|
||||||
|
if (v == null) return def;
|
||||||
|
try {
|
||||||
|
return Appearance.valueOf(v);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static VadMode parseVadMode(String v, VadMode def) {
|
private static VadMode parseVadMode(String v, VadMode def) {
|
||||||
if (v == null) return def;
|
if (v == null) return def;
|
||||||
try {
|
try {
|
||||||
@@ -232,6 +302,20 @@ public final class Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a threshold saved under the old {@code vadThresholdDb} key, which was in
|
||||||
|
* dBFS ({@code 20*log10(rms)}), to {@link com.ts3client.audio.InputLevel}'s scale, so
|
||||||
|
* upgrading doesn't silently change how sensitive the gate is.
|
||||||
|
*/
|
||||||
|
private static double migrateThreshold(String legacy, double def) {
|
||||||
|
double dbfs = parseD(legacy, Double.NaN);
|
||||||
|
if (Double.isNaN(dbfs)) {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
double converted = com.ts3client.audio.InputLevel.powerToDb(Math.pow(10.0, dbfs / 10.0));
|
||||||
|
return Math.min(com.ts3client.audio.InputLevel.MAX_DB, converted);
|
||||||
|
}
|
||||||
|
|
||||||
private static double parseD(String v, double def) {
|
private static double parseD(String v, double def) {
|
||||||
if (v == null) return def;
|
if (v == null) return def;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A system-wide source of key and mouse-button events: it reports every press and
|
||||||
|
* release on the machine, whether or not the client has focus, and without swallowing
|
||||||
|
* the event on its way to the focused application.
|
||||||
|
*
|
||||||
|
* <p>Implementations are platform code and live outside the core; the core only ever
|
||||||
|
* sees this interface.
|
||||||
|
*/
|
||||||
|
public interface GlobalInputHook extends AutoCloseable {
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
/**
|
||||||
|
* @param key the key or button
|
||||||
|
* @param pressed {@code true} for a press, {@code false} for a release
|
||||||
|
*/
|
||||||
|
void onInput(HotkeyKey key, boolean pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts delivering events on a background thread. */
|
||||||
|
void start(Listener listener);
|
||||||
|
|
||||||
|
/** Whether events are actually being delivered; false when the platform refused. */
|
||||||
|
boolean isRunning();
|
||||||
|
|
||||||
|
/** Why the hook is not running, for the options dialog to show; empty when it is. */
|
||||||
|
String unavailableReason();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name this system's layout gives a key, or {@code null} to let the caller fall
|
||||||
|
* back to {@link HotkeyKey#fallbackName()}.
|
||||||
|
*/
|
||||||
|
String keyName(HotkeyKey key);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single binding: which combination triggers which action, on which edge of the
|
||||||
|
* keypress, and whether it reaches only the active server or every connected one.
|
||||||
|
*/
|
||||||
|
public final class Hotkey {
|
||||||
|
|
||||||
|
/** Which edge of the key press runs the action, as TS3's hotkey mode combo box offers. */
|
||||||
|
public enum Trigger {
|
||||||
|
/** Action triggers when the key is pressed down. */
|
||||||
|
KEY_DOWN("On key down"),
|
||||||
|
/** Action triggers when the key is released. */
|
||||||
|
KEY_UP("On key up");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
Trigger(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public HotkeyAction action;
|
||||||
|
public HotkeyCombo combo;
|
||||||
|
public Trigger trigger = Trigger.KEY_DOWN;
|
||||||
|
/** Apply to the active server only; when false the action reaches every connection. */
|
||||||
|
public boolean activeServerOnly = true;
|
||||||
|
/** Meaning depends on {@link HotkeyAction#argument()}; empty when the action takes none. */
|
||||||
|
public String argument = "";
|
||||||
|
public boolean enabled = true;
|
||||||
|
|
||||||
|
public Hotkey() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Hotkey(HotkeyAction action, HotkeyCombo combo) {
|
||||||
|
this.action = action;
|
||||||
|
this.combo = combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The binding's place in the action tree, as TS3 spells it out:
|
||||||
|
* {@code Sounds / Activate Soundpack / Default Sound Pack (Male)}.
|
||||||
|
*/
|
||||||
|
public String path() {
|
||||||
|
if (action == null) return "";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (!action.group().isEmpty()) sb.append(action.group()).append(" / ");
|
||||||
|
sb.append(action.label());
|
||||||
|
if (argument != null && !argument.isBlank()) sb.append(" / ").append(argument.trim());
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid() {
|
||||||
|
return action != null && combo != null && !combo.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the action is a per-server one, for which {@link #activeServerOnly} matters. */
|
||||||
|
public boolean isServerScoped() {
|
||||||
|
return action != null && action.category() != HotkeyAction.Category.MISC;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Hotkey copy() {
|
||||||
|
Hotkey h = new Hotkey(action, combo);
|
||||||
|
h.trigger = trigger;
|
||||||
|
h.activeServerOnly = activeServerOnly;
|
||||||
|
h.argument = argument;
|
||||||
|
h.enabled = enabled;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue of hotkey actions, reverse-engineered from the official TeamSpeak 3
|
||||||
|
* client (its hotkey dialog builds exactly this list, in this order).
|
||||||
|
*
|
||||||
|
* <p>Every entry keeps the identifiers TS3 itself uses — a numeric action type and a
|
||||||
|
* parameter keyword — so bindings stay recognisable and could be imported from, or
|
||||||
|
* exported to, the original client. {@link #supported()} tells whether this client
|
||||||
|
* can actually carry the action out; the rest are listed but greyed out.
|
||||||
|
*
|
||||||
|
* <p>{@link #advanced()} mirrors the dialog's "Show Advanced Actions" checkbox: TS3
|
||||||
|
* hides all but a handful of everyday actions until it is ticked.
|
||||||
|
*/
|
||||||
|
public enum HotkeyAction {
|
||||||
|
|
||||||
|
// ----- Server -----
|
||||||
|
CONNECT_CURRENT_TAB(Category.SERVER, 0x0010, "Server (current tab)",
|
||||||
|
"Connect to Server on Current Tab", false, Argument.BOOKMARK, true),
|
||||||
|
CONNECT_NEW_TAB(Category.SERVER, 0x0010, "Server (new tab)",
|
||||||
|
"Connect to Server in a New Tab", false, Argument.BOOKMARK, true),
|
||||||
|
DISCONNECT_CURRENT(Category.SERVER, 0x0020, "Current Server",
|
||||||
|
"Disconnect from Current Server", false, Argument.NONE, true),
|
||||||
|
DISCONNECT_ALL(Category.SERVER, 0x0020, "All Servers",
|
||||||
|
"Disconnect from All Servers", false, Argument.NONE, true),
|
||||||
|
|
||||||
|
MIC_ACTIVATE(Category.SERVER, 0x0030, "Activate",
|
||||||
|
"Activate Microphone (current tab)", true, Argument.NONE, true),
|
||||||
|
MIC_MUTE(Category.SERVER, 0x0030, "Mute",
|
||||||
|
"Mute Microphone", true, Argument.NONE, true),
|
||||||
|
MIC_UNMUTE(Category.SERVER, 0x0030, "Unmute",
|
||||||
|
"Unmute Microphone", true, Argument.NONE, true),
|
||||||
|
MIC_TOGGLE(Category.SERVER, 0x0030, "Toggle",
|
||||||
|
"Toggle Microphone Mute", false, Argument.NONE, true),
|
||||||
|
/**
|
||||||
|
* TS3's "local" mute silences capture without publishing a mute status, or playing
|
||||||
|
* a mute/unmute sound — unlike {@link #MIC_MUTE}.
|
||||||
|
*/
|
||||||
|
MIC_LOCAL_UNMUTE(Category.SERVER, 0x0030, "ActivateLocalMute",
|
||||||
|
"Disable Local Mic Mute", true, Argument.NONE, true),
|
||||||
|
MIC_LOCAL_MUTE(Category.SERVER, 0x0030, "DeactivateLocalMute",
|
||||||
|
"Enable Local Mic Mute", true, Argument.NONE, true),
|
||||||
|
MIC_LOCAL_TOGGLE(Category.SERVER, 0x0030, "ToggleLocalMute",
|
||||||
|
"Toggle Local Mic Mute", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
SPEAKER_MUTE(Category.SERVER, 0x0040, "Mute",
|
||||||
|
"Mute Speaker", true, Argument.NONE, true),
|
||||||
|
SPEAKER_UNMUTE(Category.SERVER, 0x0040, "Unmute",
|
||||||
|
"Unmute Speaker", true, Argument.NONE, true),
|
||||||
|
SPEAKER_TOGGLE(Category.SERVER, 0x0040, "Toggle",
|
||||||
|
"Toggle Speaker Mute", false, Argument.NONE, true),
|
||||||
|
|
||||||
|
AWAY_SET(Category.SERVER, 0x0050, "SetAway",
|
||||||
|
"Set Away", true, Argument.NONE, true),
|
||||||
|
AWAY_ONLINE(Category.SERVER, 0x0050, "SetOnline",
|
||||||
|
"Set Online", true, Argument.NONE, true),
|
||||||
|
AWAY_TOGGLE(Category.SERVER, 0x0050, "Toggle",
|
||||||
|
"Toggle Away Status", false, Argument.NONE, true),
|
||||||
|
AWAY_TOGGLE_WITH_MESSAGE(Category.SERVER, 0x0050, "Toggle With Message",
|
||||||
|
"Toggle Away Status with Message", true, Argument.TEXT, true),
|
||||||
|
|
||||||
|
COMMANDER_ACTIVATE(Category.SERVER, 0x0100, "Activate",
|
||||||
|
"Activate Channel Commander", true, Argument.NONE, true),
|
||||||
|
COMMANDER_DEACTIVATE(Category.SERVER, 0x0100, "Deactivate",
|
||||||
|
"Deactivate Channel Commander", true, Argument.NONE, true),
|
||||||
|
COMMANDER_TOGGLE(Category.SERVER, 0x0100, "Toggle",
|
||||||
|
"Toggle Channel Commander", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
// ----- Self -----
|
||||||
|
CAPTURE_PROFILE_ACTIVATE(Category.SELF, 0x0110, "Activate",
|
||||||
|
"Activate Capture Profile", true, Argument.PROFILE, false),
|
||||||
|
CAPTURE_PROFILE_DEACTIVATE(Category.SELF, 0x0110, "Deactivate",
|
||||||
|
"Deactivate Capture Profile", true, Argument.PROFILE, false),
|
||||||
|
CAPTURE_PROFILE_TOGGLE(Category.SELF, 0x0110, "Toggle",
|
||||||
|
"Toggle Capture Profile", true, Argument.PROFILE, false),
|
||||||
|
PLAYBACK_PROFILE_ACTIVATE(Category.SELF, 0x0120, "Activate",
|
||||||
|
"Activate Playback Profile", true, Argument.PROFILE, false),
|
||||||
|
PLAYBACK_PROFILE_DEACTIVATE(Category.SELF, 0x0120, "Deactivate",
|
||||||
|
"Deactivate Playback Profile", true, Argument.PROFILE, false),
|
||||||
|
PLAYBACK_PROFILE_TOGGLE(Category.SELF, 0x0120, "Toggle",
|
||||||
|
"Toggle Playback Profile", true, Argument.PROFILE, false),
|
||||||
|
HOTKEY_PROFILE_ACTIVATE(Category.SELF, 0x0130, "Activate",
|
||||||
|
"Activate Hotkey Profile", true, Argument.PROFILE, false),
|
||||||
|
HOTKEY_PROFILE_DEACTIVATE(Category.SELF, 0x0130, "Deactivate",
|
||||||
|
"Deactivate Hotkey Profile", true, Argument.PROFILE, false),
|
||||||
|
|
||||||
|
PTT_ACTIVATE(Category.SELF, 0x0140, "Activate",
|
||||||
|
"Activate Push-to-Talk", false, Argument.NONE, true, true),
|
||||||
|
PTT_DEACTIVATE(Category.SELF, 0x0140, "Deactivate",
|
||||||
|
"Deactivate Push-to-Talk", true, Argument.NONE, true),
|
||||||
|
PTT_TOGGLE(Category.SELF, 0x0140, "Toggle",
|
||||||
|
"Toggle Push-to-Talk", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
CHANNEL_SWITCH(Category.SELF, 0x0150, "Strict Channel",
|
||||||
|
"Switch to Channel", true, Argument.CHANNEL, true),
|
||||||
|
CHANNEL_NEXT(Category.SELF, 0x0150, "Next Channel",
|
||||||
|
"Switch to Next Channel (Global)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_PREVIOUS(Category.SELF, 0x0150, "Previous Channel",
|
||||||
|
"Switch to Previous Channel (Global)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_LAST_VISITED(Category.SELF, 0x0150, "Last Visited Channel",
|
||||||
|
"Switch to Last Visited Channel", true, Argument.NONE, false),
|
||||||
|
CHANNEL_NEXT_FAMILY(Category.SELF, 0x0150, "Next Channel Family",
|
||||||
|
"Switch to Next Channel (Channel Family)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_PREVIOUS_FAMILY(Category.SELF, 0x0150, "Previous Channel Family",
|
||||||
|
"Switch to Previous Channel (Channel Family)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_NEXT_LEVEL(Category.SELF, 0x0150, "Next Channel Level",
|
||||||
|
"Switch to Next Channel (Same Level)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_PREVIOUS_LEVEL(Category.SELF, 0x0150, "Previous Channel Level",
|
||||||
|
"Switch to Previous Channel (Same Level)", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
SERVER_TAB_SELECT(Category.SELF, 0x0160, "Strict Server",
|
||||||
|
"Select Server Tab", true, Argument.TEXT, true),
|
||||||
|
SERVER_TAB_NEXT(Category.SELF, 0x0160, "Next Server",
|
||||||
|
"Select Next Server Tab", true, Argument.NONE, true),
|
||||||
|
SERVER_TAB_PREVIOUS(Category.SELF, 0x0160, "Previous Server",
|
||||||
|
"Select Previous Server Tab", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
SOUNDPACK_ACTIVATE(Category.SELF, 0x0170, "Activate",
|
||||||
|
"Activate Soundpack", true, Argument.PROFILE, false),
|
||||||
|
SOUND_MUTE(Category.SELF, 0x0170, "Mute",
|
||||||
|
"Mute Sounds", true, Argument.NONE, true),
|
||||||
|
SOUND_UNMUTE(Category.SELF, 0x0170, "Unmute",
|
||||||
|
"Unmute Sounds", true, Argument.NONE, true),
|
||||||
|
SOUND_TOGGLE(Category.SELF, 0x0170, "Toggle",
|
||||||
|
"Toggle Sound Mute", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
WHISPER_PUSH_ACTIVATE(Category.SELF, 0x0180, "ActivatePushToWhisper",
|
||||||
|
"Activate Push-To-Whisper", true, Argument.TEXT, false, true),
|
||||||
|
WHISPER_PUSH_DEACTIVATE(Category.SELF, 0x0180, "DeactivatePushToWhisper",
|
||||||
|
"Deactivate Push-To-Whisper", true, Argument.TEXT, false),
|
||||||
|
WHISPER_REPLY_PUSH_ACTIVATE(Category.SELF, 0x0180, "ActivateReplyPushToWhisper",
|
||||||
|
"Activate Push-To-Reply to a Whisper", true, Argument.NONE, false, true),
|
||||||
|
WHISPER_REPLY_PUSH_DEACTIVATE(Category.SELF, 0x0180, "DeactivateReplyPushToWhisper",
|
||||||
|
"Deactivate Push-To-Reply to a Whisper", true, Argument.NONE, false),
|
||||||
|
WHISPERLIST_ACTIVATE(Category.SELF, 0x0180, "Activate",
|
||||||
|
"Activate Whisperlist", true, Argument.TEXT, false),
|
||||||
|
WHISPERLIST_DEACTIVATE(Category.SELF, 0x0180, "Deactivate",
|
||||||
|
"Deactivate Whisperlist", true, Argument.TEXT, false),
|
||||||
|
WHISPER_REPLY_ACTIVATE(Category.SELF, 0x0180, "ActivateReply",
|
||||||
|
"Activate Reply to a Whisper", true, Argument.NONE, false),
|
||||||
|
WHISPER_REPLY_DEACTIVATE(Category.SELF, 0x0180, "DeactivateReply",
|
||||||
|
"Deactivate Reply to a Whisper", true, Argument.NONE, false),
|
||||||
|
WHISPER_BLOCK_ACTIVATE(Category.SELF, 0x0180, "ActivateBlock",
|
||||||
|
"Activate Block Incoming Whispers", true, Argument.NONE, false),
|
||||||
|
WHISPER_BLOCK_DEACTIVATE(Category.SELF, 0x0180, "DeactivateBlock",
|
||||||
|
"Deactivate Block Incoming Whispers", true, Argument.NONE, false),
|
||||||
|
WHISPER_BLOCK_TOGGLE(Category.SELF, 0x0180, "ToggleBlock",
|
||||||
|
"Toggle Block Incoming Whispers", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
RECORDING_START(Category.SELF, 0x01c0, "Activate",
|
||||||
|
"Start Recording", true, Argument.NONE, false),
|
||||||
|
RECORDING_START_MULTITRACK(Category.SELF, 0x01c0, "ActivateMT",
|
||||||
|
"Start Multitrack Recording", true, Argument.NONE, false),
|
||||||
|
RECORDING_STOP(Category.SELF, 0x01c0, "Deactivate",
|
||||||
|
"Stop Recording", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
VOLUME_INCREASE(Category.SELF, 0x01e0, "Increase",
|
||||||
|
"Increase Master Volume", true, Argument.NONE, true),
|
||||||
|
VOLUME_DECREASE(Category.SELF, 0x01e0, "Decrease",
|
||||||
|
"Decrease Master Volume", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
PLUGIN_ACTIVATE(Category.SELF, 0x01f0, "Activate",
|
||||||
|
"Activate Plugin", true, Argument.TEXT, false),
|
||||||
|
PLUGIN_DEACTIVATE(Category.SELF, 0x01f0, "Deactivate",
|
||||||
|
"Deactivate Plugin", true, Argument.TEXT, false),
|
||||||
|
PLUGIN_TOGGLE(Category.SELF, 0x01f0, "Toggle",
|
||||||
|
"Toggle Plugin", true, Argument.TEXT, false),
|
||||||
|
PLUGIN_COMMAND(Category.SELF, 0x01f0, "Run",
|
||||||
|
"Run Plugin Command", true, Argument.TEXT, false),
|
||||||
|
PLUGIN_HOTKEY(Category.SELF, 0x01f0, "Hotkey",
|
||||||
|
"Plugin Hotkey", true, Argument.TEXT, false),
|
||||||
|
|
||||||
|
SERVER_GROUP_ASSIGN(Category.SELF, 0x0270, "AssignSG",
|
||||||
|
"Assign Server Group", true, Argument.TEXT, false),
|
||||||
|
SERVER_GROUP_REVOKE(Category.SELF, 0x0270, "RevokeSG",
|
||||||
|
"Revoke Server Group", true, Argument.TEXT, false),
|
||||||
|
SERVER_GROUP_TOGGLE(Category.SELF, 0x0270, "ToggleSG",
|
||||||
|
"Toggle Server Group", true, Argument.TEXT, false),
|
||||||
|
|
||||||
|
STYLESHEET_HELPER_ON(Category.SELF, 0x0240, "Activate",
|
||||||
|
"Stylesheet Helper (on)", true, Argument.NONE, false),
|
||||||
|
STYLESHEET_HELPER_OFF(Category.SELF, 0x0240, "Deactivate",
|
||||||
|
"Stylesheet Helper (off)", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
SOUND_3D_ACTIVATE(Category.SELF, 0x0250, "Activate",
|
||||||
|
"Activate 3D Sound", true, Argument.NONE, false),
|
||||||
|
SOUND_3D_DEACTIVATE(Category.SELF, 0x0250, "Deactivate",
|
||||||
|
"Deactivate 3D Sound", true, Argument.NONE, false),
|
||||||
|
SOUND_3D_TOGGLE(Category.SELF, 0x0250, "Toggle",
|
||||||
|
"Toggle 3D Sound", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
NICKNAME_CHANGE(Category.SELF, 0x01a0, "Rename",
|
||||||
|
"Change Nickname", true, Argument.TEXT, true),
|
||||||
|
|
||||||
|
// ----- Misc -----
|
||||||
|
TALK_POWER_GRANT_NEXT(Category.MISC, 0x01b0, "GrantNextTalkPower",
|
||||||
|
"Grant Next User Talk Power", true, Argument.NONE, false),
|
||||||
|
TALK_POWER_REQUEST(Category.MISC, 0x01b0, "RequestTalkPower",
|
||||||
|
"Request Talk Power", true, Argument.NONE, false),
|
||||||
|
TALK_POWER_REVOKE_ALL_GRANT_NEXT(Category.MISC, 0x01b0, "RevokeAllAndGrantNext",
|
||||||
|
"Revoke All And Grant Next User Talk Power", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
FILEBROWSER(Category.MISC, 0x01d0, "Filebrowser",
|
||||||
|
"Open Filebrowser on Channel", true, Argument.NONE, true),
|
||||||
|
AUTOCONNECT_DISABLE(Category.MISC, 0x0200, "Autoconnect",
|
||||||
|
"Disable Autoconnect", true, Argument.NONE, false),
|
||||||
|
SKIN_RELOAD(Category.MISC, 0x0210, "SkinReload",
|
||||||
|
"Reload Skin", true, Argument.NONE, true),
|
||||||
|
BRING_TO_FRONT(Category.MISC, 0x0220, "Bring2Front",
|
||||||
|
"Bring Client to Front", true, Argument.NONE, true),
|
||||||
|
SEND_TO_BACK(Category.MISC, 0x0230, "Send2Back",
|
||||||
|
"Send Client to Background", true, Argument.NONE, true);
|
||||||
|
|
||||||
|
/** The three groups the TS3 hotkey dialog separates its list into. */
|
||||||
|
public enum Category {
|
||||||
|
SERVER("Server"),
|
||||||
|
SELF("Self"),
|
||||||
|
MISC("Misc");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
Category(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group an action's type belongs to in TeamSpeak's hotkey tree, taken from the
|
||||||
|
* table its dialog builds. Types absent here are ungrouped: TS3 shows them as a
|
||||||
|
* single item directly under their category.
|
||||||
|
*/
|
||||||
|
private static final Map<Integer, String> GROUPS = Map.ofEntries(
|
||||||
|
Map.entry(0x0010, "Connect to Server"),
|
||||||
|
Map.entry(0x0020, "Disconnect from Server"),
|
||||||
|
Map.entry(0x0030, "Microphone"),
|
||||||
|
Map.entry(0x0040, "Speaker"),
|
||||||
|
Map.entry(0x0050, "Away Status"),
|
||||||
|
Map.entry(0x0100, "Channel Commander"),
|
||||||
|
Map.entry(0x0110, "Capture Profile"),
|
||||||
|
Map.entry(0x0120, "Playback Profile"),
|
||||||
|
Map.entry(0x0130, "Hotkey Profile"),
|
||||||
|
Map.entry(0x0140, "Push-to-Talk"),
|
||||||
|
Map.entry(0x0150, "Switch to Channel"),
|
||||||
|
Map.entry(0x0160, "Select Server Tab"),
|
||||||
|
Map.entry(0x0170, "Sounds"),
|
||||||
|
Map.entry(0x0180, "Whisper"),
|
||||||
|
Map.entry(0x01b0, "Talk Power"),
|
||||||
|
Map.entry(0x01c0, "Recording"),
|
||||||
|
Map.entry(0x01e0, "Master Volume"),
|
||||||
|
Map.entry(0x01f0, "Plugins"),
|
||||||
|
Map.entry(0x0250, "3D Sound"),
|
||||||
|
Map.entry(0x0270, "Permissions"));
|
||||||
|
|
||||||
|
/** What the action's free-text parameter means, when it takes one. */
|
||||||
|
public enum Argument {
|
||||||
|
NONE,
|
||||||
|
/** Label of a bookmark to connect to. */
|
||||||
|
BOOKMARK,
|
||||||
|
/** Channel path, "/"-separated. */
|
||||||
|
CHANNEL,
|
||||||
|
/** Name of a capture/playback/hotkey profile or sound pack. */
|
||||||
|
PROFILE,
|
||||||
|
/** Anything else: nickname, away message, tab number, plugin command. */
|
||||||
|
TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Category category;
|
||||||
|
private final int type;
|
||||||
|
private final String parameter;
|
||||||
|
private final String label;
|
||||||
|
private final boolean advanced;
|
||||||
|
private final Argument argument;
|
||||||
|
private final boolean supported;
|
||||||
|
private final boolean momentary;
|
||||||
|
|
||||||
|
HotkeyAction(Category category, int type, String parameter, String label,
|
||||||
|
boolean advanced, Argument argument, boolean supported) {
|
||||||
|
this(category, type, parameter, label, advanced, argument, supported, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
HotkeyAction(Category category, int type, String parameter, String label,
|
||||||
|
boolean advanced, Argument argument, boolean supported, boolean momentary) {
|
||||||
|
this.category = category;
|
||||||
|
this.type = type;
|
||||||
|
this.parameter = parameter;
|
||||||
|
this.label = label;
|
||||||
|
this.advanced = advanced;
|
||||||
|
this.argument = argument;
|
||||||
|
this.supported = supported;
|
||||||
|
this.momentary = momentary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Category category() {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TS3's numeric action type; actions sharing one differ only by {@link #parameter()}. */
|
||||||
|
public int type() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The node this action hangs under in the hotkey tree ("Sounds", "Microphone", …),
|
||||||
|
* or an empty string for the actions TS3 lists on their own.
|
||||||
|
*/
|
||||||
|
public String group() {
|
||||||
|
return GROUPS.getOrDefault(type, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TS3's parameter keyword, e.g. {@code Toggle} or {@code ActivateReply}. */
|
||||||
|
public String parameter() {
|
||||||
|
return parameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name shown in the hotkey list. */
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hidden until "Show advanced actions" is ticked, as in TS3. */
|
||||||
|
public boolean advanced() {
|
||||||
|
return advanced;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Argument argument() {
|
||||||
|
return argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this client implements the action; unsupported ones cannot be bound. */
|
||||||
|
public boolean supported() {
|
||||||
|
return supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the action lasts only while the hotkey is held (push-to-talk and the
|
||||||
|
* push-to-whisper variants), in which case the trigger setting does not apply and
|
||||||
|
* the release is reported as well.
|
||||||
|
*/
|
||||||
|
public boolean momentary() {
|
||||||
|
return momentary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<HotkeyAction> of(Category category, boolean includeAdvanced) {
|
||||||
|
List<HotkeyAction> out = new ArrayList<>();
|
||||||
|
for (HotkeyAction a : values()) {
|
||||||
|
if (a.category == category && (includeAdvanced || !a.advanced)) out.add(a);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks an action up by its enum name, falling back to {@code null}. */
|
||||||
|
public static HotkeyAction byName(String name) {
|
||||||
|
for (HotkeyAction a : values()) {
|
||||||
|
if (a.name().equals(name)) return a;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key combination: one key or button, or several that have to be held together.
|
||||||
|
* There is no distinguished modifier — as in TS3, any key can take that role, and the
|
||||||
|
* combination fires once every one of its keys is down.
|
||||||
|
*/
|
||||||
|
public final class HotkeyCombo {
|
||||||
|
|
||||||
|
/** Recording order, which is also the order the combination is displayed in. */
|
||||||
|
private final List<HotkeyKey> keys;
|
||||||
|
|
||||||
|
public HotkeyCombo(Collection<HotkeyKey> keys) {
|
||||||
|
List<HotkeyKey> copy = new ArrayList<>();
|
||||||
|
for (HotkeyKey k : keys) {
|
||||||
|
if (k != null && !copy.contains(k)) copy.add(k);
|
||||||
|
}
|
||||||
|
this.keys = Collections.unmodifiableList(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<HotkeyKey> keys() {
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return keys.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean contains(HotkeyKey key) {
|
||||||
|
return keys.contains(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether every key of the combination is currently held down. */
|
||||||
|
public boolean isSatisfiedBy(Set<HotkeyKey> pressed) {
|
||||||
|
return !keys.isEmpty() && pressed.containsAll(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialised form, e.g. {@code Keyboard:37+Keyboard:45}. */
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (HotkeyKey k : keys) {
|
||||||
|
if (sb.length() > 0) sb.append('+');
|
||||||
|
sb.append(k);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HotkeyCombo parse(String text) {
|
||||||
|
List<HotkeyKey> keys = new ArrayList<>();
|
||||||
|
if (text != null && !text.isBlank()) {
|
||||||
|
for (String part : text.split("\\+")) {
|
||||||
|
HotkeyKey k = HotkeyKey.parse(part);
|
||||||
|
if (k != null) keys.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new HotkeyCombo(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the combination for the user, e.g. {@code Ctrl+Mouse 4}.
|
||||||
|
*
|
||||||
|
* @param namer resolves a key to its name on this system, typically from the
|
||||||
|
* keyboard layout; may return {@code null} to fall back
|
||||||
|
*/
|
||||||
|
public String display(Function<HotkeyKey, String> namer) {
|
||||||
|
if (keys.isEmpty()) return "No hotkey assigned";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (HotkeyKey k : keys) {
|
||||||
|
String name = namer == null ? null : namer.apply(k);
|
||||||
|
if (name == null || name.isBlank()) name = k.fallbackName();
|
||||||
|
if (sb.length() > 0) sb.append(" + ");
|
||||||
|
sb.append(name);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
return o instanceof HotkeyCombo other && keys.equals(other.keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return keys.hashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the raw stream of global key events into hotkey activations: it tracks which
|
||||||
|
* keys are down, decides when a binding's combination becomes (or stops being)
|
||||||
|
* satisfied, and reports that on the configured edge.
|
||||||
|
*
|
||||||
|
* <p>While a {@link Recorder} is installed the engine records combinations instead of
|
||||||
|
* firing them, which is how the hotkey dialog captures a new binding without the old
|
||||||
|
* ones going off.
|
||||||
|
*/
|
||||||
|
public final class HotkeyEngine implements GlobalInputHook.Listener {
|
||||||
|
|
||||||
|
/** Receives activations; called on the input hook's thread. */
|
||||||
|
public interface Handler {
|
||||||
|
/**
|
||||||
|
* @param hotkey the binding that fired
|
||||||
|
* @param active {@code true} on activation; a {@code false} call follows on release
|
||||||
|
* for {@link HotkeyAction#momentary()} actions only
|
||||||
|
*/
|
||||||
|
void onHotkey(Hotkey hotkey, boolean active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collects a combination as the user presses it, for the "set hotkey" button. */
|
||||||
|
public interface Recorder {
|
||||||
|
/** The keys held so far, in press order; called on every change. */
|
||||||
|
void onRecording(HotkeyCombo combo);
|
||||||
|
|
||||||
|
/** The user let go of everything: this is the final combination. */
|
||||||
|
void onRecorded(HotkeyCombo combo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Hotkeys hotkeys;
|
||||||
|
private final Handler handler;
|
||||||
|
|
||||||
|
private final Set<HotkeyKey> pressed = new LinkedHashSet<>();
|
||||||
|
/** Bindings whose combination is currently held, so a release can end them. */
|
||||||
|
private final Set<Hotkey> active = new HashSet<>();
|
||||||
|
|
||||||
|
private volatile Recorder recorder;
|
||||||
|
private final Set<HotkeyKey> recording = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
public HotkeyEngine(Hotkeys hotkeys, Handler handler) {
|
||||||
|
this.hotkeys = hotkeys;
|
||||||
|
this.handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diverts every event into {@code recorder} until {@link #stopRecording()}; the
|
||||||
|
* bindings themselves stay silent meanwhile.
|
||||||
|
*/
|
||||||
|
public void record(Recorder recorder) {
|
||||||
|
synchronized (this) {
|
||||||
|
recording.clear();
|
||||||
|
releaseActive();
|
||||||
|
pressed.clear();
|
||||||
|
}
|
||||||
|
this.recorder = recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopRecording() {
|
||||||
|
recorder = null;
|
||||||
|
synchronized (this) {
|
||||||
|
recording.clear();
|
||||||
|
pressed.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInput(HotkeyKey key, boolean down) {
|
||||||
|
Recorder rec = recorder;
|
||||||
|
if (rec != null) {
|
||||||
|
recordKey(rec, key, down);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Runnable> fired = new ArrayList<>();
|
||||||
|
synchronized (this) {
|
||||||
|
if (down) {
|
||||||
|
if (!pressed.add(key)) return;
|
||||||
|
} else if (!pressed.remove(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized (hotkeys.all()) {
|
||||||
|
for (Hotkey h : hotkeys.all()) {
|
||||||
|
if (!h.enabled || !h.isValid() || !h.action.supported()) continue;
|
||||||
|
if (!h.combo.contains(key)) continue;
|
||||||
|
|
||||||
|
boolean satisfied = h.combo.isSatisfiedBy(pressed);
|
||||||
|
if (satisfied && active.add(h)) {
|
||||||
|
if (h.action.momentary() || h.trigger == Hotkey.Trigger.KEY_DOWN) {
|
||||||
|
fired.add(() -> handler.onHotkey(h, true));
|
||||||
|
}
|
||||||
|
} else if (!satisfied && active.remove(h)) {
|
||||||
|
if (h.action.momentary()) {
|
||||||
|
fired.add(() -> handler.onHotkey(h, false));
|
||||||
|
} else if (h.trigger == Hotkey.Trigger.KEY_UP) {
|
||||||
|
fired.add(() -> handler.onHotkey(h, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Runnable r : fired) r.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordKey(Recorder rec, HotkeyKey key, boolean down) {
|
||||||
|
HotkeyCombo combo;
|
||||||
|
boolean finished;
|
||||||
|
synchronized (this) {
|
||||||
|
if (down) {
|
||||||
|
recording.add(key);
|
||||||
|
pressed.add(key);
|
||||||
|
} else {
|
||||||
|
pressed.remove(key);
|
||||||
|
}
|
||||||
|
combo = new HotkeyCombo(recording);
|
||||||
|
finished = !down && pressed.isEmpty() && !recording.isEmpty();
|
||||||
|
if (finished) recording.clear();
|
||||||
|
}
|
||||||
|
if (finished) {
|
||||||
|
rec.onRecorded(combo);
|
||||||
|
} else {
|
||||||
|
rec.onRecording(combo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends every held binding, so nothing stays stuck when the hook stops. */
|
||||||
|
public void releaseAll() {
|
||||||
|
List<Runnable> fired = new ArrayList<>();
|
||||||
|
synchronized (this) {
|
||||||
|
for (Hotkey h : active) {
|
||||||
|
if (h.action.momentary()) fired.add(() -> handler.onHotkey(h, false));
|
||||||
|
}
|
||||||
|
active.clear();
|
||||||
|
pressed.clear();
|
||||||
|
}
|
||||||
|
for (Runnable r : fired) r.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseActive() {
|
||||||
|
for (Hotkey h : new ArrayList<>(active)) {
|
||||||
|
if (h.action.momentary()) handler.onHotkey(h, false);
|
||||||
|
}
|
||||||
|
active.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One physical key or mouse button. Keys are identified by their position rather than by
|
||||||
|
* the character they produce, so a binding is independent of the keyboard layout in force
|
||||||
|
* when it was recorded.
|
||||||
|
*
|
||||||
|
* <p>The keyboard numbering is whatever the platform's own is — X keycodes (Linux evdev
|
||||||
|
* code + 8) on X11, set-1 scan codes with the {@code E0}/{@code E1} escape in the high
|
||||||
|
* byte on Windows — because that is what its input API reports and its key-naming call
|
||||||
|
* expects. Bindings are therefore per-machine, which is also how TeamSpeak stores them.
|
||||||
|
* Mouse buttons are unified on the X numbering everywhere, side buttons included.
|
||||||
|
*
|
||||||
|
* @param device which input device the code belongs to
|
||||||
|
* @param code the platform's key code for {@link Device#KEYBOARD}, an X-style button
|
||||||
|
* number for {@link Device#MOUSE}
|
||||||
|
*/
|
||||||
|
public record HotkeyKey(HotkeyKey.Device device, int code) {
|
||||||
|
|
||||||
|
public enum Device {
|
||||||
|
KEYBOARD("Keyboard"),
|
||||||
|
MOUSE("Mouse");
|
||||||
|
|
||||||
|
private final String prefix;
|
||||||
|
|
||||||
|
Device(String prefix) {
|
||||||
|
this.prefix = prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
String prefix() {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HotkeyKey keyboard(int keycode) {
|
||||||
|
return new HotkeyKey(Device.KEYBOARD, keycode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HotkeyKey mouse(int button) {
|
||||||
|
return new HotkeyKey(Device.MOUSE, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialised form, matching TS3's {@code Keyboard:9} / {@code Mouse:9} notation. */
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return device.prefix() + ":" + code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HotkeyKey parse(String text) {
|
||||||
|
if (text == null) return null;
|
||||||
|
int colon = text.indexOf(':');
|
||||||
|
if (colon <= 0) return null;
|
||||||
|
String prefix = text.substring(0, colon).trim();
|
||||||
|
int code;
|
||||||
|
try {
|
||||||
|
code = Integer.parseInt(text.substring(colon + 1).trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Device d : Device.values()) {
|
||||||
|
if (d.prefix().equalsIgnoreCase(prefix)) return new HotkeyKey(d, code);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A readable name that needs no help from the platform: mouse buttons carry the
|
||||||
|
* numbering users know them by, keys fall back to their raw code.
|
||||||
|
*/
|
||||||
|
public String fallbackName() {
|
||||||
|
if (device == Device.MOUSE) {
|
||||||
|
return switch (code) {
|
||||||
|
case 1 -> "Mouse Left";
|
||||||
|
case 2 -> "Mouse Middle";
|
||||||
|
case 3 -> "Mouse Right";
|
||||||
|
case 4 -> "Wheel Up";
|
||||||
|
case 5 -> "Wheel Down";
|
||||||
|
case 6 -> "Wheel Left";
|
||||||
|
case 7 -> "Wheel Right";
|
||||||
|
// X hands the side buttons out as 8 and 9; everyone else calls them 4 and 5.
|
||||||
|
case 8 -> "Mouse 4";
|
||||||
|
case 9 -> "Mouse 5";
|
||||||
|
default -> "Mouse " + code;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return "Key " + code;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java
Normal file
109
ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's hotkey bindings, stored alongside the settings file.
|
||||||
|
* Frontend-agnostic: no UI dependencies.
|
||||||
|
*/
|
||||||
|
public final class Hotkeys {
|
||||||
|
|
||||||
|
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
|
||||||
|
private static final File FILE = new File(DIR, "hotkeys.properties");
|
||||||
|
|
||||||
|
private final List<Hotkey> entries = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
|
||||||
|
/** Live view; iterate under {@code synchronized (all())} when the engine may be running. */
|
||||||
|
public List<Hotkey> all() {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void add(Hotkey hotkey) {
|
||||||
|
entries.add(hotkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set(int index, Hotkey hotkey) {
|
||||||
|
if (index >= 0 && index < entries.size()) entries.set(index, hotkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void remove(int index) {
|
||||||
|
if (index >= 0 && index < entries.size()) entries.remove(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces every binding at once, e.g. when the options dialog is applied. */
|
||||||
|
public void replaceAll(List<Hotkey> hotkeys) {
|
||||||
|
synchronized (entries) {
|
||||||
|
entries.clear();
|
||||||
|
for (Hotkey h : hotkeys) {
|
||||||
|
if (h != null && h.isValid()) entries.add(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Hotkeys load() {
|
||||||
|
Hotkeys h = new Hotkeys();
|
||||||
|
if (!FILE.isFile()) return h;
|
||||||
|
Properties p = new Properties();
|
||||||
|
try (FileInputStream in = new FileInputStream(FILE)) {
|
||||||
|
p.load(in);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
int count = parseInt(p.getProperty("count"), 0);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
String prefix = "hotkey." + i + ".";
|
||||||
|
HotkeyAction action = HotkeyAction.byName(p.getProperty(prefix + "action", ""));
|
||||||
|
if (action == null) continue;
|
||||||
|
Hotkey entry = new Hotkey(action, HotkeyCombo.parse(p.getProperty(prefix + "keys", "")));
|
||||||
|
entry.trigger = "KEY_UP".equals(p.getProperty(prefix + "trigger"))
|
||||||
|
? Hotkey.Trigger.KEY_UP : Hotkey.Trigger.KEY_DOWN;
|
||||||
|
entry.activeServerOnly = !"false".equals(p.getProperty(prefix + "activeServerOnly"));
|
||||||
|
entry.argument = p.getProperty(prefix + "argument", "");
|
||||||
|
entry.enabled = !"false".equals(p.getProperty(prefix + "enabled"));
|
||||||
|
if (entry.isValid()) h.entries.add(entry);
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save() {
|
||||||
|
Properties p = new Properties();
|
||||||
|
synchronized (entries) {
|
||||||
|
p.setProperty("count", Integer.toString(entries.size()));
|
||||||
|
for (int i = 0; i < entries.size(); i++) {
|
||||||
|
Hotkey e = entries.get(i);
|
||||||
|
String prefix = "hotkey." + i + ".";
|
||||||
|
p.setProperty(prefix + "action", e.action.name());
|
||||||
|
p.setProperty(prefix + "keys", e.combo.toString());
|
||||||
|
p.setProperty(prefix + "trigger", e.trigger.name());
|
||||||
|
p.setProperty(prefix + "activeServerOnly", Boolean.toString(e.activeServerOnly));
|
||||||
|
p.setProperty(prefix + "argument", e.argument == null ? "" : e.argument);
|
||||||
|
p.setProperty(prefix + "enabled", Boolean.toString(e.enabled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!DIR.isDirectory()) {
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
DIR.mkdirs();
|
||||||
|
}
|
||||||
|
try (FileOutputStream out = new FileOutputStream(FILE)) {
|
||||||
|
p.store(out, "TS3J client hotkeys");
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package com.ts3client.net;
|
||||||
|
|
||||||
|
import com.github.manevolent.ts3j.api.Channel;
|
||||||
|
import com.github.manevolent.ts3j.command.MultiCommand;
|
||||||
|
import com.github.manevolent.ts3j.command.SingleCommand;
|
||||||
|
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||||
|
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
|
||||||
|
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||||
|
import com.ts3client.net.filetransfer.FileTransfer;
|
||||||
|
import com.ts3client.net.filetransfer.FileTransferManager;
|
||||||
|
import com.ts3client.net.filetransfer.RemoteFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.zip.CRC32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the channel editor needs of the server: reading and writing a channel's
|
||||||
|
* properties and its needed-power permissions, plus the virtual server's icon store.
|
||||||
|
*
|
||||||
|
* <p>Split out of {@link TeamspeakConnection} because it is a self-contained request/
|
||||||
|
* response corner of the protocol that only the editor uses. Every call here blocks and
|
||||||
|
* must run off the UI thread; the connection's public wrappers do that.
|
||||||
|
*/
|
||||||
|
final class ChannelAdmin {
|
||||||
|
|
||||||
|
/** The directory a server's icons live in, where the server has one. */
|
||||||
|
private static final String ICON_DIRECTORY = "icons";
|
||||||
|
|
||||||
|
/** How long to wait for a whole icon to travel over the file connection. */
|
||||||
|
private static final long ICON_UPLOAD_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
private final TeamspeakConnection conn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code channelpermlist} reply in progress. TeamSpeak answers the command with a
|
||||||
|
* burst of {@code notifychannelpermlist} events rather than a command response, and
|
||||||
|
* only the first of them repeats the channel id, so one request may be in flight at a
|
||||||
|
* time and its entries are collected here until the command itself completes.
|
||||||
|
*/
|
||||||
|
private volatile PermRequest permRequest;
|
||||||
|
|
||||||
|
ChannelAdmin(TeamspeakConnection conn) {
|
||||||
|
this.conn = conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- channel properties ----
|
||||||
|
|
||||||
|
/** Reads a channel's full property set. */
|
||||||
|
ChannelSettings readSettings(int channelId) throws Exception {
|
||||||
|
Channel info = conn.socket().getChannelInfo(channelId);
|
||||||
|
if (info == null) throw new IllegalStateException("The server did not describe that channel");
|
||||||
|
return ChannelSettings.from(channelId, info.getMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a channel below {@code parentId} (0 being the top level).
|
||||||
|
*
|
||||||
|
* @param properties the new channel's properties, as {@link ChannelSettings#creationParameters}
|
||||||
|
* @return the new channel's id
|
||||||
|
*/
|
||||||
|
int create(int parentId, Map<String, String> properties) throws Exception {
|
||||||
|
SingleCommand cmd = new SingleCommand("channelcreate", ProtocolRole.CLIENT);
|
||||||
|
cmd.add(new CommandSingleParameter("cpid", Integer.toString(parentId)));
|
||||||
|
for (Map.Entry<String, String> property : properties.entrySet()) {
|
||||||
|
cmd.add(new CommandSingleParameter(property.getKey(), property.getValue()));
|
||||||
|
}
|
||||||
|
for (SingleCommand answer : conn.socket().executeCommand(cmd).get()) {
|
||||||
|
int cid = parseInt(answer.toMap().get("cid"), -1);
|
||||||
|
if (cid > 0) return cid;
|
||||||
|
}
|
||||||
|
// Some servers acknowledge the command without naming the channel; the
|
||||||
|
// notifychannelcreated they also send does name it, so read it off the model
|
||||||
|
// once that event has been handled.
|
||||||
|
conn.awaitEventsProcessed();
|
||||||
|
ChannelNode created = conn.getModel().findChannelByName(parentId, properties.get("channel_name"));
|
||||||
|
if (created == null) throw new IllegalStateException("The server did not report the new channel");
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies {@code changes} (property name to value) with a single {@code channeledit}. */
|
||||||
|
void edit(int channelId, Map<String, String> changes) throws Exception {
|
||||||
|
if (changes.isEmpty()) return;
|
||||||
|
SingleCommand cmd = new SingleCommand("channeledit", ProtocolRole.CLIENT);
|
||||||
|
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
|
||||||
|
for (Map.Entry<String, String> change : changes.entrySet()) {
|
||||||
|
cmd.add(new CommandSingleParameter(change.getKey(), change.getValue()));
|
||||||
|
}
|
||||||
|
conn.socket().executeCommand(cmd).complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- channel permissions ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the permissions set directly on a channel.
|
||||||
|
*
|
||||||
|
* @return permission name to value, holding only the permissions the channel itself
|
||||||
|
* carries — anything inherited from a group is absent
|
||||||
|
*/
|
||||||
|
Map<String, Integer> readPermissions(int channelId) throws Exception {
|
||||||
|
if (!conn.getModel().hasPermissionNames()) {
|
||||||
|
// channelpermlist reports numeric ids, which only the permissionlist response
|
||||||
|
// gives names to; without it the values could not be told apart.
|
||||||
|
throw new IllegalStateException("The server did not send its permission list");
|
||||||
|
}
|
||||||
|
PermRequest request = new PermRequest(channelId);
|
||||||
|
permRequest = request;
|
||||||
|
try {
|
||||||
|
SingleCommand cmd = new SingleCommand("channelpermlist", ProtocolRole.CLIENT,
|
||||||
|
new CommandSingleParameter("cid", Integer.toString(channelId)));
|
||||||
|
conn.socket().executeCommand(cmd).complete();
|
||||||
|
// The entries were queued on the event thread before the command's own reply
|
||||||
|
// was read, so draining that thread is what guarantees they have all landed.
|
||||||
|
conn.awaitEventsProcessed();
|
||||||
|
} finally {
|
||||||
|
permRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Integer> byName = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<Integer, Integer> entry : request.values.entrySet()) {
|
||||||
|
String name = conn.getModel().permissionName(entry.getKey());
|
||||||
|
if (name != null) byName.put(name, entry.getValue());
|
||||||
|
}
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collects one {@code notifychannelpermlist} entry for the request in flight. */
|
||||||
|
void onPermListEntry(UnknownTeamspeakEvent e) {
|
||||||
|
PermRequest request = permRequest;
|
||||||
|
if (request == null) return;
|
||||||
|
String cid = e.get("cid");
|
||||||
|
if (cid != null && !cid.isEmpty() && parseInt(cid, -1) != request.channelId) return;
|
||||||
|
int permId = parseInt(e.get("permid"), -1);
|
||||||
|
if (permId >= 0) request.values.put(permId, parseInt(e.get("permvalue"), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets and clears channel permissions by name. A permission set to a value is added or
|
||||||
|
* updated; one that is cleared is removed so the channel inherits it again.
|
||||||
|
*/
|
||||||
|
void writePermissions(int channelId, Map<String, Integer> set, Collection<String> remove)
|
||||||
|
throws Exception {
|
||||||
|
if (!set.isEmpty()) {
|
||||||
|
List<SingleCommand> parts = new ArrayList<>(set.size());
|
||||||
|
for (Map.Entry<String, Integer> perm : set.entrySet()) {
|
||||||
|
parts.add(new SingleCommand("channeladdperm", ProtocolRole.CLIENT,
|
||||||
|
new CommandSingleParameter("permsid", perm.getKey()),
|
||||||
|
new CommandSingleParameter("permvalue", Integer.toString(perm.getValue()))));
|
||||||
|
}
|
||||||
|
send("channeladdperm", channelId, parts);
|
||||||
|
}
|
||||||
|
if (!remove.isEmpty()) {
|
||||||
|
List<SingleCommand> parts = new ArrayList<>(remove.size());
|
||||||
|
for (String name : remove) {
|
||||||
|
parts.add(new SingleCommand("channeldelperm", ProtocolRole.CLIENT,
|
||||||
|
new CommandSingleParameter("permsid", name)));
|
||||||
|
}
|
||||||
|
send("channeldelperm", channelId, parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(String name, int channelId, List<SingleCommand> parts) throws Exception {
|
||||||
|
MultiCommand cmd = new MultiCommand(name, ProtocolRole.CLIENT, parts);
|
||||||
|
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
|
||||||
|
conn.socket().executeCommand(cmd).complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the server's icon store ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids of the icons uploaded to this virtual server, which live in channel 0's file
|
||||||
|
* repository as {@code /icon_<id>}.
|
||||||
|
*/
|
||||||
|
List<Long> listIcons() throws Exception {
|
||||||
|
List<Long> ids = new ArrayList<>();
|
||||||
|
boolean hasIconDirectory = false;
|
||||||
|
for (RemoteFile file : fileTransfers().list(0, "", "/")) {
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
hasIconDirectory |= file.getName().equals(ICON_DIRECTORY);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addIcon(ids, file);
|
||||||
|
}
|
||||||
|
// Newer servers keep the icons in their own directory rather than loose in the
|
||||||
|
// repository's root; both layouts exist, so whichever this server uses is read.
|
||||||
|
if (hasIconDirectory) {
|
||||||
|
for (RemoteFile file : fileTransfers().list(0, "", "/" + ICON_DIRECTORY)) {
|
||||||
|
if (!file.isDirectory()) addIcon(ids, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addIcon(List<Long> ids, RemoteFile file) {
|
||||||
|
if (!file.getName().startsWith("icon_")) return;
|
||||||
|
long id = parseLong(file.getName().substring("icon_".length()));
|
||||||
|
if (id > 0 && !ids.contains(id)) ids.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads an image as a server icon.
|
||||||
|
*
|
||||||
|
* @return the icon's id, which TeamSpeak derives from the file's CRC32
|
||||||
|
*/
|
||||||
|
long uploadIcon(File source) throws Exception {
|
||||||
|
byte[] data = Files.readAllBytes(source.toPath());
|
||||||
|
if (data.length == 0) throw new IllegalArgumentException("That file is empty");
|
||||||
|
if (data.length > TeamspeakConnection.MAX_ICON_BYTES) {
|
||||||
|
throw new IllegalArgumentException("Icons may not be larger than "
|
||||||
|
+ (TeamspeakConnection.MAX_ICON_BYTES / 1024) + " KiB");
|
||||||
|
}
|
||||||
|
CRC32 crc = new CRC32();
|
||||||
|
crc.update(data);
|
||||||
|
long id = crc.getValue();
|
||||||
|
|
||||||
|
CountDownLatch done = new CountDownLatch(1);
|
||||||
|
FileTransfer transfer = fileTransfers().upload(0, "", "/icon_" + id, source, true, t -> {
|
||||||
|
if (t.isDone()) done.countDown();
|
||||||
|
});
|
||||||
|
if (!done.await(ICON_UPLOAD_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||||
|
throw new IllegalStateException("The upload did not finish in time");
|
||||||
|
}
|
||||||
|
if (transfer.getState() != FileTransfer.State.COMPLETED) {
|
||||||
|
throw new IllegalStateException(transfer.getErrorMessage() == null
|
||||||
|
? "The upload failed" : transfer.getErrorMessage());
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteIcon(long iconId) throws Exception {
|
||||||
|
fileTransfers().delete(0, "", "/icon_" + iconId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTransferManager fileTransfers() {
|
||||||
|
FileTransferManager ft = conn.fileTransfers();
|
||||||
|
if (ft == null) throw new IllegalStateException("Not connected");
|
||||||
|
return ft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static int parseInt(String value, int fallback) {
|
||||||
|
try {
|
||||||
|
return value == null || value.isEmpty() ? fallback : Integer.parseInt(value.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long parseLong(String value) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(value.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One in-flight {@code channelpermlist} and the entries received for it so far. */
|
||||||
|
private static final class PermRequest {
|
||||||
|
final int channelId;
|
||||||
|
final Map<Integer, Integer> values = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
PermRequest(int channelId) {
|
||||||
|
this.channelId = channelId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package com.ts3client.net;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editable properties of a channel, as read from {@code channelinfo} and written
|
||||||
|
* back with {@code channeledit}.
|
||||||
|
*
|
||||||
|
* <p>Kept apart from {@link ChannelNode} on purpose: the node is the live tree state the
|
||||||
|
* server pushes at us, while this is a snapshot an editor works on. Submitting compares
|
||||||
|
* the edited copy against the untouched original ({@link #changesFrom}) so only the
|
||||||
|
* properties the user actually changed are sent — TeamSpeak refuses the whole command
|
||||||
|
* when it contains one property the client may not modify, even an unchanged one.
|
||||||
|
*/
|
||||||
|
public final class ChannelSettings {
|
||||||
|
|
||||||
|
/** How long a channel outlives its last occupant. */
|
||||||
|
public enum Type {TEMPORARY, SEMI_PERMANENT, PERMANENT}
|
||||||
|
|
||||||
|
public int id;
|
||||||
|
public int parentId;
|
||||||
|
public String name = "";
|
||||||
|
public String topic = "";
|
||||||
|
public String description = "";
|
||||||
|
public String phoneticName = "";
|
||||||
|
/** Whether the server reports a password on this channel; the password itself is never readable. */
|
||||||
|
public boolean hasPassword;
|
||||||
|
/** {@code null} leaves the password alone, {@code ""} removes it, anything else sets it. */
|
||||||
|
public String password;
|
||||||
|
public Type type = Type.PERMANENT;
|
||||||
|
public boolean defaultChannel;
|
||||||
|
/** The sibling this channel sorts after, or 0 to sort first. */
|
||||||
|
public int order;
|
||||||
|
public int neededTalkPower;
|
||||||
|
/** TeamSpeak codec id; see {@code com.github.manevolent.ts3j.api.Codec}. */
|
||||||
|
public int codec = 4;
|
||||||
|
public int codecQuality = 6;
|
||||||
|
public boolean encrypted = true;
|
||||||
|
public int deleteDelay;
|
||||||
|
public boolean maxClientsUnlimited = true;
|
||||||
|
public int maxClients = 16;
|
||||||
|
public boolean familyInherited = true;
|
||||||
|
public boolean familyUnlimited;
|
||||||
|
public int maxFamilyClients = 16;
|
||||||
|
public long iconId;
|
||||||
|
|
||||||
|
/** Reads a {@code channelinfo} (or {@code channellist}) property map. */
|
||||||
|
public static ChannelSettings from(int channelId, Map<String, String> props) {
|
||||||
|
ChannelSettings s = new ChannelSettings();
|
||||||
|
s.id = channelId;
|
||||||
|
s.parentId = intOf(props, "pid", intOf(props, "cpid", 0));
|
||||||
|
s.name = strOf(props, "channel_name");
|
||||||
|
s.topic = strOf(props, "channel_topic");
|
||||||
|
s.description = strOf(props, "channel_description");
|
||||||
|
s.phoneticName = strOf(props, "channel_name_phonetic");
|
||||||
|
s.hasPassword = boolOf(props, "channel_flag_password");
|
||||||
|
if (boolOf(props, "channel_flag_permanent")) s.type = Type.PERMANENT;
|
||||||
|
else if (boolOf(props, "channel_flag_semi_permanent")) s.type = Type.SEMI_PERMANENT;
|
||||||
|
else s.type = Type.TEMPORARY;
|
||||||
|
s.defaultChannel = boolOf(props, "channel_flag_default");
|
||||||
|
s.order = intOf(props, "channel_order", 0);
|
||||||
|
s.neededTalkPower = intOf(props, "channel_needed_talk_power", 0);
|
||||||
|
s.codec = intOf(props, "channel_codec", 4);
|
||||||
|
s.codecQuality = intOf(props, "channel_codec_quality", 6);
|
||||||
|
s.encrypted = !boolOf(props, "channel_codec_is_unencrypted");
|
||||||
|
s.deleteDelay = intOf(props, "channel_delete_delay", 0);
|
||||||
|
s.maxClientsUnlimited = boolOf(props, "channel_flag_maxclients_unlimited");
|
||||||
|
s.maxClients = Math.max(0, intOf(props, "channel_maxclients", 16));
|
||||||
|
s.familyInherited = boolOf(props, "channel_flag_maxfamilyclients_inherited");
|
||||||
|
s.familyUnlimited = boolOf(props, "channel_flag_maxfamilyclients_unlimited");
|
||||||
|
s.maxFamilyClients = Math.max(0, intOf(props, "channel_maxfamilyclients", 16));
|
||||||
|
s.iconId = longOf(props, "channel_icon_id");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChannelSettings copy() {
|
||||||
|
ChannelSettings c = new ChannelSettings();
|
||||||
|
c.id = id;
|
||||||
|
c.parentId = parentId;
|
||||||
|
c.name = name;
|
||||||
|
c.topic = topic;
|
||||||
|
c.description = description;
|
||||||
|
c.phoneticName = phoneticName;
|
||||||
|
c.hasPassword = hasPassword;
|
||||||
|
c.password = password;
|
||||||
|
c.type = type;
|
||||||
|
c.defaultChannel = defaultChannel;
|
||||||
|
c.order = order;
|
||||||
|
c.neededTalkPower = neededTalkPower;
|
||||||
|
c.codec = codec;
|
||||||
|
c.codecQuality = codecQuality;
|
||||||
|
c.encrypted = encrypted;
|
||||||
|
c.deleteDelay = deleteDelay;
|
||||||
|
c.maxClientsUnlimited = maxClientsUnlimited;
|
||||||
|
c.maxClients = maxClients;
|
||||||
|
c.familyInherited = familyInherited;
|
||||||
|
c.familyUnlimited = familyUnlimited;
|
||||||
|
c.maxFamilyClients = maxFamilyClients;
|
||||||
|
c.iconId = iconId;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code channeledit} parameters needed to turn {@code original} into this.
|
||||||
|
*
|
||||||
|
* @return property name to value, empty when nothing changed
|
||||||
|
*/
|
||||||
|
public Map<String, String> changesFrom(ChannelSettings original) {
|
||||||
|
Map<String, String> changes = new LinkedHashMap<>();
|
||||||
|
putIfChanged(changes, "channel_name", name, original.name);
|
||||||
|
putIfChanged(changes, "channel_topic", topic, original.topic);
|
||||||
|
putIfChanged(changes, "channel_description", description, original.description);
|
||||||
|
putIfChanged(changes, "channel_name_phonetic", phoneticName, original.phoneticName);
|
||||||
|
if (password != null) changes.put("channel_password", password);
|
||||||
|
if (type != original.type) {
|
||||||
|
// The three flags are mutually exclusive and the server wants the whole set:
|
||||||
|
// clearing one without setting another leaves the channel type ambiguous.
|
||||||
|
changes.put("channel_flag_permanent", flag(type == Type.PERMANENT));
|
||||||
|
changes.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
|
||||||
|
changes.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
|
||||||
|
}
|
||||||
|
// Clearing the default flag is meaningless — some channel has to be the default —
|
||||||
|
// so only setting it is ever sent.
|
||||||
|
if (defaultChannel && !original.defaultChannel) changes.put("channel_flag_default", "1");
|
||||||
|
putIfChanged(changes, "channel_order", order, original.order);
|
||||||
|
putIfChanged(changes, "channel_needed_talk_power", neededTalkPower, original.neededTalkPower);
|
||||||
|
putIfChanged(changes, "channel_codec", codec, original.codec);
|
||||||
|
putIfChanged(changes, "channel_codec_quality", codecQuality, original.codecQuality);
|
||||||
|
putIfChanged(changes, "channel_codec_is_unencrypted", !encrypted, !original.encrypted);
|
||||||
|
putIfChanged(changes, "channel_delete_delay", deleteDelay, original.deleteDelay);
|
||||||
|
putIfChanged(changes, "channel_flag_maxclients_unlimited",
|
||||||
|
maxClientsUnlimited, original.maxClientsUnlimited);
|
||||||
|
if (!maxClientsUnlimited) putIfChanged(changes, "channel_maxclients", maxClients, original.maxClients);
|
||||||
|
putIfChanged(changes, "channel_flag_maxfamilyclients_inherited",
|
||||||
|
familyInherited, original.familyInherited);
|
||||||
|
putIfChanged(changes, "channel_flag_maxfamilyclients_unlimited",
|
||||||
|
familyUnlimited, original.familyUnlimited);
|
||||||
|
if (!familyInherited && !familyUnlimited) {
|
||||||
|
putIfChanged(changes, "channel_maxfamilyclients", maxFamilyClients, original.maxFamilyClients);
|
||||||
|
}
|
||||||
|
if (iconId != original.iconId) changes.put("channel_icon_id", Long.toString(iconId));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code channelcreate} parameters for a channel that does not exist yet.
|
||||||
|
*
|
||||||
|
* <p>Everything a fresh channel would get anyway is left out, because TeamSpeak checks
|
||||||
|
* a create permission per property that is present — a client allowed to create plain
|
||||||
|
* channels but not, say, ones with a topic must not send an empty topic. What the
|
||||||
|
* server cannot infer is always sent: the name, the channel type (whose absent flags
|
||||||
|
* would silently mean temporary), the codec, and the client limits when they are not
|
||||||
|
* unlimited.
|
||||||
|
*/
|
||||||
|
public Map<String, String> creationParameters() {
|
||||||
|
Map<String, String> params = changesFrom(new ChannelSettings());
|
||||||
|
params.put("channel_name", name);
|
||||||
|
params.put("channel_flag_permanent", flag(type == Type.PERMANENT));
|
||||||
|
params.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
|
||||||
|
params.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
|
||||||
|
params.put("channel_codec", Integer.toString(codec));
|
||||||
|
params.put("channel_codec_quality", Integer.toString(codecQuality));
|
||||||
|
if (!maxClientsUnlimited) params.put("channel_maxclients", Integer.toString(maxClients));
|
||||||
|
if (!familyInherited && !familyUnlimited) {
|
||||||
|
params.put("channel_maxfamilyclients", Integer.toString(maxFamilyClients));
|
||||||
|
}
|
||||||
|
if (password == null || password.isEmpty()) params.remove("channel_password");
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void putIfChanged(Map<String, String> into, String key, String value, String was) {
|
||||||
|
if (!value.equals(was)) into.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void putIfChanged(Map<String, String> into, String key, int value, int was) {
|
||||||
|
if (value != was) into.put(key, Integer.toString(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void putIfChanged(Map<String, String> into, String key, boolean value, boolean was) {
|
||||||
|
if (value != was) into.put(key, flag(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String flag(boolean set) {
|
||||||
|
return set ? "1" : "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String strOf(Map<String, String> props, String key) {
|
||||||
|
String v = props.get(key);
|
||||||
|
return v == null ? "" : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean boolOf(Map<String, String> props, String key) {
|
||||||
|
return "1".equals(props.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intOf(Map<String, String> props, String key, int fallback) {
|
||||||
|
try {
|
||||||
|
String v = props.get(key);
|
||||||
|
return v == null || v.isEmpty() ? fallback : Integer.parseInt(v.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long longOf(Map<String, String> props, String key) {
|
||||||
|
try {
|
||||||
|
String v = props.get(key);
|
||||||
|
if (v == null || v.isEmpty()) return 0;
|
||||||
|
long id = Long.parseLong(v.trim());
|
||||||
|
return id < 0 ? id & 0xFFFFFFFFL : id;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ public final class ClientEntry {
|
|||||||
public int channelId;
|
public int channelId;
|
||||||
public String nickname;
|
public String nickname;
|
||||||
public String uniqueId = "";
|
public String uniqueId = "";
|
||||||
|
public int databaseId;
|
||||||
public int type; // 0 = normal voice client, 1 = server-query
|
public int type; // 0 = normal voice client, 1 = server-query
|
||||||
public int talkPower;
|
public int talkPower;
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ public final class ClientEntry {
|
|||||||
public boolean talking;
|
public boolean talking;
|
||||||
public boolean inputMuted; // microphone muted (client_input_muted)
|
public boolean inputMuted; // microphone muted (client_input_muted)
|
||||||
public boolean outputMuted; // speakers muted / deafened (client_output_muted)
|
public boolean outputMuted; // speakers muted / deafened (client_output_muted)
|
||||||
|
/** False while the capture device is unavailable: another tab holds the microphone. */
|
||||||
|
public boolean inputHardware = true;
|
||||||
|
/** False while the playback device is unavailable. */
|
||||||
|
public boolean outputHardware = true;
|
||||||
public boolean away;
|
public boolean away;
|
||||||
/** The message published with the away state, empty when there is none. */
|
/** The message published with the away state, empty when there is none. */
|
||||||
public String awayMessage = "";
|
public String awayMessage = "";
|
||||||
@@ -35,4 +40,28 @@ public final class ClientEntry {
|
|||||||
public boolean isQuery() {
|
public boolean isQuery() {
|
||||||
return type == 1;
|
return type == 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Adds a server group id, if not already present. */
|
||||||
|
public void addServerGroup(int groupId) {
|
||||||
|
for (int id : serverGroupIds) if (id == groupId) return;
|
||||||
|
int[] updated = java.util.Arrays.copyOf(serverGroupIds, serverGroupIds.length + 1);
|
||||||
|
updated[serverGroupIds.length] = groupId;
|
||||||
|
serverGroupIds = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a server group id, if present. */
|
||||||
|
public void removeServerGroup(int groupId) {
|
||||||
|
int index = -1;
|
||||||
|
for (int i = 0; i < serverGroupIds.length; i++) {
|
||||||
|
if (serverGroupIds[i] == groupId) {
|
||||||
|
index = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index < 0) return;
|
||||||
|
int[] updated = new int[serverGroupIds.length - 1];
|
||||||
|
System.arraycopy(serverGroupIds, 0, updated, 0, index);
|
||||||
|
System.arraycopy(serverGroupIds, index + 1, updated, index, serverGroupIds.length - index - 1);
|
||||||
|
serverGroupIds = updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,629 @@
|
|||||||
|
package com.ts3client.net;
|
||||||
|
|
||||||
|
import com.github.manevolent.ts3j.event.*;
|
||||||
|
import com.ts3client.sound.SoundEvent;
|
||||||
|
import com.ts3client.sound.SoundNotifier;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link TS3Listener} side of a connection: keeps {@link ServerModel} in sync with
|
||||||
|
* incoming notify events, mirrors native TeamSpeak's server-tab log lines, and picks the
|
||||||
|
* matching {@link SoundEvent} for each one. Registered alongside {@link TeamspeakConnection}
|
||||||
|
* itself (which keeps only the lifecycle-critical {@code onDisconnected} callback) so this
|
||||||
|
* class can stay focused on "event arrived, update state and tell the user" and nothing else.
|
||||||
|
*/
|
||||||
|
final class ConnectionEventHandler implements TS3Listener {
|
||||||
|
|
||||||
|
// ---- who went where: TeamSpeak's reason ids ----
|
||||||
|
|
||||||
|
/** {@code reasonid} of a client view/move notification. */
|
||||||
|
private static final int REASON_SWITCHED = 0;
|
||||||
|
private static final int REASON_MOVED = 1;
|
||||||
|
private static final int REASON_TIMEOUT = 3;
|
||||||
|
private static final int REASON_CHANNEL_KICK = 4;
|
||||||
|
private static final int REASON_SERVER_KICK = 5;
|
||||||
|
private static final int REASON_BAN = 6;
|
||||||
|
|
||||||
|
private final TeamspeakConnection conn;
|
||||||
|
|
||||||
|
ConnectionEventHandler(TeamspeakConnection conn) {
|
||||||
|
this.conn = conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClientJoin(ClientJoinEvent e) {
|
||||||
|
ClientEntry c = conn.getModel().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.inputHardware = e.isClientUsingHardwareInput();
|
||||||
|
c.outputHardware = e.isClientUsingHardwareOutput();
|
||||||
|
c.away = e.isClientAway();
|
||||||
|
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
|
||||||
|
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
|
||||||
|
c.databaseId = e.getClientDatabaseId();
|
||||||
|
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||||
|
c.channelGroupId = e.getClientChannelGroupId();
|
||||||
|
c.self = (e.getClientId() == conn.getSelfClientId());
|
||||||
|
if (e.getClientId() != conn.getSelfClientId()) {
|
||||||
|
announceClientEntered(e);
|
||||||
|
logClientEntered(e);
|
||||||
|
}
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClientLeave(ClientLeaveEvent e) {
|
||||||
|
if (e.getClientId() == conn.getSelfClientId()) {
|
||||||
|
announceOwnRemoval(safeInt(e, "reasonid"), e);
|
||||||
|
} else {
|
||||||
|
String name = conn.clientLink(e.getClientId());
|
||||||
|
announceClientLeft(e);
|
||||||
|
logClientLeft(e, name);
|
||||||
|
}
|
||||||
|
conn.getModel().removeClient(e.getClientId());
|
||||||
|
if (conn.getPlayback() != null) conn.getPlayback().removeClient(e.getClientId());
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClientMoved(ClientMovedEvent e) {
|
||||||
|
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||||
|
if (c != null) {
|
||||||
|
int from = c.channelId;
|
||||||
|
c.channelId = e.getTargetChannelId();
|
||||||
|
if (e.getClientId() == conn.getSelfClientId()) {
|
||||||
|
announceOwnMove(safeInt(e, "reasonid"), e);
|
||||||
|
} else {
|
||||||
|
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
|
||||||
|
logClientMoved(e, conn.clientLink(c.id), from, e.getTargetChannelId());
|
||||||
|
}
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client became visible to us, logged the way native TS3's server tab does. */
|
||||||
|
private void logClientEntered(ClientJoinEvent e) {
|
||||||
|
String name = conn.clientLink(e.getClientId(),
|
||||||
|
TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier()), e.getClientNickname());
|
||||||
|
switch (safeInt(e, "reasonid")) {
|
||||||
|
case REASON_MOVED:
|
||||||
|
conn.log(name + " appears, coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
|
||||||
|
break;
|
||||||
|
case REASON_CHANNEL_KICK:
|
||||||
|
conn.log(name + " appears, was kicked from channel \"" + conn.channelLink(e.getClientFromId())
|
||||||
|
+ "\" by " + invokerName(e));
|
||||||
|
break;
|
||||||
|
case REASON_SWITCHED:
|
||||||
|
conn.log(name + " switched to channel \"" + conn.channelLink(e.getClientTargetId())
|
||||||
|
+ "\", coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conn.log(name + " connected to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client stopped being visible to us. */
|
||||||
|
private void logClientLeft(ClientLeaveEvent e, String name) {
|
||||||
|
String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
|
||||||
|
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
|
||||||
|
switch (safeInt(e, "reasonid")) {
|
||||||
|
case REASON_TIMEOUT:
|
||||||
|
conn.log(name + " dropped (ping timeout)");
|
||||||
|
break;
|
||||||
|
case REASON_SERVER_KICK:
|
||||||
|
conn.log(name + " was kicked from the server by " + invokerName(e) + suffix);
|
||||||
|
break;
|
||||||
|
case REASON_BAN:
|
||||||
|
conn.log(name + " was banned from the server by " + invokerName(e) + suffix);
|
||||||
|
break;
|
||||||
|
case REASON_CHANNEL_KICK:
|
||||||
|
conn.log(name + " left: was kicked to channel \"" + conn.channelLink(e.getClientTargetId())
|
||||||
|
+ "\" by " + invokerName(e) + suffix);
|
||||||
|
break;
|
||||||
|
case REASON_MOVED:
|
||||||
|
conn.log(name + " left, heading to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
|
||||||
|
break;
|
||||||
|
case REASON_SWITCHED:
|
||||||
|
conn.log(name + " left, switched to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conn.log(name + " disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client we can see moved between two channels we can see. */
|
||||||
|
private void logClientMoved(ClientMovedEvent e, String name, int fromChannel, int toChannel) {
|
||||||
|
String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
|
||||||
|
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
|
||||||
|
switch (safeInt(e, "reasonid")) {
|
||||||
|
case REASON_MOVED:
|
||||||
|
conn.log(name + " was moved from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||||
|
+ conn.channelLink(toChannel) + "\" by " + invokerName(e));
|
||||||
|
break;
|
||||||
|
case REASON_CHANNEL_KICK:
|
||||||
|
conn.log(name + " was kicked from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||||
|
+ conn.channelLink(toChannel) + "\" by " + invokerName(e) + suffix);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conn.log(name + " switched from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||||
|
+ conn.channelLink(toChannel) + "\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client became visible: either they just connected, or they moved in from a
|
||||||
|
* channel we could not see — TeamSpeak's "appears" case.
|
||||||
|
*/
|
||||||
|
private void announceClientEntered(ClientJoinEvent e) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
int clientId = e.getClientId();
|
||||||
|
boolean current = conn.inOwnChannel(e.getClientTargetId());
|
||||||
|
Map<String, String> vars = conn.clientVars(clientId, e.getClientNickname());
|
||||||
|
switch (safeInt(e, "reasonid")) {
|
||||||
|
case REASON_MOVED:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS
|
||||||
|
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||||
|
break;
|
||||||
|
case REASON_CHANNEL_KICK:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS
|
||||||
|
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||||
|
break;
|
||||||
|
case REASON_SWITCHED:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS
|
||||||
|
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL
|
||||||
|
: SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client stopped being visible: they left the server, or moved out of sight. */
|
||||||
|
private void announceClientLeft(ClientLeaveEvent e) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
int clientId = e.getClientId();
|
||||||
|
boolean current = conn.inOwnChannel(e.getClientFromId());
|
||||||
|
Map<String, String> vars = conn.clientVars(clientId, null);
|
||||||
|
switch (safeInt(e, "reasonid")) {
|
||||||
|
case REASON_TIMEOUT:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL
|
||||||
|
: SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars);
|
||||||
|
break;
|
||||||
|
case REASON_SERVER_KICK:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL
|
||||||
|
: SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars);
|
||||||
|
break;
|
||||||
|
case REASON_BAN:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL
|
||||||
|
: SoundEvent.CLIENT_WAS_BANNED_SERVER, vars);
|
||||||
|
break;
|
||||||
|
case REASON_CHANNEL_KICK:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||||
|
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||||
|
break;
|
||||||
|
case REASON_MOVED:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||||
|
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||||
|
break;
|
||||||
|
case REASON_SWITCHED:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||||
|
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL
|
||||||
|
: SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client we can see moved between two channels we can see ("stays"). */
|
||||||
|
private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
boolean toCurrent = conn.inOwnChannel(toChannel);
|
||||||
|
boolean fromCurrent = conn.inOwnChannel(fromChannel);
|
||||||
|
Map<String, String> vars = conn.clientVars(clientId, null);
|
||||||
|
switch (reason) {
|
||||||
|
case REASON_MOVED:
|
||||||
|
conn.sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS
|
||||||
|
: fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS
|
||||||
|
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars);
|
||||||
|
break;
|
||||||
|
case REASON_CHANNEL_KICK:
|
||||||
|
conn.sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS
|
||||||
|
: fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS
|
||||||
|
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conn.sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS
|
||||||
|
: fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS
|
||||||
|
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** We changed channel ourselves, or somebody changed it for us. */
|
||||||
|
private void announceOwnMove(int reason, ClientMovedEvent e) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
Map<String, String> vars = conn.channelVars(e.getTargetChannelId(), e.get("invokername"));
|
||||||
|
switch (reason) {
|
||||||
|
case REASON_MOVED:
|
||||||
|
conn.sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars);
|
||||||
|
break;
|
||||||
|
case REASON_CHANNEL_KICK:
|
||||||
|
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conn.sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** We were removed from the server (kick or ban); the disconnect follows. */
|
||||||
|
private void announceOwnRemoval(int reason, ClientLeaveEvent e) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
Map<String, String> vars = SoundNotifier.vars(
|
||||||
|
"servername", conn.getModel().getServerName(),
|
||||||
|
"clientname", TeamspeakConnection.orEmpty(e.get("invokername")),
|
||||||
|
"reason", TeamspeakConnection.orEmpty(e.get("reasonmsg")));
|
||||||
|
if (reason == REASON_SERVER_KICK) {
|
||||||
|
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars);
|
||||||
|
} else if (reason == REASON_BAN) {
|
||||||
|
conn.sound(SoundEvent.YOU_WERE_BANNED, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClientChanged(ClientUpdatedEvent e) {
|
||||||
|
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||||
|
if (c == null) return;
|
||||||
|
boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname);
|
||||||
|
String oldName = c.nickname;
|
||||||
|
if (renamed) c.nickname = e.get("client_nickname");
|
||||||
|
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
|
||||||
|
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
|
||||||
|
if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware");
|
||||||
|
if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware");
|
||||||
|
if (has(e, "client_away")) {
|
||||||
|
c.away = e.getBoolean("client_away");
|
||||||
|
// Both fields travel together, so an absent message here means "no message"
|
||||||
|
// — which `has` cannot tell from "not reported".
|
||||||
|
c.awayMessage = c.away ? TeamspeakConnection.orEmpty(e.get("client_away_message")) : "";
|
||||||
|
} else if (has(e, "client_away_message")) {
|
||||||
|
c.awayMessage = e.get("client_away_message");
|
||||||
|
}
|
||||||
|
if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power");
|
||||||
|
if (has(e, "client_description")) {
|
||||||
|
c.description = e.get("client_description");
|
||||||
|
conn.ui.onInfoUpdated();
|
||||||
|
}
|
||||||
|
if (has(e, "client_is_channel_commander"))
|
||||||
|
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
||||||
|
announceClientUpdate(e, c, renamed, oldName);
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renames, talk-power changes and recording flags all arrive as a client update. */
|
||||||
|
private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
boolean self = c.id == conn.getSelfClientId();
|
||||||
|
Map<String, String> vars = conn.clientVars(c.id, null);
|
||||||
|
|
||||||
|
if (renamed) {
|
||||||
|
conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId()
|
||||||
|
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
|
||||||
|
conn.log(oldName + " is now known as " + conn.clientLink(c.id));
|
||||||
|
}
|
||||||
|
if (safeInt(e, "client_talk_request") > 0 && !self) {
|
||||||
|
conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
|
||||||
|
}
|
||||||
|
if (self && has(e, "client_is_talker")) {
|
||||||
|
conn.sound(e.getBoolean("client_is_talker")
|
||||||
|
? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars);
|
||||||
|
}
|
||||||
|
if (!self && has(e, "client_is_recording")) {
|
||||||
|
boolean recording = e.getBoolean("client_is_recording");
|
||||||
|
if (!recording) {
|
||||||
|
conn.sound(SoundEvent.CLIENT_RECORDING_STOP, vars);
|
||||||
|
} else {
|
||||||
|
conn.sound(conn.inOwnChannel(c.channelId)
|
||||||
|
? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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");
|
||||||
|
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order);
|
||||||
|
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id");
|
||||||
|
if (icon != 0) node.iconId = icon;
|
||||||
|
conn.getModel().relinkChannel(cid, pid, order);
|
||||||
|
if (conn.isConnected()) {
|
||||||
|
conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
|
||||||
|
SoundEvent.CHANNEL_CREATED_BY_OTHER), conn.channelVars(cid, e.get("invokername")));
|
||||||
|
conn.log("Channel \"" + conn.channelLink(cid) + "\" was created by " + invokerName(e));
|
||||||
|
}
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChannelDeleted(ChannelDeletedEvent e) {
|
||||||
|
int cid = e.getChannelId();
|
||||||
|
if (conn.isConnected()) {
|
||||||
|
conn.sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER,
|
||||||
|
SoundEvent.CHANNEL_DELETED_BY_SERVER), conn.channelVars(cid, e.get("invokername")));
|
||||||
|
conn.log("Channel \"" + conn.channelName(cid) + "\" was deleted by " + invokerName(e));
|
||||||
|
}
|
||||||
|
conn.getModel().removeChannel(cid);
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChannelEdit(ChannelEditedEvent e) {
|
||||||
|
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
|
||||||
|
if (ch != null) {
|
||||||
|
if (has(e, "channel_name")) ch.name = e.get("channel_name");
|
||||||
|
if (has(e, "channel_order")) ch.order = e.getInt("channel_order");
|
||||||
|
if (has(e, "channel_icon_id")) ch.iconId = TeamspeakConnection.safeLong(e, "channel_icon_id");
|
||||||
|
if (has(e, "channel_topic")) ch.topic = e.get("channel_topic");
|
||||||
|
if (conn.isConnected()) {
|
||||||
|
boolean current = conn.inOwnChannel(ch.id);
|
||||||
|
conn.sound(current
|
||||||
|
? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU,
|
||||||
|
SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER)
|
||||||
|
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
|
||||||
|
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
|
||||||
|
conn.channelVars(ch.id, e.get("invokername")));
|
||||||
|
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was edited by " + invokerName(e));
|
||||||
|
}
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Descriptions never travel with the notify, so a cached one has to be re-fetched.
|
||||||
|
* Only channels whose description was already loaded are refreshed; the rest pick the
|
||||||
|
* new text up the first time they are looked at.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onChannelDescriptionChanged(ChannelDescriptionEditedEvent e) {
|
||||||
|
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
|
||||||
|
if (ch == null || !ch.descriptionLoaded) return;
|
||||||
|
ch.descriptionLoaded = false;
|
||||||
|
conn.requestChannelInfo(ch.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChannelMoved(ChannelMovedEvent e) {
|
||||||
|
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
|
||||||
|
if (ch != null) {
|
||||||
|
int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId;
|
||||||
|
int order = has(e, "order") ? e.getInt("order") : ch.order;
|
||||||
|
conn.getModel().relinkChannel(ch.id, parent, order);
|
||||||
|
if (conn.isConnected()) {
|
||||||
|
conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
|
||||||
|
SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername")));
|
||||||
|
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e));
|
||||||
|
}
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChannelSubscribed(ChannelSubscribedEvent e) {
|
||||||
|
setSubscribed(safeInt(e, "cid"), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) {
|
||||||
|
setSubscribed(safeInt(e, "cid"), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setSubscribed(int cid, boolean subscribed) {
|
||||||
|
ChannelNode ch = conn.getModel().getChannel(cid);
|
||||||
|
if (ch == null || ch.subscribed == subscribed) return;
|
||||||
|
ch.subscribed = subscribed;
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerEdit(ServerEditedEvent e) {
|
||||||
|
if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name"));
|
||||||
|
if (conn.isConnected()) {
|
||||||
|
conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER,
|
||||||
|
SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars());
|
||||||
|
}
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) {
|
||||||
|
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||||
|
if (c != null) c.addServerGroup(e.getServerGroupId());
|
||||||
|
boolean self = e.getClientId() == conn.getSelfClientId();
|
||||||
|
conn.sound(self
|
||||||
|
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
|
||||||
|
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
|
||||||
|
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER,
|
||||||
|
SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER),
|
||||||
|
groupVars(e.getClientId(), e.getName()));
|
||||||
|
conn.log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
|
||||||
|
+ "\" by " + invokerName(e) + ".");
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
|
||||||
|
int clientId = safeInt(e, "clid");
|
||||||
|
ClientEntry c = conn.getModel().getClient(clientId);
|
||||||
|
if (c != null) c.removeServerGroup(e.getServerGroupId());
|
||||||
|
boolean self = clientId == conn.getSelfClientId();
|
||||||
|
conn.sound(self
|
||||||
|
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
|
||||||
|
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
|
||||||
|
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER,
|
||||||
|
SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER),
|
||||||
|
groupVars(clientId, e.get("name")));
|
||||||
|
conn.log(clientLogName(clientId) + " was removed from server group \"" + TeamspeakConnection.orEmpty(e.get("name"))
|
||||||
|
+ "\" by " + invokerName(e) + ".");
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) {
|
||||||
|
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||||
|
if (c != null) c.channelGroupId = e.getChannelGroupId();
|
||||||
|
boolean self = e.getClientId() == conn.getSelfClientId();
|
||||||
|
String groupName = conn.getModel().channelGroupName(e.getChannelGroupId());
|
||||||
|
conn.sound(self
|
||||||
|
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,
|
||||||
|
SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER)
|
||||||
|
: byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER,
|
||||||
|
SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER),
|
||||||
|
groupVars(e.getClientId(), groupName));
|
||||||
|
conn.log("Channel group \"" + TeamspeakConnection.orEmpty(groupName) + "\" was assigned to "
|
||||||
|
+ clientLogName(e.getClientId()) + " by " + invokerName(e) + ".");
|
||||||
|
conn.ui.onModelChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client's name for a log line, as a link, falling back to its id once it has left. */
|
||||||
|
private String clientLogName(int clientId) {
|
||||||
|
return conn.clientLink(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the event variant matching who caused the change: us, another client, or the server. */
|
||||||
|
private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) {
|
||||||
|
int invoker = safeInt(e, "invokerid");
|
||||||
|
if (invoker == conn.getSelfClientId() && invoker != 0) return byYou;
|
||||||
|
return invoker == 0 ? byServer : byOther;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> groupVars(int clientId, String groupName) {
|
||||||
|
Map<String, String> vars = conn.clientVars(clientId, null);
|
||||||
|
vars.put("groupname", TeamspeakConnection.orEmpty(groupName));
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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");
|
||||||
|
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order);
|
||||||
|
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id");
|
||||||
|
if (icon != 0) node.iconId = icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerGroupList(ServerGroupListEvent e) {
|
||||||
|
int id = safeInt(e, "sgid");
|
||||||
|
if (id > 0) conn.getModel().putServerGroup(toGroup(e, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChannelGroupList(ChannelGroupListEvent e) {
|
||||||
|
int id = safeInt(e, "cgid");
|
||||||
|
if (id > 0) conn.getModel().putChannelGroup(toGroup(e, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Group toGroup(BaseEvent e, int id) {
|
||||||
|
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"),
|
||||||
|
safeInt(e, "type"), safeInt(e, "n_member_addp"), safeInt(e, "n_member_removep"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPermissionList(PermissionListEvent e) {
|
||||||
|
conn.getModel().putPermissionName(e.get("permname"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClientNeededPermissions(ClientNeededPermissionsEvent e) {
|
||||||
|
conn.getModel().putSelfPermissionValue(safeInt(e, "permid"), safeInt(e, "permvalue"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTextMessage(TextMessageEvent e) {
|
||||||
|
if (e.getInvokerId() == conn.getSelfClientId()) return; // don't echo our own
|
||||||
|
ConnectionListener.ChatScope scope;
|
||||||
|
SoundEvent notification;
|
||||||
|
switch (e.getTargetMode()) {
|
||||||
|
case CLIENT:
|
||||||
|
scope = ConnectionListener.ChatScope.PRIVATE;
|
||||||
|
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT;
|
||||||
|
break;
|
||||||
|
case CHANNEL:
|
||||||
|
scope = ConnectionListener.ChatScope.CHANNEL;
|
||||||
|
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
scope = ConnectionListener.ChatScope.SERVER;
|
||||||
|
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
conn.sound(notification, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
|
||||||
|
conn.ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClientPoke(ClientPokeEvent e) {
|
||||||
|
conn.sound(SoundEvent.OTHER_RECEIVED_POKE, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
|
||||||
|
conn.ui.onPoke(TeamspeakConnection.orEmpty(e.getInvokerName()), TeamspeakConnection.orEmpty(e.get("msg")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||||
|
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
|
||||||
|
else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
/** Who caused an event, linked where the server named them; "the server" when nobody did. */
|
||||||
|
private String invokerName(BaseEvent e) {
|
||||||
|
String name = TeamspeakConnection.orEmpty(e.get("invokername"));
|
||||||
|
if (name.isEmpty()) return "the server";
|
||||||
|
return conn.clientLink(safeInt(e, "invokerid"),
|
||||||
|
TeamspeakConnection.orEmpty(e.get("invokeruid")), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the event actually carries a field. ts3j answers a missing key with an
|
||||||
|
* empty string rather than null, so a plain null check is always true — and a
|
||||||
|
* partial update (say, someone muting) would otherwise look like it reported
|
||||||
|
* every other field as well.
|
||||||
|
*/
|
||||||
|
private static boolean has(BaseEvent e, String key) {
|
||||||
|
String value = e.get(key);
|
||||||
|
return value != null && !value.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,4 +33,11 @@ public interface ConnectionListener {
|
|||||||
|
|
||||||
/** Someone poked the local client. */
|
/** Someone poked the local client. */
|
||||||
void onPoke(String fromName, String message);
|
void onPoke(String fromName, String message);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something happened on the server that native TS3 records in the server
|
||||||
|
* log: a client connecting/disconnecting/moving, a channel being edited,
|
||||||
|
* a group being (un)assigned, and so on.
|
||||||
|
*/
|
||||||
|
void onServerLog(String message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package com.ts3client.net;
|
||||||
|
|
||||||
|
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.UnknownTeamspeakEvent;
|
||||||
|
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 java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds {@link ConnectionStats} snapshots for the info dialog. The local client's own
|
||||||
|
* figures are read straight from its live packet counters; a remote client's come from
|
||||||
|
* {@code getconnectioninfo}, whose {@code notifyconnectioninfo} report arrives
|
||||||
|
* asynchronously via {@link #onReport} and is matched back up by client id.
|
||||||
|
*/
|
||||||
|
final class ConnectionStatsCollector {
|
||||||
|
|
||||||
|
private final TeamspeakConnection conn;
|
||||||
|
|
||||||
|
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
||||||
|
private final Map<Integer, PendingConnInfo> pending = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
ConnectionStatsCollector(TeamspeakConnection conn) {
|
||||||
|
this.conn = conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops any requests left over from a connection that just went away. */
|
||||||
|
void clear() {
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches connection statistics for a client and delivers them to
|
||||||
|
* {@code callback} (invoked off the Swing EDT — the callback is responsible
|
||||||
|
* for marshalling). May be called repeatedly to poll.
|
||||||
|
*/
|
||||||
|
void request(int clientId, Consumer<ConnectionStats> callback) {
|
||||||
|
if (clientId == conn.getSelfClientId()) {
|
||||||
|
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
|
||||||
|
} else {
|
||||||
|
new Thread(() -> requestRemote(clientId, callback), "ts3j-conninfo").start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches an asynchronously arriving {@code notifyconnectioninfo} report to its request. */
|
||||||
|
void onReport(UnknownTeamspeakEvent e) {
|
||||||
|
int clid = safeInt(e.get("clid"));
|
||||||
|
PendingConnInfo p = pending.remove(clid);
|
||||||
|
if (p == null) return;
|
||||||
|
applyConnectionFields(p.stats, e.getMap());
|
||||||
|
p.callback.accept(p.stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a live snapshot of the local client's connection from its own counters. */
|
||||||
|
private ConnectionStats buildLocalStats() {
|
||||||
|
ConnectionStats s = new ConnectionStats();
|
||||||
|
s.clientId = conn.getSelfClientId();
|
||||||
|
s.self = true;
|
||||||
|
s.live = true;
|
||||||
|
s.packetLoss = 0; // the local client has no server->client loss figure
|
||||||
|
|
||||||
|
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||||
|
if (self != null) {
|
||||||
|
s.nickname = self.nickname;
|
||||||
|
s.version = self.version;
|
||||||
|
s.platform = self.platform;
|
||||||
|
s.idleTimeMs = self.idleTimeMs;
|
||||||
|
}
|
||||||
|
long connectedAt = conn.connectedAtMs;
|
||||||
|
if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt;
|
||||||
|
|
||||||
|
LocalTeamspeakClientSocket c = conn.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 #onReport}. If the report does not arrive shortly (e.g. the
|
||||||
|
* server withholds it), the clientinfo-only snapshot is delivered instead.
|
||||||
|
*/
|
||||||
|
private void requestRemote(int clientId, Consumer<ConnectionStats> callback) {
|
||||||
|
ConnectionStats s = new ConnectionStats();
|
||||||
|
s.clientId = clientId;
|
||||||
|
ClientEntry entry = conn.getModel().getClient(clientId);
|
||||||
|
if (entry != null) s.nickname = entry.nickname;
|
||||||
|
|
||||||
|
LocalTeamspeakClientSocket client = conn.client;
|
||||||
|
try {
|
||||||
|
Client c = client.getClientInfo(clientId);
|
||||||
|
if (c != null) {
|
||||||
|
s.version = TeamspeakConnection.orEmpty(c.getVersion());
|
||||||
|
s.platform = TeamspeakConnection.orEmpty(c.getPlatform());
|
||||||
|
s.ip = TeamspeakConnection.orEmpty(c.getIp());
|
||||||
|
s.idleTimeMs = c.getIdleTime();
|
||||||
|
if (entry == null) s.nickname = TeamspeakConnection.orEmpty(c.getNickname());
|
||||||
|
applyConnectionFields(s, c.getMap());
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// clientinfo may be permission-restricted; continue with what we have
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingConnInfo p = new PendingConnInfo(callback, s);
|
||||||
|
pending.put(clientId, p);
|
||||||
|
|
||||||
|
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, onReport removed and delivered it.
|
||||||
|
if (pending.remove(clientId, p)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the parsed value, or 0 when it is absent or not a number */
|
||||||
|
private static int safeInt(String value) {
|
||||||
|
try {
|
||||||
|
return value == null ? 0 : Integer.parseInt(value.trim());
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,26 @@ public final class Group {
|
|||||||
public final long iconId;
|
public final long iconId;
|
||||||
/** Display order among groups; lower comes first. */
|
/** Display order among groups; lower comes first. */
|
||||||
public final int sortId;
|
public final int sortId;
|
||||||
|
/** TS3 group type: 0 = template, 1 = regular, 2 = query (server groups only). */
|
||||||
|
public final int type;
|
||||||
|
/** Power needed to add a member to this group. */
|
||||||
|
public final int neededMemberAddPower;
|
||||||
|
/** Power needed to remove a member from this group. */
|
||||||
|
public final int neededMemberRemovePower;
|
||||||
|
|
||||||
public Group(int id, String name, long iconId, int sortId) {
|
public Group(int id, String name, long iconId, int sortId, int type,
|
||||||
|
int neededMemberAddPower, int neededMemberRemovePower) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.iconId = iconId;
|
this.iconId = iconId;
|
||||||
this.sortId = sortId;
|
this.sortId = sortId;
|
||||||
|
this.type = type;
|
||||||
|
this.neededMemberAddPower = neededMemberAddPower;
|
||||||
|
this.neededMemberRemovePower = neededMemberRemovePower;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regular, user-assignable groups exclude templates and query-only groups. */
|
||||||
|
public boolean isRegular() {
|
||||||
|
return type == 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
@@ -29,6 +30,12 @@ public final class IconRepository {
|
|||||||
/** Icons at or below this id are bundled defaults rather than server uploads. */
|
/** Icons at or below this id are bundled defaults rather than server uploads. */
|
||||||
public static final int MAX_BUNDLED_ID = 1000;
|
public static final int MAX_BUNDLED_ID = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default group icons shipped with the client, in the order TeamSpeak's own icon
|
||||||
|
* packs list them. These are what the icon chooser offers as "local" icons.
|
||||||
|
*/
|
||||||
|
public static final List<Long> BUNDLED_IDS = List.of(100L, 200L, 300L, 500L, 600L);
|
||||||
|
|
||||||
/** Downloads {@code /icon_<id>} from the connected server's file repository. */
|
/** Downloads {@code /icon_<id>} from the connected server's file repository. */
|
||||||
public interface Fetcher {
|
public interface Fetcher {
|
||||||
byte[] fetchIcon(long iconId) throws Exception;
|
byte[] fetchIcon(long iconId) throws Exception;
|
||||||
|
|||||||
@@ -18,13 +18,29 @@ public final class ServerModel {
|
|||||||
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
|
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
|
||||||
private final Map<Integer, Group> serverGroups = new LinkedHashMap<>();
|
private final Map<Integer, Group> serverGroups = new LinkedHashMap<>();
|
||||||
private final Map<Integer, Group> channelGroups = new LinkedHashMap<>();
|
private final Map<Integer, Group> channelGroups = new LinkedHashMap<>();
|
||||||
|
/**
|
||||||
|
* Permission id -> name, learned from the server's {@code permissionlist} response.
|
||||||
|
* Entries carry no id of their own: TS3 numbers them by position in the response,
|
||||||
|
* and the empty "group_id_end" separator records marking category boundaries don't
|
||||||
|
* count toward that position, so only {@link #putPermissionName} advances it.
|
||||||
|
*/
|
||||||
|
private final Map<Integer, String> permissionNames = new LinkedHashMap<>();
|
||||||
|
private int nextPermissionId;
|
||||||
|
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
|
||||||
|
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
|
||||||
private String serverName = "TeamSpeak Server";
|
private String serverName = "TeamSpeak Server";
|
||||||
|
/** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */
|
||||||
|
private int defaultChannelGroupId;
|
||||||
|
|
||||||
public synchronized void clear() {
|
public synchronized void clear() {
|
||||||
channels.clear();
|
channels.clear();
|
||||||
clients.clear();
|
clients.clear();
|
||||||
serverGroups.clear();
|
serverGroups.clear();
|
||||||
channelGroups.clear();
|
channelGroups.clear();
|
||||||
|
permissionNames.clear();
|
||||||
|
nextPermissionId = 0;
|
||||||
|
selfPermissionValues.clear();
|
||||||
|
defaultChannelGroupId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- groups ----
|
// ---- groups ----
|
||||||
@@ -78,6 +94,64 @@ public final class ServerModel {
|
|||||||
return g == null ? null : g.name;
|
return g == null ? null : g.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Regular (non-template, non-query) server groups, ordered for display. */
|
||||||
|
public synchronized List<Group> allServerGroups() {
|
||||||
|
return regularGroups(serverGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regular (non-template, non-query) channel groups, ordered for display. */
|
||||||
|
public synchronized List<Group> allChannelGroups() {
|
||||||
|
return regularGroups(channelGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Group> regularGroups(Map<Integer, Group> groups) {
|
||||||
|
List<Group> list = new ArrayList<>();
|
||||||
|
for (Group g : groups.values()) {
|
||||||
|
if (g.isRegular()) list.add(g);
|
||||||
|
}
|
||||||
|
list.sort(Comparator.comparingInt((Group g) -> g.sortId).thenComparingInt(g -> g.id));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- permissions ----
|
||||||
|
|
||||||
|
/** Records the next named entry of a {@code permissionlist} response; ignores separators. */
|
||||||
|
public synchronized void putPermissionName(String name) {
|
||||||
|
if (name == null || name.isEmpty()) return;
|
||||||
|
permissionNames.put(nextPermissionId, name);
|
||||||
|
nextPermissionId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void putSelfPermissionValue(int permId, int value) {
|
||||||
|
selfPermissionValues.put(permId, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local client's resolved value for a named permission (e.g.
|
||||||
|
* {@code "i_group_needed_member_add_power"}), or {@code 0} if it isn't known
|
||||||
|
* yet (the {@code permissionlist} request is still in flight, or the server
|
||||||
|
* never reported a non-default value for it).
|
||||||
|
*/
|
||||||
|
/** Whether the server's {@code permissionlist} has arrived, so ids can be named at all. */
|
||||||
|
public synchronized boolean hasPermissionNames() {
|
||||||
|
return !permissionNames.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name the server gave permission id {@code id}, or {@code null} if unknown. */
|
||||||
|
public synchronized String permissionName(int id) {
|
||||||
|
return permissionNames.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int selfPermissionValue(String name) {
|
||||||
|
for (Map.Entry<Integer, String> entry : permissionNames.entrySet()) {
|
||||||
|
if (entry.getValue().equals(name)) {
|
||||||
|
Integer value = selfPermissionValues.get(entry.getKey());
|
||||||
|
if (value != null) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized String getServerName() {
|
public synchronized String getServerName() {
|
||||||
return serverName;
|
return serverName;
|
||||||
}
|
}
|
||||||
@@ -86,6 +160,14 @@ public final class ServerModel {
|
|||||||
if (name != null && !name.isEmpty()) this.serverName = name;
|
if (name != null && !name.isEmpty()) this.serverName = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public synchronized int defaultChannelGroupId() {
|
||||||
|
return defaultChannelGroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void setDefaultChannelGroupId(int id) {
|
||||||
|
this.defaultChannelGroupId = id;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- channels ----
|
// ---- channels ----
|
||||||
|
|
||||||
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {
|
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {
|
||||||
@@ -129,6 +211,41 @@ public final class ServerModel {
|
|||||||
c.order = newOrder;
|
c.order = newOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channels sharing a parent with {@code channelId}, in the order they appear in the
|
||||||
|
* tree and excluding the channel itself — what the editor offers as places to sort after.
|
||||||
|
*/
|
||||||
|
public synchronized List<ChannelNode> siblingsOf(int channelId) {
|
||||||
|
ChannelNode channel = channels.get(channelId);
|
||||||
|
if (channel == null) return new ArrayList<>();
|
||||||
|
// Ordered with the channel still in place: the order links form a chain, and
|
||||||
|
// pulling a link out of it first would strand everything below.
|
||||||
|
List<ChannelNode> siblings = childrenOf(channel.parentId);
|
||||||
|
siblings.remove(channel);
|
||||||
|
return siblings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channels directly below {@code parentId} (0 being the top level), in the order
|
||||||
|
* they appear in the tree — what a new channel can be sorted after.
|
||||||
|
*/
|
||||||
|
public synchronized List<ChannelNode> childrenOf(int parentId) {
|
||||||
|
List<ChannelNode> children = new ArrayList<>();
|
||||||
|
for (ChannelNode c : channels.values()) {
|
||||||
|
if (c.parentId == parentId) children.add(c);
|
||||||
|
}
|
||||||
|
sortSiblings(children);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The channel of that exact name directly below {@code parentId}, or {@code null}. */
|
||||||
|
public synchronized ChannelNode findChannelByName(int parentId, String name) {
|
||||||
|
for (ChannelNode c : channels.values()) {
|
||||||
|
if (c.parentId == parentId && c.name.equals(name)) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */
|
/** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */
|
||||||
public synchronized String channelPath(int id) {
|
public synchronized String channelPath(int id) {
|
||||||
ChannelNode c = channels.get(id);
|
ChannelNode c = channels.get(id);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,8 @@ public final class FileTransferManager {
|
|||||||
|
|
||||||
/** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */
|
/** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */
|
||||||
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
|
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
|
||||||
|
/** How long to wait for the server to finish listing a directory. */
|
||||||
|
private static final long LIST_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
private final LocalTeamspeakClientSocket socket;
|
private final LocalTeamspeakClientSocket socket;
|
||||||
/** Host we are connected to, used when the server reports no dedicated file-transfer host. */
|
/** Host we are connected to, used when the server reports no dedicated file-transfer host. */
|
||||||
@@ -70,6 +72,14 @@ public final class FileTransferManager {
|
|||||||
* routes back to the waiting transfer thread.
|
* routes back to the waiting transfer thread.
|
||||||
*/
|
*/
|
||||||
private final Map<Integer, CompletableFuture<Map<String, String>>> pendingInits = new ConcurrentHashMap<>();
|
private final Map<Integer, CompletableFuture<Map<String, String>>> pendingInits = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code ftgetfilelist} in progress. The server answers that command with a
|
||||||
|
* {@code notifyfilelist} event per entry and a closing {@code notifyfilelistfinished}
|
||||||
|
* rather than with rows of the command's own reply, so the entries are collected here
|
||||||
|
* until the listing ends. One listing may be in flight at a time.
|
||||||
|
*/
|
||||||
|
private volatile FileListRequest fileListRequest;
|
||||||
private final TS3Listener ftEventListener = new TS3Listener() {
|
private final TS3Listener ftEventListener = new TS3Listener() {
|
||||||
@Override
|
@Override
|
||||||
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||||
@@ -88,34 +98,49 @@ public final class FileTransferManager {
|
|||||||
/**
|
/**
|
||||||
* Lists the files and subdirectories directly under {@code path} in the given
|
* Lists the files and subdirectories directly under {@code path} in the given
|
||||||
* channel's repository. Returns an empty list for an empty directory.
|
* channel's repository. Returns an empty list for an empty directory.
|
||||||
|
*
|
||||||
|
* <p>Blocks until the server has sent the whole listing, so it must not be called from
|
||||||
|
* the event thread that delivers it.
|
||||||
*/
|
*/
|
||||||
public List<RemoteFile> list(int channelId, String channelPassword, String path)
|
public List<RemoteFile> list(int channelId, String channelPassword, String path)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
|
String directory = path == null || path.isEmpty() ? "/" : path;
|
||||||
SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT,
|
SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT,
|
||||||
new CommandSingleParameter("cid", Integer.toString(channelId)),
|
new CommandSingleParameter("cid", Integer.toString(channelId)),
|
||||||
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
|
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
|
||||||
new CommandSingleParameter("path", path == null || path.isEmpty() ? "/" : path));
|
new CommandSingleParameter("path", directory));
|
||||||
|
|
||||||
List<RemoteFile> files = new ArrayList<>();
|
FileListRequest request = new FileListRequest(channelId, directory);
|
||||||
Iterable<SingleCommand> rows;
|
fileListRequest = request;
|
||||||
try {
|
try {
|
||||||
rows = socket.executeCommand(cmd).get();
|
socket.executeCommand(cmd).complete();
|
||||||
|
// The command's reply only acknowledges it; the entries are events, and the
|
||||||
|
// server marks their end with notifyfilelistfinished.
|
||||||
|
request.finished.get(LIST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||||
} catch (com.github.manevolent.ts3j.command.CommandException e) {
|
} catch (com.github.manevolent.ts3j.command.CommandException e) {
|
||||||
if (e.getErrorId() == ERROR_DATABASE_EMPTY_RESULT) return files;
|
if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e;
|
||||||
throw e;
|
} catch (TimeoutException e) {
|
||||||
|
throw new IOException("The server did not finish listing " + directory);
|
||||||
|
} finally {
|
||||||
|
fileListRequest = null;
|
||||||
}
|
}
|
||||||
for (SingleCommand row : rows) {
|
return new ArrayList<>(request.files);
|
||||||
Map<String, String> m = row.toMap();
|
}
|
||||||
String name = m.get("name");
|
|
||||||
if (name == null || name.isEmpty()) continue;
|
/** Collects one {@code notifyfilelist} entry for the listing in flight. */
|
||||||
files.add(new RemoteFile(
|
private void collectListEntry(Map<String, String> row) {
|
||||||
|
FileListRequest request = fileListRequest;
|
||||||
|
if (request == null) return;
|
||||||
|
String cid = row.get("cid");
|
||||||
|
if (cid != null && !cid.isEmpty() && parseLong(cid) != request.channelId) return;
|
||||||
|
String name = row.get("name");
|
||||||
|
if (name == null || name.isEmpty()) return;
|
||||||
|
request.files.add(new RemoteFile(
|
||||||
name,
|
name,
|
||||||
m.getOrDefault("path", path),
|
row.getOrDefault("path", request.path),
|
||||||
parseLong(m.get("size")),
|
parseLong(row.get("size")),
|
||||||
"0".equals(m.get("type")),
|
"0".equals(row.get("type")),
|
||||||
parseLong(m.get("datetime"))));
|
parseLong(row.get("datetime"))));
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */
|
/** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */
|
||||||
@@ -351,6 +376,15 @@ public final class FileTransferManager {
|
|||||||
String command = e.getCommand();
|
String command = e.getCommand();
|
||||||
if (command == null) return;
|
if (command == null) return;
|
||||||
Map<String, String> map = e.getMap();
|
Map<String, String> map = e.getMap();
|
||||||
|
if (command.equals("notifyfilelist")) {
|
||||||
|
collectListEntry(map);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.equals("notifyfilelistfinished")) {
|
||||||
|
FileListRequest request = fileListRequest;
|
||||||
|
if (request != null) request.finished.complete(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Integer ftfid = tryParseInt(map.get("clientftfid"));
|
Integer ftfid = tryParseInt(map.get("clientftfid"));
|
||||||
if (ftfid == null) return;
|
if (ftfid == null) return;
|
||||||
CompletableFuture<Map<String, String>> future = pendingInits.get(ftfid);
|
CompletableFuture<Map<String, String>> future = pendingInits.get(ftfid);
|
||||||
@@ -454,4 +488,17 @@ public final class FileTransferManager {
|
|||||||
String m = r.getMessage();
|
String m = r.getMessage();
|
||||||
return m != null ? m : r.getClass().getSimpleName();
|
return m != null ? m : r.getClass().getSimpleName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One in-flight {@code ftgetfilelist} and the entries received for it so far. */
|
||||||
|
private static final class FileListRequest {
|
||||||
|
final int channelId;
|
||||||
|
final String path;
|
||||||
|
final List<RemoteFile> files = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||||
|
final CompletableFuture<Void> finished = new CompletableFuture<>();
|
||||||
|
|
||||||
|
FileListRequest(int channelId, String path) {
|
||||||
|
this.channelId = channelId;
|
||||||
|
this.path = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public final class SoundNotifier {
|
|||||||
private volatile SoundPlayer player;
|
private volatile SoundPlayer player;
|
||||||
private volatile SoundPack pack;
|
private volatile SoundPack pack;
|
||||||
private volatile List<SoundPack> available = List.of();
|
private volatile List<SoundPack> available = List.of();
|
||||||
|
private volatile boolean soundsMuted;
|
||||||
|
|
||||||
public SoundNotifier(Settings settings) {
|
public SoundNotifier(Settings settings) {
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
@@ -55,6 +56,19 @@ public final class SoundNotifier {
|
|||||||
settings.soundPack = pack == null ? "" : pack.id();
|
settings.soundPack = pack == null ? "" : pack.id();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silences every notification sound until turned back on — TS3's "Mute Sounds"
|
||||||
|
* hotkey action. Unlike deafening, this is not tied to a server and even important
|
||||||
|
* actions stay quiet.
|
||||||
|
*/
|
||||||
|
public void setMuted(boolean muted) {
|
||||||
|
this.soundsMuted = muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMuted() {
|
||||||
|
return soundsMuted;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plays the sound for an action.
|
* Plays the sound for an action.
|
||||||
*
|
*
|
||||||
@@ -70,6 +84,7 @@ public final class SoundNotifier {
|
|||||||
* {@code clientname} or {@code channelname}
|
* {@code clientname} or {@code channelname}
|
||||||
*/
|
*/
|
||||||
public void fire(SoundEvent event, boolean muted, Map<String, String> variables) {
|
public void fire(SoundEvent event, boolean muted, Map<String, String> variables) {
|
||||||
|
if (soundsMuted) return;
|
||||||
NotificationSettings notifications = settings.notifications;
|
NotificationSettings notifications = settings.notifications;
|
||||||
if (!notifications.isEnabled(event)) return;
|
if (!notifications.isEnabled(event)) return;
|
||||||
if (muted && !notifications.isImportant(event)) return;
|
if (muted && !notifications.isImportant(event)) return;
|
||||||
@@ -98,7 +113,7 @@ public final class SoundNotifier {
|
|||||||
if (script == null) return;
|
if (script == null) return;
|
||||||
|
|
||||||
String resolved = script.resolve(withDefaults(variables));
|
String resolved = script.resolve(withDefaults(variables));
|
||||||
double volume = Math.max(0, Math.min(1.0, settings.soundVolume));
|
double volume = Math.max(0, Math.min(1.0, settings.effectiveSoundVolume()));
|
||||||
if (volume <= 0) return;
|
if (volume <= 0) return;
|
||||||
|
|
||||||
if (script.kind() == SoundScript.Kind.SAY) {
|
if (script.kind() == SoundScript.Kind.SAY) {
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ public final class BBCode {
|
|||||||
/** CSS class on links that leave the client, so they are visibly not an identity. */
|
/** CSS class on links that leave the client, so they are visibly not an identity. */
|
||||||
public static final String EXTERNAL_LINK_CLASS = "extlink";
|
public static final String EXTERNAL_LINK_CLASS = "extlink";
|
||||||
|
|
||||||
|
/** A single {@code [url=href]label[/url]} pair, the only markup the log carries. */
|
||||||
|
private static final Pattern URL_LINK =
|
||||||
|
Pattern.compile("(?i)\\[url=([^\\]]+)](.*?)\\[/url]");
|
||||||
|
|
||||||
private BBCode() {
|
private BBCode() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +74,29 @@ public final class BBCode {
|
|||||||
return out.toString();
|
return out.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders text carrying {@code [url=…]…[/url]} links and nothing else: everything
|
||||||
|
* outside those tags stays literal. The server log is written this way, so that a
|
||||||
|
* name happening to contain bracket markup is shown as it is rather than rendered.
|
||||||
|
*/
|
||||||
|
public static String linksToHtml(String input) {
|
||||||
|
if (input == null || input.isEmpty()) return "";
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
Matcher m = URL_LINK.matcher(input);
|
||||||
|
int last = 0;
|
||||||
|
while (m.find()) {
|
||||||
|
out.append(escape(input.substring(last, m.start())));
|
||||||
|
String href = m.group(1);
|
||||||
|
if (SAFE_URL.matcher(href).matches()) {
|
||||||
|
out.append(link(href)).append(escape(m.group(2))).append(linkClose(href));
|
||||||
|
} else {
|
||||||
|
out.append(escape(m.group()));
|
||||||
|
}
|
||||||
|
last = m.end();
|
||||||
|
}
|
||||||
|
return out.append(escape(input.substring(last))).toString();
|
||||||
|
}
|
||||||
|
|
||||||
/** Escapes plain text for HTML without interpreting any BBCode. */
|
/** Escapes plain text for HTML without interpreting any BBCode. */
|
||||||
public static String escape(String s) {
|
public static String escape(String s) {
|
||||||
if (s == null) return "";
|
if (s == null) return "";
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class RealFftTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rnn_vad transform length is 480 = 2^5 * 3 * 5, so the mixed-radix path has to be
|
||||||
|
* right for factors other than powers of two.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void matchesDirectDftForMixedRadixLengths() {
|
||||||
|
for (int n : new int[]{480, 240, 512, 64, 60, 210, 100}) {
|
||||||
|
RealFft fft = new RealFft(n);
|
||||||
|
Random random = new Random(n);
|
||||||
|
float[] in = new float[n];
|
||||||
|
float[] out = new float[n];
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
in[i] = (float) random.nextGaussian();
|
||||||
|
}
|
||||||
|
fft.forward(in, out);
|
||||||
|
|
||||||
|
for (int k = 0; k <= n / 2; k++) {
|
||||||
|
double re = 0;
|
||||||
|
double im = 0;
|
||||||
|
for (int t = 0; t < n; t++) {
|
||||||
|
double angle = -2 * Math.PI * k * t / n;
|
||||||
|
re += in[t] * Math.cos(angle);
|
||||||
|
im += in[t] * Math.sin(angle);
|
||||||
|
}
|
||||||
|
double gotRe;
|
||||||
|
double gotIm;
|
||||||
|
if (k == 0) {
|
||||||
|
gotRe = out[0];
|
||||||
|
gotIm = 0;
|
||||||
|
} else if (k == n / 2) {
|
||||||
|
gotRe = out[1]; // Packed layout keeps Nyquist in slot 1.
|
||||||
|
gotIm = 0;
|
||||||
|
} else {
|
||||||
|
gotRe = out[2 * k];
|
||||||
|
gotIm = out[2 * k + 1];
|
||||||
|
}
|
||||||
|
assertEquals(re, gotRe, 1e-2, "n=" + n + " bin=" + k + " real");
|
||||||
|
assertEquals(im, gotIm, 1e-2, "n=" + n + " bin=" + k + " imag");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsOddLengths() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
IllegalArgumentException.class, () -> new RealFft(481));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.ts3client.audio.vad;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavioural checks on the {@code rnn_vad} port. The network weights are the ones the TS3
|
||||||
|
* client links, so these also pin the detector's agreement with it.
|
||||||
|
*/
|
||||||
|
class RnnVadTest {
|
||||||
|
|
||||||
|
private static final int FRAMES = 60;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void silenceScoresZero() {
|
||||||
|
float[] x = new float[RnnVad.FRAME_SIZE * FRAMES];
|
||||||
|
double[] probabilities = run(x);
|
||||||
|
for (double p : probabilities) {
|
||||||
|
assertEquals(0.0, p, 0.0, "silence must not score above zero");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void voicedSpeechLikeSignalScoresHigh() {
|
||||||
|
float[] x = synth(FRAMES, t -> {
|
||||||
|
double sum = 0;
|
||||||
|
for (int h = 1; h <= 30 && 120 * h < 8000; h++) {
|
||||||
|
double f = 120 * h;
|
||||||
|
double gain = formant(f, 700, 90) + formant(f, 1220, 110) + formant(f, 2600, 160);
|
||||||
|
sum += gain * Math.sin(2 * Math.PI * f * t + h);
|
||||||
|
}
|
||||||
|
// A slow tremolo keeps it from looking like a stationary tone.
|
||||||
|
return 0.25 * (0.6 + 0.4 * Math.sin(2 * Math.PI * 5 * t)) * sum / 6.0;
|
||||||
|
});
|
||||||
|
// Skip the first frames: the pitch buffer and GRU need history.
|
||||||
|
assertTrue(meanFrom(run(x), 20) > 0.8, "a voiced signal should score high");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void quietPinkNoiseScoresLow() {
|
||||||
|
Random random = new Random(3);
|
||||||
|
float[] x = new float[RnnVad.FRAME_SIZE * FRAMES];
|
||||||
|
double b0 = 0;
|
||||||
|
double b1 = 0;
|
||||||
|
double b2 = 0;
|
||||||
|
for (int i = 0; i < x.length; i++) {
|
||||||
|
double w = random.nextGaussian();
|
||||||
|
b0 = 0.99765 * b0 + w * 0.0990460;
|
||||||
|
b1 = 0.96300 * b1 + w * 0.2965164;
|
||||||
|
b2 = 0.57000 * b2 + w * 1.0526913;
|
||||||
|
// About -45 dBFS: representative of room tone rather than loud hiss.
|
||||||
|
x[i] = (float) ((b0 + b1 + b2 + w * 0.1848) * 0.0011);
|
||||||
|
}
|
||||||
|
assertTrue(meanFrom(run(x), 20) < 0.2, "quiet room tone should not read as speech");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void estimatesPitchPeriodExactly() {
|
||||||
|
// The pitch feature is fed to the network as 0.01 * (period48k - 300).
|
||||||
|
for (double f0 : new double[]{80, 100, 120, 150, 200, 250, 300, 400}) {
|
||||||
|
RnnVad vad = new RnnVad();
|
||||||
|
float[] x = synth(FRAMES, t -> {
|
||||||
|
double sum = 0;
|
||||||
|
for (int h = 1; h <= 40 && f0 * h < 11000; h++) {
|
||||||
|
sum += Math.sin(2 * Math.PI * f0 * h * t) / h;
|
||||||
|
}
|
||||||
|
return 0.2 * sum;
|
||||||
|
});
|
||||||
|
for (int f = 0; f < FRAMES; f++) {
|
||||||
|
vad.process(x, f * RnnVad.FRAME_SIZE);
|
||||||
|
}
|
||||||
|
assertEquals(48000 / f0, vad.pitchPeriod48kHz(), 1e-9,
|
||||||
|
"pitch period for f0=" + f0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetClearsState() {
|
||||||
|
float[] x = synth(FRAMES, t -> 0.3 * Math.sin(2 * Math.PI * 200 * t));
|
||||||
|
RnnVad vad = new RnnVad();
|
||||||
|
for (int f = 0; f < FRAMES; f++) {
|
||||||
|
vad.process(x, f * RnnVad.FRAME_SIZE);
|
||||||
|
}
|
||||||
|
assertTrue(vad.probability() > 0.0);
|
||||||
|
vad.reset();
|
||||||
|
assertEquals(0.0, vad.probability(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private interface Signal {
|
||||||
|
double at(double t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double formant(double f, double centre, double bandwidth) {
|
||||||
|
double d = (f - centre) / bandwidth;
|
||||||
|
return 1.0 / (1.0 + d * d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] synth(int frames, Signal signal) {
|
||||||
|
float[] x = new float[RnnVad.FRAME_SIZE * frames];
|
||||||
|
for (int i = 0; i < x.length; i++) {
|
||||||
|
x[i] = (float) signal.at((double) i / RnnVad.SAMPLE_RATE);
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double[] run(float[] x) {
|
||||||
|
RnnVad vad = new RnnVad();
|
||||||
|
int frames = x.length / RnnVad.FRAME_SIZE;
|
||||||
|
double[] probabilities = new double[frames];
|
||||||
|
for (int i = 0; i < frames; i++) {
|
||||||
|
probabilities[i] = vad.process(x, i * RnnVad.FRAME_SIZE);
|
||||||
|
}
|
||||||
|
return probabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double meanFrom(double[] values, int start) {
|
||||||
|
double sum = 0;
|
||||||
|
for (int i = start; i < values.length; i++) {
|
||||||
|
sum += values[i];
|
||||||
|
}
|
||||||
|
return sum / (values.length - start);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class HotkeyEngineTest {
|
||||||
|
|
||||||
|
private static final HotkeyKey CTRL = HotkeyKey.keyboard(37);
|
||||||
|
private static final HotkeyKey K = HotkeyKey.keyboard(45);
|
||||||
|
private static final HotkeyKey MOUSE4 = HotkeyKey.mouse(8);
|
||||||
|
|
||||||
|
private final List<String> fired = new ArrayList<>();
|
||||||
|
private final Hotkeys hotkeys = new Hotkeys();
|
||||||
|
private final HotkeyEngine engine = new HotkeyEngine(hotkeys,
|
||||||
|
(hotkey, active) -> fired.add(hotkey.action + (active ? ":on" : ":off")));
|
||||||
|
|
||||||
|
private Hotkey bind(HotkeyAction action, Hotkey.Trigger trigger, HotkeyKey... keys) {
|
||||||
|
Hotkey h = new Hotkey(action, new HotkeyCombo(List.of(keys)));
|
||||||
|
h.trigger = trigger;
|
||||||
|
hotkeys.add(h);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firesOnKeyDownOnlyWhenEveryKeyIsHeld() {
|
||||||
|
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, CTRL, K);
|
||||||
|
|
||||||
|
engine.onInput(K, true);
|
||||||
|
assertTrue(fired.isEmpty(), "one key of the combination is not enough");
|
||||||
|
engine.onInput(CTRL, true);
|
||||||
|
assertEquals(List.of("MIC_TOGGLE:on"), fired);
|
||||||
|
|
||||||
|
// Releasing and re-pressing arms it again, but only once per completion.
|
||||||
|
engine.onInput(CTRL, false);
|
||||||
|
engine.onInput(CTRL, true);
|
||||||
|
assertEquals(List.of("MIC_TOGGLE:on", "MIC_TOGGLE:on"), fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keyUpTriggerFiresOnRelease() {
|
||||||
|
bind(HotkeyAction.SPEAKER_TOGGLE, Hotkey.Trigger.KEY_UP, MOUSE4);
|
||||||
|
|
||||||
|
engine.onInput(MOUSE4, true);
|
||||||
|
assertTrue(fired.isEmpty());
|
||||||
|
engine.onInput(MOUSE4, false);
|
||||||
|
assertEquals(List.of("SPEAKER_TOGGLE:on"), fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void momentaryActionReportsPressAndRelease() {
|
||||||
|
bind(HotkeyAction.PTT_ACTIVATE, Hotkey.Trigger.KEY_UP, MOUSE4);
|
||||||
|
|
||||||
|
engine.onInput(MOUSE4, true);
|
||||||
|
engine.onInput(MOUSE4, false);
|
||||||
|
// The trigger setting does not apply: push-to-talk always spans the hold.
|
||||||
|
assertEquals(List.of("PTT_ACTIVATE:on", "PTT_ACTIVATE:off"), fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledAndUnsupportedBindingsStaySilent() {
|
||||||
|
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K).enabled = false;
|
||||||
|
bind(HotkeyAction.RECORDING_START, Hotkey.Trigger.KEY_DOWN, MOUSE4);
|
||||||
|
|
||||||
|
engine.onInput(K, true);
|
||||||
|
engine.onInput(MOUSE4, true);
|
||||||
|
assertTrue(fired.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordingCapturesTheCombinationInsteadOfFiring() {
|
||||||
|
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K);
|
||||||
|
List<HotkeyCombo> recorded = new ArrayList<>();
|
||||||
|
engine.record(new HotkeyEngine.Recorder() {
|
||||||
|
@Override
|
||||||
|
public void onRecording(HotkeyCombo combo) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRecorded(HotkeyCombo combo) {
|
||||||
|
recorded.add(combo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.onInput(CTRL, true);
|
||||||
|
engine.onInput(K, true);
|
||||||
|
engine.onInput(K, false);
|
||||||
|
engine.onInput(CTRL, false);
|
||||||
|
|
||||||
|
assertTrue(fired.isEmpty(), "bindings must not fire while recording");
|
||||||
|
assertEquals(1, recorded.size());
|
||||||
|
assertEquals(List.of(CTRL, K), recorded.get(0).keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void actionsCarryTeamSpeaksTreeGrouping() {
|
||||||
|
assertEquals("Sounds", HotkeyAction.SOUNDPACK_ACTIVATE.group());
|
||||||
|
assertEquals("Microphone", HotkeyAction.MIC_TOGGLE.group());
|
||||||
|
// TS3 leaves a handful of actions ungrouped, listed straight under their category.
|
||||||
|
assertEquals("", HotkeyAction.NICKNAME_CHANGE.group());
|
||||||
|
assertEquals("", HotkeyAction.BRING_TO_FRONT.group());
|
||||||
|
|
||||||
|
Hotkey pack = new Hotkey(HotkeyAction.SOUNDPACK_ACTIVATE, new HotkeyCombo(List.of(K)));
|
||||||
|
pack.argument = "Default Sound Pack (Male)";
|
||||||
|
assertEquals("Sounds / Activate Soundpack / Default Sound Pack (Male)", pack.path());
|
||||||
|
assertEquals("Bring Client to Front",
|
||||||
|
new Hotkey(HotkeyAction.BRING_TO_FRONT, new HotkeyCombo(List.of(K))).path());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinationsSurviveARoundTripThroughText() {
|
||||||
|
HotkeyCombo combo = new HotkeyCombo(List.of(CTRL, MOUSE4));
|
||||||
|
assertEquals("Keyboard:37+Mouse:8", combo.toString());
|
||||||
|
assertEquals(combo, HotkeyCombo.parse(combo.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pins the ts3j behaviour {@code TeamspeakConnection.has(...)} exists for: a field
|
* Pins the ts3j behaviour {@code ConnectionEventHandler.has(...)} exists for: a field
|
||||||
* the event never carried reads back as an empty string, so a null check would treat
|
* the event never carried reads back as an empty string, so a null check would treat
|
||||||
* every partial update (someone muting, say) as if it reported every other field too.
|
* every partial update (someone muting, say) as if it reported every other field too.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ts3client.text;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server log's renderer: it turns the client's own links into anchors and leaves
|
||||||
|
* everything else — names carrying bracket markup included — as plain text.
|
||||||
|
*/
|
||||||
|
class BBCodeLinksTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rendersClientAndChannelLinks() {
|
||||||
|
String log = TsLink.clientBBCode(7, "uid=", "Bob") + " connected to channel \""
|
||||||
|
+ TsLink.channelBBCode(3, "Lobby") + "\"";
|
||||||
|
String html = BBCode.linksToHtml(log);
|
||||||
|
assertTrue(html.contains("href=\"client://7/uid=~Bob\""), html);
|
||||||
|
assertTrue(html.contains(">Bob</a>"), html);
|
||||||
|
assertTrue(html.contains("href=\"channel://3/Lobby\""), html);
|
||||||
|
assertTrue(html.contains(">Lobby</a>"), html);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void leavesOtherMarkupLiteral() {
|
||||||
|
assertEquals("[b]not bold[/b] & <i>", BBCode.linksToHtml("[b]not bold[/b] & <i>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsBracketsInsideALabelLiteral() {
|
||||||
|
String html = BBCode.linksToHtml(TsLink.channelBBCode(3, "[cspacer]---"));
|
||||||
|
assertTrue(html.contains(">[cspacer]---</a>"), html);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsHrefsThatAreNotLinks() {
|
||||||
|
assertEquals("[url=javascript:alert(1)]x[/url]", BBCode.linksToHtml("[url=javascript:alert(1)]x[/url]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ package com.ts3client.audio.desktop;
|
|||||||
|
|
||||||
import com.github.manevolent.ts3j.enums.CodecType;
|
import com.github.manevolent.ts3j.enums.CodecType;
|
||||||
import com.ts3client.audio.AudioEnhancer;
|
import com.ts3client.audio.AudioEnhancer;
|
||||||
|
import com.ts3client.audio.AudioFrameListener;
|
||||||
|
import com.ts3client.audio.InputLevel;
|
||||||
import com.ts3client.audio.OpusParameters;
|
import com.ts3client.audio.OpusParameters;
|
||||||
import com.ts3client.audio.SpeechDetector;
|
import com.ts3client.audio.SpeechProbabilityDetector;
|
||||||
import com.ts3client.audio.VoiceInput;
|
import com.ts3client.audio.VoiceInput;
|
||||||
|
import com.ts3client.audio.vad.RnnSpeechDetector;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
@@ -28,10 +31,27 @@ import java.util.function.Consumer;
|
|||||||
*/
|
*/
|
||||||
public final class DesktopVoiceInput implements VoiceInput {
|
public final class DesktopVoiceInput implements VoiceInput {
|
||||||
|
|
||||||
private static final int HANGOVER_FRAMES = 15; // ~300 ms of tail after level drops
|
/**
|
||||||
|
* How long the gate stays open after the last active frame. TS3 counts 64 of its
|
||||||
|
* 10 ms preprocessor frames before closing, so the tail of a word is never clipped.
|
||||||
|
*/
|
||||||
|
private static final int HANGOVER_MS = 640;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio replayed when the gate opens, so a word's onset is not swallowed by the frame
|
||||||
|
* that detected it. TS3 keeps up to three 10 ms buffers ({@code vad_extrabuffersize}
|
||||||
|
* defaults to 2, plus one).
|
||||||
|
*/
|
||||||
|
private static final int PREROLL_MS = 30;
|
||||||
|
|
||||||
|
private static final int HANGOVER_FRAMES =
|
||||||
|
Math.max(1, HANGOVER_MS * AudioDevices.SAMPLE_RATE / 1000 / AudioDevices.FRAME_SIZE);
|
||||||
|
private static final int PREROLL_FRAMES =
|
||||||
|
Math.max(1, PREROLL_MS * AudioDevices.SAMPLE_RATE / 1000 / AudioDevices.FRAME_SIZE);
|
||||||
|
|
||||||
private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>();
|
private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>();
|
||||||
private final AtomicBoolean muted = new AtomicBoolean(false);
|
private final AtomicBoolean muted = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean localMuted = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean transmitting = new AtomicBoolean(false);
|
private final AtomicBoolean transmitting = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean pttDown = new AtomicBoolean(false);
|
private final AtomicBoolean pttDown = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
@@ -44,12 +64,14 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
private volatile double inputGain;
|
private volatile double inputGain;
|
||||||
private volatile CodecType codec = CodecType.OPUS_VOICE;
|
private volatile CodecType codec = CodecType.OPUS_VOICE;
|
||||||
|
|
||||||
private final SpeechDetector speechDetector = new SpeechDetector(AudioDevices.SAMPLE_RATE);
|
private final SpeechProbabilityDetector speechDetector =
|
||||||
|
new RnnSpeechDetector(AudioDevices.SAMPLE_RATE);
|
||||||
private final AudioEnhancer enhancer = new AudioEnhancer(AudioDevices.SAMPLE_RATE);
|
private final AudioEnhancer enhancer = new AudioEnhancer(AudioDevices.SAMPLE_RATE);
|
||||||
|
|
||||||
private volatile Consumer<Double> levelListener; // input level in dBFS
|
private volatile Consumer<Double> levelListener; // input level, InputLevel scale
|
||||||
private volatile Consumer<Boolean> talkListener; // local talk-state changes
|
private volatile Consumer<Boolean> talkListener; // local talk-state changes
|
||||||
private volatile Runnable mutedTalkListener; // speech detected while muted
|
private volatile Runnable mutedTalkListener; // speech detected while muted
|
||||||
|
private volatile AudioFrameListener monitorListener; // local monitoring of what is sent
|
||||||
private boolean mutedTalking;
|
private boolean mutedTalking;
|
||||||
|
|
||||||
private final String deviceName;
|
private final String deviceName;
|
||||||
@@ -68,6 +90,11 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
private int hangover;
|
private int hangover;
|
||||||
private boolean lastTransmitting;
|
private boolean lastTransmitting;
|
||||||
|
|
||||||
|
// Ring of recent frames captured while the gate was shut.
|
||||||
|
private final float[][] preroll = new float[PREROLL_FRAMES][];
|
||||||
|
private int prerollTail;
|
||||||
|
private int prerollCount;
|
||||||
|
|
||||||
public DesktopVoiceInput(Settings settings) {
|
public DesktopVoiceInput(Settings settings) {
|
||||||
this.deviceName = settings.inputDevice;
|
this.deviceName = settings.inputDevice;
|
||||||
this.mode = settings.inputMode;
|
this.mode = settings.inputMode;
|
||||||
@@ -195,6 +222,11 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
this.talkListener = l;
|
this.talkListener = l;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMonitorListener(AudioFrameListener l) {
|
||||||
|
this.monitorListener = l;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setMutedTalkListener(Runnable l) {
|
public void setMutedTalkListener(Runnable l) {
|
||||||
this.mutedTalkListener = l;
|
this.mutedTalkListener = l;
|
||||||
@@ -208,6 +240,16 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
this.muted.set(m);
|
this.muted.set(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setLocalMuted(boolean m) {
|
||||||
|
this.localMuted.set(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isLocalMuted() {
|
||||||
|
return localMuted.get();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- lifecycle ----
|
// ---- lifecycle ----
|
||||||
|
|
||||||
public synchronized void start() {
|
public synchronized void start() {
|
||||||
@@ -305,36 +347,76 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
// stream bypasses them and is transmitted as captured.
|
// stream bypasses them and is transmitted as captured.
|
||||||
if (!stereo) enhancer.process(mono, frameSamples);
|
if (!stereo) enhancer.process(mono, frameSamples);
|
||||||
|
|
||||||
double sumSq = 0;
|
double db = InputLevel.toDb(mono, frameSamples);
|
||||||
for (int i = 0; i < frameSamples; i++) {
|
|
||||||
sumSq += (double) mono[i] * mono[i];
|
|
||||||
}
|
|
||||||
double rms = Math.sqrt(sumSq / frameSamples);
|
|
||||||
double db = (rms <= 1e-9) ? -100.0 : 20.0 * Math.log10(rms);
|
|
||||||
Consumer<Double> ll = levelListener;
|
Consumer<Double> ll = levelListener;
|
||||||
if (ll != null) ll.accept(db);
|
if (ll != null) ll.accept(db);
|
||||||
|
|
||||||
|
boolean wasOpen = transmitting.get();
|
||||||
boolean open = decideGate(db, mono);
|
boolean open = decideGate(db, mono);
|
||||||
setTransmitting(open);
|
setTransmitting(open);
|
||||||
|
|
||||||
if (open && !muted.get() && encoder != null) {
|
if (open && !muted.get() && encoder != null) {
|
||||||
try {
|
try {
|
||||||
packet = encoder.encode(stereo ? pcm : mono);
|
// On the opening edge, send the buffered lead-in first so the
|
||||||
|
// word's onset isn't lost to the frame that detected it.
|
||||||
|
if (!wasOpen) {
|
||||||
|
flushPreroll(stereo);
|
||||||
|
}
|
||||||
|
float[] sent = stereo ? pcm : mono;
|
||||||
|
packet = encoder.encode(sent);
|
||||||
|
monitor(sent, stereo ? encoderChannels : 1);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!open) {
|
||||||
|
rememberForPreroll(stereo ? pcm : mono);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (packet != null && packet.length > 0) {
|
if (packet != null && packet.length > 0) {
|
||||||
queue.offer(packet);
|
offer(packet);
|
||||||
// Guard against unbounded growth if the network stalls.
|
|
||||||
while (queue.size() > 10) queue.poll();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void offer(byte[] packet) {
|
||||||
|
queue.offer(packet);
|
||||||
|
// Guard against unbounded growth if the network stalls.
|
||||||
|
while (queue.size() > 10) queue.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the most recent frames while the gate is shut, for {@link #flushPreroll}. */
|
||||||
|
private void rememberForPreroll(float[] frame) {
|
||||||
|
float[] slot = preroll[prerollTail];
|
||||||
|
if (slot == null || slot.length != frame.length) {
|
||||||
|
slot = new float[frame.length];
|
||||||
|
preroll[prerollTail] = slot;
|
||||||
|
}
|
||||||
|
System.arraycopy(frame, 0, slot, 0, frame.length);
|
||||||
|
prerollTail = (prerollTail + 1) % PREROLL_FRAMES;
|
||||||
|
if (prerollCount < PREROLL_FRAMES) prerollCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encodes and queues the buffered lead-in, oldest first. Call under {@link #encoderLock}. */
|
||||||
|
private void flushPreroll(boolean stereo) {
|
||||||
|
int expected = stereo ? AudioDevices.FRAME_SIZE * encoderChannels : AudioDevices.FRAME_SIZE;
|
||||||
|
for (int i = 0; i < prerollCount; i++) {
|
||||||
|
int idx = (prerollTail - prerollCount + i + PREROLL_FRAMES) % PREROLL_FRAMES;
|
||||||
|
float[] frame = preroll[idx];
|
||||||
|
// A channel-count change between capture and flush invalidates the buffer.
|
||||||
|
if (frame == null || frame.length != expected) continue;
|
||||||
|
try {
|
||||||
|
byte[] p = encoder.encode(frame);
|
||||||
|
if (p != null && p.length > 0) offer(p);
|
||||||
|
monitor(frame, stereo ? encoderChannels : 1);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prerollCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean decideGate(double db, float[] pcm) {
|
private boolean decideGate(double db, float[] pcm) {
|
||||||
if (muted.get()) {
|
if (muted.get() || localMuted.get()) {
|
||||||
hangover = 0;
|
hangover = 0;
|
||||||
detectMutedSpeech(db);
|
detectMutedSpeech(db);
|
||||||
return false;
|
return false;
|
||||||
@@ -353,8 +435,10 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected VAD mode (volume gate, speech probability, or both) with
|
* Applies the selected VAD mode with a hangover so trailing syllables aren't clipped.
|
||||||
* a deactivation-delay hangover so trailing syllables aren't clipped.
|
*
|
||||||
|
* <p>The modes match the TS3 client's: the volume gate alone, the speech detector
|
||||||
|
* alone, or both together. Volume Gate skips the detector entirely, as TS3 does.
|
||||||
*/
|
*/
|
||||||
private boolean voiceActivated(double db, float[] pcm) {
|
private boolean voiceActivated(double db, float[] pcm) {
|
||||||
boolean detected;
|
boolean detected;
|
||||||
@@ -363,11 +447,11 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
detected = db >= thresholdDb;
|
detected = db >= thresholdDb;
|
||||||
break;
|
break;
|
||||||
case AUTOMATIC:
|
case AUTOMATIC:
|
||||||
detected = speechDetector.process(pcm) >= speechThreshold;
|
detected = speechDetector.process(pcm, AudioDevices.FRAME_SIZE) >= speechThreshold;
|
||||||
break;
|
break;
|
||||||
case HYBRID:
|
case HYBRID:
|
||||||
default:
|
default:
|
||||||
double probability = speechDetector.process(pcm);
|
double probability = speechDetector.process(pcm, AudioDevices.FRAME_SIZE);
|
||||||
detected = db >= thresholdDb && probability >= speechThreshold;
|
detected = db >= thresholdDb && probability >= speechThreshold;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -395,6 +479,12 @@ public final class DesktopVoiceInput implements VoiceInput {
|
|||||||
mutedTalking = talking;
|
mutedTalking = talking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hands a transmitted frame to the monitor, if one is attached. */
|
||||||
|
private void monitor(float[] frame, int channels) {
|
||||||
|
AudioFrameListener l = monitorListener;
|
||||||
|
if (l != null) l.onFrame(frame, channels);
|
||||||
|
}
|
||||||
|
|
||||||
private void setTransmitting(boolean t) {
|
private void setTransmitting(boolean t) {
|
||||||
transmitting.set(t);
|
transmitting.set(t);
|
||||||
if (t != lastTransmitting) {
|
if (t != lastTransmitting) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import java.util.Set;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.function.BiConsumer;
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +26,16 @@ public final class DesktopVoiceOutput implements VoiceOutput {
|
|||||||
/** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */
|
/** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */
|
||||||
private static final int MAX_FRAME = 5760;
|
private static final int MAX_FRAME = 5760;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A speaker is only meant to stop when the empty voice packet marking the end of a
|
||||||
|
* talk burst arrives — but that packet is UDP too, and a lost one otherwise leaves
|
||||||
|
* the talking indicator stuck until the speaker's next burst. Opus frames are 20 ms
|
||||||
|
* apart while someone is actually talking, so a gap several times that long, with no
|
||||||
|
* packet of either kind, is unambiguous: force the indicator off rather than trust
|
||||||
|
* the one packet that could go missing.
|
||||||
|
*/
|
||||||
|
private static final long TALK_TIMEOUT_NANOS = TimeUnit.MILLISECONDS.toNanos(200);
|
||||||
|
|
||||||
/** One speaker's decode + playback pipeline. */
|
/** One speaker's decode + playback pipeline. */
|
||||||
private final class ClientStream {
|
private final class ClientStream {
|
||||||
final int clientId;
|
final int clientId;
|
||||||
@@ -81,8 +93,32 @@ public final class DesktopVoiceOutput implements VoiceOutput {
|
|||||||
/** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */
|
/** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */
|
||||||
private volatile BiConsumer<Integer, Boolean> talkListener;
|
private volatile BiConsumer<Integer, Boolean> talkListener;
|
||||||
|
|
||||||
|
/** Catches a talk burst whose end packet never arrived; see {@link #TALK_TIMEOUT_NANOS}. */
|
||||||
|
private final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "ts3j-talk-watchdog");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
public DesktopVoiceOutput(String outputDevice) {
|
public DesktopVoiceOutput(String outputDevice) {
|
||||||
this.outputDevice = outputDevice;
|
this.outputDevice = outputDevice;
|
||||||
|
watchdog.scheduleWithFixedDelay(this::checkTalkTimeouts, 50, 50, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkTalkTimeouts() {
|
||||||
|
long now = System.nanoTime();
|
||||||
|
for (ClientStream s : streams.values()) {
|
||||||
|
if (s.talking && now - s.lastPacketNanos > TALK_TIMEOUT_NANOS) {
|
||||||
|
s.worker.submit(() -> {
|
||||||
|
try {
|
||||||
|
s.line.drain();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
if (s.decoder != null) s.decoder.reset();
|
||||||
|
markTalking(s, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setTalkListener(BiConsumer<Integer, Boolean> l) {
|
public void setTalkListener(BiConsumer<Integer, Boolean> l) {
|
||||||
@@ -226,6 +262,7 @@ public final class DesktopVoiceOutput implements VoiceOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void shutdown() {
|
public void shutdown() {
|
||||||
|
watchdog.shutdownNow();
|
||||||
for (ClientStream s : streams.values()) {
|
for (ClientStream s : streams.values()) {
|
||||||
s.close();
|
s.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.GlobalInputHook;
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the global input hook that suits the running session and starts it.
|
||||||
|
*
|
||||||
|
* <p>Windows has one answer, raw input. On X11 it is XInput2, with RECORD behind it for
|
||||||
|
* servers that lack it: neither needs privileges or devices to open. On Wayland the X
|
||||||
|
* server sees only what the compositor forwards, so reading evdev is the surest way to
|
||||||
|
* catch every key — and when that is not permitted either, the returned hook is simply
|
||||||
|
* not running and says why.
|
||||||
|
*/
|
||||||
|
public final class DesktopInputHooks {
|
||||||
|
|
||||||
|
private DesktopInputHooks() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return a hook, started when it could be; check {@link GlobalInputHook#isRunning()} */
|
||||||
|
public static GlobalInputHook start(GlobalInputHook.Listener listener) {
|
||||||
|
List<String> reasons = new ArrayList<>();
|
||||||
|
for (GlobalInputHook hook : candidates()) {
|
||||||
|
hook.start(listener);
|
||||||
|
if (hook.isRunning()) return hook;
|
||||||
|
reasons.add(hook.getClass().getSimpleName() + ": " + hook.unavailableReason());
|
||||||
|
hook.close();
|
||||||
|
}
|
||||||
|
return new Unavailable(reasons.isEmpty()
|
||||||
|
? "no global input backend for this session"
|
||||||
|
: String.join("; ", reasons));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<GlobalInputHook> candidates() {
|
||||||
|
List<GlobalInputHook> hooks = new ArrayList<>();
|
||||||
|
if (Win32.isWindows()) {
|
||||||
|
if (WindowsInputHook.isSupported()) hooks.add(new WindowsInputHook());
|
||||||
|
return hooks;
|
||||||
|
}
|
||||||
|
if (isWayland()) {
|
||||||
|
// Evdev first: it is the only backend guaranteed to see keys aimed at native
|
||||||
|
// Wayland windows. Failing that, Xwayland still covers every X11 app, and on
|
||||||
|
// compositors that forward the rest — KWin, with legacy X11 app support on —
|
||||||
|
// it covers those too.
|
||||||
|
if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
|
||||||
|
addX11Hooks(hooks);
|
||||||
|
} else {
|
||||||
|
addX11Hooks(hooks);
|
||||||
|
if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
|
||||||
|
}
|
||||||
|
return hooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RECORD before XInput2: on at least some driver stacks, XInput2's raw
|
||||||
|
* {@code XI_RawButtonRelease} never reaches a client that hasn't grabbed the pointer —
|
||||||
|
* see {@link XInput2InputHook} — which makes a bound mouse button look stuck down.
|
||||||
|
* RECORD taps the same core events {@code xev} sees and is not affected. XInput2 stays
|
||||||
|
* as the fallback for servers where RECORD is disabled or missing.
|
||||||
|
*/
|
||||||
|
private static void addX11Hooks(List<GlobalInputHook> hooks) {
|
||||||
|
if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook());
|
||||||
|
if (XInput2InputHook.isSupported()) hooks.add(new XInput2InputHook());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isWayland() {
|
||||||
|
return System.getenv("WAYLAND_DISPLAY") != null
|
||||||
|
|| "wayland".equalsIgnoreCase(System.getenv("XDG_SESSION_TYPE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stands in when no backend could start, so callers need no null checks. */
|
||||||
|
private record Unavailable(String reason) implements GlobalInputHook {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Listener listener) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRunning() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String unavailableReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String keyName(HotkeyKey key) {
|
||||||
|
// Bindings recorded earlier still deserve their real names in the dialog,
|
||||||
|
// even with no hook running to have produced them.
|
||||||
|
return Win32.isWindows() ? WindowsKeyNamer.name(key) : X11KeyNamer.name(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.GlobalInputHook;
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System-wide input hook that reads the kernel's evdev devices directly, for sessions
|
||||||
|
* where {@link XRecordInputHook} cannot see everything — Wayland above all, where the X
|
||||||
|
* server is only told about events aimed at X clients.
|
||||||
|
*
|
||||||
|
* <p>It needs read access to {@code /dev/input/event*}, which normally means membership
|
||||||
|
* of the {@code input} group; without it the hook reports itself unavailable and the
|
||||||
|
* client carries on without global hotkeys.
|
||||||
|
*
|
||||||
|
* <p>Key codes are reported as X keycodes (the kernel code plus 8) and buttons as X
|
||||||
|
* button numbers, so bindings mean the same thing whichever backend recorded them.
|
||||||
|
*/
|
||||||
|
public final class EvdevInputHook implements GlobalInputHook {
|
||||||
|
|
||||||
|
private static final File INPUT_DIR = new File("/dev/input");
|
||||||
|
/** {@code struct input_event} on 64-bit Linux: two 8-byte time fields, then type/code/value. */
|
||||||
|
private static final int EVENT_SIZE = 24;
|
||||||
|
private static final int EV_KEY = 1;
|
||||||
|
/** Kernel key codes below this are keyboard keys; from here up they are BTN_* buttons. */
|
||||||
|
private static final int BTN_MISC = 0x100;
|
||||||
|
private static final int BTN_LEFT = 0x110;
|
||||||
|
|
||||||
|
private final List<InputStream> streams = new ArrayList<>();
|
||||||
|
private final List<Thread> threads = new ArrayList<>();
|
||||||
|
private volatile Listener listener;
|
||||||
|
private volatile boolean running;
|
||||||
|
private volatile String unavailable = "not started";
|
||||||
|
|
||||||
|
public static boolean isSupported() {
|
||||||
|
File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event"));
|
||||||
|
if (devices == null) return false;
|
||||||
|
for (File device : devices) {
|
||||||
|
if (device.canRead()) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Listener listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event"));
|
||||||
|
if (devices == null || devices.length == 0) {
|
||||||
|
unavailable = "no /dev/input devices";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (File device : devices) {
|
||||||
|
try {
|
||||||
|
InputStream in = new FileInputStream(device);
|
||||||
|
streams.add(in);
|
||||||
|
Thread t = new Thread(() -> read(in), "ts3j-hotkeys-evdev-" + device.getName());
|
||||||
|
t.setDaemon(true);
|
||||||
|
threads.add(t);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Devices we may not read are simply skipped.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (streams.isEmpty()) {
|
||||||
|
unavailable = "no readable /dev/input device — add your user to the \"input\" group";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
running = true;
|
||||||
|
unavailable = "";
|
||||||
|
for (Thread t : threads) t.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void read(InputStream in) {
|
||||||
|
byte[] buffer = new byte[EVENT_SIZE * 16];
|
||||||
|
ByteBuffer view = ByteBuffer.wrap(buffer).order(ByteOrder.nativeOrder());
|
||||||
|
while (running) {
|
||||||
|
try {
|
||||||
|
int read = in.read(buffer);
|
||||||
|
if (read < 0) return;
|
||||||
|
for (int offset = 0; offset + EVENT_SIZE <= read; offset += EVENT_SIZE) {
|
||||||
|
int type = view.getShort(offset + 16) & 0xffff;
|
||||||
|
int code = view.getShort(offset + 18) & 0xffff;
|
||||||
|
int value = view.getInt(offset + 20);
|
||||||
|
// 2 is auto-repeat, which must not look like a fresh press.
|
||||||
|
if (type == EV_KEY && value != 2) dispatch(code, value == 1);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatch(int code, boolean pressed) {
|
||||||
|
Listener l = listener;
|
||||||
|
if (l == null) return;
|
||||||
|
HotkeyKey key = code < BTN_MISC ? HotkeyKey.keyboard(code + 8) : mouse(code);
|
||||||
|
if (key != null) l.onInput(key, pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps the kernel's BTN_* codes onto the X button numbering. */
|
||||||
|
private static HotkeyKey mouse(int code) {
|
||||||
|
return switch (code) {
|
||||||
|
case BTN_LEFT -> HotkeyKey.mouse(1);
|
||||||
|
case BTN_LEFT + 1 -> HotkeyKey.mouse(3); // BTN_RIGHT
|
||||||
|
case BTN_LEFT + 2 -> HotkeyKey.mouse(2); // BTN_MIDDLE
|
||||||
|
case BTN_LEFT + 3 -> HotkeyKey.mouse(8); // BTN_SIDE, "mouse 4"
|
||||||
|
case BTN_LEFT + 4 -> HotkeyKey.mouse(9); // BTN_EXTRA, "mouse 5"
|
||||||
|
case BTN_LEFT + 5 -> HotkeyKey.mouse(10); // BTN_FORWARD
|
||||||
|
case BTN_LEFT + 6 -> HotkeyKey.mouse(11); // BTN_BACK
|
||||||
|
case BTN_LEFT + 7 -> HotkeyKey.mouse(12); // BTN_TASK
|
||||||
|
default -> null; // joysticks, lid switches, …
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRunning() {
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String unavailableReason() {
|
||||||
|
return running ? "" : unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String keyName(HotkeyKey key) {
|
||||||
|
// The kernel knows scancodes, not layouts; let X name the key when it can.
|
||||||
|
return X11KeyNamer.name(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
running = false;
|
||||||
|
listener = null;
|
||||||
|
// Closing the descriptor is what breaks the readers out of their blocking read.
|
||||||
|
for (InputStream in : streams) {
|
||||||
|
try {
|
||||||
|
in.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streams.clear();
|
||||||
|
threads.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Turns X keysym names into the labels users expect on a hotkey button. */
|
||||||
|
final class KeyNames {
|
||||||
|
|
||||||
|
private KeyNames() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<String, String> SPECIAL = Map.ofEntries(
|
||||||
|
Map.entry("Control_L", "Left Ctrl"),
|
||||||
|
Map.entry("Control_R", "Right Ctrl"),
|
||||||
|
Map.entry("Shift_L", "Left Shift"),
|
||||||
|
Map.entry("Shift_R", "Right Shift"),
|
||||||
|
Map.entry("Alt_L", "Left Alt"),
|
||||||
|
Map.entry("Alt_R", "Right Alt"),
|
||||||
|
Map.entry("ISO_Level3_Shift", "AltGr"),
|
||||||
|
Map.entry("Super_L", "Left Super"),
|
||||||
|
Map.entry("Super_R", "Right Super"),
|
||||||
|
Map.entry("Meta_L", "Left Meta"),
|
||||||
|
Map.entry("Meta_R", "Right Meta"),
|
||||||
|
Map.entry("Prior", "Page Up"),
|
||||||
|
Map.entry("Next", "Page Down"),
|
||||||
|
Map.entry("Return", "Enter"),
|
||||||
|
Map.entry("space", "Space"),
|
||||||
|
Map.entry("BackSpace", "Backspace"),
|
||||||
|
Map.entry("Escape", "Esc"),
|
||||||
|
Map.entry("Caps_Lock", "Caps Lock"),
|
||||||
|
Map.entry("Num_Lock", "Num Lock"),
|
||||||
|
Map.entry("Scroll_Lock", "Scroll Lock"),
|
||||||
|
Map.entry("Menu", "Menu"),
|
||||||
|
Map.entry("Print", "Print Screen"));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param keysym the X name of the keysym, e.g. {@code a}, {@code Control_L}, {@code KP_Add}
|
||||||
|
* @return a display name, or {@code null} when there is nothing sensible to show
|
||||||
|
*/
|
||||||
|
static String pretty(String keysym) {
|
||||||
|
if (keysym == null || keysym.isBlank()) return null;
|
||||||
|
String special = SPECIAL.get(keysym);
|
||||||
|
if (special != null) return special;
|
||||||
|
if (keysym.startsWith("KP_")) return "Numpad " + pretty(keysym.substring(3));
|
||||||
|
if (keysym.length() == 1) return keysym.toUpperCase();
|
||||||
|
return keysym.replace('_', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout of a {@code RAWINPUT} structure and the reading of the two kinds we ask for,
|
||||||
|
* split out from {@link WindowsInputHook} so the decoding can be tested anywhere.
|
||||||
|
*
|
||||||
|
* <p>Keyboards report a set-1 scan code, which is what a binding stores: it names the
|
||||||
|
* physical key, so it survives a layout change exactly as an X keycode does on Linux.
|
||||||
|
* Mice are renumbered to the X button numbering the rest of the client already speaks,
|
||||||
|
* so {@link HotkeyKey#fallbackName()} and the saved bindings mean the same thing on
|
||||||
|
* both platforms.
|
||||||
|
*/
|
||||||
|
final class RawInput {
|
||||||
|
|
||||||
|
private RawInput() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code RAWINPUTHEADER.dwType}. */
|
||||||
|
static final int TYPE_MOUSE = 0;
|
||||||
|
static final int TYPE_KEYBOARD = 1;
|
||||||
|
|
||||||
|
/** Field offsets in {@code RAWINPUT} under x64, past the 24-byte header. */
|
||||||
|
static final long HEADER_TYPE = 0;
|
||||||
|
static final long HEADER_SIZE = 24;
|
||||||
|
static final long KEYBOARD_MAKE_CODE = 24;
|
||||||
|
static final long KEYBOARD_FLAGS = 26;
|
||||||
|
static final long KEYBOARD_VKEY = 30;
|
||||||
|
static final long MOUSE_BUTTON_FLAGS = 28;
|
||||||
|
|
||||||
|
/** Both event kinds fit comfortably; the larger, {@code RAWMOUSE}, needs 48 bytes. */
|
||||||
|
static final long BUFFER_SIZE = 64;
|
||||||
|
|
||||||
|
/** {@code RAWKEYBOARD.Flags}. */
|
||||||
|
static final int RI_KEY_BREAK = 0x01;
|
||||||
|
static final int RI_KEY_E0 = 0x02;
|
||||||
|
static final int RI_KEY_E1 = 0x04;
|
||||||
|
|
||||||
|
/** {@code VK_NO_VKEY}: the filler half of an escaped sequence, carrying no key. */
|
||||||
|
private static final int VKEY_NONE = 0xFF;
|
||||||
|
|
||||||
|
/** {@code RAWMOUSE.usButtonFlags}, in down/up pairs. */
|
||||||
|
private static final int[] BUTTON_FLAGS = {
|
||||||
|
0x0001, 0x0002, 1, // left
|
||||||
|
0x0010, 0x0020, 2, // middle
|
||||||
|
0x0004, 0x0008, 3, // right
|
||||||
|
0x0040, 0x0080, 8, // X1, "Mouse 4"
|
||||||
|
0x0100, 0x0200, 9, // X2, "Mouse 5"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final int RI_MOUSE_WHEEL = 0x0400;
|
||||||
|
private static final int RI_MOUSE_HWHEEL = 0x0800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scan code identifying a key, with the escape prefix folded into the high byte
|
||||||
|
* so that e.g. right Ctrl ({@code E0 1D}) stays distinct from left ({@code 1D}).
|
||||||
|
*
|
||||||
|
* @return the code, or -1 when the event names no key of its own
|
||||||
|
*/
|
||||||
|
static int scanCode(int makeCode, int flags, int vkey) {
|
||||||
|
if (vkey == VKEY_NONE) return -1;
|
||||||
|
int code = makeCode & 0xFF;
|
||||||
|
if (code == 0) return -1;
|
||||||
|
if ((flags & RI_KEY_E0) != 0) code |= 0xE000;
|
||||||
|
else if ((flags & RI_KEY_E1) != 0) code |= 0xE100;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isRelease(int flags) {
|
||||||
|
return (flags & RI_KEY_BREAK) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One button transition, in the order the flags word packs them. */
|
||||||
|
record ButtonEvent(HotkeyKey key, boolean pressed) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpacks a mouse event, which may carry several transitions at once.
|
||||||
|
*
|
||||||
|
* @param buttonFlags {@code usButtonFlags}
|
||||||
|
* @param wheelDelta {@code usButtonData}, read as a signed delta when a wheel bit is set
|
||||||
|
*/
|
||||||
|
static List<ButtonEvent> buttons(int buttonFlags, short wheelDelta) {
|
||||||
|
List<ButtonEvent> events = new ArrayList<>();
|
||||||
|
for (int i = 0; i < BUTTON_FLAGS.length; i += 3) {
|
||||||
|
HotkeyKey key = HotkeyKey.mouse(BUTTON_FLAGS[i + 2]);
|
||||||
|
if ((buttonFlags & BUTTON_FLAGS[i]) != 0) events.add(new ButtonEvent(key, true));
|
||||||
|
if ((buttonFlags & BUTTON_FLAGS[i + 1]) != 0) events.add(new ButtonEvent(key, false));
|
||||||
|
}
|
||||||
|
// A wheel notch has no release of its own; X reports it as a button tap, and the
|
||||||
|
// hotkey engine expects the same shape, so synthesise both edges.
|
||||||
|
if ((buttonFlags & RI_MOUSE_WHEEL) != 0 && wheelDelta != 0) {
|
||||||
|
tap(events, wheelDelta > 0 ? 4 : 5);
|
||||||
|
}
|
||||||
|
if ((buttonFlags & RI_MOUSE_HWHEEL) != 0 && wheelDelta != 0) {
|
||||||
|
tap(events, wheelDelta > 0 ? 7 : 6);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void tap(List<ButtonEvent> events, int button) {
|
||||||
|
events.add(new ButtonEvent(HotkeyKey.mouse(button), true));
|
||||||
|
events.add(new ButtonEvent(HotkeyKey.mouse(button), false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.FunctionDescriptor;
|
||||||
|
import java.lang.foreign.Linker;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.SymbolLookup;
|
||||||
|
import java.lang.foreign.ValueLayout;
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw binding to {@code user32} and {@code kernel32}, with just the calls
|
||||||
|
* {@link WindowsInputHook} needs to own a message-only window and read raw input from it.
|
||||||
|
*
|
||||||
|
* <p>Raw input is the polite way to watch the whole machine on Windows: registering with
|
||||||
|
* {@code RIDEV_INPUTSINK} delivers every key and button even while another application is
|
||||||
|
* in the foreground, and — unlike a {@code WH_KEYBOARD_LL} hook — it observes rather than
|
||||||
|
* intercepts, so nothing can be swallowed and nothing serialises the system input queue.
|
||||||
|
*
|
||||||
|
* <p>Loading is lazy and failure is expected off Windows, where {@link #isAvailable()}
|
||||||
|
* returns {@code false} and the caller falls back to another backend.
|
||||||
|
*/
|
||||||
|
final class Win32 {
|
||||||
|
|
||||||
|
private Win32() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
|
||||||
|
private static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
|
||||||
|
private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS;
|
||||||
|
|
||||||
|
/** Window messages we care about. */
|
||||||
|
static final int WM_DESTROY = 0x0002;
|
||||||
|
static final int WM_CLOSE = 0x0010;
|
||||||
|
static final int WM_INPUT = 0x00FF;
|
||||||
|
|
||||||
|
/** {@code HWND_MESSAGE}: parent for a window that only ever receives messages. */
|
||||||
|
static final long HWND_MESSAGE = -3L;
|
||||||
|
|
||||||
|
/** {@code RIDEV_INPUTSINK}: deliver input even when the window is not in front. */
|
||||||
|
static final int RIDEV_INPUTSINK = 0x00000100;
|
||||||
|
|
||||||
|
/** HID usages for the two devices we register (usage page 1, "generic desktop"). */
|
||||||
|
static final int USAGE_PAGE_GENERIC = 0x01;
|
||||||
|
static final int USAGE_MOUSE = 0x02;
|
||||||
|
static final int USAGE_KEYBOARD = 0x06;
|
||||||
|
|
||||||
|
/** {@code RID_INPUT}: ask {@code GetRawInputData} for the payload, not the header. */
|
||||||
|
static final int RID_INPUT = 0x10000003;
|
||||||
|
|
||||||
|
/** {@code WNDCLASSEXW} size and the field offsets we fill, under x64. */
|
||||||
|
static final long WNDCLASS_SIZE = 80;
|
||||||
|
static final long WNDCLASS_CBSIZE = 0;
|
||||||
|
static final long WNDCLASS_WNDPROC = 8;
|
||||||
|
static final long WNDCLASS_HINSTANCE = 24;
|
||||||
|
static final long WNDCLASS_CLASSNAME = 64;
|
||||||
|
|
||||||
|
/** {@code RAWINPUTDEVICE}: usage page, usage, flags, target window. */
|
||||||
|
static final long RAWINPUTDEVICE_SIZE = 16;
|
||||||
|
static final long RAWINPUTDEVICE_USAGE_PAGE = 0;
|
||||||
|
static final long RAWINPUTDEVICE_USAGE = 2;
|
||||||
|
static final long RAWINPUTDEVICE_FLAGS = 4;
|
||||||
|
static final long RAWINPUTDEVICE_TARGET = 8;
|
||||||
|
|
||||||
|
/** {@code MSG} is 48 bytes under x64. */
|
||||||
|
static final long MSG_SIZE = 48;
|
||||||
|
|
||||||
|
/** The C signature of a {@code WNDPROC}. */
|
||||||
|
static final FunctionDescriptor WND_PROC =
|
||||||
|
FunctionDescriptor.of(LONG, PTR, INT, LONG, LONG);
|
||||||
|
|
||||||
|
private static final Linker LINKER = Linker.nativeLinker();
|
||||||
|
|
||||||
|
private static final class Libs {
|
||||||
|
static final SymbolLookup USER32 = SymbolLookup.libraryLookup("user32.dll", Arena.global());
|
||||||
|
static final SymbolLookup KERNEL32 = SymbolLookup.libraryLookup("kernel32.dll", Arena.global());
|
||||||
|
|
||||||
|
static final MethodHandle GET_MODULE_HANDLE =
|
||||||
|
downcall(KERNEL32, "GetModuleHandleW", FunctionDescriptor.of(PTR, PTR));
|
||||||
|
static final MethodHandle REGISTER_CLASS =
|
||||||
|
downcall(USER32, "RegisterClassExW", FunctionDescriptor.of(INT, PTR));
|
||||||
|
static final MethodHandle UNREGISTER_CLASS =
|
||||||
|
downcall(USER32, "UnregisterClassW", FunctionDescriptor.of(INT, PTR, PTR));
|
||||||
|
static final MethodHandle CREATE_WINDOW =
|
||||||
|
downcall(USER32, "CreateWindowExW", FunctionDescriptor.of(PTR,
|
||||||
|
INT, PTR, PTR, INT, INT, INT, INT, INT, PTR, PTR, PTR, PTR));
|
||||||
|
static final MethodHandle DESTROY_WINDOW =
|
||||||
|
downcall(USER32, "DestroyWindow", FunctionDescriptor.of(INT, PTR));
|
||||||
|
static final MethodHandle DEF_WINDOW_PROC =
|
||||||
|
downcall(USER32, "DefWindowProcW", FunctionDescriptor.of(LONG, PTR, INT, LONG, LONG));
|
||||||
|
static final MethodHandle GET_MESSAGE =
|
||||||
|
downcall(USER32, "GetMessageW", FunctionDescriptor.of(INT, PTR, PTR, INT, INT));
|
||||||
|
static final MethodHandle DISPATCH_MESSAGE =
|
||||||
|
downcall(USER32, "DispatchMessageW", FunctionDescriptor.of(LONG, PTR));
|
||||||
|
static final MethodHandle POST_MESSAGE =
|
||||||
|
downcall(USER32, "PostMessageW", FunctionDescriptor.of(INT, PTR, INT, LONG, LONG));
|
||||||
|
static final MethodHandle POST_QUIT_MESSAGE =
|
||||||
|
downcall(USER32, "PostQuitMessage", FunctionDescriptor.ofVoid(INT));
|
||||||
|
static final MethodHandle REGISTER_RAW_INPUT =
|
||||||
|
downcall(USER32, "RegisterRawInputDevices", FunctionDescriptor.of(INT, PTR, INT, INT));
|
||||||
|
static final MethodHandle GET_RAW_INPUT_DATA =
|
||||||
|
downcall(USER32, "GetRawInputData",
|
||||||
|
FunctionDescriptor.of(INT, PTR, INT, PTR, PTR, INT));
|
||||||
|
static final MethodHandle GET_KEY_NAME_TEXT =
|
||||||
|
downcall(USER32, "GetKeyNameTextW", FunctionDescriptor.of(INT, INT, PTR, INT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodHandle downcall(SymbolLookup lookup, String symbol,
|
||||||
|
FunctionDescriptor descriptor) {
|
||||||
|
return LINKER.downcallHandle(
|
||||||
|
lookup.find(symbol).orElseThrow(() ->
|
||||||
|
new UnsatisfiedLinkError("unresolved symbol " + symbol)),
|
||||||
|
descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isWindows() {
|
||||||
|
return System.getProperty("os.name", "").toLowerCase().startsWith("windows");
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isAvailable() {
|
||||||
|
if (!isWindows()) return false;
|
||||||
|
try {
|
||||||
|
return Libs.USER32 != null && Libs.KERNEL32 != null;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Allocates a null-terminated UTF-16 string, as every {@code ...W} entry point wants. */
|
||||||
|
static MemorySegment wide(Arena arena, String text) {
|
||||||
|
return arena.allocateFrom(text, StandardCharsets.UTF_16LE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MemorySegment moduleHandle() {
|
||||||
|
return (MemorySegment) call(Libs.GET_MODULE_HANDLE, MemorySegment.NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the class atom, or 0 when registration failed */
|
||||||
|
static int registerClass(MemorySegment wndClass) {
|
||||||
|
return (int) call(Libs.REGISTER_CLASS, wndClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void unregisterClass(MemorySegment className, MemorySegment instance) {
|
||||||
|
call(Libs.UNREGISTER_CLASS, className, instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a message-only window: no pixels, but it has a queue and can be a target. */
|
||||||
|
static MemorySegment createMessageWindow(MemorySegment className, MemorySegment instance) {
|
||||||
|
return (MemorySegment) call(Libs.CREATE_WINDOW, 0, className, MemorySegment.NULL, 0,
|
||||||
|
0, 0, 0, 0, MemorySegment.ofAddress(HWND_MESSAGE),
|
||||||
|
MemorySegment.NULL, instance, MemorySegment.NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void destroyWindow(MemorySegment window) {
|
||||||
|
call(Libs.DESTROY_WINDOW, window);
|
||||||
|
}
|
||||||
|
|
||||||
|
static long defWindowProc(MemorySegment window, int message, long wParam, long lParam) {
|
||||||
|
return (long) call(Libs.DEF_WINDOW_PROC, window, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 1 for a message, 0 for {@code WM_QUIT}, -1 on error */
|
||||||
|
static int getMessage(MemorySegment message) {
|
||||||
|
return (int) call(Libs.GET_MESSAGE, message, MemorySegment.NULL, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void dispatchMessage(MemorySegment message) {
|
||||||
|
call(Libs.DISPATCH_MESSAGE, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void postMessage(MemorySegment window, int message, long wParam, long lParam) {
|
||||||
|
call(Libs.POST_MESSAGE, window, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void postQuitMessage(int exitCode) {
|
||||||
|
call(Libs.POST_QUIT_MESSAGE, exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return whether the devices were registered for background delivery */
|
||||||
|
static boolean registerRawInputDevices(MemorySegment devices, int count) {
|
||||||
|
return (int) call(Libs.REGISTER_RAW_INPUT, devices, count, (int) RAWINPUTDEVICE_SIZE) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return bytes written into {@code buffer}, or -1 on failure */
|
||||||
|
static int getRawInputData(MemorySegment handle, MemorySegment buffer, MemorySegment size) {
|
||||||
|
return (int) call(Libs.GET_RAW_INPUT_DATA, handle, RID_INPUT, buffer, size,
|
||||||
|
(int) RawInput.HEADER_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout's name for a key.
|
||||||
|
*
|
||||||
|
* @param lParam scan code in bits 16..23, the extended flag in bit 24, as the API wants
|
||||||
|
*/
|
||||||
|
static String keyName(int lParam, Arena arena) {
|
||||||
|
MemorySegment buffer = arena.allocate(128);
|
||||||
|
int length = (int) call(Libs.GET_KEY_NAME_TEXT, lParam, buffer, 64);
|
||||||
|
if (length <= 0) return null;
|
||||||
|
return buffer.getString(0, StandardCharsets.UTF_16LE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object call(MethodHandle handle, Object... args) {
|
||||||
|
try {
|
||||||
|
return handle.invokeWithArguments(args);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw new IllegalStateException("Win32 call failed", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.GlobalInputHook;
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.ValueLayout;
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.lang.invoke.MethodHandles;
|
||||||
|
import java.lang.invoke.MethodType;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System-wide input hook for Windows, built on raw input: a message-only window
|
||||||
|
* registers the keyboard and mouse with {@code RIDEV_INPUTSINK}, so every key and button
|
||||||
|
* on the machine arrives as {@code WM_INPUT} whether or not this application is in front,
|
||||||
|
* and the event still reaches the foreground window untouched.
|
||||||
|
*
|
||||||
|
* <p>Chosen over a {@code WH_KEYBOARD_LL} hook because raw input only observes: it cannot
|
||||||
|
* swallow a keystroke, it does not put this process in the path of the system input queue
|
||||||
|
* — where a slow callback stalls typing everywhere — and it reports the side buttons and
|
||||||
|
* both edges of every key, which push-to-talk needs.
|
||||||
|
*
|
||||||
|
* <p>A window's messages belong to the thread that created it, so the window is created
|
||||||
|
* on the pump thread and {@link #start} waits to hear whether that succeeded.
|
||||||
|
*/
|
||||||
|
public final class WindowsInputHook implements GlobalInputHook {
|
||||||
|
|
||||||
|
private static final String WINDOW_CLASS = "Ts3jHotkeyInputSink";
|
||||||
|
|
||||||
|
/** How long to wait for the pump thread to stand its window up before giving up. */
|
||||||
|
private static final long STARTUP_TIMEOUT_SECONDS = 5;
|
||||||
|
|
||||||
|
private static final MethodHandle WND_PROC;
|
||||||
|
|
||||||
|
static {
|
||||||
|
try {
|
||||||
|
WND_PROC = MethodHandles.lookup().findStatic(WindowsInputHook.class, "onMessage",
|
||||||
|
MethodType.methodType(long.class, MemorySegment.class, int.class,
|
||||||
|
long.class, long.class));
|
||||||
|
} catch (ReflectiveOperationException e) {
|
||||||
|
throw new ExceptionInInitializerError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The running hook, for the static window procedure to find its way back. */
|
||||||
|
private static volatile WindowsInputHook current;
|
||||||
|
|
||||||
|
private volatile MemorySegment window = MemorySegment.NULL;
|
||||||
|
private Thread thread;
|
||||||
|
private volatile Listener listener;
|
||||||
|
private volatile boolean running;
|
||||||
|
private volatile String unavailable = "not started";
|
||||||
|
|
||||||
|
public static boolean isSupported() {
|
||||||
|
return Win32.isAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Listener listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
CountDownLatch ready = new CountDownLatch(1);
|
||||||
|
thread = new Thread(() -> pump(ready), "ts3j-hotkeys-rawinput");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
thread.start();
|
||||||
|
try {
|
||||||
|
if (!ready.await(STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||||
|
unavailable = "the raw input window did not come up";
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
unavailable = "interrupted while starting";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns the window for its whole life: creates it, pumps it, tears it down. */
|
||||||
|
private void pump(CountDownLatch ready) {
|
||||||
|
boolean signalled = false;
|
||||||
|
try (Arena arena = Arena.ofConfined()) {
|
||||||
|
MemorySegment className = Win32.wide(arena, WINDOW_CLASS);
|
||||||
|
MemorySegment instance = Win32.moduleHandle();
|
||||||
|
MemorySegment stub = java.lang.foreign.Linker.nativeLinker()
|
||||||
|
.upcallStub(WND_PROC, Win32.WND_PROC, arena);
|
||||||
|
|
||||||
|
MemorySegment wndClass = arena.allocate(Win32.WNDCLASS_SIZE);
|
||||||
|
wndClass.set(ValueLayout.JAVA_INT, Win32.WNDCLASS_CBSIZE, (int) Win32.WNDCLASS_SIZE);
|
||||||
|
wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_WNDPROC, stub);
|
||||||
|
wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_HINSTANCE, instance);
|
||||||
|
wndClass.set(ValueLayout.ADDRESS, Win32.WNDCLASS_CLASSNAME, className);
|
||||||
|
// A leftover registration from an earlier run in this process is harmless:
|
||||||
|
// CreateWindowEx only needs the class to exist.
|
||||||
|
Win32.registerClass(wndClass);
|
||||||
|
|
||||||
|
MemorySegment hwnd = Win32.createMessageWindow(className, instance);
|
||||||
|
if (hwnd.equals(MemorySegment.NULL)) {
|
||||||
|
unavailable = "cannot create the raw input window";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window = hwnd;
|
||||||
|
|
||||||
|
if (!Win32.registerRawInputDevices(rawInputDevices(arena, hwnd), 2)) {
|
||||||
|
unavailable = "RegisterRawInputDevices failed";
|
||||||
|
Win32.destroyWindow(hwnd);
|
||||||
|
window = MemorySegment.NULL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = this;
|
||||||
|
running = true;
|
||||||
|
unavailable = "";
|
||||||
|
ready.countDown();
|
||||||
|
signalled = true;
|
||||||
|
|
||||||
|
MemorySegment message = arena.allocate(Win32.MSG_SIZE);
|
||||||
|
while (running) {
|
||||||
|
int result = Win32.getMessage(message);
|
||||||
|
// 0 is WM_QUIT, -1 an error; either way the window is finished.
|
||||||
|
if (result <= 0) break;
|
||||||
|
Win32.dispatchMessage(message);
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
unavailable = describe(t);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
window = MemorySegment.NULL;
|
||||||
|
if (current == this) current = null;
|
||||||
|
if (!signalled) ready.countDown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The keyboard and the mouse, both asked for in the background. */
|
||||||
|
private static MemorySegment rawInputDevices(Arena arena, MemorySegment hwnd) {
|
||||||
|
MemorySegment devices = arena.allocate(Win32.RAWINPUTDEVICE_SIZE * 2);
|
||||||
|
int[] usages = {Win32.USAGE_KEYBOARD, Win32.USAGE_MOUSE};
|
||||||
|
for (int i = 0; i < usages.length; i++) {
|
||||||
|
long base = i * Win32.RAWINPUTDEVICE_SIZE;
|
||||||
|
devices.set(ValueLayout.JAVA_SHORT, base + Win32.RAWINPUTDEVICE_USAGE_PAGE,
|
||||||
|
(short) Win32.USAGE_PAGE_GENERIC);
|
||||||
|
devices.set(ValueLayout.JAVA_SHORT, base + Win32.RAWINPUTDEVICE_USAGE,
|
||||||
|
(short) usages[i]);
|
||||||
|
devices.set(ValueLayout.JAVA_INT, base + Win32.RAWINPUTDEVICE_FLAGS,
|
||||||
|
Win32.RIDEV_INPUTSINK);
|
||||||
|
devices.set(ValueLayout.ADDRESS, base + Win32.RAWINPUTDEVICE_TARGET, hwnd);
|
||||||
|
}
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upcall target: the window procedure. */
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private static long onMessage(MemorySegment hwnd, int message, long wParam, long lParam) {
|
||||||
|
WindowsInputHook hook = current;
|
||||||
|
try {
|
||||||
|
if (message == Win32.WM_INPUT && hook != null) {
|
||||||
|
hook.readInput(lParam);
|
||||||
|
} else if (message == Win32.WM_DESTROY) {
|
||||||
|
Win32.postQuitMessage(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
// WM_INPUT must reach DefWindowProc too, so the system can release the event.
|
||||||
|
return Win32.defWindowProc(hwnd, message, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies one {@code RAWINPUT} out of the system and turns it into hotkey events. */
|
||||||
|
private void readInput(long handle) {
|
||||||
|
Listener l = listener;
|
||||||
|
if (l == null) return;
|
||||||
|
try (Arena arena = Arena.ofConfined()) {
|
||||||
|
MemorySegment buffer = arena.allocate(RawInput.BUFFER_SIZE);
|
||||||
|
MemorySegment size = arena.allocate(ValueLayout.JAVA_INT);
|
||||||
|
size.set(ValueLayout.JAVA_INT, 0, (int) RawInput.BUFFER_SIZE);
|
||||||
|
if (Win32.getRawInputData(MemorySegment.ofAddress(handle), buffer, size) <= 0) return;
|
||||||
|
|
||||||
|
switch (buffer.get(ValueLayout.JAVA_INT, RawInput.HEADER_TYPE)) {
|
||||||
|
case RawInput.TYPE_KEYBOARD -> {
|
||||||
|
int makeCode = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_MAKE_CODE) & 0xFFFF;
|
||||||
|
int flags = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_FLAGS) & 0xFFFF;
|
||||||
|
int vkey = buffer.get(ValueLayout.JAVA_SHORT, RawInput.KEYBOARD_VKEY) & 0xFFFF;
|
||||||
|
int code = RawInput.scanCode(makeCode, flags, vkey);
|
||||||
|
if (code >= 0) l.onInput(HotkeyKey.keyboard(code), !RawInput.isRelease(flags));
|
||||||
|
}
|
||||||
|
case RawInput.TYPE_MOUSE -> {
|
||||||
|
int flags = buffer.get(ValueLayout.JAVA_SHORT, RawInput.MOUSE_BUTTON_FLAGS) & 0xFFFF;
|
||||||
|
short data = buffer.get(ValueLayout.JAVA_SHORT, RawInput.MOUSE_BUTTON_FLAGS + 2);
|
||||||
|
for (RawInput.ButtonEvent e : RawInput.buttons(flags, data)) {
|
||||||
|
l.onInput(e.key(), e.pressed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRunning() {
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String unavailableReason() {
|
||||||
|
return running ? "" : unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String keyName(HotkeyKey key) {
|
||||||
|
return WindowsKeyNamer.name(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
listener = null;
|
||||||
|
running = false;
|
||||||
|
MemorySegment hwnd = window;
|
||||||
|
try {
|
||||||
|
// Closing has to happen on the pump thread; ask it to, then let it unwind.
|
||||||
|
if (!hwnd.equals(MemorySegment.NULL)) Win32.postMessage(hwnd, Win32.WM_CLOSE, 0, 0);
|
||||||
|
if (thread != null) thread.join(2000);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
thread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(Throwable t) {
|
||||||
|
String message = t.getMessage();
|
||||||
|
return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names scan codes through the active Windows layout, so a hotkey button shows "A"
|
||||||
|
* rather than "Key 30" — the counterpart to {@link X11KeyNamer}.
|
||||||
|
*
|
||||||
|
* <p>{@code GetKeyNameTextW} wants the scan code where a {@code WM_KEYDOWN} would carry
|
||||||
|
* it: bits 16..23, with bit 24 marking the {@code E0} escape.
|
||||||
|
*/
|
||||||
|
final class WindowsKeyNamer {
|
||||||
|
|
||||||
|
private WindowsKeyNamer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<Integer, String> CACHE = new HashMap<>();
|
||||||
|
|
||||||
|
static synchronized String name(HotkeyKey key) {
|
||||||
|
if (key.device() != HotkeyKey.Device.KEYBOARD) return null;
|
||||||
|
if (!Win32.isAvailable()) return null;
|
||||||
|
return CACHE.computeIfAbsent(key.code(), code -> {
|
||||||
|
try (Arena arena = Arena.ofConfined()) {
|
||||||
|
int lParam = (code & 0xFF) << 16;
|
||||||
|
if ((code & 0xE000) == 0xE000) lParam |= 1 << 24;
|
||||||
|
String name = Win32.keyName(lParam, arena);
|
||||||
|
return (name == null || name.isBlank()) ? null : name;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names keycodes using the X keyboard layout, so a hotkey button shows "A" rather than
|
||||||
|
* "Key 38". Bindings themselves stay layout-independent; only the label comes from here.
|
||||||
|
*
|
||||||
|
* <p>Keeps one long-lived display connection of its own, opened on first use, so it can
|
||||||
|
* be asked from any thread without racing the input hooks' own connections.
|
||||||
|
*/
|
||||||
|
final class X11KeyNamer {
|
||||||
|
|
||||||
|
private X11KeyNamer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<Integer, String> CACHE = new HashMap<>();
|
||||||
|
private static MemorySegment display;
|
||||||
|
private static boolean tried;
|
||||||
|
|
||||||
|
static synchronized String name(HotkeyKey key) {
|
||||||
|
if (key.device() != HotkeyKey.Device.KEYBOARD) return null;
|
||||||
|
if (!tried) {
|
||||||
|
tried = true;
|
||||||
|
try {
|
||||||
|
if (Xlib.isCoreAvailable() && System.getenv("DISPLAY") != null) {
|
||||||
|
display = Xlib.openDisplay();
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
display = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (display == null || display.equals(MemorySegment.NULL)) return null;
|
||||||
|
return CACHE.computeIfAbsent(key.code(), code -> {
|
||||||
|
try {
|
||||||
|
long sym = Xlib.keysym(display, code);
|
||||||
|
return sym == 0 ? null : KeyNames.pretty(Xlib.keysymName(sym));
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.GlobalInputHook;
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.ValueLayout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System-wide input hook built on XInput2 raw events, the way TeamSpeak's own Linux
|
||||||
|
* client does it: {@code XI_RawKeyPress} and friends are selected on the root window, so
|
||||||
|
* every key and button on the machine arrives here regardless of which window has focus,
|
||||||
|
* and the event still reaches that window untouched.
|
||||||
|
*
|
||||||
|
* <p>Raw events report the device's own view of the key, before focus routing and before
|
||||||
|
* any layout mapping, which is precisely what a hotkey wants — the detail field is the
|
||||||
|
* keycode, matching how {@link HotkeyKey} identifies keys.
|
||||||
|
*
|
||||||
|
* <p>On at least some driver stacks, {@code XI_RawButtonRelease} is never delivered here
|
||||||
|
* at all: the X server hands the initiating {@code XI_RawButtonPress} to every raw
|
||||||
|
* listener, but the matching release only reaches whichever client holds the pointer's
|
||||||
|
* implicit per-click grab, which is never us. An active {@code XIGrabDevice} grab makes
|
||||||
|
* this hook that owner and does fix delivery, but even with {@code owner_events} set it
|
||||||
|
* stopped clicks from reaching other windows at all — worse than the bug it fixes — so it
|
||||||
|
* is not used here. {@link XRecordInputHook} is unaffected and takes over first on X11 as
|
||||||
|
* a result; this hook stays as its fallback for servers without RECORD.
|
||||||
|
*
|
||||||
|
* <p>Under Wayland this hook lives inside Xwayland and therefore sees what Xwayland is
|
||||||
|
* given: every X11 client, plus — on compositors that allow it, KWin's "legacy X11 app
|
||||||
|
* support" among them — keys aimed at native Wayland windows too. Where the compositor
|
||||||
|
* forwards nothing, {@link EvdevInputHook} is the way out.
|
||||||
|
*/
|
||||||
|
public final class XInput2InputHook implements GlobalInputHook {
|
||||||
|
|
||||||
|
/** How long the event loop sleeps on the socket before rechecking {@link #running}. */
|
||||||
|
private static final int POLL_TIMEOUT_MILLIS = 200;
|
||||||
|
|
||||||
|
private Arena arena;
|
||||||
|
private MemorySegment display = MemorySegment.NULL;
|
||||||
|
private int opcode = -1;
|
||||||
|
private Thread thread;
|
||||||
|
private volatile Listener listener;
|
||||||
|
private volatile boolean running;
|
||||||
|
private volatile String unavailable = "not started";
|
||||||
|
|
||||||
|
public static boolean isSupported() {
|
||||||
|
return Xlib.isInputAvailable() && System.getenv("DISPLAY") != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Listener listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
try {
|
||||||
|
open();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
unavailable = describe(t);
|
||||||
|
closeQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void open() {
|
||||||
|
arena = Arena.ofShared();
|
||||||
|
display = Xlib.openDisplay();
|
||||||
|
if (display.equals(MemorySegment.NULL)) {
|
||||||
|
throw new IllegalStateException("cannot open the X display");
|
||||||
|
}
|
||||||
|
opcode = Xlib.queryExtension(display, "XInputExtension", arena);
|
||||||
|
if (opcode < 0) throw new IllegalStateException("the X server has no XInput extension");
|
||||||
|
if (!Xlib.queryInputVersion(display, arena)) {
|
||||||
|
throw new IllegalStateException("the X server does not speak XInput 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
long root = Xlib.defaultRootWindow(display);
|
||||||
|
int maskLen = maskLen();
|
||||||
|
MemorySegment bits = rawMaskBits(maskLen);
|
||||||
|
int[] masters = Xlib.queryMasterDeviceIds(display, arena);
|
||||||
|
Xlib.selectInputEvents(display, root, masters, bits, maskLen, arena);
|
||||||
|
Xlib.sync(display);
|
||||||
|
|
||||||
|
running = true;
|
||||||
|
unavailable = "";
|
||||||
|
thread = new Thread(this::pump, "ts3j-hotkeys-xinput2");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
thread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int maskLen() {
|
||||||
|
// XIMaskLen(event): the byte length of a mask covering event types up to this one.
|
||||||
|
return (Xlib.XI_RAW_BUTTON_RELEASE >> 3) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The four raw event types, as a byte mask in {@code XISetMask} order. */
|
||||||
|
private MemorySegment rawMaskBits(int maskLen) {
|
||||||
|
MemorySegment bits = arena.allocate(maskLen);
|
||||||
|
for (int type : new int[]{Xlib.XI_RAW_KEY_PRESS, Xlib.XI_RAW_KEY_RELEASE,
|
||||||
|
Xlib.XI_RAW_BUTTON_PRESS, Xlib.XI_RAW_BUTTON_RELEASE}) {
|
||||||
|
long index = type / 8;
|
||||||
|
byte bit = (byte) (1 << (type % 8));
|
||||||
|
bits.set(ValueLayout.JAVA_BYTE, index,
|
||||||
|
(byte) (bits.get(ValueLayout.JAVA_BYTE, index) | bit));
|
||||||
|
}
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pump() {
|
||||||
|
try (Arena loop = Arena.ofConfined()) {
|
||||||
|
MemorySegment event = loop.allocate(Xlib.EVENT_SIZE);
|
||||||
|
MemorySegment pollFd = Xlib.pollFd(display, loop);
|
||||||
|
while (running) {
|
||||||
|
if (Xlib.pending(display) <= 0) {
|
||||||
|
Xlib.awaitEvent(pollFd, POLL_TIMEOUT_MILLIS);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Xlib.nextEvent(display, event);
|
||||||
|
handle(event);
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
unavailable = describe(t);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unpacks one event, which is ours only when it is a generic event from XInput2. */
|
||||||
|
private void handle(MemorySegment event) {
|
||||||
|
if (event.get(ValueLayout.JAVA_INT, Xlib.COOKIE_TYPE) != Xlib.GENERIC_EVENT) return;
|
||||||
|
if (event.get(ValueLayout.JAVA_INT, Xlib.COOKIE_EXTENSION) != opcode) return;
|
||||||
|
if (!Xlib.getEventData(display, event)) return;
|
||||||
|
try {
|
||||||
|
int evtype = event.get(ValueLayout.JAVA_INT, Xlib.COOKIE_EVTYPE);
|
||||||
|
MemorySegment raw = event.get(ValueLayout.ADDRESS, Xlib.COOKIE_DATA);
|
||||||
|
if (raw.equals(MemorySegment.NULL)) return;
|
||||||
|
int detail = raw.reinterpret(Xlib.RAW_EVENT_SIZE)
|
||||||
|
.get(ValueLayout.JAVA_INT, Xlib.RAW_EVENT_DETAIL);
|
||||||
|
dispatch(evtype, detail);
|
||||||
|
} finally {
|
||||||
|
Xlib.freeEventData(display, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatch(int evtype, int detail) {
|
||||||
|
Listener l = listener;
|
||||||
|
if (l == null) return;
|
||||||
|
switch (evtype) {
|
||||||
|
case Xlib.XI_RAW_KEY_PRESS -> l.onInput(HotkeyKey.keyboard(detail), true);
|
||||||
|
case Xlib.XI_RAW_KEY_RELEASE -> l.onInput(HotkeyKey.keyboard(detail), false);
|
||||||
|
case Xlib.XI_RAW_BUTTON_PRESS -> l.onInput(HotkeyKey.mouse(detail), true);
|
||||||
|
case Xlib.XI_RAW_BUTTON_RELEASE -> l.onInput(HotkeyKey.mouse(detail), false);
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRunning() {
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String unavailableReason() {
|
||||||
|
return running ? "" : unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String keyName(HotkeyKey key) {
|
||||||
|
return X11KeyNamer.name(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
listener = null;
|
||||||
|
running = false;
|
||||||
|
try {
|
||||||
|
if (thread != null) thread.join(POLL_TIMEOUT_MILLIS * 5L);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
closeQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void closeQuietly() {
|
||||||
|
// The pump owns the display connection; closing it underneath would crash Xlib.
|
||||||
|
boolean pumpDone = thread == null || !thread.isAlive();
|
||||||
|
if (pumpDone) {
|
||||||
|
try {
|
||||||
|
if (!display.equals(MemorySegment.NULL)) Xlib.closeDisplay(display);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
display = MemorySegment.NULL;
|
||||||
|
if (arena != null) {
|
||||||
|
try {
|
||||||
|
arena.close();
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
arena = null;
|
||||||
|
}
|
||||||
|
thread = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(Throwable t) {
|
||||||
|
String message = t.getMessage();
|
||||||
|
return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.GlobalInputHook;
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.ValueLayout;
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.lang.invoke.MethodHandles;
|
||||||
|
import java.lang.invoke.MethodType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System-wide input hook built on X11's RECORD extension: it taps the core key and
|
||||||
|
* button events the server delivers to every client, so hotkeys work no matter which
|
||||||
|
* window has focus, and the keystroke still reaches that window untouched.
|
||||||
|
*
|
||||||
|
* <p>Two connections are needed, as RECORD demands: a control one that creates and
|
||||||
|
* later tears the context down, and a data one that blocks inside
|
||||||
|
* {@code XRecordEnableContext} handing us events.
|
||||||
|
*
|
||||||
|
* <p>Under Wayland the X server only ever sees events aimed at X clients, so this hook
|
||||||
|
* is not truly global there; {@link EvdevInputHook} is the way out.
|
||||||
|
*/
|
||||||
|
public final class XRecordInputHook implements GlobalInputHook {
|
||||||
|
|
||||||
|
private static final MethodHandle CALLBACK;
|
||||||
|
|
||||||
|
static {
|
||||||
|
try {
|
||||||
|
CALLBACK = MethodHandles.lookup().findStatic(XRecordInputHook.class, "onRecorded",
|
||||||
|
MethodType.methodType(void.class, MemorySegment.class, MemorySegment.class));
|
||||||
|
} catch (ReflectiveOperationException e) {
|
||||||
|
throw new ExceptionInInitializerError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The running hook, for the static upcall to find its way back. */
|
||||||
|
private static volatile XRecordInputHook current;
|
||||||
|
|
||||||
|
private Arena arena;
|
||||||
|
private MemorySegment control = MemorySegment.NULL;
|
||||||
|
private MemorySegment data = MemorySegment.NULL;
|
||||||
|
private long context;
|
||||||
|
private Thread thread;
|
||||||
|
private volatile Listener listener;
|
||||||
|
private volatile boolean running;
|
||||||
|
private volatile String unavailable = "not started";
|
||||||
|
|
||||||
|
public static boolean isSupported() {
|
||||||
|
return Xlib.isAvailable() && System.getenv("DISPLAY") != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Listener listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
try {
|
||||||
|
open();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
unavailable = describe(t);
|
||||||
|
closeQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void open() {
|
||||||
|
arena = Arena.ofShared();
|
||||||
|
control = Xlib.openDisplay();
|
||||||
|
if (control.equals(MemorySegment.NULL)) {
|
||||||
|
throw new IllegalStateException("cannot open the X display");
|
||||||
|
}
|
||||||
|
if (!Xlib.queryRecordVersion(control, arena)) {
|
||||||
|
throw new IllegalStateException("the X server has no RECORD extension");
|
||||||
|
}
|
||||||
|
|
||||||
|
MemorySegment range = Xlib.allocRange();
|
||||||
|
if (range.equals(MemorySegment.NULL)) throw new IllegalStateException("XRecordAllocRange failed");
|
||||||
|
MemorySegment r = range.reinterpret(64);
|
||||||
|
r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_FIRST, (byte) Xlib.KEY_PRESS);
|
||||||
|
r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_LAST, (byte) Xlib.BUTTON_RELEASE);
|
||||||
|
|
||||||
|
MemorySegment clients = arena.allocate(ValueLayout.JAVA_LONG);
|
||||||
|
clients.set(ValueLayout.JAVA_LONG, 0, Xlib.ALL_CLIENTS);
|
||||||
|
MemorySegment ranges = arena.allocate(ValueLayout.ADDRESS);
|
||||||
|
ranges.set(ValueLayout.ADDRESS, 0, range);
|
||||||
|
|
||||||
|
context = Xlib.createContext(control, clients, 1, ranges, 1);
|
||||||
|
if (context == 0) throw new IllegalStateException("XRecordCreateContext failed");
|
||||||
|
// The context id is allocated client-side and the request is not a round trip:
|
||||||
|
// without this the second connection can enable a context the server has yet to
|
||||||
|
// create, which it answers with BadContext.
|
||||||
|
Xlib.sync(control);
|
||||||
|
|
||||||
|
data = Xlib.openDisplay();
|
||||||
|
if (data.equals(MemorySegment.NULL)) {
|
||||||
|
throw new IllegalStateException("cannot open the second X display connection");
|
||||||
|
}
|
||||||
|
|
||||||
|
MemorySegment stub = java.lang.foreign.Linker.nativeLinker()
|
||||||
|
.upcallStub(CALLBACK, Xlib.INTERCEPT_PROC, arena);
|
||||||
|
|
||||||
|
current = this;
|
||||||
|
running = true;
|
||||||
|
unavailable = "";
|
||||||
|
thread = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
// Blocks until close() disables the context from the control connection.
|
||||||
|
Xlib.enableContext(data, context, stub);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}, "ts3j-hotkeys-xrecord");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
thread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upcall target: one recorded protocol datum. */
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private static void onRecorded(MemorySegment closure, MemorySegment recorded) {
|
||||||
|
XRecordInputHook hook = current;
|
||||||
|
MemorySegment d = recorded.reinterpret(48);
|
||||||
|
try {
|
||||||
|
if (hook == null || d.get(ValueLayout.JAVA_INT, Xlib.INTERCEPT_CATEGORY) != Xlib.FROM_SERVER) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long length = d.get(ValueLayout.JAVA_LONG, Xlib.INTERCEPT_DATA_LEN);
|
||||||
|
MemorySegment event = d.get(ValueLayout.ADDRESS, Xlib.INTERCEPT_DATA);
|
||||||
|
// data_len counts 4-byte units; a core event is always 32 bytes.
|
||||||
|
if (event.equals(MemorySegment.NULL) || length < 2) return;
|
||||||
|
event = event.reinterpret(32);
|
||||||
|
hook.dispatch(event.get(ValueLayout.JAVA_BYTE, 0) & 0x7f,
|
||||||
|
event.get(ValueLayout.JAVA_BYTE, 1) & 0xff);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
} finally {
|
||||||
|
Xlib.freeData(recorded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatch(int type, int detail) {
|
||||||
|
Listener l = listener;
|
||||||
|
if (l == null) return;
|
||||||
|
switch (type) {
|
||||||
|
case Xlib.KEY_PRESS -> l.onInput(HotkeyKey.keyboard(detail), true);
|
||||||
|
case Xlib.KEY_RELEASE -> l.onInput(HotkeyKey.keyboard(detail), false);
|
||||||
|
case Xlib.BUTTON_PRESS -> l.onInput(HotkeyKey.mouse(detail), true);
|
||||||
|
case Xlib.BUTTON_RELEASE -> l.onInput(HotkeyKey.mouse(detail), false);
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRunning() {
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String unavailableReason() {
|
||||||
|
return running ? "" : unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String keyName(HotkeyKey key) {
|
||||||
|
return X11KeyNamer.name(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
listener = null;
|
||||||
|
running = false;
|
||||||
|
try {
|
||||||
|
if (!control.equals(MemorySegment.NULL) && context != 0) {
|
||||||
|
Xlib.disableContext(control, context);
|
||||||
|
Xlib.flush(control);
|
||||||
|
}
|
||||||
|
if (thread != null) thread.join(1000);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
closeQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void closeQuietly() {
|
||||||
|
if (current == this) current = null;
|
||||||
|
try {
|
||||||
|
if (!control.equals(MemorySegment.NULL) && context != 0) Xlib.freeContext(control, context);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
context = 0;
|
||||||
|
for (MemorySegment display : new MemorySegment[]{data, control}) {
|
||||||
|
try {
|
||||||
|
if (!display.equals(MemorySegment.NULL)) Xlib.closeDisplay(display);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data = control = MemorySegment.NULL;
|
||||||
|
// The arena owns the upcall stub: freeing it while the recording thread could
|
||||||
|
// still call into it would take the JVM down, so a stuck thread keeps it alive.
|
||||||
|
if (arena != null && (thread == null || !thread.isAlive())) {
|
||||||
|
try {
|
||||||
|
arena.close();
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
arena = null;
|
||||||
|
}
|
||||||
|
thread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(Throwable t) {
|
||||||
|
String message = t.getMessage();
|
||||||
|
return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import java.lang.foreign.Arena;
|
||||||
|
import java.lang.foreign.FunctionDescriptor;
|
||||||
|
import java.lang.foreign.Linker;
|
||||||
|
import java.lang.foreign.MemorySegment;
|
||||||
|
import java.lang.foreign.SymbolLookup;
|
||||||
|
import java.lang.foreign.ValueLayout;
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw binding to {@code libX11}, the XInput2 extension in {@code libXi} and the RECORD
|
||||||
|
* extension in {@code libXtst}, with just the calls the X11 hooks need.
|
||||||
|
*
|
||||||
|
* <p>Both extensions let a client watch input the server delivers to everyone else
|
||||||
|
* without intercepting it, which is exactly what a global hotkey needs: the keystroke
|
||||||
|
* still reaches the focused application. See {@link DesktopInputHooks#addX11Hooks} for
|
||||||
|
* which one is preferred and why.
|
||||||
|
*
|
||||||
|
* <p>Each library loads lazily and failure is expected — with no X11 around, the
|
||||||
|
* {@code is*Available()} probes return {@code false} and the caller falls back to
|
||||||
|
* another backend.
|
||||||
|
*/
|
||||||
|
final class Xlib {
|
||||||
|
|
||||||
|
private Xlib() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
|
||||||
|
static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
|
||||||
|
private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS;
|
||||||
|
|
||||||
|
/** Core event types (X.h) as they appear in the recorded protocol data. */
|
||||||
|
static final int KEY_PRESS = 2;
|
||||||
|
static final int KEY_RELEASE = 3;
|
||||||
|
static final int BUTTON_PRESS = 4;
|
||||||
|
static final int BUTTON_RELEASE = 5;
|
||||||
|
|
||||||
|
/** {@code XRecordAllClients}: record every client, present and future. */
|
||||||
|
static final long ALL_CLIENTS = 3L;
|
||||||
|
|
||||||
|
/** {@code XRecordInterceptData.category} values we care about. */
|
||||||
|
static final int FROM_SERVER = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code XRecordRange}: only {@code device_events} is filled in. Its two bytes sit
|
||||||
|
* after the request/reply and delivered-event ranges, at a fixed LP64 offset.
|
||||||
|
*/
|
||||||
|
static final long RANGE_DEVICE_EVENTS_FIRST = 18;
|
||||||
|
static final long RANGE_DEVICE_EVENTS_LAST = 19;
|
||||||
|
|
||||||
|
/** {@code XRecordInterceptData}: the protocol bytes and the reason we were called. */
|
||||||
|
static final long INTERCEPT_CATEGORY = 24;
|
||||||
|
static final long INTERCEPT_DATA = 32;
|
||||||
|
static final long INTERCEPT_DATA_LEN = 40;
|
||||||
|
|
||||||
|
/** {@code GenericEvent}, the envelope every extension event arrives in. */
|
||||||
|
static final int GENERIC_EVENT = 35;
|
||||||
|
|
||||||
|
/** XInput2 raw event types (XI2.h): the device's own view, before any focus routing. */
|
||||||
|
static final int XI_RAW_KEY_PRESS = 13;
|
||||||
|
static final int XI_RAW_KEY_RELEASE = 14;
|
||||||
|
static final int XI_RAW_BUTTON_PRESS = 15;
|
||||||
|
static final int XI_RAW_BUTTON_RELEASE = 16;
|
||||||
|
|
||||||
|
/** {@code XIAllMasterDevices}: raw events reach us through the master devices. */
|
||||||
|
static final int XI_ALL_MASTER_DEVICES = 1;
|
||||||
|
|
||||||
|
/** An {@code XEvent} is a union of 24 longs; a cookie is the widest member we read. */
|
||||||
|
static final long EVENT_SIZE = 192;
|
||||||
|
|
||||||
|
/** {@code XGenericEventCookie} field offsets under LP64. */
|
||||||
|
static final long COOKIE_TYPE = 0;
|
||||||
|
static final long COOKIE_EXTENSION = 32;
|
||||||
|
static final long COOKIE_EVTYPE = 36;
|
||||||
|
static final long COOKIE_DATA = 48;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code XIRawEvent.detail} — the keycode or button number — past the generic header,
|
||||||
|
* {@code time}, {@code deviceid} and {@code sourceid}.
|
||||||
|
*/
|
||||||
|
static final long RAW_EVENT_DETAIL = 56;
|
||||||
|
static final long RAW_EVENT_SIZE = 152;
|
||||||
|
|
||||||
|
/** {@code XIEventMask}: {@code deviceid}, {@code mask_len}, then the mask pointer. */
|
||||||
|
static final long EVENT_MASK_SIZE = 16;
|
||||||
|
static final long EVENT_MASK_DEVICEID = 0;
|
||||||
|
static final long EVENT_MASK_LEN = 4;
|
||||||
|
static final long EVENT_MASK_MASK = 8;
|
||||||
|
|
||||||
|
/** {@code XIDeviceInfo.deviceid} — the only field {@link #queryMasterDeviceIds} needs. */
|
||||||
|
static final long DEVICE_INFO_SIZE = 40;
|
||||||
|
static final long DEVICE_INFO_DEVICEID = 0;
|
||||||
|
|
||||||
|
private static final Linker LINKER = Linker.nativeLinker();
|
||||||
|
private static final String[] X11_NAMES = {"libX11.so.6", "libX11.so"};
|
||||||
|
private static final String[] XI_NAMES = {"libXi.so.6", "libXi.so"};
|
||||||
|
private static final String[] XTST_NAMES = {"libXtst.so.6", "libXtst.so"};
|
||||||
|
|
||||||
|
/** libX11 alone: enough to open a display and name keys. */
|
||||||
|
private static final class Core {
|
||||||
|
static final SymbolLookup LIB = load(X11_NAMES, "libX11");
|
||||||
|
|
||||||
|
static final MethodHandle X_OPEN_DISPLAY =
|
||||||
|
downcall(LIB, "XOpenDisplay", FunctionDescriptor.of(PTR, PTR));
|
||||||
|
static final MethodHandle X_CLOSE_DISPLAY =
|
||||||
|
downcall(LIB, "XCloseDisplay", FunctionDescriptor.of(INT, PTR));
|
||||||
|
static final MethodHandle X_FLUSH =
|
||||||
|
downcall(LIB, "XFlush", FunctionDescriptor.of(INT, PTR));
|
||||||
|
static final MethodHandle X_SYNC =
|
||||||
|
downcall(LIB, "XSync", FunctionDescriptor.of(INT, PTR, INT));
|
||||||
|
static final MethodHandle XKB_KEYCODE_TO_KEYSYM =
|
||||||
|
downcall(LIB, "XkbKeycodeToKeysym", FunctionDescriptor.of(LONG, PTR, INT, INT, INT));
|
||||||
|
static final MethodHandle X_KEYSYM_TO_STRING =
|
||||||
|
downcall(LIB, "XKeysymToString", FunctionDescriptor.of(PTR, LONG));
|
||||||
|
static final MethodHandle X_QUERY_EXTENSION =
|
||||||
|
downcall(LIB, "XQueryExtension", FunctionDescriptor.of(INT, PTR, PTR, PTR, PTR, PTR));
|
||||||
|
static final MethodHandle X_DEFAULT_ROOT_WINDOW =
|
||||||
|
downcall(LIB, "XDefaultRootWindow", FunctionDescriptor.of(LONG, PTR));
|
||||||
|
static final MethodHandle X_CONNECTION_NUMBER =
|
||||||
|
downcall(LIB, "XConnectionNumber", FunctionDescriptor.of(INT, PTR));
|
||||||
|
static final MethodHandle X_PENDING =
|
||||||
|
downcall(LIB, "XPending", FunctionDescriptor.of(INT, PTR));
|
||||||
|
static final MethodHandle X_NEXT_EVENT =
|
||||||
|
downcall(LIB, "XNextEvent", FunctionDescriptor.of(INT, PTR, PTR));
|
||||||
|
static final MethodHandle X_GET_EVENT_DATA =
|
||||||
|
downcall(LIB, "XGetEventData", FunctionDescriptor.of(INT, PTR, PTR));
|
||||||
|
static final MethodHandle X_FREE_EVENT_DATA =
|
||||||
|
downcall(LIB, "XFreeEventData", FunctionDescriptor.ofVoid(PTR, PTR));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XInput2, which ships separately in libXi. */
|
||||||
|
private static final class Input {
|
||||||
|
static final SymbolLookup LIB = load(XI_NAMES, "libXi");
|
||||||
|
|
||||||
|
static final MethodHandle XI_QUERY_VERSION =
|
||||||
|
downcall(LIB, "XIQueryVersion", FunctionDescriptor.of(INT, PTR, PTR, PTR));
|
||||||
|
static final MethodHandle XI_SELECT_EVENTS =
|
||||||
|
downcall(LIB, "XISelectEvents", FunctionDescriptor.of(INT, PTR, LONG, PTR, INT));
|
||||||
|
static final MethodHandle XI_QUERY_DEVICE =
|
||||||
|
downcall(LIB, "XIQueryDevice", FunctionDescriptor.of(PTR, PTR, INT, PTR));
|
||||||
|
static final MethodHandle XI_FREE_DEVICE_INFO =
|
||||||
|
downcall(LIB, "XIFreeDeviceInfo", FunctionDescriptor.ofVoid(PTR));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code poll(2)}, so the event loop can wait on the X socket and still be woken. */
|
||||||
|
private static final class Poll {
|
||||||
|
static final MethodHandle POLL = LINKER.downcallHandle(
|
||||||
|
LINKER.defaultLookup().find("poll").orElseThrow(() ->
|
||||||
|
new UnsatisfiedLinkError("unresolved symbol poll")),
|
||||||
|
FunctionDescriptor.of(INT, PTR, LONG, INT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code struct pollfd} is an int and two shorts, and {@code POLLIN} is bit 0. */
|
||||||
|
private static final long POLLFD_SIZE = 8;
|
||||||
|
private static final long POLLFD_EVENTS = 4;
|
||||||
|
private static final short POLLIN = 1;
|
||||||
|
|
||||||
|
/** The RECORD extension, which ships separately in libXtst. */
|
||||||
|
private static final class Record {
|
||||||
|
static final SymbolLookup LIB = load(XTST_NAMES, "libXtst");
|
||||||
|
|
||||||
|
static final MethodHandle QUERY_VERSION =
|
||||||
|
downcall(LIB, "XRecordQueryVersion", FunctionDescriptor.of(INT, PTR, PTR, PTR));
|
||||||
|
static final MethodHandle ALLOC_RANGE =
|
||||||
|
downcall(LIB, "XRecordAllocRange", FunctionDescriptor.of(PTR));
|
||||||
|
static final MethodHandle CREATE_CONTEXT =
|
||||||
|
downcall(LIB, "XRecordCreateContext",
|
||||||
|
FunctionDescriptor.of(LONG, PTR, INT, PTR, INT, PTR, INT));
|
||||||
|
static final MethodHandle ENABLE_CONTEXT =
|
||||||
|
downcall(LIB, "XRecordEnableContext", FunctionDescriptor.of(INT, PTR, LONG, PTR, PTR));
|
||||||
|
static final MethodHandle DISABLE_CONTEXT =
|
||||||
|
downcall(LIB, "XRecordDisableContext", FunctionDescriptor.of(INT, PTR, LONG));
|
||||||
|
static final MethodHandle FREE_CONTEXT =
|
||||||
|
downcall(LIB, "XRecordFreeContext", FunctionDescriptor.of(INT, PTR, LONG));
|
||||||
|
static final MethodHandle FREE_DATA =
|
||||||
|
downcall(LIB, "XRecordFreeData", FunctionDescriptor.ofVoid(PTR));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SymbolLookup load(String[] names, String what) {
|
||||||
|
IllegalArgumentException last = null;
|
||||||
|
for (String name : names) {
|
||||||
|
try {
|
||||||
|
return SymbolLookup.libraryLookup(name, Arena.global());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
last = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw (last != null) ? last : new IllegalArgumentException(what + " not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodHandle downcall(SymbolLookup lookup, String symbol,
|
||||||
|
FunctionDescriptor descriptor) {
|
||||||
|
return LINKER.downcallHandle(
|
||||||
|
lookup.find(symbol).orElseThrow(() ->
|
||||||
|
new UnsatisfiedLinkError("unresolved symbol " + symbol)),
|
||||||
|
descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The C signature of {@code XRecordInterceptProc}. */
|
||||||
|
static final FunctionDescriptor INTERCEPT_PROC = FunctionDescriptor.ofVoid(PTR, PTR);
|
||||||
|
|
||||||
|
/** Whether libX11 loaded, which is all that naming keys needs. */
|
||||||
|
static boolean isCoreAvailable() {
|
||||||
|
try {
|
||||||
|
return Core.LIB != null;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isAvailable() {
|
||||||
|
try {
|
||||||
|
return Core.LIB != null && Record.LIB != null;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether libX11 and libXi both loaded, which is what the XInput2 hook needs. */
|
||||||
|
static boolean isInputAvailable() {
|
||||||
|
try {
|
||||||
|
return Core.LIB != null && Input.LIB != null && Poll.POLL != null;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static MemorySegment openDisplay() {
|
||||||
|
return (MemorySegment) call(Core.X_OPEN_DISPLAY, MemorySegment.NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void closeDisplay(MemorySegment display) {
|
||||||
|
call(Core.X_CLOSE_DISPLAY, display);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void flush(MemorySegment display) {
|
||||||
|
call(Core.X_FLUSH, display);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flushes and waits for the server to have processed everything sent so far. */
|
||||||
|
static void sync(MemorySegment display) {
|
||||||
|
call(Core.X_SYNC, display, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return whether the server has the RECORD extension */
|
||||||
|
static boolean queryRecordVersion(MemorySegment display, Arena arena) {
|
||||||
|
MemorySegment major = arena.allocate(INT);
|
||||||
|
MemorySegment minor = arena.allocate(INT);
|
||||||
|
return (int) call(Record.QUERY_VERSION, display, major, minor) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static MemorySegment allocRange() {
|
||||||
|
return (MemorySegment) call(Record.ALLOC_RANGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static long createContext(MemorySegment display, MemorySegment clients, int clientCount,
|
||||||
|
MemorySegment ranges, int rangeCount) {
|
||||||
|
return (long) call(Record.CREATE_CONTEXT, display, 0, clients, clientCount, ranges, rangeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int enableContext(MemorySegment display, long context, MemorySegment callback) {
|
||||||
|
return (int) call(Record.ENABLE_CONTEXT, display, context, callback, MemorySegment.NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void disableContext(MemorySegment display, long context) {
|
||||||
|
call(Record.DISABLE_CONTEXT, display, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void freeContext(MemorySegment display, long context) {
|
||||||
|
call(Record.FREE_CONTEXT, display, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void freeData(MemorySegment data) {
|
||||||
|
call(Record.FREE_DATA, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the extension's major opcode, or -1 when the server does not have it */
|
||||||
|
static int queryExtension(MemorySegment display, String name, Arena arena) {
|
||||||
|
MemorySegment opcode = arena.allocate(INT);
|
||||||
|
MemorySegment event = arena.allocate(INT);
|
||||||
|
MemorySegment error = arena.allocate(INT);
|
||||||
|
int found = (int) call(Core.X_QUERY_EXTENSION, display, arena.allocateFrom(name),
|
||||||
|
opcode, event, error);
|
||||||
|
return found != 0 ? opcode.get(INT, 0) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return whether the server speaks at least XInput 2.0 */
|
||||||
|
static boolean queryInputVersion(MemorySegment display, Arena arena) {
|
||||||
|
MemorySegment major = arena.allocate(INT);
|
||||||
|
MemorySegment minor = arena.allocate(INT);
|
||||||
|
major.set(INT, 0, 2);
|
||||||
|
minor.set(INT, 0, 0);
|
||||||
|
// Success is 0; anything else means the server would not agree on 2.x.
|
||||||
|
return (int) call(Input.XI_QUERY_VERSION, display, major, minor) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static long defaultRootWindow(MemorySegment display) {
|
||||||
|
return (long) call(Core.X_DEFAULT_ROOT_WINDOW, display);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks for the events set in {@code bits} on {@code window}, once per device in
|
||||||
|
* {@code deviceIds}: selecting against {@code XIAllMasterDevices} itself does not
|
||||||
|
* reliably deliver every raw event type, so callers resolve it to real device ids first
|
||||||
|
* with {@link #queryMasterDeviceIds}.
|
||||||
|
*/
|
||||||
|
static void selectInputEvents(MemorySegment display, long window, int[] deviceIds,
|
||||||
|
MemorySegment bits, int maskLen, Arena arena) {
|
||||||
|
MemorySegment masks = arena.allocate(EVENT_MASK_SIZE * deviceIds.length);
|
||||||
|
for (int i = 0; i < deviceIds.length; i++) {
|
||||||
|
long base = i * EVENT_MASK_SIZE;
|
||||||
|
masks.set(INT, base + EVENT_MASK_DEVICEID, deviceIds[i]);
|
||||||
|
masks.set(INT, base + EVENT_MASK_LEN, maskLen);
|
||||||
|
masks.set(PTR, base + EVENT_MASK_MASK, bits);
|
||||||
|
}
|
||||||
|
call(Input.XI_SELECT_EVENTS, display, window, masks, deviceIds.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real device ids behind the {@code XIAllMasterDevices} pseudo-device — selecting
|
||||||
|
* raw events against each one individually, rather than the pseudo-device itself, is
|
||||||
|
* the more correct form (some drivers reportedly need it), even though it alone does
|
||||||
|
* not fix the {@code XI_RawButtonRelease} gap {@link XInput2InputHook} documents.
|
||||||
|
*/
|
||||||
|
static int[] queryMasterDeviceIds(MemorySegment display, Arena arena) {
|
||||||
|
MemorySegment count = arena.allocate(INT);
|
||||||
|
MemorySegment infos = (MemorySegment) call(Input.XI_QUERY_DEVICE, display,
|
||||||
|
XI_ALL_MASTER_DEVICES, count);
|
||||||
|
int n = count.get(INT, 0);
|
||||||
|
if (infos.equals(MemorySegment.NULL) || n <= 0) return new int[0];
|
||||||
|
try {
|
||||||
|
MemorySegment array = infos.reinterpret(DEVICE_INFO_SIZE * n);
|
||||||
|
int[] ids = new int[n];
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
ids[i] = array.get(INT, i * DEVICE_INFO_SIZE + DEVICE_INFO_DEVICEID);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
} finally {
|
||||||
|
call(Input.XI_FREE_DEVICE_INFO, infos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int pending(MemorySegment display) {
|
||||||
|
return (int) call(Core.X_PENDING, display);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void nextEvent(MemorySegment display, MemorySegment event) {
|
||||||
|
call(Core.X_NEXT_EVENT, display, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches a generic event's payload; the caller must free it when this succeeds. */
|
||||||
|
static boolean getEventData(MemorySegment display, MemorySegment cookie) {
|
||||||
|
return (int) call(Core.X_GET_EVENT_DATA, display, cookie) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void freeEventData(MemorySegment display, MemorySegment cookie) {
|
||||||
|
call(Core.X_FREE_EVENT_DATA, display, cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@code struct pollfd} watching the display connection, to be allocated once and
|
||||||
|
* handed to {@link #awaitEvent} for the life of an event loop.
|
||||||
|
*/
|
||||||
|
static MemorySegment pollFd(MemorySegment display, Arena arena) {
|
||||||
|
MemorySegment fds = arena.allocate(POLLFD_SIZE);
|
||||||
|
fds.set(INT, 0, (int) call(Core.X_CONNECTION_NUMBER, display));
|
||||||
|
fds.set(ValueLayout.JAVA_SHORT, POLLFD_EVENTS, POLLIN);
|
||||||
|
return fds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until the connection has something to read or the timeout runs out, so an
|
||||||
|
* event loop can stay asleep yet still notice it has been asked to stop.
|
||||||
|
*/
|
||||||
|
static void awaitEvent(MemorySegment pollFd, int timeoutMillis) {
|
||||||
|
call(Poll.POLL, pollFd, 1L, timeoutMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The unshifted keysym of a keycode in the first group, or 0 when unbound. */
|
||||||
|
static long keysym(MemorySegment display, int keycode) {
|
||||||
|
return (long) call(Core.XKB_KEYCODE_TO_KEYSYM, display, keycode, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The keysym's X name ("a", "Control_L", "F5"), or {@code null}. */
|
||||||
|
static String keysymName(long keysym) {
|
||||||
|
MemorySegment name = (MemorySegment) call(Core.X_KEYSYM_TO_STRING, keysym);
|
||||||
|
if (name == null || name.equals(MemorySegment.NULL)) return null;
|
||||||
|
return name.reinterpret(Long.MAX_VALUE).getString(0, StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object call(MethodHandle handle, Object... args) {
|
||||||
|
try {
|
||||||
|
return handle.invokeWithArguments(args);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
throw new IllegalStateException("X11 call failed", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.ts3client.hotkey.desktop;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Decoding of Windows raw input, which needs no Windows to check. */
|
||||||
|
class RawInputTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plainKeyKeepsItsScanCode() {
|
||||||
|
// 'A' is scan code 0x1E, no escape.
|
||||||
|
assertEquals(0x1E, RawInput.scanCode(0x1E, 0, 0x41));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void escapedKeysStayDistinctFromTheirUnescapedTwins() {
|
||||||
|
// Left Ctrl is 1D; right Ctrl is E0 1D, and the two must not collide.
|
||||||
|
int left = RawInput.scanCode(0x1D, 0, 0xA2);
|
||||||
|
int right = RawInput.scanCode(0x1D, RawInput.RI_KEY_E0, 0xA3);
|
||||||
|
assertEquals(0x1D, left);
|
||||||
|
assertEquals(0xE01D, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pauseUsesTheOtherEscape() {
|
||||||
|
assertEquals(0xE11D, RawInput.scanCode(0x1D, RawInput.RI_KEY_E1, 0x13));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fillerHalfOfAnEscapedSequenceIsIgnored() {
|
||||||
|
assertEquals(-1, RawInput.scanCode(0x2A, RawInput.RI_KEY_E0, 0xFF));
|
||||||
|
assertEquals(-1, RawInput.scanCode(0, 0, 0x41));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void breakFlagMarksTheRelease() {
|
||||||
|
assertFalse(RawInput.isRelease(0));
|
||||||
|
assertTrue(RawInput.isRelease(RawInput.RI_KEY_BREAK));
|
||||||
|
assertTrue(RawInput.isRelease(RawInput.RI_KEY_BREAK | RawInput.RI_KEY_E0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sideButtonsMapToTheNumbersUsersKnow() {
|
||||||
|
// X1 down: "Mouse 4" everywhere else in the client, X button 8.
|
||||||
|
List<RawInput.ButtonEvent> down = RawInput.buttons(0x0040, (short) 0);
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(8), true)), down);
|
||||||
|
assertEquals("Mouse 4", HotkeyKey.mouse(8).fallbackName());
|
||||||
|
|
||||||
|
List<RawInput.ButtonEvent> up = RawInput.buttons(0x0200, (short) 0);
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(9), false)), up);
|
||||||
|
assertEquals("Mouse 5", HotkeyKey.mouse(9).fallbackName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void primaryButtonsUseTheXNumbering() {
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(1), true)),
|
||||||
|
RawInput.buttons(0x0001, (short) 0));
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(2), true)),
|
||||||
|
RawInput.buttons(0x0010, (short) 0));
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(3), true)),
|
||||||
|
RawInput.buttons(0x0004, (short) 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneEventCanCarrySeveralTransitions() {
|
||||||
|
// Left up and right down in the same report.
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(1), false),
|
||||||
|
new RawInput.ButtonEvent(HotkeyKey.mouse(3), true)),
|
||||||
|
RawInput.buttons(0x0002 | 0x0004, (short) 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wheelBecomesATapSoTheEngineSeesBothEdges() {
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(4), true),
|
||||||
|
new RawInput.ButtonEvent(HotkeyKey.mouse(4), false)),
|
||||||
|
RawInput.buttons(0x0400, (short) 120));
|
||||||
|
assertEquals(List.of(new RawInput.ButtonEvent(HotkeyKey.mouse(5), true),
|
||||||
|
new RawInput.ButtonEvent(HotkeyKey.mouse(5), false)),
|
||||||
|
RawInput.buttons(0x0400, (short) -120));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mouseMovementAloneReportsNothing() {
|
||||||
|
assertEquals(List.of(), RawInput.buttons(0, (short) 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<ts3j.version>1.0.3</ts3j.version>
|
<ts3j.version>1.0.3</ts3j.version>
|
||||||
<jsvg.version>2.1.0</jsvg.version>
|
<jsvg.version>2.1.0</jsvg.version>
|
||||||
|
<flatlaf.version>3.7.2</flatlaf.version>
|
||||||
<surefire.version>3.5.6</surefire.version>
|
<surefire.version>3.5.6</surefire.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
@@ -50,6 +51,11 @@
|
|||||||
<artifactId>jsvg</artifactId>
|
<artifactId>jsvg</artifactId>
|
||||||
<version>${jsvg.version}</version>
|
<version>${jsvg.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.formdev</groupId>
|
||||||
|
<artifactId>flatlaf</artifactId>
|
||||||
|
<version>${flatlaf.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.ts3client</groupId>
|
<groupId>com.ts3client</groupId>
|
||||||
<artifactId>ts3-client-core</artifactId>
|
<artifactId>ts3-client-core</artifactId>
|
||||||
|
|||||||
@@ -35,6 +35,16 @@
|
|||||||
<groupId>com.github.weisj</groupId>
|
<groupId>com.github.weisj</groupId>
|
||||||
<artifactId>jsvg</artifactId>
|
<artifactId>jsvg</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.formdev</groupId>
|
||||||
|
<artifactId>flatlaf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.13.4</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -78,6 +88,15 @@
|
|||||||
<include>**</include>
|
<include>**</include>
|
||||||
</includes>
|
</includes>
|
||||||
</filter>
|
</filter>
|
||||||
|
<!-- FlatLaf instantiates its UI delegates by name and reads its
|
||||||
|
themes from .properties resources; neither is reachable
|
||||||
|
statically, so minimizeJar must not touch it. -->
|
||||||
|
<filter>
|
||||||
|
<artifact>com.formdev:flatlaf</artifact>
|
||||||
|
<includes>
|
||||||
|
<include>**</include>
|
||||||
|
</includes>
|
||||||
|
</filter>
|
||||||
<filter>
|
<filter>
|
||||||
<artifact>*:*</artifact>
|
<artifact>*:*</artifact>
|
||||||
<excludes>
|
<excludes>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ package com.ts3client;
|
|||||||
|
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
import com.ts3client.ui.IconTheme;
|
import com.ts3client.ui.IconTheme;
|
||||||
|
import com.ts3client.ui.LookAndFeelManager;
|
||||||
import com.ts3client.ui.MainFrame;
|
import com.ts3client.ui.MainFrame;
|
||||||
|
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
import javax.swing.UIManager;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Loads settings, applies the system look-and-feel and
|
* Application entry point. Loads settings, installs the look-and-feel and shows the
|
||||||
* shows the main window on the Swing event dispatch thread.
|
* main window on the Swing event dispatch thread.
|
||||||
*/
|
*/
|
||||||
public final class Main {
|
public final class Main {
|
||||||
|
|
||||||
@@ -23,11 +23,7 @@ public final class Main {
|
|||||||
final Settings settings = Settings.load();
|
final Settings settings = Settings.load();
|
||||||
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
try {
|
LookAndFeelManager.install(settings.appearance);
|
||||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// fall back to cross-platform L&F
|
|
||||||
}
|
|
||||||
IconTheme.get().reload(settings);
|
IconTheme.get().reload(settings);
|
||||||
new MainFrame(settings).setVisible(true);
|
new MainFrame(settings).setVisible(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ChannelSettings;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.BoxLayout;
|
||||||
|
import javax.swing.ButtonGroup;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JRadioButton;
|
||||||
|
import javax.swing.JSpinner;
|
||||||
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.SpinnerNumberModel;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.FlowLayout;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channel editor's "Advanced" tab: the phonetic name, the delete delay of a temporary
|
||||||
|
* channel, voice encryption, and the two client limits.
|
||||||
|
*/
|
||||||
|
final class ChannelAdvancedPanel extends JPanel implements ChannelDialog.Tab {
|
||||||
|
|
||||||
|
/** TeamSpeak's ceiling for {@code channel_delete_delay}, in seconds. */
|
||||||
|
private static final int MAX_DELETE_DELAY = 604800;
|
||||||
|
|
||||||
|
private final JTextField phoneticName = new JTextField();
|
||||||
|
private final JSpinner deleteDelay =
|
||||||
|
Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, MAX_DELETE_DELAY, 1)));
|
||||||
|
private final JButton deleteDelayMax = new JButton("max");
|
||||||
|
private final JCheckBox encrypted = new JCheckBox("Voice Data encrypted");
|
||||||
|
|
||||||
|
private final JRadioButton maxUsersUnlimited = new JRadioButton("Unlimited");
|
||||||
|
private final JRadioButton maxUsersLimited = new JRadioButton("Limited");
|
||||||
|
private final JSpinner maxUsers = Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
|
||||||
|
|
||||||
|
private final JRadioButton familyInherited = new JRadioButton("Inherited");
|
||||||
|
private final JRadioButton familyUnlimited = new JRadioButton("Unlimited");
|
||||||
|
private final JRadioButton familyLimited = new JRadioButton("Limited");
|
||||||
|
private final JSpinner familyMaxUsers =
|
||||||
|
Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
|
||||||
|
|
||||||
|
ChannelAdvancedPanel() {
|
||||||
|
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||||
|
|
||||||
|
phoneticName.setToolTipText("Set phonetic nickname used for this channel by text-to-speech.");
|
||||||
|
deleteDelay.setToolTipText("<html><p>Delay in seconds after temporary channels are deleted"
|
||||||
|
+ " after the last user left the channel.</p></html>");
|
||||||
|
deleteDelayMax.setToolTipText("Set delete delay to maximum value");
|
||||||
|
deleteDelayMax.addActionListener(a -> deleteDelay.setValue(MAX_DELETE_DELAY));
|
||||||
|
encrypted.setToolTipText("Voice data in this channel will be encrypted.");
|
||||||
|
|
||||||
|
ButtonGroup users = new ButtonGroup();
|
||||||
|
users.add(maxUsersUnlimited);
|
||||||
|
users.add(maxUsersLimited);
|
||||||
|
maxUsersUnlimited.setToolTipText("Unlimited number of clients can join this channel.");
|
||||||
|
maxUsersLimited.setToolTipText("Limit number of clients in this channel.");
|
||||||
|
maxUsersUnlimited.addActionListener(a -> syncLimits());
|
||||||
|
maxUsersLimited.addActionListener(a -> syncLimits());
|
||||||
|
|
||||||
|
ButtonGroup family = new ButtonGroup();
|
||||||
|
family.add(familyInherited);
|
||||||
|
family.add(familyUnlimited);
|
||||||
|
family.add(familyLimited);
|
||||||
|
familyInherited.setToolTipText("Inherit the client limit by parent channel.");
|
||||||
|
familyUnlimited.setToolTipText("The channel subtree can be joined by unlimited number of clients.");
|
||||||
|
familyLimited.setToolTipText("Limit the number of clients in the channel subtree.");
|
||||||
|
for (JRadioButton radio : new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}) {
|
||||||
|
radio.addActionListener(a -> syncLimits());
|
||||||
|
}
|
||||||
|
|
||||||
|
JPanel other = otherSettings();
|
||||||
|
other.setAlignmentX(0f);
|
||||||
|
fixHeight(other);
|
||||||
|
add(other);
|
||||||
|
add(Box.createVerticalStrut(6));
|
||||||
|
|
||||||
|
JPanel limits = new JPanel();
|
||||||
|
limits.setLayout(new BoxLayout(limits, BoxLayout.X_AXIS));
|
||||||
|
limits.add(limitBox("Max Users", new JRadioButton[]{maxUsersUnlimited, maxUsersLimited}, maxUsers));
|
||||||
|
limits.add(Box.createHorizontalStrut(8));
|
||||||
|
limits.add(limitBox("Family Max Users",
|
||||||
|
new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}, familyMaxUsers));
|
||||||
|
limits.setAlignmentX(0f);
|
||||||
|
fixHeight(limits);
|
||||||
|
add(limits);
|
||||||
|
add(Box.createVerticalGlue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The delete delay only means anything for a channel that disappears on its own. */
|
||||||
|
void setChannelType(ChannelSettings.Type type) {
|
||||||
|
boolean temporary = type == ChannelSettings.Type.TEMPORARY;
|
||||||
|
deleteDelay.setEnabled(temporary);
|
||||||
|
deleteDelayMax.setEnabled(temporary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void read(ChannelSettings settings) {
|
||||||
|
phoneticName.setText(settings.phoneticName);
|
||||||
|
deleteDelay.setValue(Math.min(MAX_DELETE_DELAY, Math.max(0, settings.deleteDelay)));
|
||||||
|
encrypted.setSelected(settings.encrypted);
|
||||||
|
|
||||||
|
if (settings.maxClientsUnlimited) maxUsersUnlimited.setSelected(true);
|
||||||
|
else maxUsersLimited.setSelected(true);
|
||||||
|
maxUsers.setValue(settings.maxClients);
|
||||||
|
|
||||||
|
if (settings.familyInherited) familyInherited.setSelected(true);
|
||||||
|
else if (settings.familyUnlimited) familyUnlimited.setSelected(true);
|
||||||
|
else familyLimited.setSelected(true);
|
||||||
|
familyMaxUsers.setValue(settings.maxFamilyClients);
|
||||||
|
|
||||||
|
setChannelType(settings.type);
|
||||||
|
syncLimits();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(ChannelSettings settings) {
|
||||||
|
settings.phoneticName = phoneticName.getText();
|
||||||
|
settings.deleteDelay = (Integer) deleteDelay.getValue();
|
||||||
|
settings.encrypted = encrypted.isSelected();
|
||||||
|
settings.maxClientsUnlimited = maxUsersUnlimited.isSelected();
|
||||||
|
settings.maxClients = (Integer) maxUsers.getValue();
|
||||||
|
settings.familyInherited = familyInherited.isSelected();
|
||||||
|
settings.familyUnlimited = familyUnlimited.isSelected();
|
||||||
|
settings.maxFamilyClients = (Integer) familyMaxUsers.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncLimits() {
|
||||||
|
maxUsers.setEnabled(maxUsersLimited.isSelected());
|
||||||
|
familyMaxUsers.setEnabled(familyLimited.isSelected());
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel otherSettings() {
|
||||||
|
JPanel panel = new JPanel(new GridBagLayout());
|
||||||
|
panel.setBorder(BorderFactory.createTitledBorder("Other Settings"));
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.insets = new Insets(3, 4, 3, 4);
|
||||||
|
c.anchor = GridBagConstraints.WEST;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 0;
|
||||||
|
panel.add(new JLabel("Phonetic Name:"), c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
panel.add(phoneticName, c);
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 1;
|
||||||
|
c.weightx = 0;
|
||||||
|
panel.add(new JLabel("Delete delay:"), c);
|
||||||
|
JPanel delay = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
|
||||||
|
delay.add(deleteDelay);
|
||||||
|
delay.add(deleteDelayMax);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
panel.add(delay, c);
|
||||||
|
|
||||||
|
c.gridx = 1;
|
||||||
|
c.gridy = 2;
|
||||||
|
panel.add(encrypted, c);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A limit box: its choices stacked, with the count beside the last of them —
|
||||||
|
* "Limited", the only choice it belongs to.
|
||||||
|
*/
|
||||||
|
private JPanel limitBox(String title, JRadioButton[] choices, JSpinner spinner) {
|
||||||
|
JPanel box = new JPanel();
|
||||||
|
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
|
||||||
|
box.setBorder(BorderFactory.createTitledBorder(title));
|
||||||
|
for (int i = 0; i < choices.length; i++) {
|
||||||
|
JRadioButton radio = choices[i];
|
||||||
|
radio.setAlignmentX(0f);
|
||||||
|
if (i < choices.length - 1) {
|
||||||
|
box.add(radio);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
|
||||||
|
row.setAlignmentX(0f);
|
||||||
|
row.add(radio);
|
||||||
|
row.add(spinner);
|
||||||
|
box.add(row);
|
||||||
|
}
|
||||||
|
box.add(Box.createVerticalGlue());
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps a section at its natural height, so the free space collects at the bottom. */
|
||||||
|
private static void fixHeight(JPanel panel) {
|
||||||
|
panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ChannelSettings;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.BoxLayout;
|
||||||
|
import javax.swing.ButtonGroup;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JRadioButton;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channel editor's "Audio" tab: the codec preset, or the codec and quality picked by
|
||||||
|
* hand, with the bandwidth one talking client then costs.
|
||||||
|
*/
|
||||||
|
final class ChannelAudioPanel extends JPanel implements ChannelDialog.Tab {
|
||||||
|
|
||||||
|
/** Codec names in TeamSpeak's own id order, which is what the combo box index is. */
|
||||||
|
private static final String[] CODEC_NAMES = {
|
||||||
|
"Speex Narrowband", "Speex Wideband", "Speex Ultra-Wideband",
|
||||||
|
"CELT Mono", "Opus Voice", "Opus Music"};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bandwidth per talking client in KiB/s at quality 0 and 10, per codec, as published by
|
||||||
|
* TeamSpeak. The steps in between are interpolated: the client shows a figure per
|
||||||
|
* quality step, and only the two ends of each codec's range are documented.
|
||||||
|
*/
|
||||||
|
private static final double[][] BANDWIDTH_RANGE = {
|
||||||
|
{2.49, 5.22}, {2.69, 7.37}, {2.73, 7.57}, {6.10, 13.92}, {2.73, 7.71}, {3.08, 11.87}};
|
||||||
|
|
||||||
|
private static final int CODEC_OPUS_VOICE = 4;
|
||||||
|
private static final int CODEC_OPUS_MUSIC = 5;
|
||||||
|
|
||||||
|
private final JRadioButton voiceMobile = new JRadioButton("Voice Mobile");
|
||||||
|
private final JRadioButton voiceDesktop = new JRadioButton("Voice Desktop");
|
||||||
|
private final JRadioButton music = new JRadioButton("Music");
|
||||||
|
private final JRadioButton custom = new JRadioButton("Custom");
|
||||||
|
private final JComboBox<String> codec = new JComboBox<>(CODEC_NAMES);
|
||||||
|
private final JSlider quality = new JSlider(0, 10, 6);
|
||||||
|
private final JLabel qualityValue = new JLabel("6");
|
||||||
|
private final JLabel bandwidth = new JLabel();
|
||||||
|
private final JPanel customSettings = new JPanel(new GridBagLayout());
|
||||||
|
|
||||||
|
ChannelAudioPanel() {
|
||||||
|
super(new BorderLayout(8, 8));
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||||
|
|
||||||
|
ButtonGroup presets = new ButtonGroup();
|
||||||
|
presets.add(voiceMobile);
|
||||||
|
presets.add(voiceDesktop);
|
||||||
|
presets.add(music);
|
||||||
|
presets.add(custom);
|
||||||
|
voiceMobile.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 4));
|
||||||
|
voiceDesktop.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 6));
|
||||||
|
music.addActionListener(a -> applyPreset(CODEC_OPUS_MUSIC, 6));
|
||||||
|
custom.addActionListener(a -> setCustomEnabled(true));
|
||||||
|
|
||||||
|
codec.addActionListener(a -> updateBandwidth());
|
||||||
|
quality.setMajorTickSpacing(1);
|
||||||
|
quality.setPaintTicks(true);
|
||||||
|
quality.setSnapToTicks(true);
|
||||||
|
quality.addChangeListener(e -> updateBandwidth());
|
||||||
|
|
||||||
|
add(presetBox(), BorderLayout.WEST);
|
||||||
|
add(customBox(), BorderLayout.CENTER);
|
||||||
|
add(bandwidthRow(), BorderLayout.SOUTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void read(ChannelSettings settings) {
|
||||||
|
codec.setSelectedIndex(Math.max(0, Math.min(CODEC_NAMES.length - 1, settings.codec)));
|
||||||
|
quality.setValue(Math.max(0, Math.min(10, settings.codecQuality)));
|
||||||
|
if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 4) voiceMobile.setSelected(true);
|
||||||
|
else if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 6) voiceDesktop.setSelected(true);
|
||||||
|
else if (settings.codec == CODEC_OPUS_MUSIC && settings.codecQuality == 6) music.setSelected(true);
|
||||||
|
else custom.setSelected(true);
|
||||||
|
setCustomEnabled(custom.isSelected());
|
||||||
|
updateBandwidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(ChannelSettings settings) {
|
||||||
|
settings.codec = codec.getSelectedIndex();
|
||||||
|
settings.codecQuality = quality.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyPreset(int codecId, int qualityValue) {
|
||||||
|
codec.setSelectedIndex(codecId);
|
||||||
|
quality.setValue(qualityValue);
|
||||||
|
setCustomEnabled(false);
|
||||||
|
updateBandwidth();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setCustomEnabled(boolean enabled) {
|
||||||
|
customSettings.setEnabled(enabled);
|
||||||
|
for (java.awt.Component child : customSettings.getComponents()) child.setEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateBandwidth() {
|
||||||
|
qualityValue.setText(Integer.toString(quality.getValue()));
|
||||||
|
int index = Math.max(0, Math.min(BANDWIDTH_RANGE.length - 1, codec.getSelectedIndex()));
|
||||||
|
double[] range = BANDWIDTH_RANGE[index];
|
||||||
|
double value = range[0] + (range[1] - range[0]) * quality.getValue() / 10.0;
|
||||||
|
bandwidth.setText(String.format("%.2f KiB/s", value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel presetBox() {
|
||||||
|
JPanel box = new JPanel();
|
||||||
|
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
|
||||||
|
box.setBorder(BorderFactory.createTitledBorder("Presets"));
|
||||||
|
for (JRadioButton radio : new JRadioButton[]{voiceMobile, voiceDesktop, music, custom}) {
|
||||||
|
radio.setAlignmentX(0f);
|
||||||
|
box.add(radio);
|
||||||
|
}
|
||||||
|
box.add(Box.createVerticalGlue());
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel customBox() {
|
||||||
|
customSettings.setBorder(BorderFactory.createTitledBorder("Custom Settings"));
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.insets = new Insets(4, 4, 4, 4);
|
||||||
|
c.anchor = GridBagConstraints.WEST;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 0;
|
||||||
|
customSettings.add(new JLabel("Codec:"), c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
customSettings.add(codec, c);
|
||||||
|
|
||||||
|
c.gridwidth = 1;
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 1;
|
||||||
|
c.weightx = 0;
|
||||||
|
customSettings.add(new JLabel("Quality:"), c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
customSettings.add(quality, c);
|
||||||
|
c.gridx = 2;
|
||||||
|
c.weightx = 0;
|
||||||
|
customSettings.add(qualityValue, c);
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 2;
|
||||||
|
c.weighty = 1;
|
||||||
|
customSettings.add(Box.createVerticalGlue(), c);
|
||||||
|
return customSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel bandwidthRow() {
|
||||||
|
JPanel row = new JPanel(new BorderLayout(6, 0));
|
||||||
|
row.add(new JLabel("Bandwidth usage:"), BorderLayout.WEST);
|
||||||
|
row.add(bandwidth, BorderLayout.CENTER);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ChannelNode;
|
||||||
|
import com.ts3client.net.ChannelSettings;
|
||||||
|
import com.ts3client.net.TeamspeakConnection;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.ImageIcon;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JDialog;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JMenuItem;
|
||||||
|
import javax.swing.JOptionPane;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JPasswordField;
|
||||||
|
import javax.swing.JPopupMenu;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JTabbedPane;
|
||||||
|
import javax.swing.JTextArea;
|
||||||
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.Timer;
|
||||||
|
import javax.swing.event.DocumentEvent;
|
||||||
|
import javax.swing.event.DocumentListener;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.awt.Window;
|
||||||
|
import java.awt.event.MouseAdapter;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TeamSpeak's channel editor: the channel's name, icon, password, topic and description
|
||||||
|
* above four tabs holding everything else. The official client uses the same dialog to
|
||||||
|
* create a channel and to edit one, and so does this.
|
||||||
|
*
|
||||||
|
* <p>When editing, the channel's properties are not in the tree model — the tree only
|
||||||
|
* carries what the server pushes for display — so the dialog opens empty and fills itself
|
||||||
|
* from a {@code channelinfo}, with its own permissions arriving separately. Saving sends
|
||||||
|
* only what was actually changed, because a {@code channeledit} carrying one property the
|
||||||
|
* client may not modify is refused as a whole.
|
||||||
|
*
|
||||||
|
* <p>When creating, there is nothing to read: the dialog starts from the defaults its
|
||||||
|
* caller chose and sends a {@code channelcreate} carrying whatever deviates from a plain
|
||||||
|
* new channel, for the same reason.
|
||||||
|
*/
|
||||||
|
final class ChannelDialog extends JDialog {
|
||||||
|
|
||||||
|
/** A tab that maps a part of the channel's settings onto its controls. */
|
||||||
|
interface Tab {
|
||||||
|
void read(ChannelSettings settings);
|
||||||
|
|
||||||
|
void write(ChannelSettings settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long, and how often, to keep looking for a channel icon that is still downloading. */
|
||||||
|
private static final int ICON_RETRY_MS = 500;
|
||||||
|
private static final int ICON_RETRIES = 20;
|
||||||
|
|
||||||
|
/** What the password field shows for an existing password, which is never readable. */
|
||||||
|
private static final String PASSWORD_PLACEHOLDER = "••••••••";
|
||||||
|
|
||||||
|
private static final String NAME_TOOLTIP = "<html>Name of this channel displayed in the tree."
|
||||||
|
+ "<table>"
|
||||||
|
+ "<tr><td style='white-space:nowrap'>Syntax: \"<strong>[?Spacer#]Text</strong>\"</td></tr>"
|
||||||
|
+ "<tr><td style='white-space:nowrap'>Where \"?\" stands for an alignment"
|
||||||
|
+ " (r=right, c=center, l=left),</td></tr>"
|
||||||
|
+ "<tr><td style='white-space:nowrap'>\"*\" will repeat the text to fill the whole line.</td></tr>"
|
||||||
|
+ "<tr><td style='white-space:nowrap'>Change \"#\" to get a unique channel name.</td></tr>"
|
||||||
|
+ "<tr><td style='white-space:nowrap'>[lSpacer0] a left aligned text</td></tr>"
|
||||||
|
+ "<tr><td style='white-space:nowrap'>[cSpacer1] a centered text</td></tr>"
|
||||||
|
+ "<tr><td style='white-space:nowrap'>[rSpacer2] a right aligned text</td></tr>"
|
||||||
|
+ "<tr><td style='white-space:nowrap'>Use one of the three-character-blocks as text for a"
|
||||||
|
+ " special spacer: \"---\", \"...\", \"-.-\", \"___\", \"-..\"</td></tr>"
|
||||||
|
+ "</table></html>";
|
||||||
|
|
||||||
|
private final TeamspeakConnection conn;
|
||||||
|
private final GroupIcons groupIcons;
|
||||||
|
/** The channel being edited, or {@code null} when a new one is being created. */
|
||||||
|
private final ChannelNode channel;
|
||||||
|
/** Where a new channel goes; 0 is the top level. Unused when editing. */
|
||||||
|
private final int parentId;
|
||||||
|
|
||||||
|
private final JTextField name = new JTextField();
|
||||||
|
private final JPasswordField password = new JPasswordField();
|
||||||
|
private final JTextField topic = new JTextField();
|
||||||
|
private final JTextArea description = new JTextArea(5, 40);
|
||||||
|
private final JButton iconButton = new JButton();
|
||||||
|
private final JButton ok = new JButton("OK");
|
||||||
|
|
||||||
|
private final ChannelStandardPanel standardPanel;
|
||||||
|
private final ChannelAudioPanel audioPanel = new ChannelAudioPanel();
|
||||||
|
private final ChannelPermissionsPanel permissionsPanel = new ChannelPermissionsPanel();
|
||||||
|
private final ChannelAdvancedPanel advancedPanel = new ChannelAdvancedPanel();
|
||||||
|
|
||||||
|
/** The tabs backed by {@link ChannelSettings}; the permissions tab has its own source. */
|
||||||
|
private final List<Tab> settingsTabs;
|
||||||
|
|
||||||
|
/** The channel as the server last described it; the baseline every change is measured against. */
|
||||||
|
private ChannelSettings original;
|
||||||
|
private long iconId;
|
||||||
|
private boolean passwordEdited;
|
||||||
|
|
||||||
|
/** The editor for an existing channel. */
|
||||||
|
static ChannelDialog toEdit(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
|
||||||
|
ChannelNode channel) {
|
||||||
|
return new ChannelDialog(owner, conn, groupIcons, "Edit Channel: " + channel.name,
|
||||||
|
channel, channel.parentId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor for a channel that does not exist yet.
|
||||||
|
*
|
||||||
|
* @param parentId the channel it goes below, 0 being the top level
|
||||||
|
* @param defaults what the dialog opens with, e.g. the channel type its caller prefers
|
||||||
|
*/
|
||||||
|
static ChannelDialog toCreate(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
|
||||||
|
String title, int parentId, ChannelSettings defaults) {
|
||||||
|
return new ChannelDialog(owner, conn, groupIcons, title, null, parentId, defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChannelDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, String title,
|
||||||
|
ChannelNode channel, int parentId, ChannelSettings defaults) {
|
||||||
|
super(owner, title, ModalityType.APPLICATION_MODAL);
|
||||||
|
this.conn = conn;
|
||||||
|
this.groupIcons = groupIcons;
|
||||||
|
this.channel = channel;
|
||||||
|
this.parentId = parentId;
|
||||||
|
this.standardPanel = new ChannelStandardPanel(channel != null
|
||||||
|
? conn.getModel().siblingsOf(channel.id) : conn.getModel().childrenOf(parentId));
|
||||||
|
|
||||||
|
standardPanel.onTypeChanged(advancedPanel::setChannelType);
|
||||||
|
settingsTabs = List.of(standardPanel, audioPanel, advancedPanel);
|
||||||
|
|
||||||
|
JTabbedPane tabs = new JTabbedPane();
|
||||||
|
tabs.addTab("Standard", standardPanel);
|
||||||
|
tabs.addTab("Audio", audioPanel);
|
||||||
|
tabs.addTab("Permissions", permissionsPanel);
|
||||||
|
tabs.addTab("Advanced", advancedPanel);
|
||||||
|
|
||||||
|
// The tabs need no more than their natural height, so they sit at the bottom
|
||||||
|
// with the buttons and every spare pixel goes to the description instead.
|
||||||
|
JPanel bottom = new JPanel(new BorderLayout(0, 6));
|
||||||
|
bottom.add(tabs, BorderLayout.CENTER);
|
||||||
|
bottom.add(buttons(), BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
getContentPane().setLayout(new BorderLayout(0, 6));
|
||||||
|
getContentPane().add(header(), BorderLayout.CENTER);
|
||||||
|
getContentPane().add(bottom, BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
setEnabledForLoading(false);
|
||||||
|
Dialogs.closeOnEscape(this);
|
||||||
|
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||||
|
setSize(new Dimension(560, 620));
|
||||||
|
setMinimumSize(new Dimension(480, 520));
|
||||||
|
setLocationRelativeTo(owner);
|
||||||
|
|
||||||
|
if (channel != null) load();
|
||||||
|
else prepareNew(defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- layout ----
|
||||||
|
|
||||||
|
private JPanel header() {
|
||||||
|
JPanel panel = new JPanel(new GridBagLayout());
|
||||||
|
panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8));
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.insets = new Insets(3, 4, 3, 4);
|
||||||
|
c.anchor = GridBagConstraints.WEST;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
|
||||||
|
name.setToolTipText(NAME_TOOLTIP);
|
||||||
|
password.setToolTipText("Optional password for this channel.");
|
||||||
|
topic.setToolTipText("Optional topic for this channel, displayed in the info area on the right.");
|
||||||
|
password.getDocument().addDocumentListener(new DocumentListener() {
|
||||||
|
@Override
|
||||||
|
public void insertUpdate(DocumentEvent e) {
|
||||||
|
passwordEdited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeUpdate(DocumentEvent e) {
|
||||||
|
passwordEdited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void changedUpdate(DocumentEvent e) {
|
||||||
|
passwordEdited = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 0;
|
||||||
|
panel.add(new JLabel("Name:"), c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
panel.add(name, c);
|
||||||
|
c.gridx = 2;
|
||||||
|
c.weightx = 0;
|
||||||
|
panel.add(iconField(), c);
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 1;
|
||||||
|
panel.add(new JLabel("Password:"), c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
c.weightx = 1;
|
||||||
|
panel.add(password, c);
|
||||||
|
|
||||||
|
c.gridwidth = 1;
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 2;
|
||||||
|
c.weightx = 0;
|
||||||
|
panel.add(new JLabel("Topic:"), c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
c.weightx = 1;
|
||||||
|
panel.add(topic, c);
|
||||||
|
|
||||||
|
c.gridwidth = 1;
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 3;
|
||||||
|
c.weightx = 0;
|
||||||
|
panel.add(new JLabel("Description:"), c);
|
||||||
|
JButton popOut = new JButton("Edit…");
|
||||||
|
popOut.setToolTipText("Tear off description editor");
|
||||||
|
popOut.addActionListener(a -> editDescription());
|
||||||
|
c.gridx = 1;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
c.fill = GridBagConstraints.NONE;
|
||||||
|
panel.add(popOut, c);
|
||||||
|
|
||||||
|
description.setLineWrap(true);
|
||||||
|
description.setWrapStyleWord(true);
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = 4;
|
||||||
|
c.gridwidth = 3;
|
||||||
|
c.weightx = 1;
|
||||||
|
c.weighty = 1;
|
||||||
|
c.fill = GridBagConstraints.BOTH;
|
||||||
|
panel.add(new JScrollPane(description), c);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel iconField() {
|
||||||
|
iconButton.setToolTipText("Set channel icon.");
|
||||||
|
iconButton.setPreferredSize(new Dimension(26, 24));
|
||||||
|
iconButton.setMargin(new Insets(1, 1, 1, 1));
|
||||||
|
iconButton.addActionListener(a -> chooseIcon());
|
||||||
|
iconButton.addMouseListener(new MouseAdapter() {
|
||||||
|
@Override
|
||||||
|
public void mousePressed(MouseEvent e) {
|
||||||
|
if (e.isPopupTrigger()) showIconMenu(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseReleased(MouseEvent e) {
|
||||||
|
if (e.isPopupTrigger()) showIconMenu(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
JPanel row = new JPanel(new BorderLayout(4, 0));
|
||||||
|
row.add(new JLabel("Icon:"), BorderLayout.WEST);
|
||||||
|
row.add(iconButton, BorderLayout.CENTER);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel buttons() {
|
||||||
|
JButton cancel = new JButton("Cancel");
|
||||||
|
ok.addActionListener(a -> save());
|
||||||
|
cancel.addActionListener(a -> dispose());
|
||||||
|
JPanel panel = new JPanel(new BorderLayout());
|
||||||
|
panel.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
|
||||||
|
JPanel right = new JPanel();
|
||||||
|
right.add(ok);
|
||||||
|
right.add(cancel);
|
||||||
|
panel.add(right, BorderLayout.EAST);
|
||||||
|
getRootPane().setDefaultButton(ok);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- loading ----
|
||||||
|
|
||||||
|
private void load() {
|
||||||
|
conn.requestChannelSettings(channel.id, (settings, error) -> SwingUtilities.invokeLater(() -> {
|
||||||
|
if (error != null) {
|
||||||
|
JOptionPane.showMessageDialog(this, "Could not read the channel: " + error,
|
||||||
|
"Error", JOptionPane.ERROR_MESSAGE);
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply(settings);
|
||||||
|
}));
|
||||||
|
conn.requestChannelPermissions(channel.id, (permissions, error) -> SwingUtilities.invokeLater(() -> {
|
||||||
|
if (error != null) permissionsPanel.showUnavailable("No permission to view channel permissions.");
|
||||||
|
else permissionsPanel.read(permissions);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the dialog on a channel that does not exist yet. Its permissions start empty
|
||||||
|
* and editable — they are applied to the channel once the server has created it.
|
||||||
|
*/
|
||||||
|
private void prepareNew(ChannelSettings defaults) {
|
||||||
|
permissionsPanel.read(Map.of());
|
||||||
|
apply(defaults);
|
||||||
|
name.selectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void apply(ChannelSettings settings) {
|
||||||
|
original = settings;
|
||||||
|
name.setText(settings.name);
|
||||||
|
topic.setText(settings.topic);
|
||||||
|
description.setText(settings.description);
|
||||||
|
description.setCaretPosition(0);
|
||||||
|
if (settings.hasPassword) password.setText(PASSWORD_PLACEHOLDER);
|
||||||
|
passwordEdited = false;
|
||||||
|
setIconId(settings.iconId);
|
||||||
|
for (Tab tab : settingsTabs) tab.read(settings);
|
||||||
|
setEnabledForLoading(true);
|
||||||
|
name.requestFocusInWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setEnabledForLoading(boolean loaded) {
|
||||||
|
ok.setEnabled(loaded);
|
||||||
|
name.setEnabled(loaded);
|
||||||
|
password.setEnabled(loaded);
|
||||||
|
topic.setEnabled(loaded);
|
||||||
|
description.setEnabled(loaded);
|
||||||
|
iconButton.setEnabled(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- icon ----
|
||||||
|
|
||||||
|
private void setIconId(long id) {
|
||||||
|
setIconId(id, ICON_RETRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param retries how many more times to look for an icon that is still downloading;
|
||||||
|
* one that never arrives (deleted, or not ours to read) simply stays blank
|
||||||
|
*/
|
||||||
|
private void setIconId(long id, int retries) {
|
||||||
|
iconId = id;
|
||||||
|
ImageIcon icon = groupIcons.icon(id);
|
||||||
|
iconButton.setIcon(icon);
|
||||||
|
if (icon != null || id == 0 || retries <= 0) return;
|
||||||
|
Timer retry = new Timer(ICON_RETRY_MS, null);
|
||||||
|
retry.setRepeats(false);
|
||||||
|
retry.addActionListener(e -> {
|
||||||
|
if (iconId == id) setIconId(id, retries - 1);
|
||||||
|
});
|
||||||
|
retry.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chooseIcon() {
|
||||||
|
IconChooserDialog chooser = new IconChooserDialog(this, conn, groupIcons);
|
||||||
|
chooser.setVisible(true);
|
||||||
|
if (chooser.isAccepted()) setIconId(chooser.getIconId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showIconMenu(MouseEvent e) {
|
||||||
|
JPopupMenu menu = new JPopupMenu();
|
||||||
|
JMenuItem edit = new JMenuItem("Edit Icon");
|
||||||
|
edit.addActionListener(a -> chooseIcon());
|
||||||
|
JMenuItem remove = new JMenuItem("Remove Icon");
|
||||||
|
remove.setEnabled(iconId != 0);
|
||||||
|
remove.addActionListener(a -> setIconId(0));
|
||||||
|
menu.add(edit);
|
||||||
|
menu.add(remove);
|
||||||
|
menu.show(e.getComponent(), e.getX(), e.getY());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- description ----
|
||||||
|
|
||||||
|
private void editDescription() {
|
||||||
|
DescriptionEditorDialog editor = new DescriptionEditorDialog(this, description.getText());
|
||||||
|
editor.setVisible(true);
|
||||||
|
if (editor.isAccepted()) description.setText(editor.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- saving ----
|
||||||
|
|
||||||
|
private void save() {
|
||||||
|
if (original == null) return;
|
||||||
|
ChannelSettings edited = original.copy();
|
||||||
|
edited.name = name.getText().trim();
|
||||||
|
edited.topic = topic.getText();
|
||||||
|
edited.description = description.getText();
|
||||||
|
edited.password = passwordEdited ? new String(password.getPassword()) : null;
|
||||||
|
edited.iconId = iconId;
|
||||||
|
for (Tab tab : settingsTabs) tab.write(edited);
|
||||||
|
|
||||||
|
if (edited.name.isEmpty()) {
|
||||||
|
JOptionPane.showMessageDialog(this, "The channel needs a name.",
|
||||||
|
"Error", JOptionPane.ERROR_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel == null) {
|
||||||
|
ok.setEnabled(false);
|
||||||
|
conn.createChannel(parentId, edited.creationParameters(), permissionsPanel.changed(),
|
||||||
|
(channelId, error) -> SwingUtilities.invokeLater(
|
||||||
|
() -> finish(error, "Could not create the channel: ")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> changes = edited.changesFrom(original);
|
||||||
|
Map<String, Integer> setPermissions = permissionsPanel.changed();
|
||||||
|
List<String> clearedPermissions = permissionsPanel.cleared();
|
||||||
|
if (changes.isEmpty() && setPermissions.isEmpty() && clearedPermissions.isEmpty()) {
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ok.setEnabled(false);
|
||||||
|
conn.applyChannelEdit(channel.id, changes, setPermissions, clearedPermissions,
|
||||||
|
error -> SwingUtilities.invokeLater(() -> finish(error, "Could not save the channel: ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the dialog, or reports why the server would not have it and lets the user retry. */
|
||||||
|
private void finish(String error, String errorPrefix) {
|
||||||
|
if (error == null) {
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ok.setEnabled(true);
|
||||||
|
JOptionPane.showMessageDialog(this, errorPrefix + error, "Error", JOptionPane.ERROR_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
import com.ts3client.net.ChannelNode;
|
import com.ts3client.net.ChannelNode;
|
||||||
|
import com.ts3client.net.ChannelSettings;
|
||||||
|
|
||||||
import javax.swing.JMenuItem;
|
import javax.swing.JMenuItem;
|
||||||
import javax.swing.JPopupMenu;
|
import javax.swing.JPopupMenu;
|
||||||
@@ -20,6 +21,18 @@ final class ChannelMenu {
|
|||||||
join.addActionListener(a -> actions.joinChannel(channel.id));
|
join.addActionListener(a -> actions.joinChannel(channel.id));
|
||||||
menu.add(join);
|
menu.add(join);
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
// Both start out temporary: a channel made from another channel's menu is
|
||||||
|
// usually a throwaway one, and a permanent channel is a deliberate choice.
|
||||||
|
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
|
||||||
|
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.TEMPORARY));
|
||||||
|
menu.add(create);
|
||||||
|
JMenuItem createSub = new JMenuItem("Create Sub-Channel", Icons.of("CHANNEL_CREATE_SUB"));
|
||||||
|
createSub.addActionListener(a -> actions.createChannel(channel, ChannelSettings.Type.TEMPORARY));
|
||||||
|
menu.add(createSub);
|
||||||
|
JMenuItem edit = new JMenuItem("Edit Channel", Icons.of("CHANNEL_EDIT"));
|
||||||
|
edit.addActionListener(a -> actions.editChannel(channel));
|
||||||
|
menu.add(edit);
|
||||||
|
menu.addSeparator();
|
||||||
addSubscriptionItems(menu, channel, actions);
|
addSubscriptionItems(menu, channel, actions);
|
||||||
JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER"));
|
JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER"));
|
||||||
files.addActionListener(a -> actions.browseFiles(channel));
|
files.addActionListener(a -> actions.browseFiles(channel));
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.BoxLayout;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JSpinner;
|
||||||
|
import javax.swing.SpinnerNumberModel;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channel editor's "Permissions" tab: the needed-power permissions set on the channel
|
||||||
|
* itself, which is the subset of channel permissions TeamSpeak surfaces here.
|
||||||
|
*
|
||||||
|
* <p>These are not channel properties but permissions, so they are read with
|
||||||
|
* {@code channelpermlist} and written with {@code channeladdperm}. A power left at zero is
|
||||||
|
* not a permission the channel carries: clearing a spinner back to zero removes the
|
||||||
|
* permission again so the channel inherits it, which is how the official client behaves.
|
||||||
|
*/
|
||||||
|
final class ChannelPermissionsPanel extends JPanel {
|
||||||
|
|
||||||
|
/** The regular powers, in the order the official dialog lists them. */
|
||||||
|
private static final String[][] REGULAR = {
|
||||||
|
{"i_channel_needed_join_power", "Join:"},
|
||||||
|
{"i_channel_needed_subscribe_power", "Subscribe:"},
|
||||||
|
{"i_channel_needed_description_view_power", "Desc. View:"},
|
||||||
|
{"i_channel_needed_modify_power", "Modify:"},
|
||||||
|
{"i_channel_needed_delete_power", "Delete:"}};
|
||||||
|
|
||||||
|
private static final String[][] FILE_TRANSFER = {
|
||||||
|
{"i_ft_needed_file_browse_power", "Browse:"},
|
||||||
|
{"i_ft_needed_file_upload_power", "Upload:"},
|
||||||
|
{"i_ft_needed_file_download_power", "Download:"},
|
||||||
|
{"i_ft_needed_file_rename_power", "Rename:"},
|
||||||
|
{"i_ft_needed_directory_create_power", "Dir. Create:"}};
|
||||||
|
|
||||||
|
private final Map<String, JSpinner> spinners = new LinkedHashMap<>();
|
||||||
|
/** The values the server reported, to tell an edited power from an untouched one. */
|
||||||
|
private final Map<String, Integer> original = new LinkedHashMap<>();
|
||||||
|
private final JLabel status = new JLabel(" ");
|
||||||
|
|
||||||
|
ChannelPermissionsPanel() {
|
||||||
|
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||||
|
|
||||||
|
JPanel groups = new JPanel();
|
||||||
|
groups.setLayout(new BoxLayout(groups, BoxLayout.X_AXIS));
|
||||||
|
groups.add(group("Regular Needed Powers", REGULAR));
|
||||||
|
groups.add(Box.createHorizontalStrut(8));
|
||||||
|
groups.add(group("File Transfer Needed Powers", FILE_TRANSFER));
|
||||||
|
groups.setAlignmentX(0f);
|
||||||
|
add(groups);
|
||||||
|
|
||||||
|
status.setAlignmentX(0f);
|
||||||
|
status.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2));
|
||||||
|
add(status);
|
||||||
|
add(Box.createVerticalGlue());
|
||||||
|
setEditable(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills the spinners from a {@code channelpermlist} result and enables editing. */
|
||||||
|
void read(Map<String, Integer> permissions) {
|
||||||
|
original.clear();
|
||||||
|
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
|
||||||
|
int value = permissions.getOrDefault(entry.getKey(), 0);
|
||||||
|
original.put(entry.getKey(), value);
|
||||||
|
entry.getValue().setValue(value);
|
||||||
|
}
|
||||||
|
setEditable(true);
|
||||||
|
setStatus(" ", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greys the tab out with a reason, e.g. when the server refuses to show the permissions. */
|
||||||
|
void showUnavailable(String message) {
|
||||||
|
setEditable(false);
|
||||||
|
setStatus(message, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The permissions whose power was changed to a non-zero value. */
|
||||||
|
Map<String, Integer> changed() {
|
||||||
|
Map<String, Integer> changed = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
|
||||||
|
int value = (Integer) entry.getValue().getValue();
|
||||||
|
if (value != 0 && value != original.getOrDefault(entry.getKey(), 0)) {
|
||||||
|
changed.put(entry.getKey(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The permissions cleared back to zero, which are removed from the channel. */
|
||||||
|
List<String> cleared() {
|
||||||
|
List<String> cleared = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
|
||||||
|
int value = (Integer) entry.getValue().getValue();
|
||||||
|
if (value == 0 && original.getOrDefault(entry.getKey(), 0) != 0) cleared.add(entry.getKey());
|
||||||
|
}
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setStatus(String message, boolean error) {
|
||||||
|
status.setText(message == null || message.isEmpty() ? " " : message);
|
||||||
|
status.setForeground(error ? Color.RED.darker() : Theme.chatSystem());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setEditable(boolean editable) {
|
||||||
|
for (JSpinner spinner : spinners.values()) spinner.setEnabled(editable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel group(String title, String[][] permissions) {
|
||||||
|
JPanel panel = new JPanel(new GridBagLayout());
|
||||||
|
panel.setBorder(BorderFactory.createTitledBorder(title));
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.insets = new Insets(3, 4, 3, 4);
|
||||||
|
c.anchor = GridBagConstraints.WEST;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
int row = 0;
|
||||||
|
for (String[] permission : permissions) {
|
||||||
|
JSpinner spinner = Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1)));
|
||||||
|
spinners.put(permission[0], spinner);
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.weightx = 0;
|
||||||
|
panel.add(new JLabel(permission[1]), c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
c.fill = GridBagConstraints.NONE;
|
||||||
|
panel.add(spinner, c);
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
// Keeps the rows at the top of the group instead of centred in whatever height
|
||||||
|
// the tab happens to give it.
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.weighty = 1;
|
||||||
|
panel.add(Box.createGlue(), c);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ChannelNode;
|
||||||
|
import com.ts3client.net.ChannelSettings;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.BoxLayout;
|
||||||
|
import javax.swing.ButtonGroup;
|
||||||
|
import javax.swing.DefaultComboBoxModel;
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JRadioButton;
|
||||||
|
import javax.swing.JSeparator;
|
||||||
|
import javax.swing.JSpinner;
|
||||||
|
import javax.swing.SpinnerNumberModel;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.FlowLayout;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channel editor's "Standard" tab: how long the channel lives, whether it is the
|
||||||
|
* server's default one, where it sorts among its siblings, and its moderation setting.
|
||||||
|
*/
|
||||||
|
final class ChannelStandardPanel extends JPanel implements ChannelDialog.Tab {
|
||||||
|
|
||||||
|
private final JRadioButton temporary = new JRadioButton("Temporary");
|
||||||
|
private final JRadioButton semiPermanent = new JRadioButton("Semi-Permanent");
|
||||||
|
private final JRadioButton permanent = new JRadioButton("Permanent");
|
||||||
|
private final JCheckBox defaultChannel = new JCheckBox("Default Channel");
|
||||||
|
private final JComboBox<SortEntry> sortAfter = new JComboBox<>();
|
||||||
|
private final JSpinner talkPower = new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1));
|
||||||
|
|
||||||
|
private Consumer<ChannelSettings.Type> typeListener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param siblings the channels this one shares a parent with, in tree order and
|
||||||
|
* excluding the channel being edited
|
||||||
|
*/
|
||||||
|
ChannelStandardPanel(List<ChannelNode> siblings) {
|
||||||
|
super(new BorderLayout(8, 8));
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||||
|
|
||||||
|
temporary.setToolTipText("Channel will be deleted when the last user left.");
|
||||||
|
semiPermanent.setToolTipText("Channel exists until server is restarted.");
|
||||||
|
permanent.setToolTipText("Channel will exist until manually deleted.");
|
||||||
|
ButtonGroup types = new ButtonGroup();
|
||||||
|
types.add(temporary);
|
||||||
|
types.add(semiPermanent);
|
||||||
|
types.add(permanent);
|
||||||
|
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
|
||||||
|
radio.addActionListener(a -> fireTypeChanged());
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultChannel.setToolTipText("<html>The default channel is the place where new clients join on login."
|
||||||
|
+ "<br>There can be only one default channel for the whole server.</html>");
|
||||||
|
|
||||||
|
DefaultComboBoxModel<SortEntry> order = new DefaultComboBoxModel<>();
|
||||||
|
order.addElement(new SortEntry(0, "(first)"));
|
||||||
|
for (ChannelNode sibling : siblings) order.addElement(new SortEntry(sibling.id, sibling.name));
|
||||||
|
sortAfter.setModel(order);
|
||||||
|
sortAfter.setToolTipText("Channel will be sorted below this channel.");
|
||||||
|
sortAfter.setPreferredSize(new Dimension(200, sortAfter.getPreferredSize().height));
|
||||||
|
Spinners.compact(talkPower);
|
||||||
|
|
||||||
|
add(typeBox(), BorderLayout.WEST);
|
||||||
|
add(rightColumn(), BorderLayout.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notified whenever the channel type changes, so the delete delay can follow it. */
|
||||||
|
void onTypeChanged(Consumer<ChannelSettings.Type> listener) {
|
||||||
|
this.typeListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void read(ChannelSettings settings) {
|
||||||
|
switch (settings.type) {
|
||||||
|
case TEMPORARY:
|
||||||
|
temporary.setSelected(true);
|
||||||
|
break;
|
||||||
|
case SEMI_PERMANENT:
|
||||||
|
semiPermanent.setSelected(true);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
permanent.setSelected(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
defaultChannel.setSelected(settings.defaultChannel);
|
||||||
|
// The default channel cannot simply stop being one; another has to take over.
|
||||||
|
defaultChannel.setEnabled(!settings.defaultChannel);
|
||||||
|
select(settings.order);
|
||||||
|
talkPower.setValue(settings.neededTalkPower);
|
||||||
|
fireTypeChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(ChannelSettings settings) {
|
||||||
|
settings.type = selectedType();
|
||||||
|
settings.defaultChannel = defaultChannel.isSelected();
|
||||||
|
SortEntry entry = (SortEntry) sortAfter.getSelectedItem();
|
||||||
|
settings.order = entry == null ? 0 : entry.channelId;
|
||||||
|
settings.neededTalkPower = (Integer) talkPower.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChannelSettings.Type selectedType() {
|
||||||
|
if (temporary.isSelected()) return ChannelSettings.Type.TEMPORARY;
|
||||||
|
if (semiPermanent.isSelected()) return ChannelSettings.Type.SEMI_PERMANENT;
|
||||||
|
return ChannelSettings.Type.PERMANENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fireTypeChanged() {
|
||||||
|
if (typeListener != null) typeListener.accept(selectedType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void select(int channelId) {
|
||||||
|
for (int i = 0; i < sortAfter.getItemCount(); i++) {
|
||||||
|
if (sortAfter.getItemAt(i).channelId == channelId) {
|
||||||
|
sortAfter.setSelectedIndex(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortAfter.setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel typeBox() {
|
||||||
|
JPanel box = new JPanel();
|
||||||
|
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
|
||||||
|
box.setBorder(BorderFactory.createTitledBorder("Channel Type"));
|
||||||
|
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
|
||||||
|
radio.setAlignmentX(0f);
|
||||||
|
box.add(radio);
|
||||||
|
}
|
||||||
|
JSeparator line = new JSeparator();
|
||||||
|
line.setAlignmentX(0f);
|
||||||
|
line.setMaximumSize(new Dimension(Integer.MAX_VALUE, 8));
|
||||||
|
box.add(Box.createVerticalStrut(4));
|
||||||
|
box.add(line);
|
||||||
|
box.add(Box.createVerticalStrut(4));
|
||||||
|
defaultChannel.setAlignmentX(0f);
|
||||||
|
box.add(defaultChannel);
|
||||||
|
box.add(Box.createVerticalGlue());
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel rightColumn() {
|
||||||
|
JPanel sort = new JPanel(new BorderLayout());
|
||||||
|
sort.setBorder(BorderFactory.createTitledBorder("Sort This Channel After:"));
|
||||||
|
sort.add(sortAfter, BorderLayout.CENTER);
|
||||||
|
|
||||||
|
JPanel moderation = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
|
||||||
|
moderation.setBorder(BorderFactory.createTitledBorder("Moderation"));
|
||||||
|
JLabel label = new JLabel("Needed Talk Power:");
|
||||||
|
label.setToolTipText("Talk Power required to speak in this channel.");
|
||||||
|
moderation.add(label);
|
||||||
|
moderation.add(talkPower);
|
||||||
|
|
||||||
|
// Both groups keep their natural height; the filler below soaks up the rest, so
|
||||||
|
// they sit at the top instead of stretching over the whole tab.
|
||||||
|
JPanel column = new JPanel(new GridBagLayout());
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.gridx = 0;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
c.weightx = 1;
|
||||||
|
c.insets = new Insets(0, 0, 6, 0);
|
||||||
|
column.add(sort, c);
|
||||||
|
column.add(moderation, c);
|
||||||
|
c.weighty = 1;
|
||||||
|
c.fill = GridBagConstraints.BOTH;
|
||||||
|
column.add(Box.createGlue(), c);
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of the "sort after" list: a sibling channel, or the top of the list. */
|
||||||
|
private static final class SortEntry {
|
||||||
|
final int channelId;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
SortEntry(int channelId, String label) {
|
||||||
|
this.channelId = channelId;
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.ts3client.text.BBCode;
|
|||||||
import com.ts3client.text.TsLink;
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Icon;
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JEditorPane;
|
import javax.swing.JEditorPane;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
@@ -15,8 +16,6 @@ import javax.swing.SwingUtilities;
|
|||||||
import javax.swing.event.HyperlinkEvent;
|
import javax.swing.event.HyperlinkEvent;
|
||||||
import javax.swing.text.BadLocationException;
|
import javax.swing.text.BadLocationException;
|
||||||
import javax.swing.text.html.HTMLDocument;
|
import javax.swing.text.html.HTMLDocument;
|
||||||
import javax.swing.text.html.HTMLEditorKit;
|
|
||||||
import javax.swing.text.html.StyleSheet;
|
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Component;
|
import java.awt.Component;
|
||||||
import java.awt.Cursor;
|
import java.awt.Cursor;
|
||||||
@@ -25,6 +24,7 @@ import java.awt.FlowLayout;
|
|||||||
import java.awt.Point;
|
import java.awt.Point;
|
||||||
import java.awt.event.MouseAdapter;
|
import java.awt.event.MouseAdapter;
|
||||||
import java.awt.event.MouseEvent;
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.awt.event.MouseWheelEvent;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -62,6 +62,10 @@ public final class ChatPanel extends JPanel {
|
|||||||
private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel");
|
private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel");
|
||||||
/** Private chats keyed by the peer's client id. */
|
/** Private chats keyed by the peer's client id. */
|
||||||
private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>();
|
private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>();
|
||||||
|
/** Static-content tabs (e.g. a moved-out description), keyed by caller-chosen id. */
|
||||||
|
private final Map<String, Tab> noteTabs = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private final TabDragReorder dragReorder = new TabDragReorder(tabs, this::moveTab);
|
||||||
|
|
||||||
private SendHandler sendHandler;
|
private SendHandler sendHandler;
|
||||||
private LinkHandler linkHandler;
|
private LinkHandler linkHandler;
|
||||||
@@ -69,7 +73,8 @@ public final class ChatPanel extends JPanel {
|
|||||||
public ChatPanel() {
|
public ChatPanel() {
|
||||||
super(new BorderLayout());
|
super(new BorderLayout());
|
||||||
|
|
||||||
tabs.setFont(Theme.UI_FONT);
|
tabs.setFont(Theme.uiFont());
|
||||||
|
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
|
||||||
addTab(serverTab, false);
|
addTab(serverTab, false);
|
||||||
addTab(channelTab, false);
|
addTab(channelTab, false);
|
||||||
tabs.setSelectedIndex(0);
|
tabs.setSelectedIndex(0);
|
||||||
@@ -77,6 +82,8 @@ public final class ChatPanel extends JPanel {
|
|||||||
Tab t = selectedTab();
|
Tab t = selectedTab();
|
||||||
if (t != null) t.setUnread(false);
|
if (t != null) t.setUnread(false);
|
||||||
});
|
});
|
||||||
|
tabs.addMouseWheelListener(this::onWheel);
|
||||||
|
dragReorder.attach(tabs);
|
||||||
add(tabs, BorderLayout.CENTER);
|
add(tabs, BorderLayout.CENTER);
|
||||||
|
|
||||||
JPanel bottom = new JPanel(new BorderLayout(4, 0));
|
JPanel bottom = new JPanel(new BorderLayout(4, 0));
|
||||||
@@ -130,6 +137,40 @@ public final class ChatPanel extends JPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drops every static-content tab (moved-out descriptions), e.g. after disconnecting. */
|
||||||
|
public void closeNoteTabs() {
|
||||||
|
edt(() -> {
|
||||||
|
for (Tab t : noteTabs.values()) tabs.remove(t.scroll);
|
||||||
|
noteTabs.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes a single static-content tab by its key, if open; does not run its {@code onClose}. */
|
||||||
|
public void closeNoteTab(String key) {
|
||||||
|
edt(() -> {
|
||||||
|
Tab tab = noteTabs.remove(key);
|
||||||
|
if (tab != null) tabs.remove(tab.scroll);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens (or replaces the content of, and focuses) a static-content tab, e.g. a
|
||||||
|
* channel/client description moved out of the info panel. {@code onClose} runs
|
||||||
|
* when the tab is closed, so the caller can un-hide the description again.
|
||||||
|
*/
|
||||||
|
public void openDescriptionTab(String key, Icon icon, String title, String htmlBody, Runnable onClose) {
|
||||||
|
edt(() -> {
|
||||||
|
Tab tab = noteTabs.get(key);
|
||||||
|
if (tab == null) {
|
||||||
|
tab = new Tab(key, icon, title, onClose);
|
||||||
|
noteTabs.put(key, tab);
|
||||||
|
addTab(tab, true);
|
||||||
|
}
|
||||||
|
tab.setStaticContent(htmlBody);
|
||||||
|
tabs.setSelectedComponent(tab.scroll);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void fireSend() {
|
private void fireSend() {
|
||||||
String text = input.getText().trim();
|
String text = input.getText().trim();
|
||||||
if (text.isEmpty() || sendHandler == null) return;
|
if (text.isEmpty() || sendHandler == null) return;
|
||||||
@@ -143,10 +184,20 @@ public final class ChatPanel extends JPanel {
|
|||||||
|
|
||||||
/** System notices always land in the server tab. */
|
/** System notices always land in the server tab. */
|
||||||
public void appendSystem(String text) {
|
public void appendSystem(String text) {
|
||||||
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp()
|
edt(() -> serverTab.appendLine("<span class=\"muted\">" + stamp()
|
||||||
+ BBCode.escape(text) + "</span>"));
|
+ BBCode.escape(text) + "</span>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side events (client joins/leaves/moves, group changes, channel edits, …).
|
||||||
|
* These lines name clients and channels with TeamSpeak's own links, which stay
|
||||||
|
* clickable here — everything else in them is literal text.
|
||||||
|
*/
|
||||||
|
public void appendServerLog(String text) {
|
||||||
|
edt(() -> serverTab.appendLine("<span class=\"event\">" + stamp()
|
||||||
|
+ BBCode.linksToHtml(text) + "</span>"));
|
||||||
|
}
|
||||||
|
|
||||||
public void appendServerMessage(int fromId, String from, String text) {
|
public void appendServerMessage(int fromId, String from, String text) {
|
||||||
edt(() -> serverTab.appendMessage(fromId, from, text));
|
edt(() -> serverTab.appendMessage(fromId, from, text));
|
||||||
}
|
}
|
||||||
@@ -180,10 +231,40 @@ public final class ChatPanel extends JPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void closeTab(Tab tab) {
|
private void closeTab(Tab tab) {
|
||||||
|
if (tab.noteKey != null) {
|
||||||
|
noteTabs.remove(tab.noteKey);
|
||||||
|
if (tab.onClose != null) tab.onClose.run();
|
||||||
|
} else {
|
||||||
privateTabs.remove(tab.clientId);
|
privateTabs.remove(tab.clientId);
|
||||||
|
}
|
||||||
tabs.remove(tab.scroll);
|
tabs.remove(tab.scroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drags a tab from one position to another, keeping the current selection on screen. */
|
||||||
|
private void moveTab(int from, int to) {
|
||||||
|
if (from < 0 || to < 0 || from >= tabs.getTabCount() || to >= tabs.getTabCount() || from == to) return;
|
||||||
|
Component content = tabs.getComponentAt(from);
|
||||||
|
String title = tabs.getTitleAt(from);
|
||||||
|
Icon icon = tabs.getIconAt(from);
|
||||||
|
String tip = tabs.getToolTipTextAt(from);
|
||||||
|
Component header = tabs.getTabComponentAt(from);
|
||||||
|
boolean wasSelected = tabs.getSelectedIndex() == from;
|
||||||
|
tabs.removeTabAt(from);
|
||||||
|
tabs.insertTab(title, icon, content, tip, to);
|
||||||
|
tabs.setTabComponentAt(to, header);
|
||||||
|
if (wasSelected) tabs.setSelectedIndex(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onWheel(MouseWheelEvent e) {
|
||||||
|
Component content = tabs.getSelectedComponent();
|
||||||
|
if (content != null && content.getBounds().contains(e.getPoint())) return;
|
||||||
|
int steps = e.getWheelRotation();
|
||||||
|
if (steps == 0) return;
|
||||||
|
int target = Math.max(0, Math.min(tabs.getTabCount() - 1, tabs.getSelectedIndex() + steps));
|
||||||
|
if (target != tabs.getSelectedIndex()) tabs.setSelectedIndex(target);
|
||||||
|
e.consume();
|
||||||
|
}
|
||||||
|
|
||||||
private Tab selectedTab() {
|
private Tab selectedTab() {
|
||||||
Component c = tabs.getSelectedComponent();
|
Component c = tabs.getSelectedComponent();
|
||||||
if (c == serverTab.scroll) return serverTab;
|
if (c == serverTab.scroll) return serverTab;
|
||||||
@@ -205,40 +286,39 @@ public final class ChatPanel extends JPanel {
|
|||||||
final Target target;
|
final Target target;
|
||||||
final int clientId;
|
final int clientId;
|
||||||
final String title;
|
final String title;
|
||||||
final JEditorPane log = new JEditorPane();
|
final JEditorPane log = HtmlStyles.pane("font-family:sans-serif; font-size:11px; margin:4px 6px;");
|
||||||
final JScrollPane scroll;
|
final JScrollPane scroll;
|
||||||
private final HTMLDocument doc;
|
/** Set for a static-content tab (e.g. a moved-out description); null for a conversation. */
|
||||||
|
final String noteKey;
|
||||||
|
final Runnable onClose;
|
||||||
|
private final Icon icon;
|
||||||
private JLabel titleLabel;
|
private JLabel titleLabel;
|
||||||
|
|
||||||
Tab(Target target, int clientId, String title) {
|
Tab(Target target, int clientId, String title) {
|
||||||
|
this(target, clientId, title, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A static-content tab: no send target, closing it runs {@code onClose}. */
|
||||||
|
Tab(String noteKey, Icon icon, String title, Runnable onClose) {
|
||||||
|
this(null, 0, title, icon, noteKey, onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Tab(Target target, int clientId, String title, Icon icon, String noteKey, Runnable onClose) {
|
||||||
this.target = target;
|
this.target = target;
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.title = title;
|
this.title = title;
|
||||||
|
this.icon = icon;
|
||||||
|
this.noteKey = noteKey;
|
||||||
|
this.onClose = onClose;
|
||||||
|
|
||||||
HTMLEditorKit kit = new HTMLEditorKit();
|
|
||||||
StyleSheet css = new StyleSheet();
|
|
||||||
css.addStyleSheet(kit.getStyleSheet());
|
|
||||||
css.addRule("body { font-family:sans-serif; font-size:11px; color:#202020; margin:4px 6px; }");
|
|
||||||
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
|
|
||||||
// Client references look exactly like a message author's name.
|
|
||||||
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
|
|
||||||
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
|
|
||||||
// External links are underlined so they can't be mistaken for an identity.
|
|
||||||
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
|
|
||||||
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
|
|
||||||
kit.setStyleSheet(css);
|
|
||||||
|
|
||||||
log.setEditorKit(kit);
|
|
||||||
log.setEditable(false);
|
|
||||||
log.setBackground(Theme.CHAT_BG);
|
|
||||||
log.setText("<html><body><div id=\"chatlog\"></div></body></html>");
|
log.setText("<html><body><div id=\"chatlog\"></div></body></html>");
|
||||||
doc = (HTMLDocument) log.getDocument();
|
|
||||||
log.addHyperlinkListener(e -> {
|
log.addHyperlinkListener(e -> {
|
||||||
if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return;
|
if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return;
|
||||||
onLink(e.getDescription());
|
onLink(e.getDescription());
|
||||||
});
|
});
|
||||||
|
|
||||||
scroll = new JScrollPane(log);
|
scroll = new JScrollPane(log);
|
||||||
|
HtmlStyles.fillViewport(log);
|
||||||
scroll.setBorder(BorderFactory.createEmptyBorder());
|
scroll.setBorder(BorderFactory.createEmptyBorder());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,13 +339,15 @@ public final class ChatPanel extends JPanel {
|
|||||||
Component header(boolean closable) {
|
Component header(boolean closable) {
|
||||||
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
|
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
|
||||||
p.setOpaque(false);
|
p.setOpaque(false);
|
||||||
titleLabel = new JLabel(title);
|
titleLabel = new JLabel(title, icon, JLabel.LEADING);
|
||||||
titleLabel.setFont(Theme.UI_FONT);
|
titleLabel.setFont(Theme.uiFont());
|
||||||
p.add(titleLabel);
|
p.add(titleLabel);
|
||||||
|
dragReorder.attach(p);
|
||||||
|
dragReorder.attach(titleLabel);
|
||||||
if (closable) {
|
if (closable) {
|
||||||
JLabel close = new JLabel("×");
|
JLabel close = new JLabel("×");
|
||||||
close.setFont(Theme.UI_BOLD);
|
close.setFont(Theme.uiBold());
|
||||||
close.setForeground(Theme.CHAT_SYSTEM);
|
close.setForeground(Theme.chatSystem());
|
||||||
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||||
close.setToolTipText("Close chat");
|
close.setToolTipText("Close chat");
|
||||||
close.addMouseListener(new MouseAdapter() {
|
close.addMouseListener(new MouseAdapter() {
|
||||||
@@ -289,8 +371,8 @@ public final class ChatPanel extends JPanel {
|
|||||||
|
|
||||||
void setUnread(boolean unread) {
|
void setUnread(boolean unread) {
|
||||||
if (titleLabel == null) return;
|
if (titleLabel == null) return;
|
||||||
titleLabel.setFont(unread ? Theme.UI_BOLD : Theme.UI_FONT);
|
titleLabel.setFont(unread ? Theme.uiBold() : Theme.uiFont());
|
||||||
titleLabel.setForeground(unread ? Theme.ACCENT : null);
|
titleLabel.setForeground(unread ? Theme.accent() : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
void appendMessage(int fromId, String from, String text) {
|
void appendMessage(int fromId, String from, String text) {
|
||||||
@@ -298,13 +380,20 @@ public final class ChatPanel extends JPanel {
|
|||||||
String sender = fromId > 0
|
String sender = fromId > 0
|
||||||
? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\""
|
? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\""
|
||||||
+ BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>"
|
+ BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>"
|
||||||
: "<b style=\"color:" + hex(Theme.CHAT_NAME) + "\">" + name + "</b>";
|
: "<span class=\"name\">" + name + "</span>";
|
||||||
appendLine("<span style=\"color:#8A8A8A\">" + stamp() + "</span>"
|
appendLine("<span class=\"muted\">" + stamp() + "</span>"
|
||||||
+ sender + ": " + BBCode.toHtml(text));
|
+ sender + ": " + BBCode.toHtml(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replaces the whole log with fixed content; used by static-content tabs. */
|
||||||
|
void setStaticContent(String html) {
|
||||||
|
log.setText("<html><body><div id=\"chatlog\">" + html + "</div></body></html>");
|
||||||
|
log.setCaretPosition(0);
|
||||||
|
}
|
||||||
|
|
||||||
void appendLine(String html) {
|
void appendLine(String html) {
|
||||||
try {
|
try {
|
||||||
|
HTMLDocument doc = (HTMLDocument) log.getDocument();
|
||||||
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
|
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
|
||||||
log.setCaretPosition(doc.getLength());
|
log.setCaretPosition(doc.getLength());
|
||||||
} catch (BadLocationException | IOException ignored) {
|
} catch (BadLocationException | IOException ignored) {
|
||||||
@@ -312,8 +401,4 @@ public final class ChatPanel extends JPanel {
|
|||||||
if (tabs.getSelectedComponent() != scroll) setUnread(true);
|
if (tabs.getSelectedComponent() != scroll) setUnread(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String hex(java.awt.Color c) {
|
|
||||||
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ChannelNode;
|
||||||
import com.ts3client.net.ClientEntry;
|
import com.ts3client.net.ClientEntry;
|
||||||
|
import com.ts3client.net.Group;
|
||||||
|
import com.ts3client.net.ServerModel;
|
||||||
|
|
||||||
|
import javax.swing.JCheckBoxMenuItem;
|
||||||
|
import javax.swing.JMenu;
|
||||||
import javax.swing.JMenuItem;
|
import javax.swing.JMenuItem;
|
||||||
import javax.swing.JPopupMenu;
|
import javax.swing.JPopupMenu;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The context menu for a client, shared by the server tree and the clickable
|
* The context menu for a client, shared by the server tree and the clickable
|
||||||
@@ -14,7 +20,13 @@ final class ClientMenu {
|
|||||||
private ClientMenu() {
|
private ClientMenu() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static JPopupMenu build(ClientEntry client, boolean self, ServerTreePanel.Actions actions) {
|
/**
|
||||||
|
* @param fromTree whether this menu was opened from the server tree itself, as
|
||||||
|
* opposed to a client link elsewhere (chat log, etc.) — "Find
|
||||||
|
* Client in Channel Tree" is redundant in the former case.
|
||||||
|
*/
|
||||||
|
static JPopupMenu build(ClientEntry client, boolean self, boolean fromTree, ServerModel model,
|
||||||
|
GroupIcons groupIcons, ServerTreePanel.Actions actions) {
|
||||||
JPopupMenu menu = new JPopupMenu();
|
JPopupMenu menu = new JPopupMenu();
|
||||||
if (!self) {
|
if (!self) {
|
||||||
JMenuItem pm = new JMenuItem("Open text chat", Icons.of("PLAYER_CHAT"));
|
JMenuItem pm = new JMenuItem("Open text chat", Icons.of("PLAYER_CHAT"));
|
||||||
@@ -44,15 +56,117 @@ final class ClientMenu {
|
|||||||
menu.add(me);
|
menu.add(me);
|
||||||
}
|
}
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
menu.add(buildServerGroupMenu(client, model, groupIcons, actions));
|
||||||
|
menu.add(buildChannelGroupMenu(client, model, groupIcons, actions));
|
||||||
|
menu.addSeparator();
|
||||||
|
if (!fromTree) {
|
||||||
|
JMenuItem findInTree = new JMenuItem("Find Client in Channel Tree", Icons.of("CHANNEL_SWITCH"));
|
||||||
|
findInTree.setEnabled(isVisible(client, model));
|
||||||
|
findInTree.addActionListener(a -> actions.findClientInTree(client));
|
||||||
|
menu.add(findInTree);
|
||||||
|
}
|
||||||
|
JMenuItem description = new JMenuItem("Change Description", Icons.of("EDIT"));
|
||||||
|
description.addActionListener(a -> actions.changeClientDescription(client));
|
||||||
|
menu.add(description);
|
||||||
JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO"));
|
JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO"));
|
||||||
info.addActionListener(a -> actions.showConnectionInfo(client));
|
info.addActionListener(a -> actions.showConnectionInfo(client));
|
||||||
menu.add(info);
|
menu.add(info);
|
||||||
if (!self) {
|
if (!self) {
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
JMenuItem joinChannel = new JMenuItem("Join Channel of Client", Icons.of("CHANNEL_SWITCH"));
|
||||||
|
joinChannel.setEnabled(canJoinChannelOf(client, model));
|
||||||
|
joinChannel.addActionListener(a -> actions.joinChannel(client.channelId));
|
||||||
|
menu.add(joinChannel);
|
||||||
JMenuItem moveHere = new JMenuItem("Move Client to own Channel", Icons.of("MOVE_CLIENT_TO_OWN_CHANNEL"));
|
JMenuItem moveHere = new JMenuItem("Move Client to own Channel", Icons.of("MOVE_CLIENT_TO_OWN_CHANNEL"));
|
||||||
moveHere.addActionListener(a -> actions.moveClientToOwnChannel(client));
|
moveHere.addActionListener(a -> actions.moveClientToOwnChannel(client));
|
||||||
menu.add(moveHere);
|
menu.add(moveHere);
|
||||||
}
|
}
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the client's channel is actually rendered in the tree — it won't be if
|
||||||
|
* we're not subscribed to it (e.g. it needs more subscribe power than we have).
|
||||||
|
*/
|
||||||
|
private static boolean isVisible(ClientEntry client, ServerModel model) {
|
||||||
|
ChannelNode channel = model.getChannel(client.channelId);
|
||||||
|
return channel != null && channel.subscribed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether we can expect a join to succeed: the channel must be visible to us, and
|
||||||
|
* not already full. This can't account for a join-power permission requirement —
|
||||||
|
* unlike group membership, TS3 doesn't expose a channel's join-power threshold as
|
||||||
|
* a plain property, only as a permission resolved through channel/channel-group/
|
||||||
|
* server-group inheritance, which isn't something a regular client can query.
|
||||||
|
*/
|
||||||
|
private static boolean canJoinChannelOf(ClientEntry client, ServerModel model) {
|
||||||
|
ChannelNode channel = model.getChannel(client.channelId);
|
||||||
|
if (channel == null || !channel.subscribed) return false;
|
||||||
|
return channel.maxClients < 0 || channel.clients.size() < channel.maxClients;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JMenu buildServerGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
|
||||||
|
ServerTreePanel.Actions actions) {
|
||||||
|
JMenu menu = new JMenu("Set Server Groups");
|
||||||
|
menu.setIcon(Icons.of("PERMISSIONS_SERVER_GROUPS"));
|
||||||
|
JMenuItem dialog = new JMenuItem("Server Groups Dialog...");
|
||||||
|
dialog.addActionListener(a -> actions.showServerGroupsDialog(client));
|
||||||
|
menu.add(dialog);
|
||||||
|
menu.addSeparator();
|
||||||
|
|
||||||
|
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
|
||||||
|
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
|
||||||
|
for (Group g : model.allServerGroups()) {
|
||||||
|
boolean assigned = contains(client.serverGroupIds, g.id);
|
||||||
|
if (!canAssign(g, assigned, addPower, removePower)) continue;
|
||||||
|
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
|
||||||
|
item.setSelected(assigned);
|
||||||
|
item.addActionListener(a -> actions.setClientServerGroup(client, g, !assigned));
|
||||||
|
menu.add(item);
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JMenu buildChannelGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
|
||||||
|
ServerTreePanel.Actions actions) {
|
||||||
|
JMenu menu = new JMenu("Set Channel Group");
|
||||||
|
menu.setIcon(Icons.of("PERMISSIONS_CHANNEL_GROUPS"));
|
||||||
|
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
|
||||||
|
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
|
||||||
|
int defaultGroupId = model.defaultChannelGroupId();
|
||||||
|
for (Group g : model.allChannelGroups()) {
|
||||||
|
if (g.id == defaultGroupId) continue;
|
||||||
|
boolean assigned = client.channelGroupId == g.id;
|
||||||
|
if (!canAssign(g, assigned, addPower, removePower)) continue;
|
||||||
|
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
|
||||||
|
item.setSelected(assigned);
|
||||||
|
item.addActionListener(a -> actions.setClientChannelGroup(client, g));
|
||||||
|
menu.add(item);
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the local client has enough power to (un)assign this group: the add
|
||||||
|
* power always gates the checkbox, and the remove power additionally gates
|
||||||
|
* unassigning an already-held group. {@code -1} is TS3's "unlimited" sentinel on
|
||||||
|
* either side: an unlimited local power always passes, and a group that needs
|
||||||
|
* unlimited power can only be touched by a local client that has it.
|
||||||
|
*/
|
||||||
|
static boolean canAssign(Group g, boolean assigned, int addPower, int removePower) {
|
||||||
|
return hasPower(g.neededMemberAddPower, addPower)
|
||||||
|
&& (!assigned || hasPower(g.neededMemberRemovePower, removePower));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasPower(int needed, int own) {
|
||||||
|
if (own == -1) return true;
|
||||||
|
if (needed == -1) return false;
|
||||||
|
return own >= needed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean contains(int[] ids, int id) {
|
||||||
|
for (int i : ids) if (i == id) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.config.ClientVersion;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.DefaultListCellRenderer;
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JList;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JTextField;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options page for the version and operating system reported to the server, which
|
||||||
|
* is what other clients see next to the nickname ("3.6.2 on Windows").
|
||||||
|
*
|
||||||
|
* <p>Servers verify TeamSpeak's signature over the version and platform and refuse
|
||||||
|
* the connection ("client is modified") when it does not match, so the page is
|
||||||
|
* built around the signed versions in {@link ClientVersion#PRESETS}. The fields
|
||||||
|
* behind them can still be edited by hand for a version whose signature is known
|
||||||
|
* from somewhere else. Changes take effect on the next connect.
|
||||||
|
*/
|
||||||
|
final class ClientVersionPanel extends JPanel {
|
||||||
|
|
||||||
|
/** Combo entry that leaves the three fields to the user. */
|
||||||
|
private static final String CUSTOM = "Custom…";
|
||||||
|
|
||||||
|
private final Settings settings;
|
||||||
|
|
||||||
|
private final JCheckBox enabled = new JCheckBox("Report a custom version and operating system");
|
||||||
|
private final JComboBox<Object> presetCombo = new JComboBox<>();
|
||||||
|
private final JTextField versionField = new JTextField(24);
|
||||||
|
private final JComboBox<String> platformCombo = new JComboBox<>();
|
||||||
|
private final JTextField signField = new JTextField(24);
|
||||||
|
|
||||||
|
/** Set while a preset is being copied into the fields, so that does not count as an edit. */
|
||||||
|
private boolean filling;
|
||||||
|
|
||||||
|
ClientVersionPanel(Settings settings) {
|
||||||
|
super(new BorderLayout(0, 8));
|
||||||
|
this.settings = settings;
|
||||||
|
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||||
|
add(buildForm(), BorderLayout.NORTH);
|
||||||
|
|
||||||
|
load();
|
||||||
|
enabled.addActionListener(e -> updateEnabled());
|
||||||
|
updateEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the edited version into the settings; the caller saves them. */
|
||||||
|
void apply() {
|
||||||
|
settings.customVersion = enabled.isSelected();
|
||||||
|
settings.clientVersion = versionField.getText().trim();
|
||||||
|
settings.clientPlatform = String.valueOf(platformCombo.getEditor().getItem()).trim();
|
||||||
|
settings.clientVersionSign = signField.getText().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JComponent buildForm() {
|
||||||
|
JPanel p = new JPanel(new GridBagLayout());
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.insets = new Insets(2, 2, 2, 2);
|
||||||
|
c.anchor = GridBagConstraints.WEST;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
|
||||||
|
presetCombo.addItem(CUSTOM);
|
||||||
|
for (ClientVersion preset : ClientVersion.PRESETS) presetCombo.addItem(preset);
|
||||||
|
presetCombo.setRenderer(new DefaultListCellRenderer() {
|
||||||
|
@Override
|
||||||
|
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||||
|
boolean selected, boolean focus) {
|
||||||
|
super.getListCellRendererComponent(list, value, index, selected, focus);
|
||||||
|
setText(value instanceof ClientVersion v ? v.label() : String.valueOf(value));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
presetCombo.setToolTipText("Versions with a signature TeamSpeak issued for them");
|
||||||
|
presetCombo.addActionListener(e -> onPresetSelected());
|
||||||
|
|
||||||
|
platformCombo.setEditable(true);
|
||||||
|
for (String platform : ClientVersion.PLATFORMS) platformCombo.addItem(platform);
|
||||||
|
|
||||||
|
versionField.setToolTipText("Version string, e.g. 3.6.2 [Build: 1695203293]");
|
||||||
|
platformCombo.setToolTipText("Operating system shown to other clients");
|
||||||
|
signField.setToolTipText("TeamSpeak's signature for this version and platform");
|
||||||
|
versionField.getDocument().addDocumentListener(new EditListener());
|
||||||
|
signField.getDocument().addDocumentListener(new EditListener());
|
||||||
|
platformCombo.addActionListener(e -> onFieldEdited());
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row++;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
p.add(enabled, c);
|
||||||
|
c.gridwidth = 1;
|
||||||
|
|
||||||
|
addRow(p, c, row++, new JLabel("Version:"), presetCombo);
|
||||||
|
addRow(p, c, row++, new JLabel("Version string:"), versionField);
|
||||||
|
addRow(p, c, row++, new JLabel("Operating system:"), platformCombo);
|
||||||
|
addRow(p, c, row++, new JLabel("Signature:"), signField);
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
p.add(new JLabel("<html><body style='width:320px'>The three fields belong together: the "
|
||||||
|
+ "signature is TeamSpeak's, over that version and operating system. A server "
|
||||||
|
+ "answers \"client is modified\" and refuses the connection when it does not "
|
||||||
|
+ "accept the version — made up, or older than the server's minimum — so prefer "
|
||||||
|
+ "a recent one from the list. Takes effect the next time you connect."
|
||||||
|
+ "</body></html>"), c);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void load() {
|
||||||
|
enabled.setSelected(settings.customVersion);
|
||||||
|
ClientVersion preset = ClientVersion.match(
|
||||||
|
settings.clientVersion, settings.clientPlatform, settings.clientVersionSign);
|
||||||
|
fill(preset != null ? preset
|
||||||
|
: new ClientVersion("", settings.clientVersion, settings.clientPlatform, settings.clientVersionSign));
|
||||||
|
presetCombo.setSelectedItem(preset != null ? preset : CUSTOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onPresetSelected() {
|
||||||
|
if (presetCombo.getSelectedItem() instanceof ClientVersion preset) fill(preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hand-edited field no longer describes the selected preset. */
|
||||||
|
private void onFieldEdited() {
|
||||||
|
if (filling) return;
|
||||||
|
ClientVersion preset = ClientVersion.match(versionField.getText().trim(),
|
||||||
|
String.valueOf(platformCombo.getEditor().getItem()).trim(), signField.getText().trim());
|
||||||
|
presetCombo.setSelectedItem(preset != null ? preset : CUSTOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fill(ClientVersion version) {
|
||||||
|
filling = true;
|
||||||
|
try {
|
||||||
|
versionField.setText(version.version());
|
||||||
|
platformCombo.setSelectedItem(version.platform());
|
||||||
|
platformCombo.getEditor().setItem(version.platform());
|
||||||
|
signField.setText(version.sign());
|
||||||
|
} finally {
|
||||||
|
filling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateEnabled() {
|
||||||
|
boolean on = enabled.isSelected();
|
||||||
|
presetCombo.setEnabled(on);
|
||||||
|
versionField.setEnabled(on);
|
||||||
|
platformCombo.setEnabled(on);
|
||||||
|
signField.setEnabled(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notices any typing in the version or signature field. */
|
||||||
|
private final class EditListener implements javax.swing.event.DocumentListener {
|
||||||
|
@Override
|
||||||
|
public void insertUpdate(javax.swing.event.DocumentEvent e) {
|
||||||
|
onFieldEdited();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeUpdate(javax.swing.event.DocumentEvent e) {
|
||||||
|
onFieldEdited();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void changedUpdate(javax.swing.event.DocumentEvent e) {
|
||||||
|
onFieldEdited();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.weightx = 0;
|
||||||
|
p.add(label, c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
p.add(field, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
|||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
|
|
||||||
JPanel content = new JPanel(new BorderLayout(0, 10));
|
JPanel content = new JPanel(new BorderLayout(0, 10));
|
||||||
content.setBackground(Theme.WINDOW_BG);
|
content.setBackground(Theme.windowBg());
|
||||||
content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
|
content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
|
||||||
content.add(buildSummary(), BorderLayout.NORTH);
|
content.add(buildSummary(), BorderLayout.NORTH);
|
||||||
content.add(buildTabs(), BorderLayout.CENTER);
|
content.add(buildTabs(), BorderLayout.CENTER);
|
||||||
@@ -92,7 +92,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
|||||||
|
|
||||||
private JPanel buildSummary() {
|
private JPanel buildSummary() {
|
||||||
JPanel grid = new JPanel(new GridBagLayout());
|
JPanel grid = new JPanel(new GridBagLayout());
|
||||||
grid.setBackground(Theme.WINDOW_BG);
|
grid.setBackground(Theme.windowBg());
|
||||||
int row = 0;
|
int row = 0;
|
||||||
addRow(grid, row++, "Address", addressValue);
|
addRow(grid, row++, "Address", addressValue);
|
||||||
addRow(grid, row++, "Client version", versionValue);
|
addRow(grid, row++, "Client version", versionValue);
|
||||||
@@ -106,8 +106,8 @@ public final class ConnectionInfoDialog extends JDialog {
|
|||||||
|
|
||||||
private JTabbedPane buildTabs() {
|
private JTabbedPane buildTabs() {
|
||||||
JTabbedPane tabs = new JTabbedPane();
|
JTabbedPane tabs = new JTabbedPane();
|
||||||
tabs.setFont(Theme.UI_FONT);
|
tabs.setFont(Theme.uiFont());
|
||||||
tabs.setBackground(Theme.WINDOW_BG);
|
tabs.setBackground(Theme.windowBg());
|
||||||
tabs.addTab("Total", totalTab);
|
tabs.addTab("Total", totalTab);
|
||||||
tabs.addTab("Speech", speechTab);
|
tabs.addTab("Speech", speechTab);
|
||||||
tabs.addTab("Keep Alive", keepAliveTab);
|
tabs.addTab("Keep Alive", keepAliveTab);
|
||||||
@@ -118,7 +118,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
|||||||
private JPanel buildButtons() {
|
private JPanel buildButtons() {
|
||||||
JPanel bar = new JPanel();
|
JPanel bar = new JPanel();
|
||||||
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
|
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
|
||||||
bar.setBackground(Theme.WINDOW_BG);
|
bar.setBackground(Theme.windowBg());
|
||||||
bar.add(Box.createHorizontalGlue());
|
bar.add(Box.createHorizontalGlue());
|
||||||
JButton close = new JButton("Close");
|
JButton close = new JButton("Close");
|
||||||
close.addActionListener(e -> dispose());
|
close.addActionListener(e -> dispose());
|
||||||
@@ -128,8 +128,8 @@ public final class ConnectionInfoDialog extends JDialog {
|
|||||||
|
|
||||||
private static JLabel value() {
|
private static JLabel value() {
|
||||||
JLabel l = new JLabel("—");
|
JLabel l = new JLabel("—");
|
||||||
l.setFont(Theme.UI_FONT);
|
l.setFont(Theme.uiFont());
|
||||||
l.setForeground(Theme.TREE_TEXT);
|
l.setForeground(Theme.treeText());
|
||||||
return l;
|
return l;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
|||||||
lc.anchor = GridBagConstraints.WEST;
|
lc.anchor = GridBagConstraints.WEST;
|
||||||
lc.insets = new Insets(2, 0, 2, 16);
|
lc.insets = new Insets(2, 0, 2, 16);
|
||||||
JLabel key = new JLabel(label);
|
JLabel key = new JLabel(label);
|
||||||
key.setFont(Theme.UI_FONT);
|
key.setFont(Theme.uiFont());
|
||||||
key.setForeground(new Color(0x5A6B7B));
|
key.setForeground(new Color(0x5A6B7B));
|
||||||
grid.add(key, lc);
|
grid.add(key, lc);
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
|||||||
|
|
||||||
KindTab() {
|
KindTab() {
|
||||||
super(new GridBagLayout());
|
super(new GridBagLayout());
|
||||||
setBackground(Theme.WINDOW_BG);
|
setBackground(Theme.windowBg());
|
||||||
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
|
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
|
||||||
int row = 0;
|
int row = 0;
|
||||||
addRow(this, row++, "Packet loss", packetLoss);
|
addRow(this, row++, "Packet loss", packetLoss);
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.text.BBCode;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JColorChooser;
|
||||||
|
import javax.swing.JDialog;
|
||||||
|
import javax.swing.JEditorPane;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JTextArea;
|
||||||
|
import javax.swing.JToggleButton;
|
||||||
|
import javax.swing.JToolBar;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.CardLayout;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.Font;
|
||||||
|
import java.awt.Window;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description editor TeamSpeak tears off the channel dialog: the raw BBCode plus
|
||||||
|
* the handful of formatting buttons it offers (bold, italic, underline, colour) and a
|
||||||
|
* preview that renders the text the way the info panel will.
|
||||||
|
*
|
||||||
|
* <p>Formatting works on the selection, wrapping it in the tag pair; with nothing
|
||||||
|
* selected the pair is inserted and the caret placed between the two halves, so typing
|
||||||
|
* continues inside it.
|
||||||
|
*/
|
||||||
|
final class DescriptionEditorDialog extends JDialog {
|
||||||
|
|
||||||
|
private static final String EDIT_CARD = "edit";
|
||||||
|
private static final String PREVIEW_CARD = "preview";
|
||||||
|
|
||||||
|
private final JTextArea area = new JTextArea();
|
||||||
|
private final JEditorPane preview = new JEditorPane("text/html", "");
|
||||||
|
private final CardLayout cards = new CardLayout();
|
||||||
|
private final JPanel body = new JPanel(cards);
|
||||||
|
private final JToggleButton previewButton = new JToggleButton("Preview");
|
||||||
|
private boolean accepted;
|
||||||
|
|
||||||
|
DescriptionEditorDialog(Window owner, String description) {
|
||||||
|
super(owner, "Channel Description", ModalityType.APPLICATION_MODAL);
|
||||||
|
area.setText(description);
|
||||||
|
area.setLineWrap(true);
|
||||||
|
area.setWrapStyleWord(true);
|
||||||
|
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
|
||||||
|
area.setCaretPosition(0);
|
||||||
|
|
||||||
|
preview.setEditable(false);
|
||||||
|
preview.setBackground(Theme.chatBg());
|
||||||
|
preview.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||||
|
|
||||||
|
body.add(new JScrollPane(area), EDIT_CARD);
|
||||||
|
body.add(new JScrollPane(preview), PREVIEW_CARD);
|
||||||
|
|
||||||
|
JLabel hint = new JLabel("Press Button to \"Preview\" the changes in channel info.");
|
||||||
|
hint.setForeground(Theme.chatSystem());
|
||||||
|
hint.setBorder(BorderFactory.createEmptyBorder(4, 6, 0, 6));
|
||||||
|
|
||||||
|
getContentPane().setLayout(new BorderLayout());
|
||||||
|
getContentPane().add(buildToolbar(), BorderLayout.NORTH);
|
||||||
|
getContentPane().add(body, BorderLayout.CENTER);
|
||||||
|
JPanel south = new JPanel(new BorderLayout());
|
||||||
|
south.add(hint, BorderLayout.NORTH);
|
||||||
|
south.add(buildButtons(), BorderLayout.SOUTH);
|
||||||
|
getContentPane().add(south, BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
Dialogs.closeOnEscape(this);
|
||||||
|
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||||
|
setSize(new Dimension(520, 380));
|
||||||
|
setMinimumSize(new Dimension(360, 260));
|
||||||
|
setLocationRelativeTo(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return whether the user confirmed; {@link #getDescription()} then holds the new text */
|
||||||
|
boolean isAccepted() {
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getDescription() {
|
||||||
|
return area.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JToolBar buildToolbar() {
|
||||||
|
JToolBar bar = new JToolBar();
|
||||||
|
bar.setFloatable(false);
|
||||||
|
bar.add(tagButton("B", "Bold", Font.BOLD, "[b]", "[/b]"));
|
||||||
|
bar.add(tagButton("I", "Italic", Font.ITALIC, "[i]", "[/i]"));
|
||||||
|
JButton underline = tagButton("<html><u>U</u></html>", "Underline", Font.PLAIN, "[u]", "[/u]");
|
||||||
|
bar.add(underline);
|
||||||
|
JButton color = new JButton("Color");
|
||||||
|
color.setToolTipText("Color");
|
||||||
|
color.addActionListener(a -> chooseColor());
|
||||||
|
bar.add(square(color));
|
||||||
|
bar.add(Box.createHorizontalGlue());
|
||||||
|
previewButton.setToolTipText("Show the description as the info panel renders it");
|
||||||
|
previewButton.addActionListener(a -> showPreview(previewButton.isSelected()));
|
||||||
|
previewButton.setMaximumSize(previewButton.getPreferredSize());
|
||||||
|
bar.add(previewButton);
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel buildButtons() {
|
||||||
|
JButton ok = new JButton("OK");
|
||||||
|
JButton cancel = new JButton("Cancel");
|
||||||
|
ok.addActionListener(a -> {
|
||||||
|
accepted = true;
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
cancel.addActionListener(a -> dispose());
|
||||||
|
JPanel panel = new JPanel(new BorderLayout());
|
||||||
|
JPanel right = new JPanel();
|
||||||
|
right.add(ok);
|
||||||
|
right.add(cancel);
|
||||||
|
panel.add(right, BorderLayout.EAST);
|
||||||
|
getRootPane().setDefaultButton(ok);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JButton tagButton(String text, String tip, int style, String open, String close) {
|
||||||
|
JButton button = new JButton(text);
|
||||||
|
button.setToolTipText(tip);
|
||||||
|
button.setFont(button.getFont().deriveFont(style));
|
||||||
|
button.addActionListener(a -> wrapSelection(open, close));
|
||||||
|
return square(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins a toolbar button to its natural size: the tool bar lays its children out along a
|
||||||
|
* box, which would otherwise let one of them soak up all the free width.
|
||||||
|
*/
|
||||||
|
private static JButton square(JButton button) {
|
||||||
|
Dimension size = new Dimension(Math.max(28, button.getPreferredSize().width),
|
||||||
|
button.getPreferredSize().height);
|
||||||
|
button.setPreferredSize(size);
|
||||||
|
button.setMaximumSize(size);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chooseColor() {
|
||||||
|
Color chosen = JColorChooser.showDialog(this, "Color", Color.BLACK);
|
||||||
|
if (chosen == null) return;
|
||||||
|
wrapSelection(String.format("[color=#%02x%02x%02x]",
|
||||||
|
chosen.getRed(), chosen.getGreen(), chosen.getBlue()), "[/color]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps the selection (or the caret) in a BBCode tag pair and returns focus to the text. */
|
||||||
|
private void wrapSelection(String open, String close) {
|
||||||
|
if (previewButton.isSelected()) showPreview(false);
|
||||||
|
int start = area.getSelectionStart();
|
||||||
|
int end = area.getSelectionEnd();
|
||||||
|
String selected = area.getSelectedText();
|
||||||
|
area.replaceRange(open + (selected == null ? "" : selected) + close, start, end);
|
||||||
|
area.setCaretPosition(start + open.length() + (selected == null ? 0 : selected.length()));
|
||||||
|
area.requestFocusInWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showPreview(boolean on) {
|
||||||
|
previewButton.setSelected(on);
|
||||||
|
if (on) {
|
||||||
|
preview.setText("<html><body style=\"font-family:sans-serif;font-size:9pt\">"
|
||||||
|
+ BBCode.toHtml(area.getText()) + "</body></html>");
|
||||||
|
preview.setCaretPosition(0);
|
||||||
|
}
|
||||||
|
cards.show(body, on ? PREVIEW_CARD : EDIT_CARD);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,14 @@ import java.awt.Insets;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options page for icon packs: which pack the user interface is drawn with, where
|
* Options page for how the client looks: the light or dark look-and-feel, and the icon
|
||||||
* to look for more of them, and a viewer showing everything the pack contains.
|
* pack the user interface is drawn with — where to look for more of them, and a viewer
|
||||||
|
* showing everything the active pack contains.
|
||||||
*
|
*
|
||||||
* <p>Picking a pack applies it right away so the change can be seen in the window
|
* <p>Both apply right away so the change can be seen in the window behind the dialog;
|
||||||
* behind the dialog; cancelling puts the previous one back.
|
* cancelling puts the previous ones back.
|
||||||
*/
|
*/
|
||||||
final class IconPackPanel extends JPanel {
|
final class DesignPanel extends JPanel {
|
||||||
|
|
||||||
/** Size of the tiles in the icon viewer. */
|
/** Size of the tiles in the icon viewer. */
|
||||||
private static final int PREVIEW_SIZE = 32;
|
private static final int PREVIEW_SIZE = 32;
|
||||||
@@ -44,18 +45,22 @@ final class IconPackPanel extends JPanel {
|
|||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private final String originalPackId;
|
private final String originalPackId;
|
||||||
private final String originalPackDir;
|
private final String originalPackDir;
|
||||||
|
private final Settings.Appearance originalAppearance;
|
||||||
|
|
||||||
|
private final JComboBox<Settings.Appearance> appearanceCombo =
|
||||||
|
new JComboBox<>(Settings.Appearance.values());
|
||||||
private final JComboBox<Object> packCombo = new JComboBox<>();
|
private final JComboBox<Object> packCombo = new JComboBox<>();
|
||||||
private final JLabel packInfo = new JLabel();
|
private final JLabel packInfo = new JLabel();
|
||||||
private final JTextField packDirField;
|
private final JTextField packDirField;
|
||||||
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
|
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
|
||||||
private final JList<String> preview = new JList<>(previewModel);
|
private final JList<String> preview = new JList<>(previewModel);
|
||||||
|
|
||||||
IconPackPanel(Settings settings) {
|
DesignPanel(Settings settings) {
|
||||||
super(new BorderLayout(0, 8));
|
super(new BorderLayout(0, 8));
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.originalPackId = settings.iconPack;
|
this.originalPackId = settings.iconPack;
|
||||||
this.originalPackDir = settings.iconPackDir;
|
this.originalPackDir = settings.iconPackDir;
|
||||||
|
this.originalAppearance = settings.appearance;
|
||||||
this.packDirField = new JTextField(settings.iconPackDir, 18);
|
this.packDirField = new JTextField(settings.iconPackDir, 18);
|
||||||
|
|
||||||
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||||
@@ -65,18 +70,23 @@ final class IconPackPanel extends JPanel {
|
|||||||
reloadPacks();
|
reloadPacks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copies the chosen pack into the settings; the caller saves them. */
|
/** Copies the chosen appearance and pack into the settings; the caller saves them. */
|
||||||
void apply() {
|
void apply() {
|
||||||
|
settings.appearance = selectedAppearance();
|
||||||
settings.iconPackDir = packDirField.getText().trim();
|
settings.iconPackDir = packDirField.getText().trim();
|
||||||
Object selected = packCombo.getSelectedItem();
|
Object selected = packCombo.getSelectedItem();
|
||||||
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts back the pack the dialog started with, for a cancelled edit. */
|
/** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
|
||||||
void revert() {
|
void revert() {
|
||||||
settings.iconPack = originalPackId;
|
settings.iconPack = originalPackId;
|
||||||
settings.iconPackDir = originalPackDir;
|
settings.iconPackDir = originalPackDir;
|
||||||
IconTheme.get().reload(settings);
|
IconTheme.get().reload(settings);
|
||||||
|
if (settings.appearance != originalAppearance) {
|
||||||
|
settings.appearance = originalAppearance;
|
||||||
|
LookAndFeelManager.switchTo(originalAppearance);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- layout ----
|
// ---- layout ----
|
||||||
@@ -100,7 +110,21 @@ final class IconPackPanel extends JPanel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
appearanceCombo.setToolTipText("Light or dark window colours");
|
||||||
|
appearanceCombo.setSelectedItem(settings.appearance);
|
||||||
|
appearanceCombo.setRenderer(new DefaultListCellRenderer() {
|
||||||
|
@Override
|
||||||
|
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||||
|
boolean selected, boolean focus) {
|
||||||
|
super.getListCellRendererComponent(list, value, index, selected, focus);
|
||||||
|
setText(value == Settings.Appearance.DARK ? "Dark" : "Light");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
appearanceCombo.addActionListener(e -> onAppearanceSelected());
|
||||||
|
|
||||||
int row = 0;
|
int row = 0;
|
||||||
|
addRow(p, c, row++, new JLabel("Theme:"), appearanceCombo);
|
||||||
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
|
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
|
||||||
c.gridx = 1;
|
c.gridx = 1;
|
||||||
c.gridy = row++;
|
c.gridy = row++;
|
||||||
@@ -159,6 +183,18 @@ final class IconPackPanel extends JPanel {
|
|||||||
onPackSelected();
|
onPackSelected();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Settings.Appearance selectedAppearance() {
|
||||||
|
Object selected = appearanceCombo.getSelectedItem();
|
||||||
|
return selected instanceof Settings.Appearance a ? a : Settings.Appearance.LIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onAppearanceSelected() {
|
||||||
|
Settings.Appearance chosen = selectedAppearance();
|
||||||
|
if (chosen == settings.appearance) return;
|
||||||
|
settings.appearance = chosen;
|
||||||
|
LookAndFeelManager.switchTo(chosen);
|
||||||
|
}
|
||||||
|
|
||||||
private void onPackSelected() {
|
private void onPackSelected() {
|
||||||
Object selected = packCombo.getSelectedItem();
|
Object selected = packCombo.getSelectedItem();
|
||||||
IconPack pack = selected instanceof IconPack p ? p : null;
|
IconPack pack = selected instanceof IconPack p ? p : null;
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.audio.VoiceInput;
|
||||||
|
import com.ts3client.audio.VoiceOutput;
|
||||||
|
import com.ts3client.audio.desktop.AudioDevices;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options dialog's "Playback / Capture" tab: device pickers, gain/volume, and the
|
||||||
|
* noise-reduction pre-processing options. Gain and pre-processing changes are pushed
|
||||||
|
* live via {@code applyLive}, which reaches both the connected microphone and the
|
||||||
|
* dialog's own microphone test.
|
||||||
|
*/
|
||||||
|
final class DevicesPanel extends FormPanel {
|
||||||
|
|
||||||
|
private final JComboBox<AudioDevices.Device> inputCombo;
|
||||||
|
private final JComboBox<AudioDevices.Device> outputCombo;
|
||||||
|
private final JSlider inputGain;
|
||||||
|
private final JSlider outputVol;
|
||||||
|
private final JCheckBox denoiseCheck;
|
||||||
|
private final JSlider denoiseLevel;
|
||||||
|
private final JCheckBox typingCheck;
|
||||||
|
private final JCheckBox agcCheck;
|
||||||
|
|
||||||
|
DevicesPanel(Settings settings, VoiceOutput livePlayback,
|
||||||
|
Consumer<Consumer<VoiceInput>> applyLive,
|
||||||
|
Runnable onInputDeviceChanged, Consumer<String> onOutputDeviceChanged) {
|
||||||
|
GridBagConstraints c = gbc();
|
||||||
|
|
||||||
|
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
|
||||||
|
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
|
||||||
|
|
||||||
|
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
|
||||||
|
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
|
||||||
|
selectOrDefault(inputCombo, settings.inputDevice);
|
||||||
|
selectOrDefault(outputCombo, settings.outputDevice);
|
||||||
|
|
||||||
|
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
|
||||||
|
+ "(so per-application volume and rerouting keep working).<br>"
|
||||||
|
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
|
||||||
|
inputCombo.setToolTipText(deviceHint);
|
||||||
|
outputCombo.setToolTipText(deviceHint);
|
||||||
|
limitWidth(inputCombo, FIELD_WIDTH);
|
||||||
|
limitWidth(outputCombo, FIELD_WIDTH);
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
addRow(this, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
|
||||||
|
addRow(this, 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));
|
||||||
|
limitWidth(inputGain, SLIDER_WIDTH);
|
||||||
|
limitWidth(outputVol, SLIDER_WIDTH);
|
||||||
|
addRow(this, c, row++, new JLabel("Microphone gain:"), inputGain);
|
||||||
|
addRow(this, c, row++, new JLabel("Playback volume:"), outputVol);
|
||||||
|
|
||||||
|
outputVol.addChangeListener(e -> {
|
||||||
|
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
|
||||||
|
});
|
||||||
|
inputGain.addChangeListener(e ->
|
||||||
|
applyLive.accept(m -> m.setInputGain(inputGain.getValue() / 100.0)));
|
||||||
|
inputCombo.addActionListener(e -> onInputDeviceChanged.run());
|
||||||
|
outputCombo.addActionListener(e -> onOutputDeviceChanged.accept(comboValue(outputCombo)));
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row++;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
c.insets = new Insets(14, 4, 2, 4);
|
||||||
|
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));
|
||||||
|
limitWidth(denoiseLevel, SLIDER_WIDTH);
|
||||||
|
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;
|
||||||
|
add(denoiseCheck, c);
|
||||||
|
c.gridwidth = 1;
|
||||||
|
addRow(this, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row++;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
add(typingCheck, c);
|
||||||
|
c.gridy = row++;
|
||||||
|
add(agcCheck, c);
|
||||||
|
c.gridwidth = 1;
|
||||||
|
|
||||||
|
Runnable syncNoise = () -> {
|
||||||
|
denoiseLevel.setEnabled(denoiseCheck.isSelected());
|
||||||
|
applyLive.accept(m -> {
|
||||||
|
m.setNoiseSuppression(denoiseCheck.isSelected());
|
||||||
|
m.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
|
||||||
|
m.setTypingAttenuation(typingCheck.isSelected());
|
||||||
|
m.setAgc(agcCheck.isSelected());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
denoiseCheck.addActionListener(e -> syncNoise.run());
|
||||||
|
typingCheck.addActionListener(e -> syncNoise.run());
|
||||||
|
agcCheck.addActionListener(e -> syncNoise.run());
|
||||||
|
denoiseLevel.addChangeListener(e ->
|
||||||
|
applyLive.accept(m -> m.setDenoiserLevel(denoiseLevel.getValue() / 100.0)));
|
||||||
|
syncNoise.run();
|
||||||
|
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.weighty = 1;
|
||||||
|
add(Box.createGlue(), c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the form into {@code target}, without touching anything else. */
|
||||||
|
void writeInto(Settings target) {
|
||||||
|
target.inputDevice = comboValue(inputCombo);
|
||||||
|
target.outputDevice = comboValue(outputCombo);
|
||||||
|
target.inputVolume = inputGain.getValue() / 100.0;
|
||||||
|
target.outputVolume = outputVol.getValue() / 100.0;
|
||||||
|
target.denoise = denoiseCheck.isSelected();
|
||||||
|
target.denoiserLevel = denoiseLevel.getValue() / 100.0;
|
||||||
|
target.typingAttenuation = typingCheck.isSelected();
|
||||||
|
target.agc = agcCheck.isSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
|
||||||
|
if (deviceId != null && !deviceId.isEmpty()) {
|
||||||
|
for (int i = 0; i < combo.getItemCount(); i++) {
|
||||||
|
if (deviceId.equals(combo.getItemAt(i).id())) {
|
||||||
|
combo.setSelectedIndex(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
combo.setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
|
||||||
|
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
|
||||||
|
return d == null ? "" : d.id();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ChannelNode;
|
||||||
|
import com.ts3client.net.ClientEntry;
|
||||||
|
import com.ts3client.net.ServerModel;
|
||||||
|
|
||||||
|
import javax.swing.Icon;
|
||||||
|
import javax.swing.JTree;
|
||||||
|
import javax.swing.tree.DefaultMutableTreeNode;
|
||||||
|
import javax.swing.tree.TreeModel;
|
||||||
|
import javax.swing.tree.TreePath;
|
||||||
|
import java.awt.BasicStroke;
|
||||||
|
import java.awt.Graphics;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.Point;
|
||||||
|
import java.awt.Rectangle;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.event.MouseAdapter;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link ServerTreePanel} tree, extended to draw where a drag-and-drop would
|
||||||
|
* land: an insertion line between rows, or an outline around the row that will
|
||||||
|
* receive the dragged node. Also keeps the server row permanently expanded and
|
||||||
|
* paints the right-aligned group icon strip, both of which need to hook into
|
||||||
|
* this component's own paint cycle.
|
||||||
|
*/
|
||||||
|
final class DropIndicatorTree extends JTree {
|
||||||
|
|
||||||
|
/** Gap kept between a row's label and the right-aligned icon strip. */
|
||||||
|
private static final int BADGE_GAP = 8;
|
||||||
|
/** Inset of the strip from the visible right edge. */
|
||||||
|
private static final int BADGE_MARGIN = 4;
|
||||||
|
|
||||||
|
private final ServerModel model;
|
||||||
|
private final GroupIcons groupIcons;
|
||||||
|
/** Set while a drop would move a client into this channel row. */
|
||||||
|
private TreePath highlight;
|
||||||
|
/** The row the pointer is over, or -1 when it is over none. */
|
||||||
|
private int hoverRow = -1;
|
||||||
|
private Predicate<TreePath> pathEditable = path -> false;
|
||||||
|
|
||||||
|
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
|
||||||
|
super(treeModel);
|
||||||
|
this.model = model;
|
||||||
|
this.groupIcons = groupIcons;
|
||||||
|
MouseAdapter hover = new MouseAdapter() {
|
||||||
|
@Override
|
||||||
|
public void mouseMoved(MouseEvent e) {
|
||||||
|
setHoverRow(rowAt(e.getY()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseDragged(MouseEvent e) {
|
||||||
|
setHoverRow(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseExited(MouseEvent e) {
|
||||||
|
setHoverRow(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
addMouseMotionListener(hover);
|
||||||
|
addMouseListener(hover);
|
||||||
|
// The selected row is filled across the full width below, before the rows
|
||||||
|
// themselves are drawn, so this component must not clear its own background.
|
||||||
|
setOpaque(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row a point falls on, going by its vertical band alone: a row reaches
|
||||||
|
* across the whole width, not just as far as its label.
|
||||||
|
*/
|
||||||
|
int rowAt(int y) {
|
||||||
|
int row = getClosestRowForLocation(0, y);
|
||||||
|
if (row < 0) return -1;
|
||||||
|
Rectangle bounds = getRowBounds(row);
|
||||||
|
return bounds != null && y >= bounds.y && y < bounds.y + bounds.height ? row : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The path a point falls on, by the same rule as {@link #rowAt(int)}. */
|
||||||
|
TreePath pathAt(int y) {
|
||||||
|
int row = rowAt(y);
|
||||||
|
return row < 0 ? null : getPathForRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-reads the row the pointer is over. Scrolling moves the rows under a pointer
|
||||||
|
* that never moved itself, so the enclosing scroll pane calls this on every scroll.
|
||||||
|
*/
|
||||||
|
void refreshHoverRow() {
|
||||||
|
Point pointer = getMousePosition();
|
||||||
|
setHoverRow(pointer == null ? -1 : rowAt(pointer.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setHoverRow(int row) {
|
||||||
|
if (row == hoverRow) return;
|
||||||
|
repaintRow(hoverRow);
|
||||||
|
hoverRow = row;
|
||||||
|
repaintRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void repaintRow(int row) {
|
||||||
|
if (row < 0) return;
|
||||||
|
Rectangle bounds = getRowBounds(row);
|
||||||
|
if (bounds != null) repaint(0, bounds.y, getWidth(), bounds.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which rows an inline editor may open on; nothing, until one is installed. */
|
||||||
|
void setPathEditable(Predicate<TreePath> editable) {
|
||||||
|
this.pathEditable = editable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isPathEditable(TreePath path) {
|
||||||
|
return isEditable() && pathEditable.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the server row permanently open; collapsing it would hide everything. */
|
||||||
|
@Override
|
||||||
|
public void setExpandedState(TreePath path, boolean state) {
|
||||||
|
if (!state && path.getPathCount() == 1) return;
|
||||||
|
super.setExpandedState(path, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widens every repaint request to the full visible width. Swing only asks for
|
||||||
|
* the row rectangle, which stops short of the right-aligned icon strip and would
|
||||||
|
* leave it behind when a row's label changes width.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void repaint(long tm, int x, int y, int width, int height) {
|
||||||
|
Rectangle visible = getVisibleRect();
|
||||||
|
super.repaint(tm, visible.x, y, visible.width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
void highlightChannel(TreePath path) {
|
||||||
|
if (path == highlight || (path != null && path.equals(highlight))) return;
|
||||||
|
highlight = path;
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void paintComponent(Graphics g) {
|
||||||
|
Rectangle clip = g.getClipBounds();
|
||||||
|
g.setColor(getBackground());
|
||||||
|
g.fillRect(clip.x, clip.y, clip.width, clip.height);
|
||||||
|
paintSelection(g);
|
||||||
|
paintHover(g);
|
||||||
|
super.paintComponent(g);
|
||||||
|
paintBadges(g);
|
||||||
|
JTree.DropLocation loc = getDropLocation();
|
||||||
|
if (loc == null || loc.getPath() == null) return;
|
||||||
|
|
||||||
|
Graphics2D g2 = (Graphics2D) g.create();
|
||||||
|
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g2.setColor(Theme.accent());
|
||||||
|
if (highlight != null || loc.getChildIndex() < 0) {
|
||||||
|
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
|
||||||
|
if (r != null) {
|
||||||
|
g2.setStroke(new BasicStroke(2f));
|
||||||
|
g2.drawRoundRect(r.x, r.y + 1, r.width - 1, r.height - 3, 4, 4);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Rectangle line = insertLine(loc);
|
||||||
|
if (line != null) {
|
||||||
|
g2.fillRect(line.x, line.y - 1, line.width, 2);
|
||||||
|
g2.fillOval(line.x - 3, line.y - 4, 7, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g2.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the selected row across the full width, in the colour the cell renderer
|
||||||
|
* puts behind the label itself, so the selection covers the whole line rather
|
||||||
|
* than stopping where the name ends.
|
||||||
|
*/
|
||||||
|
private void paintSelection(Graphics g) {
|
||||||
|
int[] rows = getSelectionRows();
|
||||||
|
if (rows == null) return;
|
||||||
|
Rectangle visible = getVisibleRect();
|
||||||
|
g.setColor(Theme.treeSelection());
|
||||||
|
for (int row : rows) {
|
||||||
|
Rectangle bounds = getRowBounds(row);
|
||||||
|
if (bounds != null) g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Washes the row under the pointer across the full width it occupies — the same
|
||||||
|
* area a click on it acts on. A selected row keeps its selection colour, and
|
||||||
|
* spacers, which nothing can be done with, are left alone.
|
||||||
|
*/
|
||||||
|
private void paintHover(Graphics g) {
|
||||||
|
if (hoverRow < 0 || getDropLocation() != null || isRowSelected(hoverRow)) return;
|
||||||
|
Rectangle bounds = getRowBounds(hoverRow);
|
||||||
|
TreePath path = getPathForRow(hoverRow);
|
||||||
|
if (bounds == null || path == null || isSpacer(path)) return;
|
||||||
|
|
||||||
|
Rectangle visible = getVisibleRect();
|
||||||
|
g.setColor(Theme.hover());
|
||||||
|
g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSpacer(TreePath path) {
|
||||||
|
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||||
|
return obj instanceof ChannelNode && Spacers.isSpacer(((ChannelNode) obj).name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 1px-tall strip where the insertion line goes, in tree coordinates. */
|
||||||
|
private Rectangle insertLine(JTree.DropLocation loc) {
|
||||||
|
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent();
|
||||||
|
int index = loc.getChildIndex();
|
||||||
|
if (index < parent.getChildCount()) {
|
||||||
|
Rectangle r = getPathBounds(loc.getPath().pathByAddingChild(parent.getChildAt(index)));
|
||||||
|
return r == null ? null : new Rectangle(r.x, r.y, getWidth() - r.x, 2);
|
||||||
|
}
|
||||||
|
if (parent.getChildCount() == 0) {
|
||||||
|
Rectangle r = getPathBounds(loc.getPath());
|
||||||
|
if (r == null) return null;
|
||||||
|
int x = r.x + getRowHeight();
|
||||||
|
return new Rectangle(x, r.y + r.height, getWidth() - x, 2);
|
||||||
|
}
|
||||||
|
// Past the last child: below that child's whole (expanded) subtree.
|
||||||
|
TreePath lastChild = loc.getPath().pathByAddingChild(parent.getChildAt(parent.getChildCount() - 1));
|
||||||
|
Rectangle head = getPathBounds(lastChild);
|
||||||
|
Rectangle tail = getPathBounds(lastVisibleRow(lastChild));
|
||||||
|
if (head == null || tail == null) return null;
|
||||||
|
return new Rectangle(head.x, tail.y + tail.height, getWidth() - head.x, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TreePath lastVisibleRow(TreePath path) {
|
||||||
|
int row = getRowForPath(path);
|
||||||
|
if (row < 0) return path;
|
||||||
|
for (int i = row + 1; i < getRowCount(); i++) {
|
||||||
|
if (!path.isDescendant(getPathForRow(i))) break;
|
||||||
|
row = i;
|
||||||
|
}
|
||||||
|
return getPathForRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints the icons of every visible row — a client's group icons, a channel's
|
||||||
|
* own icon — flush with the right edge of the viewport, the way TeamSpeak lines
|
||||||
|
* them up. Drawing them separately from the cell renderer keeps the rows'
|
||||||
|
* measured widths (and thus the selection highlight) tied to the label alone.
|
||||||
|
*/
|
||||||
|
private void paintBadges(Graphics g) {
|
||||||
|
Rectangle visible = getVisibleRect();
|
||||||
|
int right = visible.x + visible.width - BADGE_MARGIN;
|
||||||
|
for (int row = 0; row < getRowCount(); row++) {
|
||||||
|
Rectangle bounds = getRowBounds(row);
|
||||||
|
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
|
||||||
|
if (bounds.y > visible.y + visible.height) break;
|
||||||
|
|
||||||
|
TreePath path = getPathForRow(row);
|
||||||
|
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||||
|
List<Icon> icons = badgesOf(obj);
|
||||||
|
if (icons.isEmpty()) continue;
|
||||||
|
|
||||||
|
GroupIcons.Row strip = new GroupIcons.Row(icons);
|
||||||
|
int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth());
|
||||||
|
strip.paintIcon(this, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The icon strip a row shows on its right, empty when it has none (yet). */
|
||||||
|
private List<Icon> badgesOf(Object node) {
|
||||||
|
if (node instanceof ClientEntry) {
|
||||||
|
ClientEntry cl = (ClientEntry) node;
|
||||||
|
return groupIcons.iconsOf(
|
||||||
|
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
|
||||||
|
}
|
||||||
|
if (node instanceof ChannelNode) {
|
||||||
|
ChannelNode ch = (ChannelNode) node;
|
||||||
|
Icon icon = Spacers.isSpacer(ch.name) ? null : groupIcons.icon(ch.iconId);
|
||||||
|
if (icon != null) return List.of(icon);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ public final class FileBrowserDialog extends JDialog {
|
|||||||
this.channelPassword = "";
|
this.channelPassword = "";
|
||||||
|
|
||||||
JPanel content = new JPanel(new BorderLayout(0, 8));
|
JPanel content = new JPanel(new BorderLayout(0, 8));
|
||||||
content.setBackground(Theme.WINDOW_BG);
|
content.setBackground(Theme.windowBg());
|
||||||
content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
|
content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
|
||||||
content.add(buildToolbar(), BorderLayout.NORTH);
|
content.add(buildToolbar(), BorderLayout.NORTH);
|
||||||
content.add(buildTable(), BorderLayout.CENTER);
|
content.add(buildTable(), BorderLayout.CENTER);
|
||||||
@@ -85,7 +85,7 @@ public final class FileBrowserDialog extends JDialog {
|
|||||||
private JPanel buildToolbar() {
|
private JPanel buildToolbar() {
|
||||||
JPanel bar = new JPanel();
|
JPanel bar = new JPanel();
|
||||||
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
|
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
|
||||||
bar.setBackground(Theme.WINDOW_BG);
|
bar.setBackground(Theme.windowBg());
|
||||||
|
|
||||||
upButton.addActionListener(e -> navigateUp());
|
upButton.addActionListener(e -> navigateUp());
|
||||||
JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH"));
|
JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH"));
|
||||||
@@ -111,13 +111,13 @@ public final class FileBrowserDialog extends JDialog {
|
|||||||
bar.add(Box.createHorizontalStrut(6));
|
bar.add(Box.createHorizontalStrut(6));
|
||||||
bar.add(deleteButton);
|
bar.add(deleteButton);
|
||||||
|
|
||||||
pathLabel.setFont(Theme.UI_FONT);
|
pathLabel.setFont(Theme.uiFont());
|
||||||
pathLabel.setForeground(Theme.TREE_TEXT);
|
pathLabel.setForeground(Theme.treeText());
|
||||||
return bar;
|
return bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
private JScrollPane buildTable() {
|
private JScrollPane buildTable() {
|
||||||
table.setFont(Theme.UI_FONT);
|
table.setFont(Theme.uiFont());
|
||||||
table.setRowHeight(20);
|
table.setRowHeight(20);
|
||||||
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
||||||
table.setFillsViewportHeight(true);
|
table.setFillsViewportHeight(true);
|
||||||
@@ -137,17 +137,17 @@ public final class FileBrowserDialog extends JDialog {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
JScrollPane scroll = new JScrollPane(table);
|
JScrollPane scroll = new JScrollPane(table);
|
||||||
scroll.getViewport().setBackground(Theme.TREE_BG);
|
scroll.getViewport().setBackground(Theme.treeBg());
|
||||||
return scroll;
|
return scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
private JScrollPane buildTransfers() {
|
private JScrollPane buildTransfers() {
|
||||||
transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS));
|
transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS));
|
||||||
transfersPanel.setBackground(Theme.WINDOW_BG);
|
transfersPanel.setBackground(Theme.windowBg());
|
||||||
JScrollPane scroll = new JScrollPane(transfersPanel);
|
JScrollPane scroll = new JScrollPane(transfersPanel);
|
||||||
scroll.setBorder(BorderFactory.createTitledBorder("Transfers"));
|
scroll.setBorder(BorderFactory.createTitledBorder("Transfers"));
|
||||||
scroll.setPreferredSize(new Dimension(10, 120));
|
scroll.setPreferredSize(new Dimension(10, 120));
|
||||||
scroll.getViewport().setBackground(Theme.WINDOW_BG);
|
scroll.getViewport().setBackground(Theme.windowBg());
|
||||||
return scroll;
|
return scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,10 +326,10 @@ public final class FileBrowserDialog extends JDialog {
|
|||||||
|
|
||||||
TransferRow() {
|
TransferRow() {
|
||||||
setLayout(new BorderLayout(8, 0));
|
setLayout(new BorderLayout(8, 0));
|
||||||
setBackground(Theme.WINDOW_BG);
|
setBackground(Theme.windowBg());
|
||||||
setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2));
|
setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2));
|
||||||
setMaximumSize(new Dimension(Integer.MAX_VALUE, 44));
|
setMaximumSize(new Dimension(Integer.MAX_VALUE, 44));
|
||||||
label.setFont(Theme.UI_FONT);
|
label.setFont(Theme.uiFont());
|
||||||
bar.setStringPainted(true);
|
bar.setStringPainted(true);
|
||||||
add(label, BorderLayout.NORTH);
|
add(label, BorderLayout.NORTH);
|
||||||
add(bar, BorderLayout.CENTER);
|
add(bar, BorderLayout.CENTER);
|
||||||
|
|||||||
104
ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java
Normal file
104
ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import javax.swing.Scrollable;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.awt.Rectangle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base for a settings tab's field grid: follows the scroll pane's width instead of
|
||||||
|
* demanding its own preferred one, so rows stay inside the dialog instead of scrolling
|
||||||
|
* sideways. Also holds the small GridBagLayout helpers every such tab needs.
|
||||||
|
*/
|
||||||
|
class FormPanel extends JPanel implements Scrollable {
|
||||||
|
|
||||||
|
static final int FIELD_WIDTH = 240;
|
||||||
|
static final int SLIDER_WIDTH = 200;
|
||||||
|
static final int MIN_FIELD_WIDTH = 60;
|
||||||
|
|
||||||
|
FormPanel() {
|
||||||
|
super(new GridBagLayout());
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Dimension getPreferredScrollableViewportSize() {
|
||||||
|
return getPreferredSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
|
||||||
|
return 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
|
||||||
|
return visible.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean getScrollableTracksViewportWidth() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean getScrollableTracksViewportHeight() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
|
||||||
|
* would otherwise force the form past the dialog's edge, where the scroll pane (which
|
||||||
|
* never scrolls horizontally) simply clips them.
|
||||||
|
*/
|
||||||
|
static void limitWidth(JComponent comp, int preferredWidth) {
|
||||||
|
int height = comp.getPreferredSize().height;
|
||||||
|
comp.setPreferredSize(new Dimension(preferredWidth, height));
|
||||||
|
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
|
||||||
|
return sliderWithLabel(slider, valueLabel, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
|
||||||
|
* the longest value so the slider does not jump around as it is dragged.
|
||||||
|
*/
|
||||||
|
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
|
||||||
|
JPanel panel = new JPanel(new BorderLayout(6, 0));
|
||||||
|
limitWidth(slider, SLIDER_WIDTH);
|
||||||
|
panel.add(slider, BorderLayout.CENTER);
|
||||||
|
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
|
||||||
|
panel.add(valueLabel, BorderLayout.EAST);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.Hotkey;
|
||||||
|
import com.ts3client.hotkey.HotkeyEngine;
|
||||||
|
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries a fired hotkey out on the client.
|
||||||
|
*
|
||||||
|
* <p>Activations arrive on the input hook's thread, so everything is handed to the
|
||||||
|
* event dispatch thread first. Which connections an action reaches is the binding's
|
||||||
|
* "on active server" flag: set, only the selected tab; clear, every connected one.
|
||||||
|
*/
|
||||||
|
final class HotkeyActions implements HotkeyEngine.Handler {
|
||||||
|
|
||||||
|
/** How much one press of the master-volume hotkeys moves the slider. */
|
||||||
|
private static final double VOLUME_STEP = 0.05;
|
||||||
|
|
||||||
|
private final MainFrame frame;
|
||||||
|
/** Latched push-to-talk, driven by the "Toggle Push-to-Talk" action. */
|
||||||
|
private boolean pttLatched;
|
||||||
|
|
||||||
|
HotkeyActions(MainFrame frame) {
|
||||||
|
this.frame = frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onHotkey(Hotkey hotkey, boolean active) {
|
||||||
|
SwingUtilities.invokeLater(() -> perform(hotkey, active));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void perform(Hotkey hotkey, boolean active) {
|
||||||
|
List<ServerTab> targets = frame.hotkeyTargets(hotkey.activeServerOnly);
|
||||||
|
switch (hotkey.action) {
|
||||||
|
case CONNECT_CURRENT_TAB -> frame.connectBookmark(hotkey.argument, false);
|
||||||
|
case CONNECT_NEW_TAB -> frame.connectBookmark(hotkey.argument, true);
|
||||||
|
case DISCONNECT_CURRENT -> {
|
||||||
|
ServerTab tab = frame.selectedTab();
|
||||||
|
if (tab != null) tab.disconnect();
|
||||||
|
}
|
||||||
|
case DISCONNECT_ALL -> {
|
||||||
|
for (ServerTab tab : frame.allTabs()) tab.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
case MIC_ACTIVATE -> frame.moveMicrophoneToSelectedTab();
|
||||||
|
case MIC_MUTE -> setMicMuted(targets, true);
|
||||||
|
case MIC_UNMUTE -> setMicMuted(targets, false);
|
||||||
|
case MIC_TOGGLE -> setMicMuted(targets, !anyMicMuted(targets));
|
||||||
|
|
||||||
|
case MIC_LOCAL_MUTE -> setMicLocalMuted(targets, true);
|
||||||
|
case MIC_LOCAL_UNMUTE -> setMicLocalMuted(targets, false);
|
||||||
|
case MIC_LOCAL_TOGGLE -> setMicLocalMuted(targets, !anyMicLocalMuted(targets));
|
||||||
|
|
||||||
|
case SPEAKER_MUTE -> setDeafened(targets, true);
|
||||||
|
case SPEAKER_UNMUTE -> setDeafened(targets, false);
|
||||||
|
case SPEAKER_TOGGLE -> setDeafened(targets, !anyDeafened(targets));
|
||||||
|
|
||||||
|
case AWAY_SET -> setAway(targets, true, "");
|
||||||
|
case AWAY_ONLINE -> setAway(targets, false, "");
|
||||||
|
case AWAY_TOGGLE -> setAway(targets, !anyAway(targets), "");
|
||||||
|
case AWAY_TOGGLE_WITH_MESSAGE -> setAway(targets, !anyAway(targets), hotkey.argument);
|
||||||
|
|
||||||
|
case COMMANDER_ACTIVATE -> setCommander(targets, true);
|
||||||
|
case COMMANDER_DEACTIVATE -> setCommander(targets, false);
|
||||||
|
case COMMANDER_TOGGLE -> setCommander(targets, !anyCommander(targets));
|
||||||
|
|
||||||
|
// Momentary: the engine reports the release too, so the key simply holds it open.
|
||||||
|
case PTT_ACTIVATE -> frame.setPushToTalk(active || pttLatched);
|
||||||
|
case PTT_DEACTIVATE -> {
|
||||||
|
pttLatched = false;
|
||||||
|
frame.setPushToTalk(false);
|
||||||
|
}
|
||||||
|
case PTT_TOGGLE -> {
|
||||||
|
pttLatched = !pttLatched;
|
||||||
|
frame.setPushToTalk(pttLatched);
|
||||||
|
}
|
||||||
|
|
||||||
|
case CHANNEL_SWITCH -> {
|
||||||
|
for (ServerTab tab : targets) tab.joinChannelPath(hotkey.argument);
|
||||||
|
}
|
||||||
|
case SERVER_TAB_SELECT -> frame.selectTabNumber(parseIndex(hotkey.argument));
|
||||||
|
case SERVER_TAB_NEXT -> frame.stepTab(1);
|
||||||
|
case SERVER_TAB_PREVIOUS -> frame.stepTab(-1);
|
||||||
|
|
||||||
|
case SOUND_MUTE -> frame.setSoundsMuted(true);
|
||||||
|
case SOUND_UNMUTE -> frame.setSoundsMuted(false);
|
||||||
|
case SOUND_TOGGLE -> frame.setSoundsMuted(!frame.areSoundsMuted());
|
||||||
|
|
||||||
|
case VOLUME_INCREASE -> frame.adjustMasterVolume(VOLUME_STEP);
|
||||||
|
case VOLUME_DECREASE -> frame.adjustMasterVolume(-VOLUME_STEP);
|
||||||
|
|
||||||
|
case NICKNAME_CHANGE -> frame.changeNickname(hotkey.argument);
|
||||||
|
|
||||||
|
case FILEBROWSER -> frame.browseCurrentChannel();
|
||||||
|
case SKIN_RELOAD -> frame.reloadSkin();
|
||||||
|
case BRING_TO_FRONT -> frame.bringToFront();
|
||||||
|
case SEND_TO_BACK -> frame.sendToBack();
|
||||||
|
|
||||||
|
default -> {
|
||||||
|
// Listed for completeness in the action catalogue, but not implemented here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setMicMuted(List<ServerTab> targets, boolean muted) {
|
||||||
|
for (ServerTab tab : targets) tab.setMicMuted(muted);
|
||||||
|
frame.refreshAfterHotkey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setMicLocalMuted(List<ServerTab> targets, boolean muted) {
|
||||||
|
for (ServerTab tab : targets) tab.setMicLocalMuted(muted);
|
||||||
|
frame.refreshAfterHotkey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setDeafened(List<ServerTab> targets, boolean deaf) {
|
||||||
|
for (ServerTab tab : targets) tab.setDeafened(deaf);
|
||||||
|
frame.refreshAfterHotkey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setAway(List<ServerTab> targets, boolean away, String message) {
|
||||||
|
for (ServerTab tab : targets) tab.setAway(away, message == null ? "" : message);
|
||||||
|
frame.refreshAfterHotkey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setCommander(List<ServerTab> targets, boolean commander) {
|
||||||
|
for (ServerTab tab : targets) tab.setCommander(commander);
|
||||||
|
frame.refreshAfterHotkey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean anyMicMuted(List<ServerTab> tabs) {
|
||||||
|
return tabs.stream().anyMatch(ServerTab::isMicMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean anyMicLocalMuted(List<ServerTab> tabs) {
|
||||||
|
return tabs.stream().anyMatch(ServerTab::isMicLocalMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean anyDeafened(List<ServerTab> tabs) {
|
||||||
|
return tabs.stream().anyMatch(ServerTab::isDeafened);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean anyAway(List<ServerTab> tabs) {
|
||||||
|
return tabs.stream().anyMatch(ServerTab::isAway);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean anyCommander(List<ServerTab> tabs) {
|
||||||
|
return tabs.stream().anyMatch(ServerTab::isCommander);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the 1-based tab number in the argument, or 1 when it is not a number */
|
||||||
|
private static int parseIndex(String argument) {
|
||||||
|
try {
|
||||||
|
return Math.max(1, Integer.parseInt(argument.trim()));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.HotkeyAction;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies the concrete values an action's parameter can take — the bookmarks, sound
|
||||||
|
* packs and channels the hotkey tree hangs under an action as its leaves, so a binding
|
||||||
|
* reads as "Sounds / Activate Soundpack / Default Sound Pack (Male)".
|
||||||
|
*/
|
||||||
|
interface HotkeyArguments {
|
||||||
|
|
||||||
|
/** @return the choices for this action, or an empty list when it is free-form */
|
||||||
|
List<String> choices(HotkeyAction action);
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.Hotkey;
|
||||||
|
import com.ts3client.hotkey.HotkeyAction;
|
||||||
|
import com.ts3client.hotkey.HotkeyCombo;
|
||||||
|
import com.ts3client.hotkey.HotkeyEngine;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.DefaultListCellRenderer;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.JDialog;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JList;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.JTree;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.tree.DefaultMutableTreeNode;
|
||||||
|
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||||
|
import javax.swing.tree.DefaultTreeModel;
|
||||||
|
import javax.swing.tree.TreePath;
|
||||||
|
import javax.swing.tree.TreeSelectionModel;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.Font;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.awt.Window;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or edits one hotkey, following the official client's dialog: pick the action
|
||||||
|
* from the tree — category, action group, action, and where the action takes one, the
|
||||||
|
* concrete bookmark, sound pack or channel — press the key combination to bind, and
|
||||||
|
* choose the edge it triggers on and whether it applies to the active server only.
|
||||||
|
*/
|
||||||
|
final class HotkeyDialog extends JDialog {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tree node: a category or group heading ({@code action == null}), an action, or
|
||||||
|
* one of the values an action's parameter can take ({@code argument != null}).
|
||||||
|
*/
|
||||||
|
private record Node(HotkeyAction action, String argument, String label) {
|
||||||
|
|
||||||
|
static Node heading(String label) {
|
||||||
|
return new Node(null, null, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Node of(HotkeyAction action) {
|
||||||
|
return new Node(action, null, action.label());
|
||||||
|
}
|
||||||
|
|
||||||
|
static Node value(HotkeyAction action, String argument) {
|
||||||
|
return new Node(action, argument, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isHeading() {
|
||||||
|
return action == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final HotkeyService service;
|
||||||
|
private final Hotkey hotkey;
|
||||||
|
|
||||||
|
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode("Actions");
|
||||||
|
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
|
||||||
|
private final JTree tree = new JTree(treeModel);
|
||||||
|
private final JCheckBox advancedCheck = new JCheckBox("Show advanced actions");
|
||||||
|
private final JButton keyButton = new JButton();
|
||||||
|
private final JComboBox<Hotkey.Trigger> triggerCombo = new JComboBox<>(Hotkey.Trigger.values());
|
||||||
|
private final JCheckBox activeServerCheck = new JCheckBox("On active server");
|
||||||
|
private final JLabel argumentLabel = new JLabel();
|
||||||
|
private final JTextField argumentField = new JTextField();
|
||||||
|
private final JLabel hint = new JLabel();
|
||||||
|
|
||||||
|
private HotkeyCombo combo;
|
||||||
|
private boolean recording;
|
||||||
|
private boolean confirmed;
|
||||||
|
|
||||||
|
HotkeyDialog(Window owner, HotkeyService service, Hotkey existing) {
|
||||||
|
// Any window may open this: the options dialog's hotkey tab as much as a frame.
|
||||||
|
super(owner, existing == null ? "Add hotkey" : "Edit hotkey", ModalityType.APPLICATION_MODAL);
|
||||||
|
this.service = service;
|
||||||
|
this.hotkey = existing == null ? new Hotkey() : existing.copy();
|
||||||
|
this.combo = hotkey.combo;
|
||||||
|
|
||||||
|
advancedCheck.setSelected(hotkey.action != null && hotkey.action.advanced());
|
||||||
|
advancedCheck.addActionListener(e -> rebuildTree());
|
||||||
|
tree.setRootVisible(false);
|
||||||
|
tree.setShowsRootHandles(true);
|
||||||
|
tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
|
||||||
|
tree.setCellRenderer(new NodeRenderer());
|
||||||
|
tree.addTreeSelectionListener(e -> selectionChanged());
|
||||||
|
rebuildTree();
|
||||||
|
|
||||||
|
keyButton.addActionListener(e -> startRecording());
|
||||||
|
triggerCombo.setRenderer(new DefaultListCellRenderer() {
|
||||||
|
@Override
|
||||||
|
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||||
|
boolean selected, boolean focused) {
|
||||||
|
super.getListCellRendererComponent(list, value, index, selected, focused);
|
||||||
|
if (value instanceof Hotkey.Trigger t) setText(t.label());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
triggerCombo.setSelectedItem(hotkey.trigger);
|
||||||
|
activeServerCheck.setSelected(hotkey.activeServerOnly);
|
||||||
|
argumentField.setText(hotkey.argument == null ? "" : hotkey.argument);
|
||||||
|
hint.setFont(hint.getFont().deriveFont(Font.ITALIC, hint.getFont().getSize2D() - 1f));
|
||||||
|
|
||||||
|
getContentPane().setLayout(new BorderLayout(8, 8));
|
||||||
|
((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||||
|
getContentPane().add(buildActionPane(), BorderLayout.CENTER);
|
||||||
|
getContentPane().add(buildForm(), BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
updateKeyButton();
|
||||||
|
updateForAction();
|
||||||
|
Dialogs.closeOnEscape(this, this::cancel);
|
||||||
|
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||||
|
setSize(new Dimension(470, 560));
|
||||||
|
setLocationRelativeTo(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel buildActionPane() {
|
||||||
|
JPanel p = new JPanel(new BorderLayout(0, 4));
|
||||||
|
p.add(new JLabel("Action:"), BorderLayout.NORTH);
|
||||||
|
p.add(new JScrollPane(tree), BorderLayout.CENTER);
|
||||||
|
p.add(advancedCheck, BorderLayout.SOUTH);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel buildForm() {
|
||||||
|
JPanel p = new JPanel(new GridBagLayout());
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.insets = new Insets(3, 3, 3, 3);
|
||||||
|
c.anchor = GridBagConstraints.WEST;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
addRow(p, c, row++, new JLabel("Hotkey:"), keyButton);
|
||||||
|
addRow(p, c, row++, argumentLabel, argumentField);
|
||||||
|
addRow(p, c, row++, new JLabel("Trigger:"), triggerCombo);
|
||||||
|
|
||||||
|
c.gridx = 1;
|
||||||
|
c.gridy = row++;
|
||||||
|
p.add(activeServerCheck, c);
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row++;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
p.add(hint, c);
|
||||||
|
|
||||||
|
JPanel buttons = new JPanel();
|
||||||
|
JButton ok = new JButton("OK");
|
||||||
|
JButton cancel = new JButton("Cancel");
|
||||||
|
ok.addActionListener(e -> confirm());
|
||||||
|
cancel.addActionListener(e -> cancel());
|
||||||
|
buttons.add(Box.createHorizontalGlue());
|
||||||
|
buttons.add(ok);
|
||||||
|
buttons.add(cancel);
|
||||||
|
c.gridy = row;
|
||||||
|
p.add(buttons, c);
|
||||||
|
getRootPane().setDefaultButton(ok);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addRow(JPanel p, GridBagConstraints c, int row, JComponent left, JComponent right) {
|
||||||
|
c.gridwidth = 1;
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.weightx = 0;
|
||||||
|
p.add(left, c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
p.add(right, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds category → group → action → value, collapsing the groups TS3 leaves
|
||||||
|
* unnamed so their actions sit directly under the category.
|
||||||
|
*/
|
||||||
|
private void rebuildTree() {
|
||||||
|
boolean advanced = advancedCheck.isSelected();
|
||||||
|
root.removeAllChildren();
|
||||||
|
for (HotkeyAction.Category category : HotkeyAction.Category.values()) {
|
||||||
|
List<HotkeyAction> actions = HotkeyAction.of(category, advanced);
|
||||||
|
if (actions.isEmpty()) continue;
|
||||||
|
DefaultMutableTreeNode categoryNode =
|
||||||
|
new DefaultMutableTreeNode(Node.heading(category.label()));
|
||||||
|
DefaultMutableTreeNode groupNode = null;
|
||||||
|
String groupName = null;
|
||||||
|
for (HotkeyAction action : actions) {
|
||||||
|
DefaultMutableTreeNode parent = categoryNode;
|
||||||
|
if (!action.group().isEmpty()) {
|
||||||
|
if (groupNode == null || !action.group().equals(groupName)) {
|
||||||
|
groupName = action.group();
|
||||||
|
groupNode = new DefaultMutableTreeNode(Node.heading(groupName));
|
||||||
|
categoryNode.add(groupNode);
|
||||||
|
}
|
||||||
|
parent = groupNode;
|
||||||
|
}
|
||||||
|
DefaultMutableTreeNode actionNode = new DefaultMutableTreeNode(Node.of(action));
|
||||||
|
parent.add(actionNode);
|
||||||
|
for (String value : service.argumentChoices(action)) {
|
||||||
|
actionNode.add(new DefaultMutableTreeNode(Node.value(action, value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.add(categoryNode);
|
||||||
|
}
|
||||||
|
treeModel.reload();
|
||||||
|
// Categories open, groups closed: the whole action set at a glance, as in TS3.
|
||||||
|
for (int i = 0; i < root.getChildCount(); i++) {
|
||||||
|
tree.expandPath(new TreePath(((DefaultMutableTreeNode) root.getChildAt(i)).getPath()));
|
||||||
|
}
|
||||||
|
selectCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reveals and selects the node matching the binding being edited. */
|
||||||
|
private void selectCurrent() {
|
||||||
|
if (hotkey.action == null) return;
|
||||||
|
DefaultMutableTreeNode match = null;
|
||||||
|
var nodes = root.depthFirstEnumeration();
|
||||||
|
while (nodes.hasMoreElements()) {
|
||||||
|
DefaultMutableTreeNode node = (DefaultMutableTreeNode) nodes.nextElement();
|
||||||
|
if (!(node.getUserObject() instanceof Node n) || n.action() != hotkey.action) continue;
|
||||||
|
boolean sameArgument = n.argument() != null && n.argument().equals(hotkey.argument);
|
||||||
|
if (sameArgument) {
|
||||||
|
match = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (n.argument() == null && match == null) match = node;
|
||||||
|
}
|
||||||
|
if (match == null) return;
|
||||||
|
TreePath path = new TreePath(match.getPath());
|
||||||
|
tree.setSelectionPath(path);
|
||||||
|
SwingUtilities.invokeLater(() -> tree.scrollPathToVisible(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectionChanged() {
|
||||||
|
if (!(tree.getLastSelectedPathComponent() instanceof DefaultMutableTreeNode node)
|
||||||
|
|| !(node.getUserObject() instanceof Node selected)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected.isHeading()) {
|
||||||
|
// Headings only structure the tree; keep the action that was chosen before.
|
||||||
|
tree.clearSelection();
|
||||||
|
selectCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hotkey.action = selected.action();
|
||||||
|
if (selected.argument() != null) argumentField.setText(selected.argument());
|
||||||
|
updateForAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Syncs the form to the selected action: parameter row, trigger, scope and hint. */
|
||||||
|
private void updateForAction() {
|
||||||
|
HotkeyAction action = hotkey.action;
|
||||||
|
boolean takesArgument = action != null && action.argument() != HotkeyAction.Argument.NONE;
|
||||||
|
argumentLabel.setText(takesArgument ? argumentLabel(action) : "");
|
||||||
|
argumentLabel.setVisible(takesArgument);
|
||||||
|
argumentField.setVisible(takesArgument);
|
||||||
|
|
||||||
|
boolean momentary = action != null && action.momentary();
|
||||||
|
triggerCombo.setEnabled(!momentary);
|
||||||
|
activeServerCheck.setEnabled(action != null && action.category() != HotkeyAction.Category.MISC);
|
||||||
|
|
||||||
|
if (action != null && !action.supported()) {
|
||||||
|
hint.setText("This action is part of TeamSpeak's hotkey set but is not implemented yet.");
|
||||||
|
} else if (momentary) {
|
||||||
|
hint.setText("Held down: the action lasts as long as the hotkey is pressed.");
|
||||||
|
} else {
|
||||||
|
hint.setText(service.isRunning() ? " " : service.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String argumentLabel(HotkeyAction action) {
|
||||||
|
return switch (action.argument()) {
|
||||||
|
case BOOKMARK -> "Bookmark:";
|
||||||
|
case CHANNEL -> "Channel path:";
|
||||||
|
case PROFILE -> "Profile:";
|
||||||
|
default -> "Parameter:";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startRecording() {
|
||||||
|
if (recording) return;
|
||||||
|
if (!service.isRunning()) {
|
||||||
|
hint.setText(service.status());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recording = true;
|
||||||
|
keyButton.setText("Press hotkey combination…");
|
||||||
|
service.record(new HotkeyEngine.Recorder() {
|
||||||
|
@Override
|
||||||
|
public void onRecording(HotkeyCombo partial) {
|
||||||
|
SwingUtilities.invokeLater(() -> keyButton.setText(service.display(partial) + "…"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRecorded(HotkeyCombo recorded) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
combo = recorded;
|
||||||
|
stopRecording();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopRecording() {
|
||||||
|
if (!recording) return;
|
||||||
|
recording = false;
|
||||||
|
service.stopRecording();
|
||||||
|
updateKeyButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateKeyButton() {
|
||||||
|
keyButton.setText(combo == null || combo.isEmpty()
|
||||||
|
? "No hotkey assigned" : service.display(combo));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void confirm() {
|
||||||
|
stopRecording();
|
||||||
|
if (hotkey.action == null || combo == null || combo.isEmpty()) {
|
||||||
|
hint.setText("Pick an action and press a key combination first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hotkey.action.supported()) {
|
||||||
|
hint.setText("This action is not implemented yet — pick another one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hotkey.combo = combo;
|
||||||
|
hotkey.trigger = (Hotkey.Trigger) triggerCombo.getSelectedItem();
|
||||||
|
hotkey.activeServerOnly = activeServerCheck.isSelected();
|
||||||
|
hotkey.argument = argumentField.getText().trim();
|
||||||
|
confirmed = true;
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancel() {
|
||||||
|
stopRecording();
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isConfirmed() {
|
||||||
|
return confirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Hotkey result() {
|
||||||
|
return hotkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws headings in bold and greys out the actions this client cannot perform. */
|
||||||
|
private static final class NodeRenderer extends DefaultTreeCellRenderer {
|
||||||
|
@Override
|
||||||
|
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected,
|
||||||
|
boolean expanded, boolean leaf, int row,
|
||||||
|
boolean focused) {
|
||||||
|
super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, focused);
|
||||||
|
setIcon(null);
|
||||||
|
if (!(value instanceof DefaultMutableTreeNode node)
|
||||||
|
|| !(node.getUserObject() instanceof Node n)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (n.isHeading()) {
|
||||||
|
setFont(getFont().deriveFont(Font.BOLD));
|
||||||
|
setToolTipText(null);
|
||||||
|
} else {
|
||||||
|
setEnabled(n.action().supported());
|
||||||
|
setToolTipText(n.action().supported() ? null : "Not implemented by this client");
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.GlobalInputHook;
|
||||||
|
import com.ts3client.hotkey.Hotkey;
|
||||||
|
import com.ts3client.hotkey.HotkeyAction;
|
||||||
|
import com.ts3client.hotkey.HotkeyCombo;
|
||||||
|
import com.ts3client.hotkey.HotkeyEngine;
|
||||||
|
import com.ts3client.hotkey.HotkeyKey;
|
||||||
|
import com.ts3client.hotkey.Hotkeys;
|
||||||
|
import com.ts3client.hotkey.desktop.DesktopInputHooks;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the hotkey machinery for the UI: the stored bindings, the matching engine and
|
||||||
|
* the platform input hook they are fed from. Everything the dialogs need — recording a
|
||||||
|
* combination, naming keys, telling the user why hotkeys are dead — goes through here.
|
||||||
|
*/
|
||||||
|
final class HotkeyService {
|
||||||
|
|
||||||
|
private final Hotkeys hotkeys = Hotkeys.load();
|
||||||
|
private final HotkeyEngine engine;
|
||||||
|
private final GlobalInputHook hook;
|
||||||
|
private final HotkeyArguments arguments;
|
||||||
|
|
||||||
|
HotkeyService(HotkeyEngine.Handler handler, HotkeyArguments arguments) {
|
||||||
|
this.arguments = arguments;
|
||||||
|
engine = new HotkeyEngine(hotkeys, handler);
|
||||||
|
hook = DesktopInputHooks.start(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The values this action's parameter can take right now, for the action tree. */
|
||||||
|
List<String> argumentChoices(HotkeyAction action) {
|
||||||
|
if (arguments == null || action.argument() == HotkeyAction.Argument.NONE) return List.of();
|
||||||
|
try {
|
||||||
|
return arguments.choices(action);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Hotkey> all() {
|
||||||
|
return hotkeys.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
void replaceAll(List<Hotkey> updated) {
|
||||||
|
engine.releaseAll();
|
||||||
|
hotkeys.replaceAll(updated);
|
||||||
|
hotkeys.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The binding for an action with no argument, or {@code null} when unbound. */
|
||||||
|
Hotkey find(HotkeyAction action) {
|
||||||
|
synchronized (hotkeys.all()) {
|
||||||
|
for (Hotkey h : hotkeys.all()) {
|
||||||
|
if (h.action == action) return h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isRunning() {
|
||||||
|
return hook.isRunning();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line for the options dialog: either working, or why it is not. */
|
||||||
|
String status() {
|
||||||
|
return hook.isRunning()
|
||||||
|
? "Global hotkeys are active."
|
||||||
|
: "Global hotkeys are unavailable (" + hook.unavailableReason() + ").";
|
||||||
|
}
|
||||||
|
|
||||||
|
String display(HotkeyCombo combo) {
|
||||||
|
return combo == null ? "No hotkey assigned" : combo.display(this::keyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String keyName(HotkeyKey key) {
|
||||||
|
return hook.keyName(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Captures the next combination instead of firing bindings; see {@link #stopRecording()}. */
|
||||||
|
void record(HotkeyEngine.Recorder recorder) {
|
||||||
|
engine.record(recorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopRecording() {
|
||||||
|
engine.stopRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
engine.releaseAll();
|
||||||
|
hook.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.Hotkey;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JTable;
|
||||||
|
import javax.swing.ListSelectionModel;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.table.AbstractTableModel;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Font;
|
||||||
|
import java.awt.Window;
|
||||||
|
import java.awt.event.MouseAdapter;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options dialog's hotkey tab: the list of bindings with the buttons to add, edit
|
||||||
|
* and remove them. Edits happen on a working copy and only reach the running engine
|
||||||
|
* when the dialog is applied.
|
||||||
|
*/
|
||||||
|
final class HotkeysPanel extends JPanel {
|
||||||
|
|
||||||
|
private static final String[] COLUMNS = {"Action", "Hotkey", "Trigger", "Active server", "On"};
|
||||||
|
|
||||||
|
private final HotkeyService service;
|
||||||
|
private final List<Hotkey> working = new ArrayList<>();
|
||||||
|
private final Model model = new Model();
|
||||||
|
private final JTable table = new JTable(model);
|
||||||
|
|
||||||
|
HotkeysPanel(HotkeyService service) {
|
||||||
|
super(new BorderLayout(6, 6));
|
||||||
|
this.service = service;
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||||
|
|
||||||
|
synchronized (service.all()) {
|
||||||
|
for (Hotkey h : service.all()) working.add(h.copy());
|
||||||
|
}
|
||||||
|
|
||||||
|
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||||
|
table.setRowHeight(table.getRowHeight() + 4);
|
||||||
|
table.getColumnModel().getColumn(0).setPreferredWidth(240);
|
||||||
|
table.getColumnModel().getColumn(1).setPreferredWidth(150);
|
||||||
|
table.addMouseListener(new MouseAdapter() {
|
||||||
|
@Override
|
||||||
|
public void mouseClicked(MouseEvent e) {
|
||||||
|
if (e.getClickCount() == 2) edit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
JButton add = new JButton("Add…");
|
||||||
|
JButton edit = new JButton("Edit…");
|
||||||
|
JButton remove = new JButton("Remove");
|
||||||
|
add.addActionListener(e -> add());
|
||||||
|
edit.addActionListener(e -> edit());
|
||||||
|
remove.addActionListener(e -> remove());
|
||||||
|
|
||||||
|
JPanel buttons = new JPanel();
|
||||||
|
buttons.add(add);
|
||||||
|
buttons.add(edit);
|
||||||
|
buttons.add(remove);
|
||||||
|
|
||||||
|
JLabel status = new JLabel(service.status());
|
||||||
|
status.setFont(status.getFont().deriveFont(Font.ITALIC, status.getFont().getSize2D() - 1f));
|
||||||
|
|
||||||
|
add(new JScrollPane(table), BorderLayout.CENTER);
|
||||||
|
JPanel south = new JPanel(new BorderLayout());
|
||||||
|
south.add(buttons, BorderLayout.WEST);
|
||||||
|
south.add(status, BorderLayout.SOUTH);
|
||||||
|
add(south, BorderLayout.SOUTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void add() {
|
||||||
|
HotkeyDialog dlg = new HotkeyDialog(owner(), service, null);
|
||||||
|
dlg.setVisible(true);
|
||||||
|
if (!dlg.isConfirmed()) return;
|
||||||
|
working.add(dlg.result());
|
||||||
|
model.fireTableDataChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void edit() {
|
||||||
|
int row = table.getSelectedRow();
|
||||||
|
if (row < 0) return;
|
||||||
|
HotkeyDialog dlg = new HotkeyDialog(owner(), service, working.get(row));
|
||||||
|
dlg.setVisible(true);
|
||||||
|
if (!dlg.isConfirmed()) return;
|
||||||
|
working.set(row, dlg.result());
|
||||||
|
model.fireTableRowsUpdated(row, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void remove() {
|
||||||
|
int row = table.getSelectedRow();
|
||||||
|
if (row < 0) return;
|
||||||
|
working.remove(row);
|
||||||
|
model.fireTableDataChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Window owner() {
|
||||||
|
return SwingUtilities.getWindowAncestor(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commits the edited list to the engine and to disk. */
|
||||||
|
void apply() {
|
||||||
|
service.replaceAll(working);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the stored bindings up again after something else changed them. */
|
||||||
|
void reload() {
|
||||||
|
working.clear();
|
||||||
|
synchronized (service.all()) {
|
||||||
|
for (Hotkey h : service.all()) working.add(h.copy());
|
||||||
|
}
|
||||||
|
model.fireTableDataChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Model extends AbstractTableModel {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getRowCount() {
|
||||||
|
return working.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getColumnCount() {
|
||||||
|
return COLUMNS.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getColumnName(int column) {
|
||||||
|
return COLUMNS[column];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Class<?> getColumnClass(int column) {
|
||||||
|
return column >= 3 ? Boolean.class : String.class;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCellEditable(int row, int column) {
|
||||||
|
return column == 4 || (column == 3 && working.get(row).isServerScoped());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getValueAt(int row, int column) {
|
||||||
|
Hotkey h = working.get(row);
|
||||||
|
return switch (column) {
|
||||||
|
case 0 -> h.path();
|
||||||
|
case 1 -> service.display(h.combo);
|
||||||
|
case 2 -> h.action.momentary() ? "While held" : h.trigger.label();
|
||||||
|
case 3 -> h.isServerScoped() && h.activeServerOnly;
|
||||||
|
default -> h.enabled;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setValueAt(Object value, int row, int column) {
|
||||||
|
Hotkey h = working.get(row);
|
||||||
|
if (column == 3) {
|
||||||
|
h.activeServerOnly = Boolean.TRUE.equals(value);
|
||||||
|
} else if (column == 4) {
|
||||||
|
h.enabled = Boolean.TRUE.equals(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java
Normal file
108
ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.text.BBCode;
|
||||||
|
|
||||||
|
import javax.swing.JEditorPane;
|
||||||
|
import javax.swing.JViewport;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.text.html.HTMLEditorKit;
|
||||||
|
import javax.swing.text.html.StyleSheet;
|
||||||
|
import java.awt.Color;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTML panes showing TeamSpeak text — chat logs, client and channel information —
|
||||||
|
* and the theme-dependent stylesheet they share.
|
||||||
|
*
|
||||||
|
* <p>Text that is already on screen keeps the colours its stylesheet was built with, so
|
||||||
|
* a look-and-feel change re-installs the editor kit and parses the pane's content again
|
||||||
|
* instead of only recolouring what is written afterwards.
|
||||||
|
*/
|
||||||
|
final class HtmlStyles {
|
||||||
|
|
||||||
|
private static final String BODY_STYLE = "HtmlStyles.bodyStyle";
|
||||||
|
|
||||||
|
private HtmlStyles() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bodyStyle extra CSS for {@code body}, e.g. font and margins; the text colour
|
||||||
|
* is added by the theme
|
||||||
|
* @return a read-only HTML pane whose stylesheet follows the current theme
|
||||||
|
*/
|
||||||
|
static JEditorPane pane(String bodyStyle) {
|
||||||
|
JEditorPane pane = new JEditorPane() {
|
||||||
|
@Override
|
||||||
|
public void updateUI() {
|
||||||
|
super.updateUI();
|
||||||
|
restyle(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pane.putClientProperty(BODY_STYLE, bodyStyle);
|
||||||
|
pane.setEditable(false);
|
||||||
|
installKit(pane, null, false);
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints the scroll pane {@code pane} sits in with the pane's own background: text
|
||||||
|
* only reaches as far as it goes, and the empty space below it would otherwise show
|
||||||
|
* the window's colour instead of the log's.
|
||||||
|
*/
|
||||||
|
static void fillViewport(JEditorPane pane) {
|
||||||
|
if (pane.getParent() instanceof JViewport viewport) {
|
||||||
|
viewport.setBackground(pane.getBackground());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-themes {@code pane}, keeping the text it shows. */
|
||||||
|
private static void restyle(JEditorPane pane) {
|
||||||
|
// Also runs from JEditorPane's constructor, before the pane is one of ours.
|
||||||
|
if (pane.getClientProperty(BODY_STYLE) == null) return;
|
||||||
|
|
||||||
|
int length = pane.getDocument().getLength();
|
||||||
|
String html = length == 0 ? null : pane.getText();
|
||||||
|
// A chat log sits at its newest line; keep it there rather than jumping to the top.
|
||||||
|
boolean atEnd = pane.getCaretPosition() >= length;
|
||||||
|
// Not during the look-and-feel update that got us here: the pane is rebuilding its
|
||||||
|
// views, and replacing the document underneath it would race with that.
|
||||||
|
SwingUtilities.invokeLater(() -> installKit(pane, html, atEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void installKit(JEditorPane pane, String html, boolean atEnd) {
|
||||||
|
HTMLEditorKit kit = new HTMLEditorKit();
|
||||||
|
StyleSheet css = new StyleSheet();
|
||||||
|
css.addStyleSheet(kit.getStyleSheet());
|
||||||
|
|
||||||
|
Object bodyStyle = pane.getClientProperty(BODY_STYLE);
|
||||||
|
css.addRule("body { " + (bodyStyle == null ? "" : bodyStyle + " ")
|
||||||
|
+ "color:" + hex(Theme.chatText()) + "; }");
|
||||||
|
// Secondary text (timestamps, labels, placeholders), an author's name and the lines
|
||||||
|
// about what happened on the server.
|
||||||
|
css.addRule(".muted { color:" + hex(Theme.chatSystem()) + "; }");
|
||||||
|
css.addRule(".name { color:" + hex(Theme.chatName()) + "; font-weight:bold; }");
|
||||||
|
css.addRule(".event { color:" + hex(Theme.chatEvent()) + "; }");
|
||||||
|
css.addRule("a { color:" + hex(Theme.chatName()) + "; text-decoration:none; }");
|
||||||
|
// Client references look exactly like a message author's name.
|
||||||
|
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
|
||||||
|
+ " { color:" + hex(Theme.chatName()) + "; font-weight:bold; text-decoration:none; }");
|
||||||
|
// Channel (and other TeamSpeak protocol) references stand out of a line the same
|
||||||
|
// way a client's name does.
|
||||||
|
css.addRule("a." + BBCode.TS_LINK_CLASS
|
||||||
|
+ " { color:" + hex(Theme.chatName()) + "; font-weight:bold; text-decoration:none; }");
|
||||||
|
// External links are underlined so they can't be mistaken for an identity.
|
||||||
|
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
|
||||||
|
+ " { color:" + hex(Theme.link()) + "; text-decoration:underline; }");
|
||||||
|
kit.setStyleSheet(css);
|
||||||
|
|
||||||
|
pane.setEditorKit(kit);
|
||||||
|
fillViewport(pane);
|
||||||
|
if (html != null) {
|
||||||
|
pane.setText(html);
|
||||||
|
pane.setCaretPosition(atEnd ? pane.getDocument().getLength() : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hex(Color c) {
|
||||||
|
return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.IconRepository;
|
||||||
|
import com.ts3client.net.TeamspeakConnection;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.DefaultListModel;
|
||||||
|
import javax.swing.ImageIcon;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JDialog;
|
||||||
|
import javax.swing.JFileChooser;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JList;
|
||||||
|
import javax.swing.JOptionPane;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JSplitPane;
|
||||||
|
import javax.swing.ListSelectionModel;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.Timer;
|
||||||
|
import javax.swing.filechooser.FileNameExtensionFilter;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.Window;
|
||||||
|
import java.awt.event.MouseAdapter;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TeamSpeak's icon viewer: the icons uploaded to this virtual server on one side (which
|
||||||
|
* can be added to and removed from here) and the ones the icon pack ships on the other.
|
||||||
|
*
|
||||||
|
* <p>Server icons arrive through the {@link IconRepository}, which downloads them in the
|
||||||
|
* background, so the lists are simply repainted until every icon has turned up.
|
||||||
|
*/
|
||||||
|
final class IconChooserDialog extends JDialog {
|
||||||
|
|
||||||
|
/** How often the lists are repainted while icons are still being downloaded. */
|
||||||
|
private static final int REFRESH_MS = 300;
|
||||||
|
/** Give up repainting once nothing has arrived for this long. */
|
||||||
|
private static final int REFRESH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
private final TeamspeakConnection conn;
|
||||||
|
private final GroupIcons groupIcons;
|
||||||
|
|
||||||
|
private final DefaultListModel<Long> remoteModel = new DefaultListModel<>();
|
||||||
|
private final JList<Long> remoteList = new JList<>(remoteModel);
|
||||||
|
private final JList<Long> localList = new JList<>(new DefaultListModel<>());
|
||||||
|
private final JButton deleteButton = new JButton("Delete");
|
||||||
|
private final JButton selectButton = new JButton("Select");
|
||||||
|
private final JLabel status = new JLabel(" ");
|
||||||
|
|
||||||
|
private long chosenIconId;
|
||||||
|
private boolean accepted;
|
||||||
|
|
||||||
|
IconChooserDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons) {
|
||||||
|
super(owner, "Icons", ModalityType.APPLICATION_MODAL);
|
||||||
|
this.conn = conn;
|
||||||
|
this.groupIcons = groupIcons;
|
||||||
|
|
||||||
|
configure(remoteList);
|
||||||
|
configure(localList);
|
||||||
|
DefaultListModel<Long> localModel = (DefaultListModel<Long>) localList.getModel();
|
||||||
|
for (Long id : IconRepository.BUNDLED_IDS) localModel.addElement(id);
|
||||||
|
|
||||||
|
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
|
||||||
|
group("Remote", remoteList, remoteButtons()),
|
||||||
|
group("Local", localList, localFooter()));
|
||||||
|
split.setResizeWeight(0.6);
|
||||||
|
|
||||||
|
status.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8));
|
||||||
|
status.setForeground(Theme.chatSystem());
|
||||||
|
|
||||||
|
getContentPane().setLayout(new BorderLayout());
|
||||||
|
getContentPane().add(split, BorderLayout.CENTER);
|
||||||
|
JPanel south = new JPanel(new BorderLayout());
|
||||||
|
south.add(status, BorderLayout.NORTH);
|
||||||
|
south.add(buttons(), BorderLayout.SOUTH);
|
||||||
|
getContentPane().add(south, BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
Dialogs.closeOnEscape(this);
|
||||||
|
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||||
|
setSize(new Dimension(520, 360));
|
||||||
|
setLocationRelativeTo(owner);
|
||||||
|
|
||||||
|
loadRemoteIcons();
|
||||||
|
startRefreshing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return whether an icon was picked; {@link #getIconId()} then holds it */
|
||||||
|
boolean isAccepted() {
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
long getIconId() {
|
||||||
|
return chosenIconId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configure(JList<Long> list) {
|
||||||
|
list.setLayoutOrientation(JList.HORIZONTAL_WRAP);
|
||||||
|
list.setVisibleRowCount(-1);
|
||||||
|
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||||
|
list.setFixedCellWidth(32);
|
||||||
|
list.setFixedCellHeight(32);
|
||||||
|
list.setCellRenderer(new IconCellRenderer());
|
||||||
|
list.addListSelectionListener(e -> onSelectionChanged(list));
|
||||||
|
list.addMouseListener(new MouseAdapter() {
|
||||||
|
@Override
|
||||||
|
public void mouseClicked(MouseEvent e) {
|
||||||
|
if (e.getClickCount() == 2 && list.getSelectedValue() != null) choose(list.getSelectedValue());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps one list's selection exclusive: picking on one side clears the other. */
|
||||||
|
private void onSelectionChanged(JList<Long> list) {
|
||||||
|
JList<Long> other = list == remoteList ? localList : remoteList;
|
||||||
|
if (list.getSelectedValue() != null) other.clearSelection();
|
||||||
|
deleteButton.setEnabled(remoteList.getSelectedValue() != null);
|
||||||
|
selectButton.setEnabled(selectedIcon() != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long selectedIcon() {
|
||||||
|
Long remote = remoteList.getSelectedValue();
|
||||||
|
if (remote != null) return remote;
|
||||||
|
Long local = localList.getSelectedValue();
|
||||||
|
return local == null ? 0 : local;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel group(String title, JList<Long> list, Component footer) {
|
||||||
|
JPanel panel = new JPanel(new BorderLayout(0, 4));
|
||||||
|
panel.setBorder(BorderFactory.createTitledBorder(title));
|
||||||
|
panel.add(new JScrollPane(list), BorderLayout.CENTER);
|
||||||
|
panel.add(footer, BorderLayout.SOUTH);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component remoteButtons() {
|
||||||
|
JButton upload = new JButton("Upload");
|
||||||
|
upload.addActionListener(a -> uploadIcon());
|
||||||
|
deleteButton.setEnabled(false);
|
||||||
|
deleteButton.addActionListener(a -> deleteSelectedIcon());
|
||||||
|
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 4, 0));
|
||||||
|
row.add(upload);
|
||||||
|
row.add(deleteButton);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Component localFooter() {
|
||||||
|
com.ts3client.gfx.IconPack active = IconTheme.get().activePack();
|
||||||
|
JLabel pack = new JLabel("Icon Pack: " + (active == null ? "none" : active.name()));
|
||||||
|
pack.setBorder(BorderFactory.createEmptyBorder(0, 4, 0, 4));
|
||||||
|
return pack;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel buttons() {
|
||||||
|
selectButton.setEnabled(false);
|
||||||
|
selectButton.addActionListener(a -> choose(selectedIcon()));
|
||||||
|
JButton cancel = new JButton("Cancel");
|
||||||
|
cancel.addActionListener(a -> dispose());
|
||||||
|
JPanel panel = new JPanel(new BorderLayout());
|
||||||
|
JPanel right = new JPanel();
|
||||||
|
right.add(selectButton);
|
||||||
|
right.add(cancel);
|
||||||
|
panel.add(right, BorderLayout.EAST);
|
||||||
|
getRootPane().setDefaultButton(selectButton);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void choose(long iconId) {
|
||||||
|
if (iconId == 0) return;
|
||||||
|
chosenIconId = iconId;
|
||||||
|
accepted = true;
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadRemoteIcons() {
|
||||||
|
status.setText("Loading icons…");
|
||||||
|
conn.requestServerIcons((ids, error) -> SwingUtilities.invokeLater(() -> {
|
||||||
|
if (error != null) {
|
||||||
|
status.setText("Could not list the server's icons: " + error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.setText(" ");
|
||||||
|
remoteModel.clear();
|
||||||
|
for (Long id : ids) remoteModel.addElement(id);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadIcon() {
|
||||||
|
JFileChooser chooser = new JFileChooser();
|
||||||
|
chooser.setDialogTitle("Select an image to upload");
|
||||||
|
chooser.setFileFilter(new FileNameExtensionFilter("Images", "png", "jpg", "jpeg", "gif", "bmp", "svg"));
|
||||||
|
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return;
|
||||||
|
File file = chooser.getSelectedFile();
|
||||||
|
status.setText("Uploading " + file.getName() + "…");
|
||||||
|
conn.uploadIcon(file, (iconId, error) -> SwingUtilities.invokeLater(() -> {
|
||||||
|
if (error != null) {
|
||||||
|
status.setText(" ");
|
||||||
|
JOptionPane.showMessageDialog(this, "Error uploading icon: " + error,
|
||||||
|
"Error", JOptionPane.ERROR_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadRemoteIcons();
|
||||||
|
startRefreshing();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteSelectedIcon() {
|
||||||
|
Long id = remoteList.getSelectedValue();
|
||||||
|
if (id == null) return;
|
||||||
|
int answer = JOptionPane.showConfirmDialog(this,
|
||||||
|
"Permanently delete this icon from the server?", "Confirmation",
|
||||||
|
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
|
||||||
|
if (answer != JOptionPane.YES_OPTION) return;
|
||||||
|
conn.deleteIcon(id, error -> SwingUtilities.invokeLater(() -> {
|
||||||
|
if (error != null) {
|
||||||
|
JOptionPane.showMessageDialog(this, "Failed to delete remote icon file: " + error,
|
||||||
|
"Error", JOptionPane.ERROR_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remoteModel.removeElement(id);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repaints while the repository is still fetching icons; it hands them over silently,
|
||||||
|
* so the only way to show them as they land is to keep asking.
|
||||||
|
*/
|
||||||
|
private void startRefreshing() {
|
||||||
|
Timer timer = new Timer(REFRESH_MS, null);
|
||||||
|
long deadline = System.currentTimeMillis() + REFRESH_TIMEOUT_MS;
|
||||||
|
timer.addActionListener(e -> {
|
||||||
|
remoteList.repaint();
|
||||||
|
localList.repaint();
|
||||||
|
if (!isDisplayable() || System.currentTimeMillis() > deadline || allIconsLoaded()) timer.stop();
|
||||||
|
});
|
||||||
|
timer.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean allIconsLoaded() {
|
||||||
|
for (int i = 0; i < remoteModel.size(); i++) {
|
||||||
|
if (groupIcons.icon(remoteModel.get(i)) == null) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws one icon, blank until the repository has it. */
|
||||||
|
private final class IconCellRenderer extends javax.swing.DefaultListCellRenderer {
|
||||||
|
@Override
|
||||||
|
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||||
|
boolean selected, boolean focused) {
|
||||||
|
super.getListCellRendererComponent(list, "", index, selected, focused);
|
||||||
|
long id = value instanceof Long ? (Long) value : 0;
|
||||||
|
ImageIcon icon = groupIcons.icon(id);
|
||||||
|
setIcon(icon);
|
||||||
|
setHorizontalAlignment(CENTER);
|
||||||
|
setToolTipText("Icon " + id);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience for callers that only need the picked id. */
|
||||||
|
static long pick(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, long current) {
|
||||||
|
IconChooserDialog dialog = new IconChooserDialog(owner, conn, groupIcons);
|
||||||
|
dialog.setVisible(true);
|
||||||
|
return dialog.isAccepted() ? dialog.getIconId() : current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ public final class Icons {
|
|||||||
|
|
||||||
private static ImageIcon themed(String key, int size, Painter fallback) {
|
private static ImageIcon themed(String key, int size, Painter fallback) {
|
||||||
ImageIcon icon = IconTheme.icon(key, size);
|
ImageIcon icon = IconTheme.icon(key, size);
|
||||||
return icon != null ? icon : make(fallback);
|
return icon != null ? icon : make(size, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,10 +71,16 @@ public final class Icons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ImageIcon make(Painter p) {
|
private static ImageIcon make(Painter p) {
|
||||||
BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB);
|
return make(SZ, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a painter (which draws in a 16×16 box) scaled to {@code size}. */
|
||||||
|
private static ImageIcon make(int size, Painter p) {
|
||||||
|
BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||||
Graphics2D g = img.createGraphics();
|
Graphics2D g = img.createGraphics();
|
||||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
|
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
|
||||||
|
g.scale(size / (double) SZ, size / (double) SZ);
|
||||||
p.paint(g);
|
p.paint(g);
|
||||||
g.dispose();
|
g.dispose();
|
||||||
return new ImageIcon(img);
|
return new ImageIcon(img);
|
||||||
@@ -111,37 +117,94 @@ public final class Icons {
|
|||||||
// ---- client status icons ----
|
// ---- client status icons ----
|
||||||
|
|
||||||
public static ImageIcon clientIdle() {
|
public static ImageIcon clientIdle() {
|
||||||
return themed("PLAYER_OFF", g -> paintPerson(g, Theme.IDLE_CLIENT));
|
return clientIdle(SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImageIcon clientIdle(int size) {
|
||||||
|
return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ImageIcon clientTalking() {
|
public static ImageIcon clientTalking() {
|
||||||
return themed("PLAYER_ON", g -> paintPerson(g, Theme.TALKING));
|
return clientTalking(SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImageIcon clientTalking(int size) {
|
||||||
|
return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.talking()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ImageIcon clientAway() {
|
public static ImageIcon clientAway() {
|
||||||
return themed("AWAY", g -> paintPerson(g, Theme.AWAY));
|
return clientAway(SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImageIcon clientAway(int size) {
|
||||||
|
return themed("AWAY", size, g -> paintPerson(g, Theme.away()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A channel commander that is not talking. */
|
/** A channel commander that is not talking. */
|
||||||
public static ImageIcon clientCommander() {
|
public static ImageIcon clientCommander() {
|
||||||
return themed("PLAYER_COMMANDER_OFF", g -> paintPerson(g, Theme.IDLE_CLIENT));
|
return clientCommander(SZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A channel commander that is talking. */
|
/** A channel commander that is talking. */
|
||||||
public static ImageIcon clientCommanderTalking() {
|
public static ImageIcon clientCommanderTalking() {
|
||||||
return themed("PLAYER_COMMANDER_ON", g -> paintPerson(g, Theme.TALKING));
|
return clientCommanderTalking(SZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ImageIcon clientQuery() {
|
public static ImageIcon clientQuery() {
|
||||||
return themed("SERVER_QUERY", g -> paintPerson(g, Theme.IDLE_CLIENT));
|
return themed("SERVER_QUERY", g -> paintPerson(g, Theme.idleClient()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ImageIcon micMuted() {
|
public static ImageIcon micMuted() {
|
||||||
return themed("INPUT_MUTED", Icons::paintMicMuted);
|
return micMuted(SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImageIcon micMuted(int size) {
|
||||||
|
return themed("INPUT_MUTED", size, Icons::paintMicMuted);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ImageIcon speakerMuted() {
|
public static ImageIcon speakerMuted() {
|
||||||
return themed("OUTPUT_MUTED", Icons::paintSpeakerMuted);
|
return speakerMuted(SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImageIcon speakerMuted(int size) {
|
||||||
|
return themed("OUTPUT_MUTED", size, Icons::paintSpeakerMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The capture device is unavailable (another tab holds it), as opposed to muted. */
|
||||||
|
public static ImageIcon micDisabled() {
|
||||||
|
return micDisabled(SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImageIcon micDisabled(int size) {
|
||||||
|
return themed("HARDWARE_INPUT_MUTED", size, Icons::paintMicDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The playback device is unavailable, as opposed to muted/deafened. */
|
||||||
|
public static ImageIcon speakerDisabled() {
|
||||||
|
return speakerDisabled(SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImageIcon speakerDisabled(int size) {
|
||||||
|
return themed("HARDWARE_OUTPUT_MUTED", size, Icons::paintSpeakerDisabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TS3's "Local Mic Mute": silenced, but not reported as muted. */
|
||||||
|
public static ImageIcon micLocalMuted() {
|
||||||
|
return micLocalMuted(SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImageIcon micLocalMuted(int size) {
|
||||||
|
return themed("INPUT_MUTED_LOCAL", size, Icons::paintMicLocalMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A channel commander that is not talking. */
|
||||||
|
public static ImageIcon clientCommander(int size) {
|
||||||
|
return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A channel commander that is talking. */
|
||||||
|
public static ImageIcon clientCommanderTalking(int size) {
|
||||||
|
return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.talking()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- toolbar / action icons ----
|
// ---- toolbar / action icons ----
|
||||||
@@ -180,9 +243,34 @@ public final class Icons {
|
|||||||
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
|
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fixed-width "channel icon / edit icon" label icon, used where a tab must not
|
||||||
|
* change width as the object it shows changes (e.g. the info-panel-in-chat tab).
|
||||||
|
*/
|
||||||
|
public static ImageIcon channelClientPair() {
|
||||||
|
ImageIcon channel = channel(true);
|
||||||
|
ImageIcon edit = themed("EDIT", Icons::paintEdit);
|
||||||
|
int gap = 8;
|
||||||
|
int width = channel.getIconWidth() + gap + edit.getIconWidth();
|
||||||
|
int height = Math.max(channel.getIconHeight(), edit.getIconHeight());
|
||||||
|
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g = img.createGraphics();
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g.drawImage(channel.getImage(), 0, (height - channel.getIconHeight()) / 2, null);
|
||||||
|
g.setColor(Theme.idleClient());
|
||||||
|
g.setFont(Theme.uiFont());
|
||||||
|
java.awt.FontMetrics fm = g.getFontMetrics();
|
||||||
|
int slashX = channel.getIconWidth() + (gap - fm.stringWidth("/")) / 2;
|
||||||
|
g.drawString("/", slashX, (height + fm.getAscent()) / 2 - 1);
|
||||||
|
g.drawImage(edit.getImage(), channel.getIconWidth() + gap,
|
||||||
|
(height - edit.getIconHeight()) / 2, null);
|
||||||
|
g.dispose();
|
||||||
|
return new ImageIcon(img);
|
||||||
|
}
|
||||||
|
|
||||||
/** The toolbar's away marker. */
|
/** The toolbar's away marker. */
|
||||||
public static ImageIcon away() {
|
public static ImageIcon away() {
|
||||||
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.AWAY));
|
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.away()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ImageIcon settings() {
|
public static ImageIcon settings() {
|
||||||
@@ -190,7 +278,11 @@ public final class Icons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static ImageIcon app() {
|
public static ImageIcon app() {
|
||||||
return make(Icons::paintApp);
|
return app(SZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ImageIcon app(int size) {
|
||||||
|
return make(size, Icons::paintApp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- built-in painters ----
|
// ---- built-in painters ----
|
||||||
@@ -237,7 +329,7 @@ public final class Icons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void paintMicMuted(Graphics2D g) {
|
private static void paintMicMuted(Graphics2D g) {
|
||||||
g.setColor(Theme.MUTED);
|
g.setColor(Theme.muted());
|
||||||
g.fillRoundRect(6, 2, 4, 7, 2, 2);
|
g.fillRoundRect(6, 2, 4, 7, 2, 2);
|
||||||
g.setStroke(new BasicStroke(1.4f));
|
g.setStroke(new BasicStroke(1.4f));
|
||||||
g.drawArc(4, 6, 8, 6, 200, 140);
|
g.drawArc(4, 6, 8, 6, 200, 140);
|
||||||
@@ -247,7 +339,7 @@ public final class Icons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void paintSpeakerMuted(Graphics2D g) {
|
private static void paintSpeakerMuted(Graphics2D g) {
|
||||||
g.setColor(Theme.MUTED);
|
g.setColor(Theme.muted());
|
||||||
g.fillRect(2, 6, 3, 4);
|
g.fillRect(2, 6, 3, 4);
|
||||||
int[] xs = {5, 9, 9, 5};
|
int[] xs = {5, 9, 9, 5};
|
||||||
int[] ys = {6, 3, 13, 10};
|
int[] ys = {6, 3, 13, 10};
|
||||||
@@ -256,6 +348,35 @@ public final class Icons {
|
|||||||
g.drawLine(2, 2, 14, 14);
|
g.drawLine(2, 2, 14, 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */
|
||||||
|
private static void paintMicDisabled(Graphics2D g) {
|
||||||
|
g.setColor(Theme.idleClient());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */
|
||||||
|
private static void paintSpeakerDisabled(Graphics2D g) {
|
||||||
|
g.setColor(Theme.idleClient());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */
|
||||||
|
private static void paintMicLocalMuted(Graphics2D g) {
|
||||||
|
paintMicMuted(g);
|
||||||
|
g.setColor(Theme.accent());
|
||||||
|
g.fillOval(11, 10, 4, 4);
|
||||||
|
}
|
||||||
|
|
||||||
private static void paintConnect(Graphics2D g) {
|
private static void paintConnect(Graphics2D g) {
|
||||||
g.setColor(new Color(0x2E8B57));
|
g.setColor(new Color(0x2E8B57));
|
||||||
g.setStroke(new BasicStroke(2f));
|
g.setStroke(new BasicStroke(2f));
|
||||||
@@ -266,7 +387,7 @@ public final class Icons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void paintDisconnect(Graphics2D g) {
|
private static void paintDisconnect(Graphics2D g) {
|
||||||
g.setColor(Theme.MUTED);
|
g.setColor(Theme.muted());
|
||||||
g.setStroke(new BasicStroke(2f));
|
g.setStroke(new BasicStroke(2f));
|
||||||
g.drawLine(3, 8, 8, 8);
|
g.drawLine(3, 8, 8, 8);
|
||||||
g.fillRoundRect(8, 5, 5, 6, 2, 2);
|
g.fillRoundRect(8, 5, 5, 6, 2, 2);
|
||||||
@@ -293,7 +414,7 @@ public final class Icons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void paintMicActive(Graphics2D g) {
|
private static void paintMicActive(Graphics2D g) {
|
||||||
g.setColor(Theme.TALKING);
|
g.setColor(Theme.talking());
|
||||||
g.fillOval(1, 1, 14, 14);
|
g.fillOval(1, 1, 14, 14);
|
||||||
g.setColor(Color.WHITE);
|
g.setColor(Color.WHITE);
|
||||||
g.fillRoundRect(6, 3, 4, 6, 2, 2);
|
g.fillRoundRect(6, 3, 4, 6, 2, 2);
|
||||||
@@ -302,6 +423,18 @@ public final class Icons {
|
|||||||
g.drawLine(8, 11, 8, 13);
|
g.drawLine(8, 11, 8, 13);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A pencil glyph, drawn when no icon pack ships an "EDIT" icon. */
|
||||||
|
private static void paintEdit(Graphics2D g) {
|
||||||
|
g.setColor(new Color(0x37474F));
|
||||||
|
g.setStroke(new BasicStroke(1.4f));
|
||||||
|
g.drawLine(3, 13, 11, 5);
|
||||||
|
g.drawLine(3, 13, 4, 10);
|
||||||
|
g.drawLine(4, 10, 11, 3);
|
||||||
|
g.drawLine(11, 3, 13, 5);
|
||||||
|
g.drawLine(13, 5, 11, 7);
|
||||||
|
g.drawLine(11, 5, 13, 7);
|
||||||
|
}
|
||||||
|
|
||||||
private static void paintSettings(Graphics2D g) {
|
private static void paintSettings(Graphics2D g) {
|
||||||
g.setColor(new Color(0x37474F));
|
g.setColor(new Color(0x37474F));
|
||||||
g.setStroke(new BasicStroke(2f));
|
g.setStroke(new BasicStroke(2f));
|
||||||
@@ -317,7 +450,7 @@ public final class Icons {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void paintApp(Graphics2D g) {
|
private static void paintApp(Graphics2D g) {
|
||||||
g.setColor(Theme.ACCENT);
|
g.setColor(Theme.accent());
|
||||||
g.fillRoundRect(1, 1, 14, 14, 4, 4);
|
g.fillRoundRect(1, 1, 14, 14, 4, 4);
|
||||||
g.setColor(Color.WHITE);
|
g.setColor(Color.WHITE);
|
||||||
g.setStroke(new BasicStroke(1.6f));
|
g.setStroke(new BasicStroke(1.6f));
|
||||||
|
|||||||
@@ -20,39 +20,100 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public final class InfoPanel extends JScrollPane {
|
public final class InfoPanel extends JScrollPane {
|
||||||
|
|
||||||
private final JEditorPane pane = new JEditorPane();
|
/** Moves the whole info panel's content out to a persistent chat tab. */
|
||||||
|
public interface DescriptionHandler {
|
||||||
|
/** Shows/updates {@code html} in the info chat tab, creating it if needed. */
|
||||||
|
void showInfoTab(String html, Runnable onClose);
|
||||||
|
|
||||||
|
/** Closes the info chat tab (the user chose to show details in the panel again). */
|
||||||
|
void closeInfoTab();
|
||||||
|
|
||||||
|
/** Hides (or restores) the panel itself, freeing its space, while its content lives in the chat tab. */
|
||||||
|
void setPanelHidden(boolean hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String TOGGLE_HREF = "app:toggle-info-tab";
|
||||||
|
|
||||||
|
private final JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
|
||||||
|
private DescriptionHandler descriptionHandler;
|
||||||
|
private ChannelNode shownChannel;
|
||||||
|
private ClientEntry shownClient;
|
||||||
|
private ServerModel shownModel;
|
||||||
|
private IconRepository shownIcons;
|
||||||
|
private boolean inChatTab;
|
||||||
|
|
||||||
public InfoPanel() {
|
public InfoPanel() {
|
||||||
javax.swing.text.html.HTMLEditorKit kit = new javax.swing.text.html.HTMLEditorKit();
|
|
||||||
javax.swing.text.html.StyleSheet css = new javax.swing.text.html.StyleSheet();
|
|
||||||
css.addStyleSheet(kit.getStyleSheet());
|
|
||||||
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
|
|
||||||
// Client references look exactly like a message author's name.
|
|
||||||
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
|
|
||||||
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
|
|
||||||
// External links are underlined so they can't be mistaken for an identity.
|
|
||||||
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
|
|
||||||
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
|
|
||||||
kit.setStyleSheet(css);
|
|
||||||
pane.setEditorKit(kit);
|
|
||||||
pane.setEditable(false);
|
|
||||||
pane.setBackground(Theme.CHAT_BG);
|
|
||||||
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||||
pane.addHyperlinkListener(e -> {
|
pane.addHyperlinkListener(e -> {
|
||||||
if (e.getEventType() == javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) {
|
if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return;
|
||||||
|
if (TOGGLE_HREF.equals(e.getDescription())) {
|
||||||
|
inChatTab = !inChatTab;
|
||||||
|
if (!inChatTab && descriptionHandler != null) descriptionHandler.closeInfoTab();
|
||||||
|
render();
|
||||||
|
} else {
|
||||||
Links.open(e.getDescription(), this);
|
Links.open(e.getDescription(), this);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setViewportView(pane);
|
setViewportView(pane);
|
||||||
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
|
HtmlStyles.fillViewport(pane);
|
||||||
clear();
|
clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateUI() {
|
||||||
|
super.updateUI();
|
||||||
|
setBorder(BorderFactory.createLineBorder(Theme.border()));
|
||||||
|
}
|
||||||
|
|
||||||
public void clear() {
|
public void clear() {
|
||||||
setHtml("<i style='color:#8a8a8a'>Select a channel or client to see details.</i>");
|
shownChannel = null;
|
||||||
|
shownClient = null;
|
||||||
|
inChatTab = false;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionHandler(DescriptionHandler handler) {
|
||||||
|
this.descriptionHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs when the user closes the info chat tab; shows details in the panel again. */
|
||||||
|
private void onInfoTabClosed() {
|
||||||
|
inChatTab = false;
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void showChannel(ChannelNode ch) {
|
public void showChannel(ChannelNode ch) {
|
||||||
|
shownChannel = ch;
|
||||||
|
shownClient = null;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
|
||||||
|
shownClient = cl;
|
||||||
|
shownChannel = null;
|
||||||
|
shownModel = model;
|
||||||
|
shownIcons = icons;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void render() {
|
||||||
|
boolean selected = shownChannel != null || shownClient != null;
|
||||||
|
boolean hide = inChatTab && selected;
|
||||||
|
if (descriptionHandler != null) descriptionHandler.setPanelHidden(hide);
|
||||||
|
if (hide) {
|
||||||
|
descriptionHandler.showInfoTab(body(), this::onInfoTabClosed);
|
||||||
|
} else {
|
||||||
|
setHtml(body() + (selected ? toggleLink(true) : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String body() {
|
||||||
|
if (shownChannel != null) return channelBody(shownChannel);
|
||||||
|
if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons);
|
||||||
|
return "<i class='muted'>Select a channel or client to see details.</i>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String channelBody(ChannelNode ch) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(heading(esc(ch.name)));
|
sb.append(heading(esc(ch.name)));
|
||||||
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
|
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
|
||||||
@@ -63,16 +124,16 @@ public final class InfoPanel extends JScrollPane {
|
|||||||
if (ch.description != null && !ch.description.isEmpty()) {
|
if (ch.description != null && !ch.description.isEmpty()) {
|
||||||
sb.append("<div>").append(multiline(ch.description)).append("</div>");
|
sb.append("<div>").append(multiline(ch.description)).append("</div>");
|
||||||
} else if (ch.descriptionLoaded) {
|
} else if (ch.descriptionLoaded) {
|
||||||
sb.append("<i style='color:#8a8a8a'>No description.</i>");
|
sb.append("<i class='muted'>No description.</i>");
|
||||||
} else {
|
} else {
|
||||||
sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
|
sb.append("<i class='muted'>Loading description…</i>");
|
||||||
}
|
}
|
||||||
setHtml(sb.toString());
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
|
private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
|
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span class='muted'>(you)</span>" : "")));
|
||||||
|
|
||||||
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
|
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
|
||||||
if (serverGroups.isEmpty()) {
|
if (serverGroups.isEmpty()) {
|
||||||
@@ -96,19 +157,26 @@ public final class InfoPanel extends JScrollPane {
|
|||||||
if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
|
if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
|
||||||
if (!cl.version.isEmpty()) row(sb, "Version", esc(cl.version));
|
if (!cl.version.isEmpty()) row(sb, "Version", esc(cl.version));
|
||||||
if (cl.away) row(sb, "Status", "Away");
|
if (cl.away) row(sb, "Status", "Away");
|
||||||
if (cl.inputMuted) row(sb, "Microphone", "muted");
|
if (!cl.inputHardware) row(sb, "Microphone", "disabled");
|
||||||
if (cl.outputMuted) row(sb, "Speakers", "muted");
|
else if (cl.inputMuted) row(sb, "Microphone", "muted");
|
||||||
|
if (!cl.outputHardware) row(sb, "Speakers", "disabled");
|
||||||
|
else if (cl.outputMuted) row(sb, "Speakers", "muted");
|
||||||
if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId));
|
if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId));
|
||||||
|
|
||||||
if (cl.description != null && !cl.description.isEmpty()) {
|
if (cl.description != null && !cl.description.isEmpty()) {
|
||||||
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
|
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
|
||||||
}
|
}
|
||||||
setHtml(sb.toString());
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The link that toggles between showing details here and in a chat tab. */
|
||||||
|
private static String toggleLink(boolean toChat) {
|
||||||
|
return "<div style='margin-top:8px'><a href='" + TOGGLE_HREF + "' style='font-size:10px'>"
|
||||||
|
+ (toChat ? "Show in chat tab" : "Show here") + "</a></div>";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setHtml(String body) {
|
private void setHtml(String body) {
|
||||||
pane.setText("<html><body style='font-family:sans-serif;font-size:11px;color:#202020'>"
|
pane.setText("<html><body>" + body + "</body></html>");
|
||||||
+ body + "</body></html>");
|
|
||||||
pane.setCaretPosition(0);
|
pane.setCaretPosition(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +197,7 @@ public final class InfoPanel extends JScrollPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void row(StringBuilder sb, String label, String value) {
|
private static void row(StringBuilder sb, String label, String value) {
|
||||||
sb.append("<div style='margin:1px 0'><span style='color:#5a6b7b'>")
|
sb.append("<div style='margin:1px 0'><span class='muted'>")
|
||||||
.append(label).append(":</span> ").append(value).append("</div>");
|
.append(label).append(":</span> ").append(value).append("</div>");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +210,4 @@ public final class InfoPanel extends JScrollPane {
|
|||||||
return BBCode.escape(s);
|
return BBCode.escape(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String hex(java.awt.Color c) {
|
|
||||||
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,28 @@ import java.awt.Graphics2D;
|
|||||||
import java.awt.RenderingHints;
|
import java.awt.RenderingHints;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal audio level meter (dBFS) with an optional VAD threshold marker.
|
* Horizontal audio level meter with an optional VAD threshold marker. The filled portion
|
||||||
* The filled portion turns green once the level crosses the threshold, giving
|
* turns green once the level crosses the threshold, giving immediate visual feedback while
|
||||||
* immediate visual feedback while tuning voice activation.
|
* tuning voice activation.
|
||||||
|
*
|
||||||
|
* <p>Scaled to {@link com.ts3client.audio.InputLevel}, matching the TS3 client's slider.
|
||||||
*/
|
*/
|
||||||
public final class LevelMeter extends JComponent {
|
public final class LevelMeter extends JComponent {
|
||||||
|
|
||||||
private static final double MIN_DB = -70.0;
|
private static final double MIN_DB = com.ts3client.audio.InputLevel.MIN_DB;
|
||||||
private static final double MAX_DB = 0.0;
|
private static final double MAX_DB = com.ts3client.audio.InputLevel.MAX_DB;
|
||||||
|
|
||||||
private volatile double levelDb = MIN_DB;
|
private volatile double levelDb = MIN_DB;
|
||||||
private volatile double thresholdDb = -45.0;
|
private volatile double thresholdDb = -40.0;
|
||||||
private volatile boolean showThreshold = true;
|
private volatile boolean showThreshold = true;
|
||||||
|
private volatile boolean transmitting;
|
||||||
|
|
||||||
public LevelMeter() {
|
public LevelMeter() {
|
||||||
setPreferredSize(new Dimension(240, 18));
|
setPreferredSize(new Dimension(240, 18));
|
||||||
|
// A bare JComponent reports no minimum of its own, so a layout tight on space
|
||||||
|
// collapses the bar to nothing. Keep the height and let only the width give.
|
||||||
|
setMinimumSize(new Dimension(60, 18));
|
||||||
|
setMaximumSize(new Dimension(Integer.MAX_VALUE, 18));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setLevel(double db) {
|
public void setLevel(double db) {
|
||||||
@@ -40,6 +47,16 @@ public final class LevelMeter extends JComponent {
|
|||||||
repaint();
|
repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the gate is currently open. This is the real transmit decision, which in the
|
||||||
|
* Automatic and Hybrid modes depends on the speech detector and the hangover as well as
|
||||||
|
* on the level, so the bar cannot infer it from the threshold alone.
|
||||||
|
*/
|
||||||
|
public void setTransmitting(boolean transmitting) {
|
||||||
|
this.transmitting = transmitting;
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
|
|
||||||
private int dbToX(double db, int w) {
|
private int dbToX(double db, int w) {
|
||||||
double clamped = Math.max(MIN_DB, Math.min(MAX_DB, db));
|
double clamped = Math.max(MIN_DB, Math.min(MAX_DB, db));
|
||||||
return (int) ((clamped - MIN_DB) / (MAX_DB - MIN_DB) * w);
|
return (int) ((clamped - MIN_DB) / (MAX_DB - MIN_DB) * w);
|
||||||
@@ -56,8 +73,7 @@ public final class LevelMeter extends JComponent {
|
|||||||
g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
|
g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
|
||||||
|
|
||||||
int level = dbToX(levelDb, w - 2);
|
int level = dbToX(levelDb, w - 2);
|
||||||
boolean over = levelDb >= thresholdDb;
|
g.setColor(transmitting ? Theme.talking() : new Color(0x5A9BD4));
|
||||||
g.setColor(over ? Theme.TALKING : new Color(0x5A9BD4));
|
|
||||||
g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
|
g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
|
||||||
|
|
||||||
if (showThreshold) {
|
if (showThreshold) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.formdev.flatlaf.FlatDarkLaf;
|
||||||
|
import com.formdev.flatlaf.FlatLaf;
|
||||||
|
import com.formdev.flatlaf.FlatLightLaf;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
|
import java.awt.Window;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs FlatLaf in the requested variant and keeps {@link Theme} in step with it.
|
||||||
|
*
|
||||||
|
* <p>The client's own look-and-feel defaults — rounded controls, and the darker palette
|
||||||
|
* the dark theme is built on — live beside this class as FlatLaf properties files, which
|
||||||
|
* FlatLaf loads on top of the theme's own.
|
||||||
|
*/
|
||||||
|
public final class LookAndFeelManager {
|
||||||
|
|
||||||
|
/** Where our own FlatLaf properties files sit. */
|
||||||
|
private static final String DEFAULTS_PACKAGE = "com.ts3client.ui.laf";
|
||||||
|
|
||||||
|
static {
|
||||||
|
// Registered once: FlatLaf keeps every registration, and install() runs again on
|
||||||
|
// each theme switch.
|
||||||
|
FlatLaf.registerCustomDefaultsSource(DEFAULTS_PACKAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LookAndFeelManager() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installs the look-and-feel for {@code appearance}. Call before building any window. */
|
||||||
|
public static void install(Settings.Appearance appearance) {
|
||||||
|
FlatLaf laf = appearance == Settings.Appearance.DARK ? new FlatDarkLaf() : new FlatLightLaf();
|
||||||
|
// Whatever look-and-feel stays installed on failure, Theme falls back to its own palette.
|
||||||
|
FlatLaf.setup(laf);
|
||||||
|
Theme.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the look-and-feel of the running client. Components that read {@link Theme}
|
||||||
|
* while painting follow immediately; the ones that copied a colour when they were built
|
||||||
|
* are refreshed by the look-and-feel update.
|
||||||
|
*/
|
||||||
|
public static void switchTo(Settings.Appearance appearance) {
|
||||||
|
install(appearance);
|
||||||
|
FlatLaf.updateUI();
|
||||||
|
for (Window w : Window.getWindows()) {
|
||||||
|
w.repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,33 +7,24 @@ import com.ts3client.config.Bookmark;
|
|||||||
import com.ts3client.config.Bookmarks;
|
import com.ts3client.config.Bookmarks;
|
||||||
import com.ts3client.config.IdentityStore;
|
import com.ts3client.config.IdentityStore;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.net.ChannelNode;
|
||||||
import com.ts3client.sound.SoundNotifier;
|
import com.ts3client.sound.SoundNotifier;
|
||||||
import com.ts3client.sound.SoundPlayer;
|
import com.ts3client.sound.SoundPlayer;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
import javax.swing.Box;
|
|
||||||
import javax.swing.JButton;
|
|
||||||
import javax.swing.JCheckBox;
|
import javax.swing.JCheckBox;
|
||||||
import javax.swing.JCheckBoxMenuItem;
|
import javax.swing.JCheckBoxMenuItem;
|
||||||
import javax.swing.JFrame;
|
import javax.swing.JFrame;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JMenu;
|
|
||||||
import javax.swing.JMenuBar;
|
|
||||||
import javax.swing.JMenuItem;
|
import javax.swing.JMenuItem;
|
||||||
import javax.swing.JOptionPane;
|
import javax.swing.JOptionPane;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JPopupMenu;
|
import javax.swing.JPopupMenu;
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
import javax.swing.JToggleButton;
|
|
||||||
import javax.swing.JToolBar;
|
|
||||||
import javax.swing.KeyStroke;
|
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.GridLayout;
|
import java.awt.GridLayout;
|
||||||
import java.awt.KeyEventDispatcher;
|
|
||||||
import java.awt.KeyboardFocusManager;
|
|
||||||
import java.awt.event.KeyEvent;
|
|
||||||
import java.awt.event.WindowAdapter;
|
import java.awt.event.WindowAdapter;
|
||||||
import java.awt.event.WindowEvent;
|
import java.awt.event.WindowEvent;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -67,25 +58,20 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
/** The tab that owns the capture device; null when nobody is capturing. */
|
/** The tab that owns the capture device; null when nobody is capturing. */
|
||||||
private ServerTab micTab;
|
private ServerTab micTab;
|
||||||
|
|
||||||
private JMenu bookmarksMenu;
|
private MainMenuBar menuBar;
|
||||||
private JCheckBoxMenuItem awayItem;
|
|
||||||
private JMenuItem awayStatusItem;
|
|
||||||
private JCheckBoxMenuItem commanderItem;
|
|
||||||
private javax.swing.Timer statusTimer;
|
private javax.swing.Timer statusTimer;
|
||||||
|
|
||||||
private final JLabel statusLabel = new JLabel("Not connected");
|
/** Mirrors the active server's own client state next to the clock. */
|
||||||
private final JLabel codecLabel = new JLabel();
|
private TrayController tray;
|
||||||
|
|
||||||
private JToolBar toolbar;
|
private MainToolbar toolbar;
|
||||||
private JButton connectButton;
|
private StatusBar statusBar;
|
||||||
private JButton disconnectButton;
|
|
||||||
private JToggleButton activeButton;
|
|
||||||
private JToggleButton micButton;
|
|
||||||
private JToggleButton speakerButton;
|
|
||||||
private DropDownToggleButton awayButton;
|
|
||||||
|
|
||||||
private boolean pttPressed;
|
private boolean pttPressed;
|
||||||
|
|
||||||
|
/** Global hotkeys: the bindings, the matching engine and the platform input hook. */
|
||||||
|
private final HotkeyService hotkeys;
|
||||||
|
|
||||||
/** Guards {@link #shutdown()} so the window listener and JVM hook don't both run it. */
|
/** Guards {@link #shutdown()} so the window listener and JVM hook don't both run it. */
|
||||||
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
|
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
|
||||||
private final Thread shutdownHook = new Thread(this::shutdown, "ts3j-shutdown");
|
private final Thread shutdownHook = new Thread(this::shutdown, "ts3j-shutdown");
|
||||||
@@ -97,6 +83,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
this.sounds = new SoundNotifier(settings);
|
this.sounds = new SoundNotifier(settings);
|
||||||
this.soundPlayer = audio.createSoundPlayer(settings);
|
this.soundPlayer = audio.createSoundPlayer(settings);
|
||||||
this.sounds.setPlayer(soundPlayer);
|
this.sounds.setPlayer(soundPlayer);
|
||||||
|
this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices);
|
||||||
|
|
||||||
setIconImage(Icons.app().getImage());
|
setIconImage(Icons.app().getImage());
|
||||||
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
|
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
|
||||||
@@ -104,31 +91,33 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
addWindowListener(new WindowAdapter() {
|
addWindowListener(new WindowAdapter() {
|
||||||
@Override
|
@Override
|
||||||
public void windowClosing(WindowEvent e) {
|
public void windowClosing(WindowEvent e) {
|
||||||
shutdown();
|
quit();
|
||||||
System.exit(0);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Catches Ctrl+C / SIGTERM so we still leave the servers cleanly.
|
// Catches Ctrl+C / SIGTERM so we still leave the servers cleanly.
|
||||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||||
setMinimumSize(new Dimension(720, 480));
|
setMinimumSize(new Dimension(720, 480));
|
||||||
|
|
||||||
setJMenuBar(buildMenuBar());
|
tray = new TrayController(this, this::quit);
|
||||||
|
menuBar = buildMenuBar();
|
||||||
|
setJMenuBar(menuBar);
|
||||||
|
|
||||||
toolbar = buildToolbar();
|
toolbar = buildToolbar();
|
||||||
add(toolbar, BorderLayout.NORTH);
|
add(toolbar, BorderLayout.NORTH);
|
||||||
// Switching the icon pack in the options dialog re-decorates the whole window.
|
// Switching the icon pack in the options dialog re-decorates the whole window.
|
||||||
IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons));
|
IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons));
|
||||||
add(tabPane, BorderLayout.CENTER);
|
add(tabPane, BorderLayout.CENTER);
|
||||||
add(buildStatusBar(), BorderLayout.SOUTH);
|
statusBar = new StatusBar();
|
||||||
|
statusBar.setVisible(settings.showStatusBar);
|
||||||
|
add(statusBar, BorderLayout.SOUTH);
|
||||||
|
|
||||||
codecLabel.setText(audio.description());
|
statusBar.setCodec(audio.description());
|
||||||
|
|
||||||
ServerTab first = newTab();
|
ServerTab first = newTab();
|
||||||
selectTab(first);
|
selectTab(first);
|
||||||
first.chat().appendSystem("Welcome to the TS3J Swing client.");
|
first.chat().appendSystem("Welcome to the TS3J Swing client.");
|
||||||
first.chat().appendSystem("Use Connections → Connect to join a server.");
|
first.chat().appendSystem("Use Connections → Connect to join a server.");
|
||||||
|
|
||||||
installPushToTalk();
|
|
||||||
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
||||||
statusTimer.start();
|
statusTimer.start();
|
||||||
setSize(880, 560);
|
setSize(880, 560);
|
||||||
@@ -145,184 +134,166 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
|
|
||||||
// ---- UI construction ----
|
// ---- UI construction ----
|
||||||
|
|
||||||
private JMenuBar buildMenuBar() {
|
private MainMenuBar buildMenuBar() {
|
||||||
JMenuBar bar = new JMenuBar();
|
return new MainMenuBar(bookmarks, new MainMenuBar.Listener() {
|
||||||
|
@Override
|
||||||
JMenu connections = new JMenu("Connections");
|
public void onConnect() {
|
||||||
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT"));
|
showConnectDialog();
|
||||||
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
|
|
||||||
connect.addActionListener(e -> showConnectDialog());
|
|
||||||
JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
|
|
||||||
disconnect.addActionListener(e -> doDisconnect());
|
|
||||||
JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON"));
|
|
||||||
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
|
|
||||||
closeTab.addActionListener(e -> closeTab(selected));
|
|
||||||
JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
|
|
||||||
quit.addActionListener(e -> {
|
|
||||||
shutdown();
|
|
||||||
System.exit(0);
|
|
||||||
});
|
|
||||||
connections.add(connect);
|
|
||||||
connections.add(disconnect);
|
|
||||||
connections.add(closeTab);
|
|
||||||
connections.addSeparator();
|
|
||||||
connections.add(quit);
|
|
||||||
|
|
||||||
bookmarksMenu = new JMenu("Bookmarks");
|
|
||||||
rebuildBookmarksMenu();
|
|
||||||
|
|
||||||
JMenu self = new JMenu("Self");
|
|
||||||
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
|
|
||||||
mute.addActionListener(e -> micButton.doClick());
|
|
||||||
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK"));
|
|
||||||
deaf.addActionListener(e -> speakerButton.doClick());
|
|
||||||
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
|
|
||||||
awayItem.addActionListener(e -> toggleAway());
|
|
||||||
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
|
|
||||||
awayStatusItem.addActionListener(e -> setAwayStatus());
|
|
||||||
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
|
|
||||||
commanderItem.addActionListener(e -> {
|
|
||||||
if (selected != null) selected.setCommander(commanderItem.isSelected());
|
|
||||||
});
|
|
||||||
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
|
|
||||||
nick.addActionListener(e -> changeNickname());
|
|
||||||
self.add(mute);
|
|
||||||
self.add(deaf);
|
|
||||||
self.addSeparator();
|
|
||||||
self.add(awayItem);
|
|
||||||
self.add(awayStatusItem);
|
|
||||||
self.add(commanderItem);
|
|
||||||
self.addSeparator();
|
|
||||||
self.add(nick);
|
|
||||||
|
|
||||||
JMenu tools = new JMenu("Tools");
|
|
||||||
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
|
|
||||||
identitiesItem.addActionListener(e -> showIdentities());
|
|
||||||
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
|
|
||||||
options.addActionListener(e -> showSettings());
|
|
||||||
tools.add(identitiesItem);
|
|
||||||
tools.addSeparator();
|
|
||||||
tools.add(options);
|
|
||||||
|
|
||||||
JMenu help = new JMenu("Help");
|
|
||||||
JMenuItem about = new JMenuItem("About", Icons.of("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() {
|
@Override
|
||||||
bookmarksMenu.removeAll();
|
public void onDisconnect() {
|
||||||
for (Bookmark b : bookmarks.all()) {
|
doDisconnect();
|
||||||
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
|
|
||||||
item.addActionListener(e -> connectToBookmark(b));
|
|
||||||
bookmarksMenu.add(item);
|
|
||||||
}
|
|
||||||
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
|
|
||||||
JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD"));
|
|
||||||
addCurrent.addActionListener(e -> addCurrentServerBookmark());
|
|
||||||
JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER"));
|
|
||||||
manage.addActionListener(e -> new BookmarksDialog(this, bookmarks, identities,
|
|
||||||
this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true));
|
|
||||||
bookmarksMenu.add(addCurrent);
|
|
||||||
bookmarksMenu.add(manage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private JToolBar buildToolbar() {
|
@Override
|
||||||
JToolBar tb = new JToolBar();
|
public void onCloseTab() {
|
||||||
tb.setFloatable(false);
|
closeTab(selected);
|
||||||
tb.setBackground(Theme.TOOLBAR_BG);
|
}
|
||||||
tb.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
|
|
||||||
|
|
||||||
connectButton = new JButton(Icons.connect());
|
@Override
|
||||||
connectButton.setToolTipText("Connect to a server");
|
public void onQuit() {
|
||||||
connectButton.addActionListener(e -> showConnectDialog());
|
quit();
|
||||||
|
}
|
||||||
|
|
||||||
disconnectButton = new JButton(Icons.disconnect());
|
@Override
|
||||||
disconnectButton.setToolTipText("Disconnect");
|
public void onToggleMic() {
|
||||||
disconnectButton.addActionListener(e -> doDisconnect());
|
toolbar.clickMic();
|
||||||
|
}
|
||||||
|
|
||||||
activeButton = new JToggleButton(Icons.micActive());
|
@Override
|
||||||
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)");
|
public void onToggleSpeaker() {
|
||||||
activeButton.addActionListener(e -> {
|
toolbar.clickSpeaker();
|
||||||
if (selected != null && selected.isConnected()) setMicTab(selected);
|
}
|
||||||
updateToolbar();
|
|
||||||
|
@Override
|
||||||
|
public void onAwayToggle(boolean away) {
|
||||||
|
toggleAway(away);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAwayStatus() {
|
||||||
|
setAwayStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCommanderToggle(boolean commander) {
|
||||||
|
if (selected != null) selected.setCommander(commander);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChangeNickname() {
|
||||||
|
changeNickname();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onShowIdentities() {
|
||||||
|
showIdentities();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onShowSettings() {
|
||||||
|
showSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onShowAbout() {
|
||||||
|
showAbout();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConnectBookmark(Bookmark bookmark) {
|
||||||
|
connectToBookmark(bookmark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAddCurrentServerBookmark() {
|
||||||
|
addCurrentServerBookmark();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onManageBookmarks() {
|
||||||
|
new BookmarksDialog(MainFrame.this, bookmarks, identities,
|
||||||
|
MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
micButton = new JToggleButton(Icons.mic());
|
private MainToolbar buildToolbar() {
|
||||||
micButton.setToolTipText("Mute / unmute microphone on this server");
|
return new MainToolbar(settings, new MainToolbar.Listener() {
|
||||||
micButton.addActionListener(e -> {
|
@Override
|
||||||
|
public void onConnect() {
|
||||||
|
showConnectDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisconnect() {
|
||||||
|
doDisconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onActivate() {
|
||||||
|
moveMicrophoneToSelectedTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMicMuteToggle(boolean muted) {
|
||||||
if (selected == null) return;
|
if (selected == null) return;
|
||||||
selected.setMicMuted(micButton.isSelected());
|
selected.setMicMuted(muted);
|
||||||
updateToolbar();
|
updateToolbar();
|
||||||
});
|
}
|
||||||
|
|
||||||
speakerButton = new JToggleButton(Icons.speaker());
|
@Override
|
||||||
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
|
public void onDeafenToggle(boolean deafened) {
|
||||||
speakerButton.addActionListener(e -> {
|
|
||||||
if (selected == null) return;
|
if (selected == null) return;
|
||||||
selected.setDeafened(speakerButton.isSelected());
|
selected.setDeafened(deafened);
|
||||||
updateToolbar();
|
updateToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAwayToggle(boolean away) {
|
||||||
|
toggleAway(away);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public JPopupMenu buildAwayMenu() {
|
||||||
|
return MainFrame.this.buildAwayMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSettings() {
|
||||||
|
showSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMasterVolumeChanged() {
|
||||||
|
applyOutputSettingsToAllTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStatusBarVisibilityChanged(boolean visible) {
|
||||||
|
statusBar.setVisible(visible);
|
||||||
|
revalidate();
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
awayButton = new DropDownToggleButton(Icons.away(),
|
|
||||||
"Away on this server (the arrow offers the global actions and presets)",
|
|
||||||
this::buildAwayMenu);
|
|
||||||
awayButton.addActionListener(e -> {
|
|
||||||
if (selected == null) return;
|
|
||||||
// The plain toggle carries no message; the menu is where messages are chosen.
|
|
||||||
selected.setAway(awayButton.isSelected(), "");
|
|
||||||
updateToolbar();
|
|
||||||
});
|
|
||||||
|
|
||||||
JButton settingsButton = new JButton(Icons.settings());
|
|
||||||
settingsButton.setToolTipText("Options");
|
|
||||||
settingsButton.addActionListener(e -> showSettings());
|
|
||||||
|
|
||||||
tb.add(connectButton);
|
|
||||||
tb.add(disconnectButton);
|
|
||||||
tb.addSeparator();
|
|
||||||
tb.add(activeButton);
|
|
||||||
tb.add(micButton);
|
|
||||||
tb.add(speakerButton);
|
|
||||||
tb.add(awayButton);
|
|
||||||
tb.addSeparator();
|
|
||||||
tb.add(settingsButton);
|
|
||||||
tb.add(Box.createHorizontalGlue());
|
|
||||||
return tb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
|
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
|
||||||
private void rebuildIcons() {
|
private void rebuildIcons() {
|
||||||
setIconImage(Icons.app().getImage());
|
setIconImage(Icons.app().getImage());
|
||||||
setJMenuBar(buildMenuBar());
|
menuBar = buildMenuBar();
|
||||||
|
setJMenuBar(menuBar);
|
||||||
remove(toolbar);
|
remove(toolbar);
|
||||||
toolbar = buildToolbar();
|
toolbar = buildToolbar();
|
||||||
add(toolbar, BorderLayout.NORTH);
|
add(toolbar, BorderLayout.NORTH);
|
||||||
updateToolbar();
|
updateToolbar();
|
||||||
refreshTabs();
|
refreshTabs();
|
||||||
|
if (tray != null) tray.refreshIcon();
|
||||||
revalidate();
|
revalidate();
|
||||||
repaint();
|
repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- tab management ----
|
// ---- tab management ----
|
||||||
|
|
||||||
private ServerTab newTab() {
|
private ServerTab newTab() {
|
||||||
@@ -376,6 +347,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
ServerTab previous = micTab;
|
ServerTab previous = micTab;
|
||||||
micTab = tab;
|
micTab = tab;
|
||||||
refreshTabs();
|
refreshTabs();
|
||||||
|
updateTray();
|
||||||
// Closing and reopening the capture line can block briefly; keep it off the EDT.
|
// Closing and reopening the capture line can block briefly; keep it off the EDT.
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
if (previous != null) previous.setMicrophoneActive(false);
|
if (previous != null) previous.setMicrophoneActive(false);
|
||||||
@@ -411,6 +383,12 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
updateToolbar();
|
updateToolbar();
|
||||||
updateStatusLabel();
|
updateStatusLabel();
|
||||||
}
|
}
|
||||||
|
updateTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The local client on {@code tab} started or stopped talking, muted itself, … */
|
||||||
|
void selfStateChanged(ServerTab tab) {
|
||||||
|
if (tabs.contains(tab)) updateTray();
|
||||||
}
|
}
|
||||||
|
|
||||||
void tabConnected(ServerTab tab) {
|
void tabConnected(ServerTab tab) {
|
||||||
@@ -427,26 +405,151 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
tabUpdated(tab);
|
tabUpdated(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- push to talk ----
|
// ---- hotkeys ----
|
||||||
|
|
||||||
private void installPushToTalk() {
|
/**
|
||||||
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(new KeyEventDispatcher() {
|
* Which connections a fired hotkey reaches: with "on active server" ticked only the
|
||||||
@Override
|
* selected tab, otherwise every connected one.
|
||||||
public boolean dispatchKeyEvent(KeyEvent e) {
|
*/
|
||||||
if (settings.inputMode != Settings.InputMode.PUSH_TO_TALK) return false;
|
List<ServerTab> hotkeyTargets(boolean activeServerOnly) {
|
||||||
|
List<ServerTab> out = new ArrayList<>();
|
||||||
|
if (activeServerOnly) {
|
||||||
|
if (selected != null && selected.isConnected()) out.add(selected);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (ServerTab tab : tabs) {
|
||||||
|
if (tab.isConnected()) out.add(tab);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerTab selectedTab() {
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ServerTab> allTabs() {
|
||||||
|
return new ArrayList<>(tabs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the microphone while a push-to-talk hotkey is held. */
|
||||||
|
void setPushToTalk(boolean talking) {
|
||||||
|
pttPressed = talking;
|
||||||
ServerTab tab = micTab;
|
ServerTab tab = micTab;
|
||||||
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return false;
|
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return;
|
||||||
if (e.getKeyCode() != settings.pushToTalkKey) return false;
|
tab.connection().getMicrophone().setPushToTalk(talking);
|
||||||
if (e.getID() == KeyEvent.KEY_PRESSED && !pttPressed) {
|
|
||||||
pttPressed = true;
|
|
||||||
tab.connection().getMicrophone().setPushToTalk(true);
|
|
||||||
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
|
|
||||||
pttPressed = false;
|
|
||||||
tab.connection().getMicrophone().setPushToTalk(false);
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
void moveMicrophoneToSelectedTab() {
|
||||||
|
if (selected != null && selected.isConnected()) setMicTab(selected);
|
||||||
|
updateToolbar();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
/** Connects to the bookmark with this label; no label means the first one. */
|
||||||
|
void connectBookmark(String label, boolean newTab) {
|
||||||
|
Bookmark match = null;
|
||||||
|
for (Bookmark b : bookmarks.all()) {
|
||||||
|
if (label == null || label.isBlank() || label.equalsIgnoreCase(b.label)) {
|
||||||
|
match = b;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match == null) return;
|
||||||
|
if (newTab) selectTab(newTab());
|
||||||
|
connectToBookmark(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selects the tab with this 1-based number, as the "Select Server Tab" action names it. */
|
||||||
|
void selectTabNumber(int number) {
|
||||||
|
if (number >= 1 && number <= tabs.size()) selectTab(tabs.get(number - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void stepTab(int delta) {
|
||||||
|
if (tabs.isEmpty()) return;
|
||||||
|
int index = Math.max(0, tabs.indexOf(selected));
|
||||||
|
selectTab(tabs.get(Math.floorMod(index + delta, tabs.size())));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSoundsMuted(boolean muted) {
|
||||||
|
sounds.setMuted(muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean areSoundsMuted() {
|
||||||
|
return sounds.isMuted();
|
||||||
|
}
|
||||||
|
|
||||||
|
void adjustMasterVolume(double delta) {
|
||||||
|
settings.outputVolume = Math.max(0, Math.min(2.0, settings.outputVolume + delta));
|
||||||
|
settings.save();
|
||||||
|
applyOutputSettingsToAllTabs();
|
||||||
|
updateStatusLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a new nickname, or asks for one when the hotkey carries none. */
|
||||||
|
void changeNickname(String nickname) {
|
||||||
|
if (nickname == null || nickname.isBlank()) {
|
||||||
|
changeNickname();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.nickname = nickname.trim();
|
||||||
|
settings.save();
|
||||||
|
for (ServerTab tab : tabs) tab.setNickname(settings.nickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
void browseCurrentChannel() {
|
||||||
|
if (selected == null || !selected.isConnected()) return;
|
||||||
|
ChannelNode channel = selected.currentChannel();
|
||||||
|
if (channel != null) selected.browseFiles(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reloadSkin() {
|
||||||
|
IconTheme.get().reload(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bringToFront() {
|
||||||
|
setVisible(true);
|
||||||
|
setExtendedState(getExtendedState() & ~JFrame.ICONIFIED);
|
||||||
|
toFront();
|
||||||
|
requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendToBack() {
|
||||||
|
setExtendedState(getExtendedState() | JFrame.ICONIFIED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The values a parameterised hotkey action can take, for the tree in the hotkey
|
||||||
|
* dialog: the saved bookmarks, the installed sound packs and the channels of the
|
||||||
|
* server on screen.
|
||||||
|
*/
|
||||||
|
private List<String> hotkeyArgumentChoices(com.ts3client.hotkey.HotkeyAction action) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
switch (action.argument()) {
|
||||||
|
case BOOKMARK -> {
|
||||||
|
for (Bookmark b : bookmarks.all()) {
|
||||||
|
if (b.label != null && !b.label.isBlank()) out.add(b.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case PROFILE -> {
|
||||||
|
if (action == com.ts3client.hotkey.HotkeyAction.SOUNDPACK_ACTIVATE) {
|
||||||
|
for (com.ts3client.sound.SoundPack pack : sounds.availablePacks()) out.add(pack.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case CHANNEL -> {
|
||||||
|
if (selected != null && selected.isConnected()) {
|
||||||
|
out.addAll(selected.channelPaths());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repaints the chrome after a hotkey changed the local client's state. */
|
||||||
|
void refreshAfterHotkey() {
|
||||||
|
updateToolbar();
|
||||||
|
updateTray();
|
||||||
|
refreshTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- actions ----
|
// ---- actions ----
|
||||||
@@ -511,7 +614,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
if (joinChannel.isSelected()) bookmark.channel = channelPath;
|
if (joinChannel.isSelected()) bookmark.channel = channelPath;
|
||||||
bookmarks.add(bookmark);
|
bookmarks.add(bookmark);
|
||||||
bookmarks.save();
|
bookmarks.save();
|
||||||
rebuildBookmarksMenu();
|
menuBar.rebuildBookmarks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The away button's drop-down: the global actions, the presets and their editor. */
|
/** The away button's drop-down: the global actions, the presets and their editor. */
|
||||||
@@ -548,9 +651,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void toggleAway() {
|
private void toggleAway(boolean away) {
|
||||||
if (selected == null) return;
|
if (selected == null) return;
|
||||||
selected.setAway(awayItem.isSelected(), "");
|
selected.setAway(away, "");
|
||||||
updateToolbar();
|
updateToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +721,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
tab.shutdown();
|
tab.shutdown();
|
||||||
}
|
}
|
||||||
soundPlayer.shutdown();
|
soundPlayer.shutdown();
|
||||||
|
hotkeys.dispose();
|
||||||
|
if (tray != null) tray.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaves every server and ends the process. */
|
||||||
|
private void quit() {
|
||||||
|
shutdown();
|
||||||
|
System.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showIdentities() {
|
private void showIdentities() {
|
||||||
@@ -630,7 +741,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
SettingsDialog dlg = new SettingsDialog(this, settings,
|
SettingsDialog dlg = new SettingsDialog(this, settings,
|
||||||
micTab == null ? null : micTab.connection().getMicrophone(),
|
micTab == null ? null : micTab.connection().getMicrophone(),
|
||||||
selected == null ? null : selected.connection().getPlayback(),
|
selected == null ? null : selected.connection().getPlayback(),
|
||||||
sounds,
|
sounds, hotkeys,
|
||||||
this::applyOutputSettingsToAllTabs);
|
this::applyOutputSettingsToAllTabs);
|
||||||
dlg.setVisible(true);
|
dlg.setVisible(true);
|
||||||
}
|
}
|
||||||
@@ -640,7 +751,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
soundPlayer.setOutputDevice(settings.outputDevice);
|
soundPlayer.setOutputDevice(settings.outputDevice);
|
||||||
for (ServerTab tab : tabs) {
|
for (ServerTab tab : tabs) {
|
||||||
if (tab.connection().getPlayback() == null) continue;
|
if (tab.connection().getPlayback() == null) continue;
|
||||||
tab.connection().getPlayback().setMasterVolume(settings.outputVolume);
|
tab.connection().getPlayback().setMasterVolume(settings.effectiveOutputVolume());
|
||||||
tab.connection().getPlayback().setOutputDevice(settings.outputDevice);
|
tab.connection().getPlayback().setOutputDevice(settings.outputDevice);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,7 +771,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
"An open-source TeamSpeak 3 desktop client built on the ts3j\n" +
|
"An open-source TeamSpeak 3 desktop client built on the ts3j\n" +
|
||||||
"reverse-engineered protocol library, with native Opus voice,\n" +
|
"reverse-engineered protocol library, with native Opus voice,\n" +
|
||||||
"voice-activation detection and push-to-talk.\n\n" +
|
"voice-activation detection and push-to-talk.\n\n" +
|
||||||
codecLabel.getText(),
|
statusBar.codecText(),
|
||||||
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
|
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,32 +779,39 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
|
|
||||||
private void updateToolbar() {
|
private void updateToolbar() {
|
||||||
boolean connected = selected != null && selected.isConnected();
|
boolean connected = selected != null && selected.isConnected();
|
||||||
disconnectButton.setEnabled(connected);
|
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
|
||||||
micButton.setEnabled(connected);
|
|
||||||
speakerButton.setEnabled(connected);
|
|
||||||
activeButton.setEnabled(connected);
|
|
||||||
awayItem.setEnabled(connected);
|
|
||||||
awayStatusItem.setEnabled(connected);
|
|
||||||
// The menu's actions are global, so the arrow stays live while any server is up.
|
|
||||||
awayButton.setEnabled(tabs.stream().anyMatch(ServerTab::isConnected));
|
|
||||||
awayButton.setToggleEnabled(connected);
|
|
||||||
commanderItem.setEnabled(connected);
|
|
||||||
|
|
||||||
boolean micMuted = connected && selected.isMicMuted();
|
boolean micMuted = connected && selected.isMicMuted();
|
||||||
boolean deaf = connected && selected.isDeafened();
|
boolean deaf = connected && selected.isDeafened();
|
||||||
micButton.setSelected(micMuted);
|
boolean active = selected != null && selected == micTab;
|
||||||
micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
|
|
||||||
speakerButton.setSelected(deaf);
|
|
||||||
speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker());
|
|
||||||
activeButton.setSelected(selected != null && selected == micTab);
|
|
||||||
boolean away = connected && selected.isAway();
|
boolean away = connected && selected.isAway();
|
||||||
awayItem.setSelected(away);
|
boolean commander = connected && selected.isCommander();
|
||||||
awayButton.setSelected(away);
|
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
|
||||||
commanderItem.setSelected(connected && selected.isCommander());
|
menuBar.refresh(connected, away, commander);
|
||||||
|
updateTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server the tray icon speaks for: the one holding the microphone, or the
|
||||||
|
* visible one when nobody is capturing.
|
||||||
|
*/
|
||||||
|
private ServerTab trayTab() {
|
||||||
|
if (micTab != null && micTab.isConnected()) return micTab;
|
||||||
|
return selected != null && selected.isConnected() ? selected : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateTray() {
|
||||||
|
if (tray == null) return;
|
||||||
|
ServerTab tab = trayTab();
|
||||||
|
if (tab == null) {
|
||||||
|
tray.update(SelfState.DISCONNECTED, null);
|
||||||
|
} else {
|
||||||
|
tray.update(tab.selfState(), tab.title());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateStatusLabel() {
|
private void updateStatusLabel() {
|
||||||
statusLabel.setText(selected == null ? "Not connected" : selected.status());
|
statusBar.setStatus(selected == null ? "Not connected" : selected.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateConnectionStatus() {
|
private void updateConnectionStatus() {
|
||||||
@@ -706,6 +824,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
double ping = tab.connection().getPingMillis();
|
double ping = tab.connection().getPingMillis();
|
||||||
if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms");
|
if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms");
|
||||||
if (tab != micTab) s.append(" | microphone on another tab");
|
if (tab != micTab) s.append(" | microphone on another tab");
|
||||||
statusLabel.setText(s.toString());
|
statusBar.setStatus(s.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java
Normal file
155
ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.config.Bookmark;
|
||||||
|
import com.ts3client.config.Bookmarks;
|
||||||
|
|
||||||
|
import javax.swing.JCheckBoxMenuItem;
|
||||||
|
import javax.swing.JMenu;
|
||||||
|
import javax.swing.JMenuBar;
|
||||||
|
import javax.swing.JMenuItem;
|
||||||
|
import javax.swing.KeyStroke;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main window's menu bar: Connections, Bookmarks, Self, Tools and Help.
|
||||||
|
* Talks to {@link MainFrame} only through {@link Listener}; the away/commander
|
||||||
|
* item states are pushed in by {@link #refresh}, mirroring how {@link MainToolbar}
|
||||||
|
* is kept in sync.
|
||||||
|
*/
|
||||||
|
final class MainMenuBar extends JMenuBar {
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
void onConnect();
|
||||||
|
|
||||||
|
void onDisconnect();
|
||||||
|
|
||||||
|
void onCloseTab();
|
||||||
|
|
||||||
|
void onQuit();
|
||||||
|
|
||||||
|
void onToggleMic();
|
||||||
|
|
||||||
|
void onToggleSpeaker();
|
||||||
|
|
||||||
|
/** The plain toggle carries no message; "Set away status…" is where messages are chosen. */
|
||||||
|
void onAwayToggle(boolean away);
|
||||||
|
|
||||||
|
void onAwayStatus();
|
||||||
|
|
||||||
|
void onCommanderToggle(boolean commander);
|
||||||
|
|
||||||
|
void onChangeNickname();
|
||||||
|
|
||||||
|
void onShowIdentities();
|
||||||
|
|
||||||
|
void onShowSettings();
|
||||||
|
|
||||||
|
void onShowAbout();
|
||||||
|
|
||||||
|
void onConnectBookmark(Bookmark bookmark);
|
||||||
|
|
||||||
|
void onAddCurrentServerBookmark();
|
||||||
|
|
||||||
|
void onManageBookmarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Bookmarks bookmarks;
|
||||||
|
private final Listener listener;
|
||||||
|
|
||||||
|
private JMenu bookmarksMenu;
|
||||||
|
private JCheckBoxMenuItem awayItem;
|
||||||
|
private JMenuItem awayStatusItem;
|
||||||
|
private JCheckBoxMenuItem commanderItem;
|
||||||
|
|
||||||
|
MainMenuBar(Bookmarks bookmarks, Listener listener) {
|
||||||
|
this.bookmarks = bookmarks;
|
||||||
|
this.listener = listener;
|
||||||
|
|
||||||
|
JMenu connections = new JMenu("Connections");
|
||||||
|
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT"));
|
||||||
|
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
|
||||||
|
connect.addActionListener(e -> listener.onConnect());
|
||||||
|
JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
|
||||||
|
disconnect.addActionListener(e -> listener.onDisconnect());
|
||||||
|
JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON"));
|
||||||
|
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
|
||||||
|
closeTab.addActionListener(e -> listener.onCloseTab());
|
||||||
|
JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
|
||||||
|
quit.addActionListener(e -> listener.onQuit());
|
||||||
|
connections.add(connect);
|
||||||
|
connections.add(disconnect);
|
||||||
|
connections.add(closeTab);
|
||||||
|
connections.addSeparator();
|
||||||
|
connections.add(quit);
|
||||||
|
|
||||||
|
bookmarksMenu = new JMenu("Bookmarks");
|
||||||
|
rebuildBookmarks();
|
||||||
|
|
||||||
|
JMenu self = new JMenu("Self");
|
||||||
|
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
|
||||||
|
mute.addActionListener(e -> listener.onToggleMic());
|
||||||
|
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK"));
|
||||||
|
deaf.addActionListener(e -> listener.onToggleSpeaker());
|
||||||
|
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
|
||||||
|
awayItem.addActionListener(e -> listener.onAwayToggle(awayItem.isSelected()));
|
||||||
|
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
|
||||||
|
awayStatusItem.addActionListener(e -> listener.onAwayStatus());
|
||||||
|
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
|
||||||
|
commanderItem.addActionListener(e -> listener.onCommanderToggle(commanderItem.isSelected()));
|
||||||
|
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
|
||||||
|
nick.addActionListener(e -> listener.onChangeNickname());
|
||||||
|
self.add(mute);
|
||||||
|
self.add(deaf);
|
||||||
|
self.addSeparator();
|
||||||
|
self.add(awayItem);
|
||||||
|
self.add(awayStatusItem);
|
||||||
|
self.add(commanderItem);
|
||||||
|
self.addSeparator();
|
||||||
|
self.add(nick);
|
||||||
|
|
||||||
|
JMenu tools = new JMenu("Tools");
|
||||||
|
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
|
||||||
|
identitiesItem.addActionListener(e -> listener.onShowIdentities());
|
||||||
|
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
|
||||||
|
options.addActionListener(e -> listener.onShowSettings());
|
||||||
|
tools.add(identitiesItem);
|
||||||
|
tools.addSeparator();
|
||||||
|
tools.add(options);
|
||||||
|
|
||||||
|
JMenu help = new JMenu("Help");
|
||||||
|
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
|
||||||
|
about.addActionListener(e -> listener.onShowAbout());
|
||||||
|
help.add(about);
|
||||||
|
|
||||||
|
add(connections);
|
||||||
|
add(bookmarksMenu);
|
||||||
|
add(self);
|
||||||
|
add(tools);
|
||||||
|
add(help);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reflects the current tab's away/commander state on the menu items. */
|
||||||
|
void refresh(boolean connected, boolean away, boolean commander) {
|
||||||
|
awayItem.setEnabled(connected);
|
||||||
|
awayStatusItem.setEnabled(connected);
|
||||||
|
commanderItem.setEnabled(connected);
|
||||||
|
awayItem.setSelected(away);
|
||||||
|
commanderItem.setSelected(commander);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-lists the saved bookmarks; called after they change (add, remove, manage…). */
|
||||||
|
void rebuildBookmarks() {
|
||||||
|
bookmarksMenu.removeAll();
|
||||||
|
for (Bookmark b : bookmarks.all()) {
|
||||||
|
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
|
||||||
|
item.addActionListener(e -> listener.onConnectBookmark(b));
|
||||||
|
bookmarksMenu.add(item);
|
||||||
|
}
|
||||||
|
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
|
||||||
|
JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD"));
|
||||||
|
addCurrent.addActionListener(e -> listener.onAddCurrentServerBookmark());
|
||||||
|
JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER"));
|
||||||
|
manage.addActionListener(e -> listener.onManageBookmarks());
|
||||||
|
bookmarksMenu.add(addCurrent);
|
||||||
|
bookmarksMenu.add(manage);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java
Normal file
226
ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JCheckBoxMenuItem;
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JPopupMenu;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import javax.swing.JToggleButton;
|
||||||
|
import javax.swing.JToolBar;
|
||||||
|
import javax.swing.event.PopupMenuEvent;
|
||||||
|
import javax.swing.event.PopupMenuListener;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.FlowLayout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main window's toolbar: connect/disconnect, the per-tab mic/speaker/away
|
||||||
|
* controls, and the Master Volume slider on the right. A right-click anywhere on
|
||||||
|
* the bar opens a customization menu for the window's optional chrome (this
|
||||||
|
* toolbar has no say over the status bar's own visibility beyond reporting the
|
||||||
|
* toggle).
|
||||||
|
*
|
||||||
|
* <p>Talks to {@link MainFrame} only through {@link Listener}, so it owns no
|
||||||
|
* connection/tab state itself — {@link #refresh} is handed everything it
|
||||||
|
* needs to redraw each time the selection changes.
|
||||||
|
*/
|
||||||
|
final class MainToolbar extends JToolBar {
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
void onConnect();
|
||||||
|
|
||||||
|
void onDisconnect();
|
||||||
|
|
||||||
|
/** Moves the microphone to the tab on screen. */
|
||||||
|
void onActivate();
|
||||||
|
|
||||||
|
void onMicMuteToggle(boolean muted);
|
||||||
|
|
||||||
|
void onDeafenToggle(boolean deafened);
|
||||||
|
|
||||||
|
/** The plain toggle carries no message; the drop-down arrow is where messages are chosen. */
|
||||||
|
void onAwayToggle(boolean away);
|
||||||
|
|
||||||
|
JPopupMenu buildAwayMenu();
|
||||||
|
|
||||||
|
void onSettings();
|
||||||
|
|
||||||
|
/** The slider changed; push the new master volume to every open connection. */
|
||||||
|
void onMasterVolumeChanged();
|
||||||
|
|
||||||
|
void onStatusBarVisibilityChanged(boolean visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Settings settings;
|
||||||
|
private final Listener listener;
|
||||||
|
|
||||||
|
private final JButton connectButton;
|
||||||
|
private final JButton disconnectButton;
|
||||||
|
private final JToggleButton activeButton;
|
||||||
|
private final JToggleButton micButton;
|
||||||
|
private final JToggleButton speakerButton;
|
||||||
|
private final DropDownToggleButton awayButton;
|
||||||
|
private final JComponent masterVolumePanel;
|
||||||
|
|
||||||
|
MainToolbar(Settings settings, Listener listener) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.listener = listener;
|
||||||
|
|
||||||
|
setFloatable(false);
|
||||||
|
setBackground(Theme.toolbarBg());
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
|
||||||
|
setComponentPopupMenu(buildContextMenu());
|
||||||
|
|
||||||
|
connectButton = new JButton(Icons.connect());
|
||||||
|
connectButton.setToolTipText("Connect to a server");
|
||||||
|
connectButton.addActionListener(e -> listener.onConnect());
|
||||||
|
|
||||||
|
disconnectButton = new JButton(Icons.disconnect());
|
||||||
|
disconnectButton.setToolTipText("Disconnect");
|
||||||
|
disconnectButton.addActionListener(e -> listener.onDisconnect());
|
||||||
|
|
||||||
|
activeButton = new JToggleButton(Icons.micActive());
|
||||||
|
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)");
|
||||||
|
activeButton.addActionListener(e -> listener.onActivate());
|
||||||
|
|
||||||
|
micButton = new JToggleButton(Icons.mic());
|
||||||
|
micButton.setToolTipText("Mute / unmute microphone on this server");
|
||||||
|
micButton.addActionListener(e -> listener.onMicMuteToggle(micButton.isSelected()));
|
||||||
|
|
||||||
|
speakerButton = new JToggleButton(Icons.speaker());
|
||||||
|
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
|
||||||
|
speakerButton.addActionListener(e -> listener.onDeafenToggle(speakerButton.isSelected()));
|
||||||
|
|
||||||
|
awayButton = new DropDownToggleButton(Icons.away(),
|
||||||
|
"Away on this server (the arrow offers the global actions and presets)",
|
||||||
|
listener::buildAwayMenu);
|
||||||
|
awayButton.addActionListener(e -> listener.onAwayToggle(awayButton.isSelected()));
|
||||||
|
|
||||||
|
JButton settingsButton = new JButton(Icons.settings());
|
||||||
|
settingsButton.setToolTipText("Options");
|
||||||
|
settingsButton.addActionListener(e -> listener.onSettings());
|
||||||
|
|
||||||
|
add(connectButton);
|
||||||
|
add(disconnectButton);
|
||||||
|
addSeparator();
|
||||||
|
add(activeButton);
|
||||||
|
add(micButton);
|
||||||
|
add(speakerButton);
|
||||||
|
add(awayButton);
|
||||||
|
addSeparator();
|
||||||
|
add(settingsButton);
|
||||||
|
add(Box.createHorizontalGlue());
|
||||||
|
masterVolumePanel = buildMasterVolumeControl();
|
||||||
|
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
|
||||||
|
add(masterVolumePanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reflects the current tab's state on the buttons. */
|
||||||
|
void refresh(boolean connected, boolean anyConnected, boolean micMuted, boolean deafened,
|
||||||
|
boolean active, boolean away) {
|
||||||
|
disconnectButton.setEnabled(connected);
|
||||||
|
micButton.setEnabled(connected);
|
||||||
|
speakerButton.setEnabled(connected);
|
||||||
|
activeButton.setEnabled(connected);
|
||||||
|
// The away menu's actions are global, so the arrow stays live while any server is up.
|
||||||
|
awayButton.setEnabled(anyConnected);
|
||||||
|
awayButton.setToggleEnabled(connected);
|
||||||
|
|
||||||
|
micButton.setSelected(micMuted);
|
||||||
|
micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
|
||||||
|
speakerButton.setSelected(deafened);
|
||||||
|
speakerButton.setIcon(deafened ? Icons.speakerMutedLarge() : Icons.speaker());
|
||||||
|
activeButton.setSelected(active);
|
||||||
|
awayButton.setSelected(away);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulates a click, for the "Toggle microphone" menu item. */
|
||||||
|
void clickMic() {
|
||||||
|
micButton.doClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulates a click, for the "Toggle speakers" menu item. */
|
||||||
|
void clickSpeaker() {
|
||||||
|
speakerButton.doClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Master Volume slider: multiplies both voice and notification volume. */
|
||||||
|
private JComponent buildMasterVolumeControl() {
|
||||||
|
JPanel panel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
|
||||||
|
panel.setOpaque(false);
|
||||||
|
panel.setAlignmentY(Component.CENTER_ALIGNMENT);
|
||||||
|
|
||||||
|
JLabel icon = new JLabel(Icons.speaker());
|
||||||
|
icon.setToolTipText("Master volume (voice + notifications)");
|
||||||
|
icon.setAlignmentY(Component.CENTER_ALIGNMENT);
|
||||||
|
|
||||||
|
JSlider slider = new JSlider(0, 200, (int) Math.round(settings.masterVolume * 100));
|
||||||
|
slider.setOpaque(false);
|
||||||
|
slider.setToolTipText("Master volume (voice + notifications) — scroll to adjust");
|
||||||
|
slider.setAlignmentY(Component.CENTER_ALIGNMENT);
|
||||||
|
slider.setPreferredSize(new Dimension(135, slider.getPreferredSize().height));
|
||||||
|
slider.addChangeListener(e -> {
|
||||||
|
settings.masterVolume = slider.getValue() / 100.0;
|
||||||
|
listener.onMasterVolumeChanged();
|
||||||
|
// Only touch the disk once the drag (or a wheel step) has settled.
|
||||||
|
if (!slider.getValueIsAdjusting()) settings.save();
|
||||||
|
});
|
||||||
|
slider.addMouseWheelListener(e -> {
|
||||||
|
int step = e.getWheelRotation() < 0 ? 5 : -5;
|
||||||
|
slider.setValue(slider.getValue() + step);
|
||||||
|
e.consume();
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.add(icon);
|
||||||
|
panel.add(slider);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Right-click on the toolbar: toggles for the window's optional chrome. */
|
||||||
|
private JPopupMenu buildContextMenu() {
|
||||||
|
JPopupMenu menu = new JPopupMenu();
|
||||||
|
|
||||||
|
JCheckBoxMenuItem statusBarItem = new JCheckBoxMenuItem("Show Status Bar", settings.showStatusBar);
|
||||||
|
statusBarItem.addActionListener(e -> {
|
||||||
|
settings.showStatusBar = statusBarItem.isSelected();
|
||||||
|
settings.save();
|
||||||
|
listener.onStatusBarVisibilityChanged(settings.showStatusBar);
|
||||||
|
});
|
||||||
|
|
||||||
|
JCheckBoxMenuItem volumeItem = new JCheckBoxMenuItem("Show Master Volume Slider", settings.showMasterVolumeSlider);
|
||||||
|
volumeItem.addActionListener(e -> {
|
||||||
|
settings.showMasterVolumeSlider = volumeItem.isSelected();
|
||||||
|
settings.save();
|
||||||
|
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
|
||||||
|
revalidate();
|
||||||
|
repaint();
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.add(statusBarItem);
|
||||||
|
menu.add(volumeItem);
|
||||||
|
// Either toggle can also be flipped elsewhere (or via settings.properties), so
|
||||||
|
// re-sync the ticks each time the menu is about to be shown.
|
||||||
|
menu.addPopupMenuListener(new PopupMenuListener() {
|
||||||
|
@Override
|
||||||
|
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
|
||||||
|
statusBarItem.setSelected(settings.showStatusBar);
|
||||||
|
volumeItem.setSelected(settings.showMasterVolumeSlider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void popupMenuCanceled(PopupMenuEvent e) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.audio.VoiceInput;
|
||||||
|
import com.ts3client.audio.desktop.AudioDevices;
|
||||||
|
import com.ts3client.audio.desktop.AudioPlayback;
|
||||||
|
import com.ts3client.audio.desktop.DesktopVoiceInput;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the settings dialog's microphone test from a real capture chain.
|
||||||
|
*
|
||||||
|
* <p>The dialog runs its own {@link DesktopVoiceInput} rather than borrowing the connected
|
||||||
|
* one, whose listeners belong to the connection. Because it is the same class that feeds
|
||||||
|
* the server, the level and the gate shown here are exactly what would be transmitted —
|
||||||
|
* pre-processing, voice detection, hangover and pre-roll included.
|
||||||
|
*
|
||||||
|
* <p>Loopback is optional: when enabled, the frames that would be sent are played back
|
||||||
|
* locally so you can hear precisely what the other side would.
|
||||||
|
*/
|
||||||
|
final class MicrophoneTest {
|
||||||
|
|
||||||
|
/** Frames buffered for loopback before the oldest is dropped (~100 ms). */
|
||||||
|
private static final int LOOPBACK_QUEUE_FRAMES = 5;
|
||||||
|
|
||||||
|
private final Consumer<Double> onLevel;
|
||||||
|
private final Consumer<Boolean> onTransmitting;
|
||||||
|
|
||||||
|
private DesktopVoiceInput mic;
|
||||||
|
|
||||||
|
private final ArrayBlockingQueue<byte[]> loopbackQueue =
|
||||||
|
new ArrayBlockingQueue<>(LOOPBACK_QUEUE_FRAMES);
|
||||||
|
private volatile boolean loopbackEnabled;
|
||||||
|
private volatile boolean loopbackRunning;
|
||||||
|
private Thread loopbackThread;
|
||||||
|
private String outputDevice = "";
|
||||||
|
|
||||||
|
MicrophoneTest(Consumer<Double> onLevel, Consumer<Boolean> onTransmitting) {
|
||||||
|
this.onLevel = onLevel;
|
||||||
|
this.onTransmitting = onTransmitting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a capture chain is currently running. */
|
||||||
|
boolean isRunning() {
|
||||||
|
return mic != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Re)starts the test chain against {@code settings}' devices and voice options.
|
||||||
|
*
|
||||||
|
* @return false if the capture device could not be opened
|
||||||
|
*/
|
||||||
|
boolean start(Settings settings) {
|
||||||
|
stop();
|
||||||
|
DesktopVoiceInput input = new DesktopVoiceInput(settings);
|
||||||
|
input.setLevelListener(db -> SwingUtilities.invokeLater(() -> onLevel.accept(db)));
|
||||||
|
input.setTalkListener(talking -> SwingUtilities.invokeLater(() -> onTransmitting.accept(talking)));
|
||||||
|
input.setMonitorListener(this::enqueueForLoopback);
|
||||||
|
this.outputDevice = settings.outputDevice;
|
||||||
|
try {
|
||||||
|
input.start();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return false; // Device busy or gone; leave the test switched off.
|
||||||
|
}
|
||||||
|
this.mic = input;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the capture chain. The caller owns the UI reset: doing it here would race with
|
||||||
|
* a restart, whose own state has already been put on screen.
|
||||||
|
*/
|
||||||
|
void stop() {
|
||||||
|
setLoopback(false);
|
||||||
|
if (mic != null) {
|
||||||
|
mic.stop();
|
||||||
|
mic = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a change to the test chain, if it is running. */
|
||||||
|
void configure(Consumer<VoiceInput> change) {
|
||||||
|
VoiceInput m = mic;
|
||||||
|
if (m != null) change.accept(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The playback device to loop back through; takes effect on the next enable. */
|
||||||
|
void setOutputDevice(String deviceId) {
|
||||||
|
this.outputDevice = deviceId == null ? "" : deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLoopback(boolean enabled) {
|
||||||
|
if (enabled == loopbackEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loopbackEnabled = enabled;
|
||||||
|
if (enabled) {
|
||||||
|
loopbackRunning = true;
|
||||||
|
loopbackThread = new Thread(this::loopbackLoop, "settings-loopback");
|
||||||
|
loopbackThread.setDaemon(true);
|
||||||
|
loopbackThread.start();
|
||||||
|
} else {
|
||||||
|
loopbackRunning = false;
|
||||||
|
if (loopbackThread != null) {
|
||||||
|
loopbackThread.interrupt();
|
||||||
|
loopbackThread = null;
|
||||||
|
}
|
||||||
|
loopbackQueue.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a transmitted frame to 16-bit PCM and queues it. Runs on the capture thread,
|
||||||
|
* so it must not block: a full queue means playback has fallen behind and the oldest
|
||||||
|
* frame is dropped instead.
|
||||||
|
*/
|
||||||
|
private void enqueueForLoopback(float[] interleaved, int channels) {
|
||||||
|
if (!loopbackEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[] pcm = toPcm16(interleaved);
|
||||||
|
if (!loopbackQueue.offer(pcm)) {
|
||||||
|
loopbackQueue.poll();
|
||||||
|
loopbackQueue.offer(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loopbackLoop() {
|
||||||
|
AudioPlayback line = null;
|
||||||
|
try {
|
||||||
|
// The monitored frames are mono for voice; ask for a matching line so no
|
||||||
|
// channel juggling is needed, and up-mix only if the device insists on stereo.
|
||||||
|
line = AudioDevices.openPlayback(outputDevice, 1);
|
||||||
|
line.start();
|
||||||
|
int channels = line.channels();
|
||||||
|
while (loopbackRunning) {
|
||||||
|
byte[] frame = loopbackQueue.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||||
|
if (frame == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byte[] out = (channels == 1) ? frame : upmix(frame, channels);
|
||||||
|
line.write(out, 0, out.length);
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// The device may be busy or gone; the test simply runs without loopback.
|
||||||
|
} finally {
|
||||||
|
if (line != null) line.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts float samples in [-1, 1] to 16-bit little-endian PCM. */
|
||||||
|
static byte[] toPcm16(float[] samples) {
|
||||||
|
byte[] pcm = new byte[samples.length * 2];
|
||||||
|
for (int i = 0; i < samples.length; i++) {
|
||||||
|
float f = samples[i];
|
||||||
|
if (f > 1f) f = 1f;
|
||||||
|
else if (f < -1f) f = -1f;
|
||||||
|
int s = Math.round(f * 32767f);
|
||||||
|
pcm[2 * i] = (byte) (s & 0xFF);
|
||||||
|
pcm[2 * i + 1] = (byte) ((s >> 8) & 0xFF);
|
||||||
|
}
|
||||||
|
return pcm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies a mono frame across {@code channels} interleaved channels. */
|
||||||
|
static byte[] upmix(byte[] mono, int channels) {
|
||||||
|
byte[] out = new byte[mono.length * channels];
|
||||||
|
for (int i = 0, frames = mono.length / 2; i < frames; i++) {
|
||||||
|
for (int c = 0; c < channels; c++) {
|
||||||
|
out[2 * (i * channels + c)] = mono[2 * i];
|
||||||
|
out[2 * (i * channels + c) + 1] = mono[2 * i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ClientEntry;
|
||||||
|
|
||||||
|
import javax.swing.DefaultCellEditor;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.JTree;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.Timer;
|
||||||
|
import javax.swing.tree.DefaultMutableTreeNode;
|
||||||
|
import javax.swing.tree.DefaultTreeCellEditor;
|
||||||
|
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||||
|
import javax.swing.tree.TreePath;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.util.EventObject;
|
||||||
|
import java.util.function.IntPredicate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns our own row in the server tree into a text field on a double click, the way
|
||||||
|
* TeamSpeak renames a client in place. Only that one row is editable; every other
|
||||||
|
* double click keeps its usual meaning (joining a channel, opening a chat).
|
||||||
|
*
|
||||||
|
* <p>The field opens on the current nickname with it selected, so typing replaces it,
|
||||||
|
* Enter commits and Escape leaves the name alone.
|
||||||
|
*/
|
||||||
|
final class NicknameCellEditor extends DefaultTreeCellEditor {
|
||||||
|
|
||||||
|
/** Pulls the field left by its own border, so the text sits where the label did. */
|
||||||
|
private static final int FIELD_NUDGE = 2;
|
||||||
|
|
||||||
|
/** Long enough for the click's own release to have been handled first. */
|
||||||
|
private static final int SELECT_DELAY_MS = 80;
|
||||||
|
|
||||||
|
private final JTextField field;
|
||||||
|
/** Whether a client id is our own — the tree learns ours only once connected. */
|
||||||
|
private final IntPredicate isSelf;
|
||||||
|
|
||||||
|
private NicknameCellEditor(JTree tree, DefaultTreeCellRenderer renderer, JTextField field,
|
||||||
|
IntPredicate isSelf) {
|
||||||
|
super(tree, renderer, new DefaultCellEditor(field));
|
||||||
|
this.field = field;
|
||||||
|
this.isSelf = isSelf;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NicknameCellEditor create(JTree tree, DefaultTreeCellRenderer renderer, IntPredicate isSelf) {
|
||||||
|
JTextField field = new JTextField();
|
||||||
|
// Only our own row is edited, and that name is bold in the tree.
|
||||||
|
field.setFont(Theme.uiBold());
|
||||||
|
return new NicknameCellEditor(tree, renderer, field, isSelf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a path is the row this editor works on. */
|
||||||
|
boolean editsPath(TreePath path) {
|
||||||
|
ClientEntry client = clientOf(path);
|
||||||
|
return client != null && isSelf.test(client.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editing is started by the tree's own double-click handling, which works a row by
|
||||||
|
* its whole line; Swing's would only see clicks that land on the label, and its
|
||||||
|
* click-pause-click timer would rename on clicks meant as a selection.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isCellEditable(EventObject event) {
|
||||||
|
return !(event instanceof MouseEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Component getTreeCellEditorComponent(JTree tree, Object value, boolean isSelected,
|
||||||
|
boolean expanded, boolean leaf, int row) {
|
||||||
|
// The node itself has to reach super, whose renderer measures the row from it;
|
||||||
|
// the text it derives is the node's toString, so the nickname is filled in after.
|
||||||
|
Component c = super.getTreeCellEditorComponent(tree, value, isSelected, expanded, leaf, row);
|
||||||
|
ClientEntry client = clientOf(value);
|
||||||
|
if (client != null) {
|
||||||
|
field.setText(client.nickname);
|
||||||
|
field.selectAll();
|
||||||
|
}
|
||||||
|
SwingUtilities.invokeLater(field::requestFocusInWindow);
|
||||||
|
// The double click that opened the field ends in a release, which a text
|
||||||
|
// component turns into a caret placement — undoing any selection made before
|
||||||
|
// it. Selecting after that has been delivered is what leaves the whole name
|
||||||
|
// selected, ready to be typed over.
|
||||||
|
Timer select = new Timer(SELECT_DELAY_MS, e -> field.selectAll());
|
||||||
|
select.setRepeats(false);
|
||||||
|
select.start();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the row's own icon while it is being edited. The inherited version knows
|
||||||
|
* only the renderer's default leaf/branch icons, which would swap a client's state
|
||||||
|
* icon for a blank page for as long as the field is open.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void determineOffset(JTree tree, Object value, boolean isSelected, boolean expanded,
|
||||||
|
boolean leaf, int row) {
|
||||||
|
super.determineOffset(tree, value, isSelected, expanded, leaf, row);
|
||||||
|
Component c = renderer.getTreeCellRendererComponent(tree, value, isSelected, expanded, leaf,
|
||||||
|
row, false);
|
||||||
|
if (!(c instanceof JLabel)) return;
|
||||||
|
editingIcon = ((JLabel) c).getIcon();
|
||||||
|
offset = editingIcon == null ? 0
|
||||||
|
: editingIcon.getIconWidth() + renderer.getIconTextGap() - FIELD_NUDGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The edited nickname, trimmed; empty when nothing usable was typed. */
|
||||||
|
String editedNickname() {
|
||||||
|
String text = field.getText();
|
||||||
|
return text == null ? "" : text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClientEntry clientOf(TreePath path) {
|
||||||
|
return path == null ? null : clientOf(path.getLastPathComponent());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClientEntry clientOf(Object node) {
|
||||||
|
if (!(node instanceof DefaultMutableTreeNode)) return null;
|
||||||
|
Object obj = ((DefaultMutableTreeNode) node).getUserObject();
|
||||||
|
return obj instanceof ClientEntry ? (ClientEntry) obj : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import javax.swing.ImageIcon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The state of the local client on one server, as the tree shows it for any other
|
||||||
|
* client — the states are listed in the order the official client gives them
|
||||||
|
* priority, the strongest one first.
|
||||||
|
*/
|
||||||
|
enum SelfState {
|
||||||
|
|
||||||
|
DISCONNECTED("Not connected"),
|
||||||
|
DEAFENED("Speakers muted"),
|
||||||
|
MIC_MUTED("Microphone muted"),
|
||||||
|
MIC_LOCAL_MUTED("Microphone locally muted"),
|
||||||
|
AWAY("Away"),
|
||||||
|
COMMANDER_TALKING("Talking (channel commander)"),
|
||||||
|
COMMANDER("Channel commander"),
|
||||||
|
TALKING("Talking"),
|
||||||
|
IDLE("Connected");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
SelfState(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The icon this state is drawn with, from the active icon pack. */
|
||||||
|
ImageIcon icon(int size) {
|
||||||
|
switch (this) {
|
||||||
|
case DISCONNECTED:
|
||||||
|
return Icons.app(size);
|
||||||
|
case DEAFENED:
|
||||||
|
return Icons.speakerMuted(size);
|
||||||
|
case MIC_MUTED:
|
||||||
|
return Icons.micMuted(size);
|
||||||
|
case MIC_LOCAL_MUTED:
|
||||||
|
return Icons.micLocalMuted(size);
|
||||||
|
case AWAY:
|
||||||
|
return Icons.clientAway(size);
|
||||||
|
case COMMANDER_TALKING:
|
||||||
|
return Icons.clientCommanderTalking(size);
|
||||||
|
case COMMANDER:
|
||||||
|
return Icons.clientCommander(size);
|
||||||
|
case TALKING:
|
||||||
|
return Icons.clientTalking(size);
|
||||||
|
default:
|
||||||
|
return Icons.clientIdle(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ClientEntry;
|
||||||
|
import com.ts3client.net.Group;
|
||||||
|
import com.ts3client.net.TeamspeakConnection;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.BoxLayout;
|
||||||
|
import javax.swing.GrayFilter;
|
||||||
|
import javax.swing.ImageIcon;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JDialog;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.SwingConstants;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every regular server group on the server, as a scrollable checkbox list. Groups
|
||||||
|
* the local client has no power to (un)assign are shown too, so the client's full
|
||||||
|
* membership is visible, but greyed out and disabled.
|
||||||
|
*/
|
||||||
|
final class ServerGroupsDialog extends JDialog {
|
||||||
|
|
||||||
|
private final TeamspeakConnection conn;
|
||||||
|
private final GroupIcons groupIcons;
|
||||||
|
private final int clientId;
|
||||||
|
private final ServerTreePanel.Actions actions;
|
||||||
|
|
||||||
|
private final JPanel list = new JPanel();
|
||||||
|
private final JLabel header = new JLabel();
|
||||||
|
/** Cached grayscale icons, keyed by the source icon's identity. */
|
||||||
|
private final Map<ImageIcon, ImageIcon> grayscale = new HashMap<>();
|
||||||
|
|
||||||
|
ServerGroupsDialog(MainFrame owner, TeamspeakConnection conn, GroupIcons groupIcons,
|
||||||
|
ClientEntry client, ServerTreePanel.Actions actions) {
|
||||||
|
super(owner, "Server Groups", true);
|
||||||
|
this.conn = conn;
|
||||||
|
this.groupIcons = groupIcons;
|
||||||
|
this.clientId = client.id;
|
||||||
|
this.actions = actions;
|
||||||
|
|
||||||
|
list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS));
|
||||||
|
JScrollPane scroll = new JScrollPane(list);
|
||||||
|
scroll.setPreferredSize(new Dimension(280, 360));
|
||||||
|
scroll.getVerticalScrollBar().setUnitIncrement(16);
|
||||||
|
|
||||||
|
header.setBorder(BorderFactory.createEmptyBorder(8, 10, 4, 10));
|
||||||
|
header.setHorizontalAlignment(SwingConstants.LEFT);
|
||||||
|
|
||||||
|
JButton close = new JButton("Close");
|
||||||
|
close.addActionListener(a -> dispose());
|
||||||
|
JPanel buttons = new JPanel();
|
||||||
|
buttons.add(close);
|
||||||
|
getRootPane().setDefaultButton(close);
|
||||||
|
|
||||||
|
getContentPane().setLayout(new BorderLayout());
|
||||||
|
getContentPane().add(header, BorderLayout.NORTH);
|
||||||
|
getContentPane().add(scroll, BorderLayout.CENTER);
|
||||||
|
getContentPane().add(buttons, BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
Dialogs.closeOnEscape(this);
|
||||||
|
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||||
|
refresh();
|
||||||
|
pack();
|
||||||
|
setLocationRelativeTo(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds the list from the current model state; closes the dialog if the client left. */
|
||||||
|
void refresh() {
|
||||||
|
ClientEntry client = conn.getModel().getClient(clientId);
|
||||||
|
if (client == null) {
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
header.setText(client.nickname);
|
||||||
|
|
||||||
|
int addPower = conn.getModel().selfPermissionValue("i_group_needed_member_add_power");
|
||||||
|
int removePower = conn.getModel().selfPermissionValue("i_group_needed_member_remove_power");
|
||||||
|
List<Group> groups = conn.getModel().allServerGroups();
|
||||||
|
|
||||||
|
list.removeAll();
|
||||||
|
for (Group g : groups) {
|
||||||
|
boolean assigned = contains(client.serverGroupIds, g.id);
|
||||||
|
boolean allowed = ClientMenu.canAssign(g, assigned, addPower, removePower);
|
||||||
|
|
||||||
|
ImageIcon icon = groupIcons.iconOf(g);
|
||||||
|
JCheckBox box = new JCheckBox(g.name, allowed ? icon : grayscale(icon));
|
||||||
|
box.setSelected(assigned);
|
||||||
|
box.setEnabled(allowed);
|
||||||
|
box.setAlignmentX(0f);
|
||||||
|
if (allowed) {
|
||||||
|
box.addActionListener(a -> actions.setClientServerGroup(client, g, box.isSelected()));
|
||||||
|
}
|
||||||
|
list.add(box);
|
||||||
|
}
|
||||||
|
list.revalidate();
|
||||||
|
list.repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImageIcon grayscale(ImageIcon icon) {
|
||||||
|
if (icon == null) return null;
|
||||||
|
return grayscale.computeIfAbsent(icon,
|
||||||
|
i -> new ImageIcon(GrayFilter.createDisabledImage(i.getImage())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean contains(int[] ids, int id) {
|
||||||
|
for (int i : ids) if (i == id) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ChannelSettings;
|
||||||
|
|
||||||
|
import javax.swing.JMenuItem;
|
||||||
|
import javax.swing.JPopupMenu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The context menu for the server itself — the tree's root row.
|
||||||
|
*/
|
||||||
|
final class ServerMenu {
|
||||||
|
|
||||||
|
private ServerMenu() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static JPopupMenu build(ServerTreePanel.Actions actions) {
|
||||||
|
JPopupMenu menu = new JPopupMenu();
|
||||||
|
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
|
||||||
|
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.PERMANENT));
|
||||||
|
menu.add(create);
|
||||||
|
JMenuItem spacer = new JMenuItem("Create Spacer",
|
||||||
|
Icons.ofAny("CHANNEL_CREATE_SPACER", "CHANNEL_CREATE"));
|
||||||
|
spacer.setToolTipText("Create a new spacer");
|
||||||
|
spacer.addActionListener(a -> actions.createSpacer());
|
||||||
|
menu.add(spacer);
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,15 @@ import com.ts3client.config.IdentityStore;
|
|||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
import com.ts3client.net.ChannelNode;
|
import com.ts3client.net.ChannelNode;
|
||||||
import com.ts3client.net.ClientEntry;
|
import com.ts3client.net.ClientEntry;
|
||||||
import com.ts3client.net.ConnectionListener;
|
|
||||||
import com.ts3client.net.TeamspeakConnection;
|
import com.ts3client.net.TeamspeakConnection;
|
||||||
import com.ts3client.sound.SoundNotifier;
|
import com.ts3client.sound.SoundNotifier;
|
||||||
import com.ts3client.text.TsLink;
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
import javax.swing.JComponent;
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JOptionPane;
|
|
||||||
import javax.swing.JSplitPane;
|
import javax.swing.JSplitPane;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.awt.Component;
|
import java.awt.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,10 +22,12 @@ import java.awt.Component;
|
|||||||
* client keeps several of these side by side; {@link MainFrame} shows one at a
|
* client keeps several of these side by side; {@link MainFrame} shows one at a
|
||||||
* time and owns the toolbar, menus and status bar that act on it.
|
* time and owns the toolbar, menus and status bar that act on it.
|
||||||
*
|
*
|
||||||
* <p>Everything that is per-server lives here: the connection, its microphone
|
* <p>ServerTab itself owns the connection, the panels and this tab's identity
|
||||||
* and speaker mute state, the away/commander flags and the chat history.
|
* (title/status); the mic/away/deafen flags live in {@link ServerTabSelfState},
|
||||||
|
* tree context-menu actions and selection in {@link ServerTabTreeActions}, and
|
||||||
|
* {@link com.ts3client.net.ConnectionListener} callbacks in {@link ServerTabConnectionEvents}.
|
||||||
*/
|
*/
|
||||||
final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
final class ServerTab implements ServerTabConnectionEvents.Listener {
|
||||||
|
|
||||||
private final MainFrame host;
|
private final MainFrame host;
|
||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
@@ -33,10 +35,16 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
|
|
||||||
private final TeamspeakConnection conn;
|
private final TeamspeakConnection conn;
|
||||||
private final GroupIcons groupIcons;
|
private final GroupIcons groupIcons;
|
||||||
|
private final ServerTabConnectionEvents events;
|
||||||
|
private final ServerTabSelfState selfState;
|
||||||
|
private final ServerTabTreeActions treeActions;
|
||||||
private final ServerTreePanel treePanel;
|
private final ServerTreePanel treePanel;
|
||||||
private final ChatPanel chatPanel;
|
private final ChatPanel chatPanel;
|
||||||
private final InfoPanel infoPanel = new InfoPanel();
|
private final InfoPanel infoPanel = new InfoPanel();
|
||||||
private final JComponent component;
|
private final JComponent component;
|
||||||
|
private JSplitPane leftColumn;
|
||||||
|
private int normalDividerSize;
|
||||||
|
private int savedDividerLocation = -1;
|
||||||
|
|
||||||
/** Label shown in the tab bar: the server name once known, the address before that. */
|
/** Label shown in the tab bar: the server name once known, the address before that. */
|
||||||
private String title = "New connection";
|
private String title = "New connection";
|
||||||
@@ -46,42 +54,61 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
/** Identity used for the current connection, so it can be saved into a bookmark. */
|
/** Identity used for the current connection, so it can be saved into a bookmark. */
|
||||||
private String identityId = "";
|
private String identityId = "";
|
||||||
|
|
||||||
private boolean micMuted;
|
|
||||||
private boolean deafened;
|
|
||||||
private boolean away;
|
|
||||||
/** Away message currently published, empty when away carries no message. */
|
|
||||||
private String awayMessage = "";
|
|
||||||
private boolean commander;
|
|
||||||
|
|
||||||
private Object currentSelection;
|
|
||||||
|
|
||||||
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
|
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
|
||||||
SoundNotifier sounds) {
|
SoundNotifier sounds) {
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.identities = identities;
|
this.identities = identities;
|
||||||
this.conn = new TeamspeakConnection(settings, audio, this, sounds);
|
|
||||||
|
// ServerTabConnectionEvents must exist before the connection (which needs a
|
||||||
|
// listener up front), and TeamspeakConnection must exist before the tree/chat
|
||||||
|
// panels and the other tab helpers that read from it — so wiring finishes with
|
||||||
|
// an explicit attach() once everything is built. Nothing fires callbacks before then.
|
||||||
|
this.events = new ServerTabConnectionEvents(host, this, this);
|
||||||
|
this.conn = new TeamspeakConnection(settings, audio, events, sounds);
|
||||||
this.groupIcons = new GroupIcons(conn.getIcons());
|
this.groupIcons = new GroupIcons(conn.getIcons());
|
||||||
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, this);
|
this.selfState = new ServerTabSelfState(conn);
|
||||||
this.chatPanel = new ChatPanel();
|
this.chatPanel = new ChatPanel();
|
||||||
|
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons);
|
||||||
|
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, treeActions);
|
||||||
|
treeActions.attach(treePanel);
|
||||||
|
events.attach(conn, treePanel, chatPanel, selfState, treeActions);
|
||||||
|
|
||||||
chatPanel.setSendHandler(this::onSendChat);
|
chatPanel.setSendHandler(this::onSendChat);
|
||||||
chatPanel.setLinkHandler(new ChatPanel.LinkHandler() {
|
chatPanel.setLinkHandler(new ChatPanel.LinkHandler() {
|
||||||
@Override
|
@Override
|
||||||
public void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
public void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||||
ServerTab.this.onClientLink(ref, source, x, y);
|
treeActions.handleClientLink(ref, source, x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
public void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||||
ServerTab.this.onChannelLink(ref, source, x, y);
|
treeActions.handleChannelLink(ref, source, x, y);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
chatPanel.setInputEnabled(false);
|
chatPanel.setInputEnabled(false);
|
||||||
|
|
||||||
JSplitPane leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
|
infoPanel.setDescriptionHandler(new InfoPanel.DescriptionHandler() {
|
||||||
|
@Override
|
||||||
|
public void showInfoTab(String html, Runnable onClose) {
|
||||||
|
chatPanel.openDescriptionTab("info", Icons.channelClientPair(), "", html, onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void closeInfoTab() {
|
||||||
|
chatPanel.closeNoteTab("info");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setPanelHidden(boolean hidden) {
|
||||||
|
ServerTab.this.setInfoPanelHidden(hidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
|
||||||
leftColumn.setResizeWeight(0.68);
|
leftColumn.setResizeWeight(0.68);
|
||||||
leftColumn.setContinuousLayout(true);
|
leftColumn.setContinuousLayout(true);
|
||||||
|
normalDividerSize = leftColumn.getDividerSize();
|
||||||
|
|
||||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
|
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
|
||||||
split.setResizeWeight(0.55);
|
split.setResizeWeight(0.55);
|
||||||
@@ -134,23 +161,32 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean isMicMuted() {
|
boolean isMicMuted() {
|
||||||
return micMuted;
|
return selfState.isMicMuted();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isMicLocalMuted() {
|
||||||
|
return selfState.isMicLocalMuted();
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isDeafened() {
|
boolean isDeafened() {
|
||||||
return deafened;
|
return selfState.isDeafened();
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isAway() {
|
boolean isAway() {
|
||||||
return away;
|
return selfState.isAway();
|
||||||
}
|
}
|
||||||
|
|
||||||
String awayMessage() {
|
String awayMessage() {
|
||||||
return awayMessage;
|
return selfState.awayMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isCommander() {
|
boolean isCommander() {
|
||||||
return commander;
|
return selfState.isCommander();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the local client looks like on this server, for the tray icon. */
|
||||||
|
SelfState selfState() {
|
||||||
|
return selfState.compute();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Path of the channel we are in, or empty when not connected. */
|
/** Path of the channel we are in, or empty when not connected. */
|
||||||
@@ -174,8 +210,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
title = address + ":" + port;
|
title = address + ":" + port;
|
||||||
chatPanel.appendSystem("Connecting to " + address + ":" + port
|
chatPanel.appendSystem("Connecting to " + address + ":" + port
|
||||||
+ (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + " …");
|
+ (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + " …");
|
||||||
onStatus("Loading identity…");
|
events.onStatus("Loading identity…");
|
||||||
host.tabUpdated(this);
|
|
||||||
|
|
||||||
// Resolving may have to generate a first identity, so keep it off the EDT.
|
// Resolving may have to generate a first identity, so keep it off the EDT.
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
@@ -184,7 +219,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
entry = identities.resolve(settings, identityId);
|
entry = identities.resolve(settings, identityId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
connecting = false;
|
connecting = false;
|
||||||
onError("Could not load identity: " + e.getMessage());
|
events.onError("Could not load identity: " + e.getMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
@@ -209,19 +244,36 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
if (conn.isConnected()) conn.disconnectBlocking("Leaving");
|
if (conn.isConnected()) conn.disconnectBlocking("Leaving");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ServerTabConnectionEvents.Listener ----
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setConnecting(boolean connecting) {
|
||||||
|
this.connecting = connecting;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- self state ----
|
// ---- self state ----
|
||||||
|
|
||||||
void setMicMuted(boolean muted) {
|
void setMicMuted(boolean muted) {
|
||||||
micMuted = muted;
|
selfState.setMicMuted(muted);
|
||||||
conn.setMicMuted(muted);
|
}
|
||||||
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
|
|
||||||
|
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
|
||||||
|
void setMicLocalMuted(boolean muted) {
|
||||||
|
selfState.setMicLocalMuted(muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDeafened(boolean deaf) {
|
void setDeafened(boolean deaf) {
|
||||||
deafened = deaf;
|
selfState.setDeafened(deaf);
|
||||||
conn.setDeafened(deaf);
|
|
||||||
if (deaf) micMuted = true;
|
|
||||||
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hands the capture device to (or takes it from) this connection. */
|
/** Hands the capture device to (or takes it from) this connection. */
|
||||||
@@ -234,16 +286,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
* message outlives coming back, so toggling away again restores it
|
* message outlives coming back, so toggling away again restores it
|
||||||
*/
|
*/
|
||||||
void setAway(boolean away, String message) {
|
void setAway(boolean away, String message) {
|
||||||
this.away = away;
|
selfState.setAway(away, message);
|
||||||
if (message != null) this.awayMessage = message;
|
|
||||||
conn.setAway(away, awayMessage);
|
|
||||||
chatPanel.appendSystem(!away ? "No longer away."
|
|
||||||
: awayMessage.isEmpty() ? "Away." : "Away: " + awayMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setCommander(boolean commander) {
|
void setCommander(boolean commander) {
|
||||||
this.commander = commander;
|
selfState.setCommander(commander);
|
||||||
conn.setChannelCommander(commander);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setNickname(String nickname) {
|
void setNickname(String nickname) {
|
||||||
@@ -275,261 +322,56 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A client link in the chat log was clicked: show the same menu as the tree does. */
|
|
||||||
private void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
|
||||||
ClientEntry client = conn.getModel().getClient(ref.id);
|
|
||||||
// The id is only valid for the session the link was made in; fall back to
|
|
||||||
// the unique id (and finally the nickname) so older links still resolve.
|
|
||||||
if (client == null || (!ref.uniqueId.isEmpty() && !ref.uniqueId.equals(client.uniqueId))) {
|
|
||||||
ClientEntry byUid = ref.uniqueId.isEmpty() ? null
|
|
||||||
: conn.getModel().findClientByUniqueId(ref.uniqueId);
|
|
||||||
if (byUid == null && !ref.name.isEmpty()) byUid = conn.getModel().findClientByName(ref.name);
|
|
||||||
if (byUid != null) client = byUid;
|
|
||||||
}
|
|
||||||
if (client == null) {
|
|
||||||
chatPanel.appendSystem("That client is no longer on the server.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ClientMenu.build(client, client.id == conn.getSelfClientId(), this).show(source, x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
|
||||||
ChannelNode channel = conn.getModel().getChannel(ref.id);
|
|
||||||
if (channel == null) {
|
|
||||||
chatPanel.appendSystem("That channel no longer exists.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ChannelMenu.build(channel, this).show(source, x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String peerName(int clientId) {
|
private String peerName(int clientId) {
|
||||||
ClientEntry c = conn.getModel().getClient(clientId);
|
ClientEntry c = conn.getModel().getClient(clientId);
|
||||||
return c != null ? c.nickname : "Client " + clientId;
|
return c != null ? c.nickname : "Client " + clientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ServerTreePanel.Actions ----
|
/** Joins the channel at a "/"-separated path, as the hotkey action names it. */
|
||||||
|
void joinChannelPath(String path) {
|
||||||
@Override
|
if (!conn.isConnected() || path == null || path.isBlank()) return;
|
||||||
public void joinChannel(int channelId) {
|
ChannelNode target = conn.getModel().findChannelByPath(path);
|
||||||
if (conn.isConnected()) conn.joinChannel(channelId, null);
|
if (target != null) conn.joinChannel(target.id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/** Every channel of this server as a "/"-separated path, in the tree's own order. */
|
||||||
public void moveClientToChannel(ClientEntry client, ChannelNode target) {
|
List<String> channelPaths() {
|
||||||
if (!conn.isConnected()) return;
|
List<String> out = new ArrayList<>();
|
||||||
if (client.id == conn.getSelfClientId()) {
|
if (!conn.isConnected()) return out;
|
||||||
conn.joinChannel(target.id, null);
|
for (ChannelNode root : conn.getModel().buildTree()) collectPaths(root, out);
|
||||||
} else {
|
return out;
|
||||||
conn.moveClient(client.id, target.id, null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private void collectPaths(ChannelNode channel, List<String> out) {
|
||||||
public void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId) {
|
out.add(conn.getModel().channelPath(channel.id));
|
||||||
if (conn.isConnected()) conn.moveChannel(channel.id, newParentId, orderPredecessorId);
|
for (ChannelNode child : channel.children) collectPaths(child, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/** The channel we are in, or null when not connected. */
|
||||||
public void openPrivateChat(ClientEntry client) {
|
ChannelNode currentChannel() {
|
||||||
chatPanel.openPrivateChat(client.id, client.nickname);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void pokeClient(ClientEntry client) {
|
|
||||||
String msg = JOptionPane.showInputDialog(host, "Poke message for " + client.nickname + ":", "Poke!");
|
|
||||||
if (msg != null) conn.poke(client.id, msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void kickClientFromChannel(ClientEntry client) {
|
|
||||||
String reason = kickReason("Kick Client from Channel", client);
|
|
||||||
if (reason != null) conn.kickFromChannel(client.id, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void kickClientFromServer(ClientEntry client) {
|
|
||||||
String reason = kickReason("Kick Client from Server", client);
|
|
||||||
if (reason != null) conn.kickFromServer(client.id, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String kickReason(String title, ClientEntry client) {
|
|
||||||
if (!conn.isConnected()) return null;
|
if (!conn.isConnected()) return null;
|
||||||
return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":",
|
|
||||||
ReasonDialog.KICK_REASON_LIMIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void banClient(ClientEntry client) {
|
|
||||||
if (!conn.isConnected()) return;
|
|
||||||
BanDialog dialog = new BanDialog(host, client.nickname);
|
|
||||||
dialog.setVisible(true);
|
|
||||||
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
|
|
||||||
}
|
|
||||||
|
|
||||||
@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(host, conn, client.id, client.nickname).setVisible(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void moveClientToOwnChannel(ClientEntry client) {
|
|
||||||
if (!conn.isConnected() || client.id == conn.getSelfClientId()) return;
|
|
||||||
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||||
if (self != null) conn.moveClient(client.id, self.channelId, null);
|
return self == null ? null : conn.getModel().getChannel(self.channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/** Opens the file repository browser for a channel, as the "Browse Files" hotkey/menu action does. */
|
||||||
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
|
void browseFiles(ChannelNode channel) {
|
||||||
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
|
treeActions.browseFiles(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/** Collapses the info panel to nothing while its content lives in the chat tab, or restores it. */
|
||||||
public void browseFiles(ChannelNode channel) {
|
private void setInfoPanelHidden(boolean hidden) {
|
||||||
if (!conn.canTransferFiles()) return;
|
if (hidden == !infoPanel.isVisible()) return;
|
||||||
new FileBrowserDialog(host, conn, channel).setVisible(true);
|
if (hidden) {
|
||||||
}
|
savedDividerLocation = leftColumn.getDividerLocation();
|
||||||
|
infoPanel.setVisible(false);
|
||||||
@Override
|
leftColumn.setDividerSize(0);
|
||||||
public boolean isClientLocallyMuted(int clientId) {
|
leftColumn.setDividerLocation(1.0);
|
||||||
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(), conn.getIcons());
|
|
||||||
} else {
|
} else {
|
||||||
infoPanel.clear();
|
infoPanel.setVisible(true);
|
||||||
|
leftColumn.setDividerSize(normalDividerSize);
|
||||||
|
if (savedDividerLocation >= 0) leftColumn.setDividerLocation(savedDividerLocation);
|
||||||
}
|
}
|
||||||
}
|
leftColumn.revalidate();
|
||||||
|
|
||||||
// ---- ConnectionListener (marshal to EDT) ----
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStatus(String text) {
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
status = text;
|
|
||||||
host.tabUpdated(this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onConnected() {
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
connecting = false;
|
|
||||||
treePanel.setSelfClientId(conn.getSelfClientId());
|
|
||||||
micMuted = false;
|
|
||||||
deafened = false;
|
|
||||||
away = false;
|
|
||||||
awayMessage = "";
|
|
||||||
commander = false;
|
|
||||||
chatPanel.setInputEnabled(true);
|
|
||||||
chatPanel.appendSystem("Connected.");
|
|
||||||
host.tabConnected(this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDisconnected(String reason) {
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
connecting = false;
|
|
||||||
conn.getModel().clear();
|
|
||||||
treePanel.showDisconnected();
|
|
||||||
currentSelection = null;
|
|
||||||
infoPanel.clear();
|
|
||||||
chatPanel.setInputEnabled(false);
|
|
||||||
chatPanel.closePrivateChats();
|
|
||||||
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
|
||||||
host.tabDisconnected(this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onModelChanged() {
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
treePanel.rebuild();
|
|
||||||
renderInfo();
|
|
||||||
String name = conn.getModel().getServerName();
|
|
||||||
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(title)) {
|
|
||||||
title = name;
|
|
||||||
host.tabUpdated(this);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onInfoUpdated() {
|
|
||||||
SwingUtilities.invokeLater(this::renderInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onIconsUpdated() {
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
treePanel.refreshRowSizes();
|
|
||||||
renderInfo();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
|
|
||||||
switch (scope) {
|
|
||||||
case PRIVATE:
|
|
||||||
chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message);
|
|
||||||
break;
|
|
||||||
case SERVER:
|
|
||||||
chatPanel.appendServerMessage(fromClientId, fromName, message);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
chatPanel.appendChannelMessage(fromClientId, fromName, message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onTalkStateChanged(int clientId, boolean talking) {
|
|
||||||
SwingUtilities.invokeLater(treePanel::refreshVisual);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(String message) {
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
chatPanel.appendSystem("Error: " + message);
|
|
||||||
status = message;
|
|
||||||
host.tabUpdated(this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPoke(String fromName, String message) {
|
|
||||||
SwingUtilities.invokeLater(() -> {
|
|
||||||
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
|
|
||||||
host.selectTab(this);
|
|
||||||
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
|
|
||||||
"Poke", JOptionPane.INFORMATION_MESSAGE);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ConnectionListener;
|
||||||
|
import com.ts3client.net.TeamspeakConnection;
|
||||||
|
|
||||||
|
import javax.swing.JOptionPane;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marshals {@link ConnectionListener} callbacks onto the EDT and fans them out to
|
||||||
|
* this tab's views (tree, chat, info panel) and to {@link MainFrame}.
|
||||||
|
*
|
||||||
|
* <p>Constructed before the {@link TeamspeakConnection} it listens to exists — the
|
||||||
|
* connection's constructor needs a listener up front — so {@link #attach} wires the
|
||||||
|
* rest of the tab in once everything else has been built.
|
||||||
|
*/
|
||||||
|
final class ServerTabConnectionEvents implements ConnectionListener {
|
||||||
|
|
||||||
|
/** The handful of {@link ServerTab} fields this class updates but can't reach directly. */
|
||||||
|
interface Listener {
|
||||||
|
void setStatus(String status);
|
||||||
|
|
||||||
|
void setTitle(String title);
|
||||||
|
|
||||||
|
void setConnecting(boolean connecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final MainFrame host;
|
||||||
|
private final ServerTab tab;
|
||||||
|
private final Listener listener;
|
||||||
|
|
||||||
|
private TeamspeakConnection conn;
|
||||||
|
private ServerTreePanel treePanel;
|
||||||
|
private ChatPanel chatPanel;
|
||||||
|
private ServerTabSelfState selfState;
|
||||||
|
private ServerTabTreeActions treeActions;
|
||||||
|
|
||||||
|
ServerTabConnectionEvents(MainFrame host, ServerTab tab, Listener listener) {
|
||||||
|
this.host = host;
|
||||||
|
this.tab = tab;
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
void attach(TeamspeakConnection conn, ServerTreePanel treePanel, ChatPanel chatPanel,
|
||||||
|
ServerTabSelfState selfState, ServerTabTreeActions treeActions) {
|
||||||
|
this.conn = conn;
|
||||||
|
this.treePanel = treePanel;
|
||||||
|
this.chatPanel = chatPanel;
|
||||||
|
this.selfState = selfState;
|
||||||
|
this.treeActions = treeActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStatus(String text) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
listener.setStatus(text);
|
||||||
|
host.tabUpdated(tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConnected() {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
listener.setConnecting(false);
|
||||||
|
treePanel.setSelfClientId(conn.getSelfClientId());
|
||||||
|
selfState.resetOnConnect();
|
||||||
|
chatPanel.setInputEnabled(true);
|
||||||
|
chatPanel.appendSystem("Connected.");
|
||||||
|
host.tabConnected(tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisconnected(String reason) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
listener.setConnecting(false);
|
||||||
|
conn.getModel().clear();
|
||||||
|
treePanel.showDisconnected();
|
||||||
|
treeActions.clearSelection();
|
||||||
|
chatPanel.setInputEnabled(false);
|
||||||
|
chatPanel.closePrivateChats();
|
||||||
|
chatPanel.closeNoteTabs();
|
||||||
|
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
||||||
|
host.tabDisconnected(tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onModelChanged() {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
treePanel.rebuild();
|
||||||
|
treeActions.renderInfo();
|
||||||
|
treeActions.refreshGroupsDialog();
|
||||||
|
String name = conn.getModel().getServerName();
|
||||||
|
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(tab.title())) {
|
||||||
|
listener.setTitle(name);
|
||||||
|
host.tabUpdated(tab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInfoUpdated() {
|
||||||
|
SwingUtilities.invokeLater(treeActions::renderInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onIconsUpdated() {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
treePanel.refreshRowSizes();
|
||||||
|
treeActions.renderInfo();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
|
||||||
|
switch (scope) {
|
||||||
|
case PRIVATE:
|
||||||
|
chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message);
|
||||||
|
break;
|
||||||
|
case SERVER:
|
||||||
|
chatPanel.appendServerMessage(fromClientId, fromName, message);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
chatPanel.appendChannelMessage(fromClientId, fromName, message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTalkStateChanged(int clientId, boolean talking) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
treePanel.refreshVisual();
|
||||||
|
if (clientId == conn.getSelfClientId()) host.selfStateChanged(tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
chatPanel.appendSystem("Error: " + message);
|
||||||
|
listener.setStatus(message);
|
||||||
|
host.tabUpdated(tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerLog(String message) {
|
||||||
|
SwingUtilities.invokeLater(() -> chatPanel.appendServerLog(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPoke(String fromName, String message) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
|
||||||
|
host.selectTab(tab);
|
||||||
|
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
|
||||||
|
"Poke", JOptionPane.INFORMATION_MESSAGE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,11 +34,14 @@ final class ServerTabPane extends JPanel {
|
|||||||
private final Listener listener;
|
private final Listener listener;
|
||||||
private final JTabbedPane tabbed = new JTabbedPane();
|
private final JTabbedPane tabbed = new JTabbedPane();
|
||||||
private final List<ServerTab> tabs = new ArrayList<>();
|
private final List<ServerTab> tabs = new ArrayList<>();
|
||||||
|
private final TabDragReorder dragReorder = new TabDragReorder(tabbed, this::moveTab);
|
||||||
|
|
||||||
/** True while the tab strip is in use, i.e. more than one connection is open. */
|
/** True while the tab strip is in use, i.e. more than one connection is open. */
|
||||||
private boolean tabbedMode;
|
private boolean tabbedMode;
|
||||||
/** Suppresses selection callbacks while we rearrange the pane ourselves. */
|
/** Suppresses selection callbacks while we rearrange the pane ourselves. */
|
||||||
private boolean updating;
|
private boolean updating;
|
||||||
|
/** Remembered so a drag-reorder can rebuild the tab labels without the caller's help. */
|
||||||
|
private ServerTab lastMicTab;
|
||||||
|
|
||||||
ServerTabPane(Listener listener) {
|
ServerTabPane(Listener listener) {
|
||||||
super(new BorderLayout());
|
super(new BorderLayout());
|
||||||
@@ -51,6 +54,24 @@ final class ServerTabPane extends JPanel {
|
|||||||
if (i >= 0 && i < tabs.size()) listener.selectTab(tabs.get(i));
|
if (i >= 0 && i < tabs.size()) listener.selectTab(tabs.get(i));
|
||||||
});
|
});
|
||||||
tabbed.addMouseWheelListener(this::onWheel);
|
tabbed.addMouseWheelListener(this::onWheel);
|
||||||
|
dragReorder.attach(tabbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drags a tab from one position to another, keeping the current selection on screen. */
|
||||||
|
private void moveTab(int from, int to) {
|
||||||
|
if (from < 0 || to < 0 || from >= tabs.size() || to >= tabs.size() || from == to) return;
|
||||||
|
ServerTab selected = tabbed.getSelectedIndex() >= 0 && tabbed.getSelectedIndex() < tabs.size()
|
||||||
|
? tabs.get(tabbed.getSelectedIndex()) : null;
|
||||||
|
tabs.add(to, tabs.remove(from));
|
||||||
|
updating = true;
|
||||||
|
try {
|
||||||
|
tabbed.removeAll();
|
||||||
|
for (ServerTab tab : tabs) tabbed.addTab(tab.title(), tab.component());
|
||||||
|
if (selected != null) tabbed.setSelectedIndex(tabs.indexOf(selected));
|
||||||
|
} finally {
|
||||||
|
updating = false;
|
||||||
|
}
|
||||||
|
refresh(lastMicTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,6 +114,7 @@ final class ServerTabPane extends JPanel {
|
|||||||
|
|
||||||
/** Refreshes the tab labels; {@code micTab} is marked as owning the microphone. */
|
/** Refreshes the tab labels; {@code micTab} is marked as owning the microphone. */
|
||||||
void refresh(ServerTab micTab) {
|
void refresh(ServerTab micTab) {
|
||||||
|
lastMicTab = micTab;
|
||||||
if (!tabbedMode) return;
|
if (!tabbedMode) return;
|
||||||
for (int i = 0; i < tabs.size(); i++) {
|
for (int i = 0; i < tabs.size(); i++) {
|
||||||
ServerTab tab = tabs.get(i);
|
ServerTab tab = tabs.get(i);
|
||||||
@@ -133,7 +155,7 @@ final class ServerTabPane extends JPanel {
|
|||||||
cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0));
|
cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0));
|
||||||
|
|
||||||
JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING);
|
JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING);
|
||||||
label.setFont(Theme.UI_FONT);
|
label.setFont(Theme.uiFont());
|
||||||
label.setToolTipText(hasMic ? "Speaking on this server" : tab.status());
|
label.setToolTipText(hasMic ? "Speaking on this server" : tab.status());
|
||||||
// A label with a tooltip swallows mouse events, so select the tab explicitly.
|
// A label with a tooltip swallows mouse events, so select the tab explicitly.
|
||||||
label.addMouseListener(new MouseAdapter() {
|
label.addMouseListener(new MouseAdapter() {
|
||||||
@@ -143,14 +165,16 @@ final class ServerTabPane extends JPanel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
cell.add(label);
|
cell.add(label);
|
||||||
|
dragReorder.attach(cell);
|
||||||
|
dragReorder.attach(label);
|
||||||
|
|
||||||
JButton close = new JButton("✕");
|
JButton close = new JButton("✕");
|
||||||
close.setFont(Theme.UI_FONT);
|
close.setFont(Theme.uiFont());
|
||||||
close.setFocusable(false);
|
close.setFocusable(false);
|
||||||
close.setBorder(BorderFactory.createEmptyBorder());
|
close.setBorder(BorderFactory.createEmptyBorder());
|
||||||
close.setContentAreaFilled(false);
|
close.setContentAreaFilled(false);
|
||||||
close.setMargin(new Insets(0, 0, 0, 0));
|
close.setMargin(new Insets(0, 0, 0, 0));
|
||||||
close.setForeground(Theme.CHAT_SYSTEM);
|
close.setForeground(Theme.chatSystem());
|
||||||
close.setPreferredSize(new Dimension(14, 14));
|
close.setPreferredSize(new Dimension(14, 14));
|
||||||
close.setToolTipText("Close this connection");
|
close.setToolTipText("Close this connection");
|
||||||
close.addActionListener(e -> listener.closeTab(tab));
|
close.addActionListener(e -> listener.closeTab(tab));
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ClientEntry;
|
||||||
|
import com.ts3client.net.TeamspeakConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mic/speaker/away/commander flags for one connection, plus forwarding them into
|
||||||
|
* {@link TeamspeakConnection}. Split out of {@link ServerTab} because the flags
|
||||||
|
* are reset together in one place (on (re)connect) and read from several
|
||||||
|
* (tray icon, toolbar, menu checkmarks).
|
||||||
|
*/
|
||||||
|
final class ServerTabSelfState {
|
||||||
|
|
||||||
|
private final TeamspeakConnection conn;
|
||||||
|
|
||||||
|
private boolean micMuted;
|
||||||
|
private boolean micLocalMuted;
|
||||||
|
private boolean deafened;
|
||||||
|
private boolean away;
|
||||||
|
/** Away message currently published, empty when away carries no message. */
|
||||||
|
private String awayMessage = "";
|
||||||
|
private boolean commander;
|
||||||
|
|
||||||
|
ServerTabSelfState(TeamspeakConnection conn) {
|
||||||
|
this.conn = conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isMicMuted() {
|
||||||
|
return micMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isMicLocalMuted() {
|
||||||
|
return micLocalMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isDeafened() {
|
||||||
|
return deafened;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isAway() {
|
||||||
|
return away;
|
||||||
|
}
|
||||||
|
|
||||||
|
String awayMessage() {
|
||||||
|
return awayMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isCommander() {
|
||||||
|
return commander;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMicMuted(boolean muted) {
|
||||||
|
micMuted = muted;
|
||||||
|
conn.setMicMuted(muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
|
||||||
|
void setMicLocalMuted(boolean muted) {
|
||||||
|
micLocalMuted = muted;
|
||||||
|
conn.setMicLocalMuted(muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDeafened(boolean deaf) {
|
||||||
|
deafened = deaf;
|
||||||
|
conn.setDeafened(deaf);
|
||||||
|
if (deaf) micMuted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param message the away message, or null to keep the one already set — the
|
||||||
|
* message outlives coming back, so toggling away again restores it
|
||||||
|
*/
|
||||||
|
void setAway(boolean away, String message) {
|
||||||
|
this.away = away;
|
||||||
|
if (message != null) this.awayMessage = message;
|
||||||
|
conn.setAway(away, awayMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCommander(boolean commander) {
|
||||||
|
this.commander = commander;
|
||||||
|
conn.setChannelCommander(commander);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears all flags on a fresh connection; the server starts us out clean, so nothing to publish. */
|
||||||
|
void resetOnConnect() {
|
||||||
|
micMuted = false;
|
||||||
|
micLocalMuted = false;
|
||||||
|
deafened = false;
|
||||||
|
away = false;
|
||||||
|
awayMessage = "";
|
||||||
|
commander = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the local client looks like on this server, for the tray icon. */
|
||||||
|
SelfState compute() {
|
||||||
|
if (!conn.isConnected()) return SelfState.DISCONNECTED;
|
||||||
|
if (deafened) return SelfState.DEAFENED;
|
||||||
|
if (micMuted) return SelfState.MIC_MUTED;
|
||||||
|
if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED;
|
||||||
|
if (away) return SelfState.AWAY;
|
||||||
|
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||||
|
boolean talking = self != null && self.talking;
|
||||||
|
if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER;
|
||||||
|
return talking ? SelfState.TALKING : SelfState.IDLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.net.ChannelNode;
|
||||||
|
import com.ts3client.net.ChannelSettings;
|
||||||
|
import com.ts3client.net.ClientEntry;
|
||||||
|
import com.ts3client.net.Group;
|
||||||
|
import com.ts3client.net.TeamspeakConnection;
|
||||||
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
|
import javax.swing.JOptionPane;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import java.awt.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context-menu and drag/drop actions for one server's tree, plus tracking which
|
||||||
|
* node is selected so the info panel stays in sync. Also handles client/channel
|
||||||
|
* links clicked in the chat log, which open the same menus as the tree does.
|
||||||
|
*
|
||||||
|
* <p>Constructed before the {@link ServerTreePanel} it drives exists — the tree's
|
||||||
|
* constructor needs an {@link ServerTreePanel.Actions} up front — so {@link #attach}
|
||||||
|
* wires the tree back in once it has been built.
|
||||||
|
*/
|
||||||
|
final class ServerTabTreeActions implements ServerTreePanel.Actions {
|
||||||
|
|
||||||
|
private final MainFrame host;
|
||||||
|
private final ServerTab tab;
|
||||||
|
private final TeamspeakConnection conn;
|
||||||
|
private final ChatPanel chatPanel;
|
||||||
|
private final InfoPanel infoPanel;
|
||||||
|
private final GroupIcons groupIcons;
|
||||||
|
private ServerTreePanel treePanel;
|
||||||
|
|
||||||
|
private Object currentSelection;
|
||||||
|
/** The one open {@link ServerGroupsDialog}, if any, refreshed whenever the model changes. */
|
||||||
|
private ServerGroupsDialog groupsDialog;
|
||||||
|
|
||||||
|
ServerTabTreeActions(MainFrame host, ServerTab tab, TeamspeakConnection conn,
|
||||||
|
ChatPanel chatPanel, InfoPanel infoPanel, GroupIcons groupIcons) {
|
||||||
|
this.host = host;
|
||||||
|
this.tab = tab;
|
||||||
|
this.conn = conn;
|
||||||
|
this.chatPanel = chatPanel;
|
||||||
|
this.infoPanel = infoPanel;
|
||||||
|
this.groupIcons = groupIcons;
|
||||||
|
}
|
||||||
|
|
||||||
|
void attach(ServerTreePanel treePanel) {
|
||||||
|
this.treePanel = treePanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by {@link ServerTabConnectionEvents} whenever the model changes, to live-refresh an open dialog. */
|
||||||
|
void refreshGroupsDialog() {
|
||||||
|
if (groupsDialog != null) groupsDialog.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refreshes the info panel for whatever is currently selected (or clears it). */
|
||||||
|
void renderInfo() {
|
||||||
|
Object sel = currentSelection;
|
||||||
|
if (sel instanceof ChannelNode) {
|
||||||
|
infoPanel.showChannel((ChannelNode) sel);
|
||||||
|
} else if (sel instanceof ClientEntry) {
|
||||||
|
infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons());
|
||||||
|
} else {
|
||||||
|
infoPanel.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the selection on disconnect, since the model it points into is gone. */
|
||||||
|
void clearSelection() {
|
||||||
|
currentSelection = null;
|
||||||
|
infoPanel.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client link in the chat log was clicked: show the same menu as the tree does. */
|
||||||
|
void handleClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||||
|
ClientEntry client = conn.getModel().getClient(ref.id);
|
||||||
|
// The id is only valid for the session the link was made in; fall back to
|
||||||
|
// the unique id (and finally the nickname) so older links still resolve.
|
||||||
|
if (client == null || (!ref.uniqueId.isEmpty() && !ref.uniqueId.equals(client.uniqueId))) {
|
||||||
|
ClientEntry byUid = ref.uniqueId.isEmpty() ? null
|
||||||
|
: conn.getModel().findClientByUniqueId(ref.uniqueId);
|
||||||
|
if (byUid == null && !ref.name.isEmpty()) byUid = conn.getModel().findClientByName(ref.name);
|
||||||
|
if (byUid != null) client = byUid;
|
||||||
|
}
|
||||||
|
if (client == null) {
|
||||||
|
chatPanel.appendSystem("That client is no longer on the server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ClientMenu.build(client, client.id == conn.getSelfClientId(), false, conn.getModel(), groupIcons, this)
|
||||||
|
.show(source, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||||
|
ChannelNode channel = conn.getModel().getChannel(ref.id);
|
||||||
|
if (channel == null) {
|
||||||
|
chatPanel.appendSystem("That channel no longer exists.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChannelMenu.build(channel, this).show(source, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ServerTreePanel.Actions ----
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void joinChannel(int channelId) {
|
||||||
|
if (conn.isConnected()) conn.joinChannel(channelId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void moveClientToChannel(ClientEntry client, ChannelNode target) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
if (client.id == conn.getSelfClientId()) {
|
||||||
|
conn.joinChannel(target.id, null);
|
||||||
|
} else {
|
||||||
|
conn.moveClient(client.id, target.id, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId) {
|
||||||
|
if (conn.isConnected()) conn.moveChannel(channel.id, newParentId, orderPredecessorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void openPrivateChat(ClientEntry client) {
|
||||||
|
chatPanel.openPrivateChat(client.id, client.nickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void pokeClient(ClientEntry client) {
|
||||||
|
String msg = JOptionPane.showInputDialog(host, "Poke message for " + client.nickname + ":", "Poke!");
|
||||||
|
if (msg != null) conn.poke(client.id, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void kickClientFromChannel(ClientEntry client) {
|
||||||
|
String reason = kickReason("Kick Client from Channel", client);
|
||||||
|
if (reason != null) conn.kickFromChannel(client.id, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void kickClientFromServer(ClientEntry client) {
|
||||||
|
String reason = kickReason("Kick Client from Server", client);
|
||||||
|
if (reason != null) conn.kickFromServer(client.id, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String kickReason(String title, ClientEntry client) {
|
||||||
|
if (!conn.isConnected()) return null;
|
||||||
|
return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":",
|
||||||
|
ReasonDialog.KICK_REASON_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void banClient(ClientEntry client) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
BanDialog dialog = new BanDialog(host, client.nickname);
|
||||||
|
dialog.setVisible(true);
|
||||||
|
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void toggleClientMute(ClientEntry client) {
|
||||||
|
if (conn.getPlayback() == null) return;
|
||||||
|
boolean now = !conn.getPlayback().isClientMuted(client.id);
|
||||||
|
conn.getPlayback().setClientMuted(client.id, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void findClientInTree(ClientEntry client) {
|
||||||
|
host.selectTab(tab);
|
||||||
|
treePanel.selectClient(client.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void showConnectionInfo(ClientEntry client) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void moveClientToOwnChannel(ClientEntry client) {
|
||||||
|
if (!conn.isConnected() || client.id == conn.getSelfClientId()) return;
|
||||||
|
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||||
|
if (self != null) conn.moveClient(client.id, self.channelId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setClientServerGroup(ClientEntry client, Group group, boolean assign) {
|
||||||
|
if (conn.isConnected()) conn.setClientServerGroup(client.databaseId, group.id, assign);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setClientChannelGroup(ClientEntry client, Group group) {
|
||||||
|
if (conn.isConnected()) conn.setClientChannelGroup(client.databaseId, client.channelId, group.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void showServerGroupsDialog(ClientEntry client) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
groupsDialog = new ServerGroupsDialog(host, conn, groupIcons, client, this);
|
||||||
|
groupsDialog.setVisible(true);
|
||||||
|
groupsDialog = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the description from the server before prompting, since the model only
|
||||||
|
* carries one for clients whose info has been looked at.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void changeClientDescription(ClientEntry client) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
conn.requestClientDescription(client.id, (description, error) -> SwingUtilities.invokeLater(() -> {
|
||||||
|
if (error != null) {
|
||||||
|
JOptionPane.showMessageDialog(host, "Could not read the description: " + error,
|
||||||
|
"Error", JOptionPane.ERROR_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String edited = (String) JOptionPane.showInputDialog(host,
|
||||||
|
"Description for " + client.nickname + ":", "Change Description",
|
||||||
|
JOptionPane.PLAIN_MESSAGE, Icons.of("EDIT"), null, description);
|
||||||
|
if (edited == null || edited.equals(description)) return;
|
||||||
|
conn.setClientDescription(client.id, edited, failure -> SwingUtilities.invokeLater(() -> {
|
||||||
|
if (failure != null) {
|
||||||
|
JOptionPane.showMessageDialog(host, "Could not change the description: " + failure,
|
||||||
|
"Error", JOptionPane.ERROR_MESSAGE);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void renameSelf(String nickname) {
|
||||||
|
host.changeNickname(nickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
|
||||||
|
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void editChannel(ChannelNode channel) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
ChannelDialog.toEdit(host, conn, groupIcons, channel).setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createChannel(ChannelNode parent, ChannelSettings.Type type) {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
ChannelSettings defaults = new ChannelSettings();
|
||||||
|
defaults.type = type;
|
||||||
|
ChannelDialog.toCreate(host, conn, groupIcons,
|
||||||
|
parent == null ? "Create Channel" : "Create Sub-Channel of " + parent.name,
|
||||||
|
parent == null ? 0 : parent.id, defaults).setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createSpacer() {
|
||||||
|
if (!conn.isConnected()) return;
|
||||||
|
ChannelSettings defaults = new ChannelSettings();
|
||||||
|
// Spacers are cosmetic root channels, so they outlive everyone and, being channels,
|
||||||
|
// need a name no other channel has — which is what the tag's number is for.
|
||||||
|
defaults.name = Spacers.freeName(conn.getModel().childrenOf(0));
|
||||||
|
ChannelDialog.toCreate(host, conn, groupIcons, "Create Spacer", 0, defaults)
|
||||||
|
.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isConnected() {
|
||||||
|
return conn.isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void browseFiles(ChannelNode channel) {
|
||||||
|
if (!conn.canTransferFiles()) return;
|
||||||
|
new FileBrowserDialog(host, conn, channel).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user