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

@@ -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<AudioDevices.Device> inputCombo;
private JComboBox<AudioDevices.Device> 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<Hotkey> 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) {