diff --git a/ts3-client/README.md b/ts3-client/README.md
index 08a4046..98cf320 100644
--- a/ts3-client/README.md
+++ b/ts3-client/README.md
@@ -80,6 +80,25 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
- **Status bar** shows the server name, user count and live ping.
- 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.
+- Capture uses X11's RECORD extension, which needs no privileges and does not swallow
+ the keystroke. On Wayland the X server never sees keys aimed at other applications,
+ so the client falls back to reading `/dev/input/event*` — 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
- **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`, …)
@@ -151,6 +170,12 @@ core/ com.ts3client
├── gfx
│ ├── IconPack an icon pack zip/folder + its settings.ini mapping
│ └── 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
│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids)
│ ├── SoundPack a pack folder + its settings.ini mapping
@@ -164,14 +189,18 @@ core/ com.ts3client
├── ChannelNode/ClientEntry view models
└── ConnectionListener frontend callbacks
-desktop/ com.ts3client.audio.desktop
+desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop
├── Opus Panama (FFM) binding to native libopus
├── OpusEncoder/OpusDecoder thin codec wrappers
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing
├── 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
+ ├── XRecordInputHook global key/button capture via X11's RECORD extension
+ ├── EvdevInputHook /dev/input fallback for Wayland sessions
+ └── DesktopInputHooks picks the backend that suits the session
swing/ com.ts3client
├── Main entry point (look & feel, settings, backend injection)
@@ -182,6 +211,10 @@ swing/ com.ts3client
├── InfoPanel channel description / client group + details view
├── ChatPanel chat log + input
├── 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
├── IconPackPanel icon pack chooser + icon viewer (Options → Design)
├── ConnectDialog connect form
@@ -200,6 +233,9 @@ swing/ com.ts3client
dependency-free and reusable in the core library.
- Whisper is received/played but not yet **sendable** from the UI.
- Playback decodes streams as mono; stereo music-bot audio is down-mixed.
-- Push-to-talk is captured via Swing key events, so it only works while the app
- window has focus (no global hotkey).
+- Hotkey actions TeamSpeak offers but this client does not perform yet (listed but
+ 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".
- No file transfer, avatars, or server/channel administration UI yet.
diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java
index a322b2a..fcc6fec 100644
--- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java
+++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java
@@ -75,8 +75,6 @@ public final class Settings {
public double speechThreshold = 0.5;
/** Keep voice activation running while in push-to-talk mode. */
public boolean vadOverPtt = false;
- /** Push-to-talk key as an AWT virtual-key code; the frontend interprets it. Default: Ctrl (VK_CONTROL). */
- public int pushToTalkKey = 17;
/** Opus target bitrate in bits/sec. */
public int bitrate = 48000;
/** Opus complexity 0..10. */
@@ -173,7 +171,6 @@ public final class Settings {
vadThresholdDb = parseD(props.getProperty("vadThresholdDb"), vadThresholdDb);
speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold);
vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt);
- pushToTalkKey = parseI(props.getProperty("pushToTalkKey"), pushToTalkKey);
bitrate = parseI(props.getProperty("bitrate"), bitrate);
complexity = parseI(props.getProperty("complexity"), complexity);
vbr = parseB(props.getProperty("vbr"), vbr);
@@ -211,7 +208,6 @@ public final class Settings {
props.setProperty("vadThresholdDb", Double.toString(vadThresholdDb));
props.setProperty("speechThreshold", Double.toString(speechThreshold));
props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt));
- props.setProperty("pushToTalkKey", Integer.toString(pushToTalkKey));
props.setProperty("bitrate", Integer.toString(bitrate));
props.setProperty("complexity", Integer.toString(complexity));
props.setProperty("vbr", Boolean.toString(vbr));
diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/GlobalInputHook.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/GlobalInputHook.java
new file mode 100644
index 0000000..8ded28c
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/GlobalInputHook.java
@@ -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.
+ *
+ *
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();
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkey.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkey.java
new file mode 100644
index 0000000..7307ffd
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkey.java
@@ -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;
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java
new file mode 100644
index 0000000..4e905a3
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java
@@ -0,0 +1,363 @@
+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).
+ *
+ *
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.
+ *
+ *
{@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 is the hardware-level mute of the capture device itself. */
+ MIC_LOCAL_UNMUTE(Category.SERVER, 0x0030, "ActivateLocalMute",
+ "Disable Local Mic Mute", true, Argument.NONE, false),
+ MIC_LOCAL_MUTE(Category.SERVER, 0x0030, "DeactivateLocalMute",
+ "Enable Local Mic Mute", true, Argument.NONE, false),
+ MIC_LOCAL_TOGGLE(Category.SERVER, 0x0030, "ToggleLocalMute",
+ "Toggle Local Mic Mute", true, Argument.NONE, false),
+
+ 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 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 of(Category category, boolean includeAdvanced) {
+ List 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;
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyCombo.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyCombo.java
new file mode 100644
index 0000000..742238a
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyCombo.java
@@ -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 keys;
+
+ public HotkeyCombo(Collection keys) {
+ List copy = new ArrayList<>();
+ for (HotkeyKey k : keys) {
+ if (k != null && !copy.contains(k)) copy.add(k);
+ }
+ this.keys = Collections.unmodifiableList(copy);
+ }
+
+ public List 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 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 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 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();
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyEngine.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyEngine.java
new file mode 100644
index 0000000..8bcb2d3
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyEngine.java
@@ -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.
+ *
+ * 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 pressed = new LinkedHashSet<>();
+ /** Bindings whose combination is currently held, so a release can end them. */
+ private final Set active = new HashSet<>();
+
+ private volatile Recorder recorder;
+ private final Set 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 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 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();
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java
new file mode 100644
index 0000000..d60fbd2
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java
@@ -0,0 +1,81 @@
+package com.ts3client.hotkey;
+
+/**
+ * One physical key or mouse button, identified the way the X server numbers them:
+ * keyboards by keycode (Linux evdev code + 8) and mice by button number, so a binding
+ * is independent of the keyboard layout in force when it was recorded.
+ *
+ * @param device which input device the code belongs to
+ * @param code X keycode for {@link Device#KEYBOARD}, X 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;
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java
new file mode 100644
index 0000000..756a4cd
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java
@@ -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 entries = Collections.synchronizedList(new ArrayList<>());
+
+ /** Live view; iterate under {@code synchronized (all())} when the engine may be running. */
+ public List 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 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;
+ }
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java
index 6730942..bb1e6ef 100644
--- a/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java
@@ -24,6 +24,7 @@ public final class SoundNotifier {
private volatile SoundPlayer player;
private volatile SoundPack pack;
private volatile List available = List.of();
+ private volatile boolean soundsMuted;
public SoundNotifier(Settings settings) {
this.settings = settings;
@@ -55,6 +56,19 @@ public final class SoundNotifier {
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.
*
@@ -70,6 +84,7 @@ public final class SoundNotifier {
* {@code clientname} or {@code channelname}
*/
public void fire(SoundEvent event, boolean muted, Map variables) {
+ if (soundsMuted) return;
NotificationSettings notifications = settings.notifications;
if (!notifications.isEnabled(event)) return;
if (muted && !notifications.isImportant(event)) return;
diff --git a/ts3-client/core/src/test/java/com/ts3client/hotkey/HotkeyEngineTest.java b/ts3-client/core/src/test/java/com/ts3client/hotkey/HotkeyEngineTest.java
new file mode 100644
index 0000000..68c9908
--- /dev/null
+++ b/ts3-client/core/src/test/java/com/ts3client/hotkey/HotkeyEngineTest.java
@@ -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 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 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()));
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java
new file mode 100644
index 0000000..14a87cc
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java
@@ -0,0 +1,79 @@
+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.
+ *
+ * On X11, RECORD is the polite choice: no extra privileges, no devices to open. On
+ * Wayland the X server sees only what is aimed at X clients, so reading evdev is the
+ * only 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 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 candidates() {
+ List hooks = new ArrayList<>();
+ if (isWayland()) {
+ if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
+ if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook());
+ } else {
+ if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook());
+ if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook());
+ }
+ return hooks;
+ }
+
+ 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) {
+ return X11KeyNamer.name(key);
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/EvdevInputHook.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/EvdevInputHook.java
new file mode 100644
index 0000000..b3f4e8a
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/EvdevInputHook.java
@@ -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.
+ *
+ * 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.
+ *
+ *
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 streams = new ArrayList<>();
+ private final List 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();
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/KeyNames.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/KeyNames.java
new file mode 100644
index 0000000..77b2921
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/KeyNames.java
@@ -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 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('_', ' ');
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/X11KeyNamer.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/X11KeyNamer.java
new file mode 100644
index 0000000..7ff49d3
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/X11KeyNamer.java
@@ -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.
+ *
+ * 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 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;
+ }
+ });
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XRecordInputHook.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XRecordInputHook.java
new file mode 100644
index 0000000..44b527a
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XRecordInputHook.java
@@ -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.
+ *
+ * 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.
+ *
+ *
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;
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java
new file mode 100644
index 0000000..550c764
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java
@@ -0,0 +1,207 @@
+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} and the RECORD extension in {@code libXtst}, with just
+ * the calls {@link XRecordInputHook} needs.
+ *
+ *
RECORD lets a client watch the core input events the server delivers to everyone
+ * else without intercepting them, which is exactly what a global hotkey needs: the
+ * keystroke still reaches the focused application.
+ *
+ *
Loading is lazy and failure is expected — with no X11 around, {@link #isAvailable()}
+ * returns {@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;
+
+ private static final Linker LINKER = Linker.nativeLinker();
+ private static final String[] X11_NAMES = {"libX11.so.6", "libX11.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));
+ }
+
+ /** 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;
+ }
+ }
+
+ 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);
+ }
+
+ /** 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);
+ }
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java
new file mode 100644
index 0000000..a6575c2
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java
@@ -0,0 +1,147 @@
+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.
+ *
+ *
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 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 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 targets, boolean muted) {
+ for (ServerTab tab : targets) tab.setMicMuted(muted);
+ frame.refreshAfterHotkey();
+ }
+
+ private void setDeafened(List targets, boolean deaf) {
+ for (ServerTab tab : targets) tab.setDeafened(deaf);
+ frame.refreshAfterHotkey();
+ }
+
+ private void setAway(List targets, boolean away, String message) {
+ for (ServerTab tab : targets) tab.setAway(away, message == null ? "" : message);
+ frame.refreshAfterHotkey();
+ }
+
+ private void setCommander(List targets, boolean commander) {
+ for (ServerTab tab : targets) tab.setCommander(commander);
+ frame.refreshAfterHotkey();
+ }
+
+ private static boolean anyMicMuted(List tabs) {
+ return tabs.stream().anyMatch(ServerTab::isMicMuted);
+ }
+
+ private static boolean anyDeafened(List tabs) {
+ return tabs.stream().anyMatch(ServerTab::isDeafened);
+ }
+
+ private static boolean anyAway(List tabs) {
+ return tabs.stream().anyMatch(ServerTab::isAway);
+ }
+
+ private static boolean anyCommander(List 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;
+ }
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyArguments.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyArguments.java
new file mode 100644
index 0000000..c456b72
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyArguments.java
@@ -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 choices(HotkeyAction action);
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyDialog.java
new file mode 100644
index 0000000..58b817a
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyDialog.java
@@ -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 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 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;
+ }
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyService.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyService.java
new file mode 100644
index 0000000..10ae8e6
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyService.java
@@ -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 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 all() {
+ return hotkeys.all();
+ }
+
+ void replaceAll(List 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();
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeysPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeysPanel.java
new file mode 100644
index 0000000..70e3134
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeysPanel.java
@@ -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 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);
+ }
+ }
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
index 5bca4c1..e5d13eb 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
@@ -7,6 +7,7 @@ import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
+import com.ts3client.net.ChannelNode;
import com.ts3client.sound.SoundNotifier;
import com.ts3client.sound.SoundPlayer;
@@ -31,9 +32,6 @@ import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Dimension;
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.WindowEvent;
import java.util.ArrayList;
@@ -89,6 +87,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
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. */
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
private final Thread shutdownHook = new Thread(this::shutdown, "ts3j-shutdown");
@@ -100,6 +101,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
this.sounds = new SoundNotifier(settings);
this.soundPlayer = audio.createSoundPlayer(settings);
this.sounds.setPlayer(soundPlayer);
+ this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices);
setIconImage(Icons.app().getImage());
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
@@ -132,7 +134,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
first.chat().appendSystem("Use Connections → Connect to join a server.");
first.chat().appendSystem(tray.status());
- installPushToTalk();
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
statusTimer.start();
setSize(880, 560);
@@ -436,26 +437,151 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
tabUpdated(tab);
}
- // ---- push to talk ----
+ // ---- hotkeys ----
- private void installPushToTalk() {
- KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(new KeyEventDispatcher() {
- @Override
- public boolean dispatchKeyEvent(KeyEvent e) {
- if (settings.inputMode != Settings.InputMode.PUSH_TO_TALK) return false;
- ServerTab tab = micTab;
- if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return false;
- if (e.getKeyCode() != settings.pushToTalkKey) return false;
- 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;
+ /**
+ * Which connections a fired hotkey reaches: with "on active server" ticked only the
+ * selected tab, otherwise every connected one.
+ */
+ List hotkeyTargets(boolean activeServerOnly) {
+ List 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 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;
+ if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return;
+ tab.connection().getMicrophone().setPushToTalk(talking);
+ }
+
+ 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 hotkeyArgumentChoices(com.ts3client.hotkey.HotkeyAction action) {
+ List 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 ----
@@ -627,6 +753,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
tab.shutdown();
}
soundPlayer.shutdown();
+ hotkeys.dispose();
if (tray != null) tray.dispose();
}
@@ -646,7 +773,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
SettingsDialog dlg = new SettingsDialog(this, settings,
micTab == null ? null : micTab.connection().getMicrophone(),
selected == null ? null : selected.connection().getPlayback(),
- sounds,
+ sounds, hotkeys,
this::applyOutputSettingsToAllTabs);
dlg.setVisible(true);
}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
index 026464a..bd4c4b1 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
@@ -15,6 +15,8 @@ import javax.swing.JComponent;
import javax.swing.JOptionPane;
import javax.swing.JSplitPane;
import javax.swing.SwingUtilities;
+import java.util.ArrayList;
+import java.util.List;
import java.awt.Component;
/**
@@ -319,6 +321,33 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
return c != null ? c.nickname : "Client " + clientId;
}
+ /** Joins the channel at a "/"-separated path, as the hotkey action names it. */
+ void joinChannelPath(String path) {
+ if (!conn.isConnected() || path == null || path.isBlank()) return;
+ ChannelNode target = conn.getModel().findChannelByPath(path);
+ if (target != null) conn.joinChannel(target.id, null);
+ }
+
+ /** Every channel of this server as a "/"-separated path, in the tree's own order. */
+ List channelPaths() {
+ List out = new ArrayList<>();
+ if (!conn.isConnected()) return out;
+ for (ChannelNode root : conn.getModel().buildTree()) collectPaths(root, out);
+ return out;
+ }
+
+ private void collectPaths(ChannelNode channel, List out) {
+ out.add(conn.getModel().channelPath(channel.id));
+ for (ChannelNode child : channel.children) collectPaths(child, out);
+ }
+
+ /** The channel we are in, or null when not connected. */
+ ChannelNode currentChannel() {
+ if (!conn.isConnected()) return null;
+ ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
+ return self == null ? null : conn.getModel().getChannel(self.channelId);
+ }
+
// ---- ServerTreePanel.Actions ----
@Override
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java
index 911e216..647005d 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java
@@ -6,6 +6,8 @@ import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.AudioCapture;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.config.Settings;
+import com.ts3client.hotkey.Hotkey;
+import com.ts3client.hotkey.HotkeyAction;
import com.ts3client.sound.SoundNotifier;
import javax.swing.BorderFactory;
@@ -30,8 +32,6 @@ import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Rectangle;
-import java.awt.event.KeyAdapter;
-import java.awt.event.KeyEvent;
import java.util.List;
/**
@@ -53,11 +53,13 @@ public final class SettingsDialog extends JDialog {
private final VoiceInput liveMic;
private final VoiceOutput livePlayback;
private final SoundNotifier sounds;
+ private final HotkeyService hotkeys;
private final Runnable onApply;
private NotificationsPanel notificationsPanel;
private IconPackPanel iconPackPanel;
private ClientVersionPanel clientVersionPanel;
+ private HotkeysPanel hotkeysPanel;
private JComboBox inputCombo;
private JComboBox outputCombo;
@@ -79,7 +81,6 @@ public final class SettingsDialog extends JDialog {
private JLabel speechLabel;
private LevelMeter meter;
private JButton pttKeyButton;
- private int pttKey;
private JSlider bitrateSlider;
private JLabel bitrateLabel;
private JSlider complexitySlider;
@@ -92,14 +93,14 @@ public final class SettingsDialog extends JDialog {
public SettingsDialog(Frame owner, Settings settings,
VoiceInput liveMic, VoiceOutput livePlayback,
- SoundNotifier sounds, Runnable onApply) {
+ SoundNotifier sounds, HotkeyService hotkeys, Runnable onApply) {
super(owner, "Options", true);
this.settings = settings;
this.liveMic = liveMic;
this.livePlayback = livePlayback;
this.sounds = sounds;
this.onApply = onApply;
- this.pttKey = settings.pushToTalkKey;
+ this.hotkeys = hotkeys;
JTabbedPane tabs = new JTabbedPane();
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
@@ -108,6 +109,8 @@ public final class SettingsDialog extends JDialog {
tabs.addTab("Notifications", notificationsPanel);
iconPackPanel = new IconPackPanel(settings);
tabs.addTab("Design", iconPackPanel);
+ hotkeysPanel = new HotkeysPanel(hotkeys);
+ tabs.addTab("Hotkeys", hotkeysPanel);
clientVersionPanel = new ClientVersionPanel(settings);
tabs.addTab("Client Version", scrollable(clientVersionPanel));
@@ -313,9 +316,10 @@ public final class SettingsDialog extends JDialog {
});
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
- pttKeyButton = new JButton(keyName(pttKey));
- pttKeyButton.addActionListener(e -> capturePttKey());
- addRow(p, c, row++, new JLabel("Push-to-talk key:"), pttKeyButton);
+ pttKeyButton = new JButton(pushToTalkHotkeyText());
+ pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
+ pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
+ addRow(p, c, row++, new JLabel("Push-to-talk hotkey:"), pttKeyButton);
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
c.gridx = 0;
@@ -511,23 +515,31 @@ public final class SettingsDialog extends JDialog {
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
}
- private void capturePttKey() {
- pttKeyButton.setText("Press a key…");
- pttKeyButton.requestFocusInWindow();
- KeyAdapter ka = new KeyAdapter() {
- @Override
- public void keyPressed(KeyEvent e) {
- pttKey = e.getKeyCode();
- pttKeyButton.setText(keyName(pttKey));
- pttKeyButton.removeKeyListener(this);
- }
- };
- pttKeyButton.addKeyListener(ka);
+ private String pushToTalkHotkeyText() {
+ Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
+ return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
}
- private static String keyName(int code) {
- String t = KeyEvent.getKeyText(code);
- return (t == null || t.isEmpty()) ? ("Key " + code) : t;
+ /**
+ * Push-to-talk is an ordinary hotkey, so this shortcut edits that binding — adding
+ * it when there is none — rather than keeping a key of its own.
+ */
+ private void editPushToTalkHotkey() {
+ Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
+ HotkeyDialog dlg = new HotkeyDialog(this, hotkeys,
+ existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
+ dlg.setVisible(true);
+ if (!dlg.isConfirmed()) return;
+ List updated = new java.util.ArrayList<>();
+ synchronized (hotkeys.all()) {
+ for (Hotkey h : hotkeys.all()) {
+ if (h != existing) updated.add(h.copy());
+ }
+ }
+ updated.add(dlg.result());
+ hotkeys.replaceAll(updated);
+ pttKeyButton.setText(pushToTalkHotkeyText());
+ hotkeysPanel.reload();
}
private void apply() {
@@ -546,7 +558,6 @@ public final class SettingsDialog extends JDialog {
settings.vadThresholdDb = thresholdSlider.getValue();
settings.speechThreshold = speechSlider.getValue() / 100.0;
settings.vadOverPtt = vadOverPttCheck.isSelected();
- settings.pushToTalkKey = pttKey;
settings.bitrate = bitrateSlider.getValue() * 1000;
settings.complexity = complexitySlider.getValue();
settings.vbr = vbrCheck.isSelected();
@@ -555,6 +566,7 @@ public final class SettingsDialog extends JDialog {
notificationsPanel.apply();
iconPackPanel.apply();
clientVersionPanel.apply();
+ hotkeysPanel.apply();
settings.save();
if (liveMic != null) {