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

@@ -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<ServerTab> hotkeyTargets(boolean activeServerOnly) {
List<ServerTab> out = new ArrayList<>();
if (activeServerOnly) {
if (selected != null && selected.isConnected()) out.add(selected);
return out;
}
for (ServerTab tab : tabs) {
if (tab.isConnected()) out.add(tab);
}
return out;
}
ServerTab selectedTab() {
return selected;
}
List<ServerTab> allTabs() {
return new ArrayList<>(tabs);
}
/** Opens the microphone while a push-to-talk hotkey is held. */
void setPushToTalk(boolean talking) {
pttPressed = talking;
ServerTab tab = micTab;
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<String> hotkeyArgumentChoices(com.ts3client.hotkey.HotkeyAction action) {
List<String> out = new ArrayList<>();
switch (action.argument()) {
case BOOKMARK -> {
for (Bookmark b : bookmarks.all()) {
if (b.label != null && !b.label.isBlank()) out.add(b.label);
}
}
case PROFILE -> {
if (action == com.ts3client.hotkey.HotkeyAction.SOUNDPACK_ACTIVATE) {
for (com.ts3client.sound.SoundPack pack : sounds.availablePacks()) out.add(pack.name());
}
}
case CHANNEL -> {
if (selected != null && selected.isConnected()) {
out.addAll(selected.channelPaths());
}
}
default -> {
}
}
return out;
}
/** Repaints the chrome after a hotkey changed the local client's state. */
void refreshAfterHotkey() {
updateToolbar();
updateTray();
refreshTabs();
}
// ---- actions ----
@@ -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);
}