Global hotkeys, in TeamSpeak's own shape

Bindings are captured system-wide rather than only while the window has
focus: X11's RECORD extension where the X server sees every key, and a
/dev/input reader as the Wayland fallback. Any key can act as a modifier,
mouse buttons included, as TS3 allows.

The action catalogue, its three categories and the "advanced actions"
split are reverse-engineered from the original client; actions this
client cannot perform are listed but greyed out. Push-to-talk becomes one
of these hotkeys, so the old focus-bound pushToTalkKey setting is gone and
the Voice Activation button edits that binding instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 17:08:28 +00:00
parent 3e2c06248f
commit 7e4b9671fe
25 changed files with 2858 additions and 55 deletions

View File

@@ -0,0 +1,94 @@
package com.ts3client.ui;
import com.ts3client.hotkey.GlobalInputHook;
import com.ts3client.hotkey.Hotkey;
import com.ts3client.hotkey.HotkeyAction;
import com.ts3client.hotkey.HotkeyCombo;
import com.ts3client.hotkey.HotkeyEngine;
import com.ts3client.hotkey.HotkeyKey;
import com.ts3client.hotkey.Hotkeys;
import com.ts3client.hotkey.desktop.DesktopInputHooks;
import java.util.List;
/**
* Owns the hotkey machinery for the UI: the stored bindings, the matching engine and
* the platform input hook they are fed from. Everything the dialogs need — recording a
* combination, naming keys, telling the user why hotkeys are dead — goes through here.
*/
final class HotkeyService {
private final Hotkeys hotkeys = Hotkeys.load();
private final HotkeyEngine engine;
private final GlobalInputHook hook;
private final HotkeyArguments arguments;
HotkeyService(HotkeyEngine.Handler handler, HotkeyArguments arguments) {
this.arguments = arguments;
engine = new HotkeyEngine(hotkeys, handler);
hook = DesktopInputHooks.start(engine);
}
/** The values this action's parameter can take right now, for the action tree. */
List<String> argumentChoices(HotkeyAction action) {
if (arguments == null || action.argument() == HotkeyAction.Argument.NONE) return List.of();
try {
return arguments.choices(action);
} catch (RuntimeException e) {
return List.of();
}
}
List<Hotkey> all() {
return hotkeys.all();
}
void replaceAll(List<Hotkey> updated) {
engine.releaseAll();
hotkeys.replaceAll(updated);
hotkeys.save();
}
/** The binding for an action with no argument, or {@code null} when unbound. */
Hotkey find(HotkeyAction action) {
synchronized (hotkeys.all()) {
for (Hotkey h : hotkeys.all()) {
if (h.action == action) return h;
}
}
return null;
}
boolean isRunning() {
return hook.isRunning();
}
/** One line for the options dialog: either working, or why it is not. */
String status() {
return hook.isRunning()
? "Global hotkeys are active."
: "Global hotkeys are unavailable (" + hook.unavailableReason() + ").";
}
String display(HotkeyCombo combo) {
return combo == null ? "No hotkey assigned" : combo.display(this::keyName);
}
private String keyName(HotkeyKey key) {
return hook.keyName(key);
}
/** Captures the next combination instead of firing bindings; see {@link #stopRecording()}. */
void record(HotkeyEngine.Recorder recorder) {
engine.record(recorder);
}
void stopRecording() {
engine.stopRecording();
}
void dispose() {
engine.releaseAll();
hook.close();
}
}