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,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.
*
* <p>Activations arrive on the input hook's thread, so everything is handed to the
* event dispatch thread first. Which connections an action reaches is the binding's
* "on active server" flag: set, only the selected tab; clear, every connected one.
*/
final class HotkeyActions implements HotkeyEngine.Handler {
/** How much one press of the master-volume hotkeys moves the slider. */
private static final double VOLUME_STEP = 0.05;
private final MainFrame frame;
/** Latched push-to-talk, driven by the "Toggle Push-to-Talk" action. */
private boolean pttLatched;
HotkeyActions(MainFrame frame) {
this.frame = frame;
}
@Override
public void onHotkey(Hotkey hotkey, boolean active) {
SwingUtilities.invokeLater(() -> perform(hotkey, active));
}
private void perform(Hotkey hotkey, boolean active) {
List<ServerTab> targets = frame.hotkeyTargets(hotkey.activeServerOnly);
switch (hotkey.action) {
case CONNECT_CURRENT_TAB -> frame.connectBookmark(hotkey.argument, false);
case CONNECT_NEW_TAB -> frame.connectBookmark(hotkey.argument, true);
case DISCONNECT_CURRENT -> {
ServerTab tab = frame.selectedTab();
if (tab != null) tab.disconnect();
}
case DISCONNECT_ALL -> {
for (ServerTab tab : frame.allTabs()) tab.disconnect();
}
case MIC_ACTIVATE -> frame.moveMicrophoneToSelectedTab();
case MIC_MUTE -> setMicMuted(targets, true);
case MIC_UNMUTE -> setMicMuted(targets, false);
case MIC_TOGGLE -> setMicMuted(targets, !anyMicMuted(targets));
case SPEAKER_MUTE -> setDeafened(targets, true);
case SPEAKER_UNMUTE -> setDeafened(targets, false);
case SPEAKER_TOGGLE -> setDeafened(targets, !anyDeafened(targets));
case AWAY_SET -> setAway(targets, true, "");
case AWAY_ONLINE -> setAway(targets, false, "");
case AWAY_TOGGLE -> setAway(targets, !anyAway(targets), "");
case AWAY_TOGGLE_WITH_MESSAGE -> setAway(targets, !anyAway(targets), hotkey.argument);
case COMMANDER_ACTIVATE -> setCommander(targets, true);
case COMMANDER_DEACTIVATE -> setCommander(targets, false);
case COMMANDER_TOGGLE -> setCommander(targets, !anyCommander(targets));
// Momentary: the engine reports the release too, so the key simply holds it open.
case PTT_ACTIVATE -> frame.setPushToTalk(active || pttLatched);
case PTT_DEACTIVATE -> {
pttLatched = false;
frame.setPushToTalk(false);
}
case PTT_TOGGLE -> {
pttLatched = !pttLatched;
frame.setPushToTalk(pttLatched);
}
case CHANNEL_SWITCH -> {
for (ServerTab tab : targets) tab.joinChannelPath(hotkey.argument);
}
case SERVER_TAB_SELECT -> frame.selectTabNumber(parseIndex(hotkey.argument));
case SERVER_TAB_NEXT -> frame.stepTab(1);
case SERVER_TAB_PREVIOUS -> frame.stepTab(-1);
case SOUND_MUTE -> frame.setSoundsMuted(true);
case SOUND_UNMUTE -> frame.setSoundsMuted(false);
case SOUND_TOGGLE -> frame.setSoundsMuted(!frame.areSoundsMuted());
case VOLUME_INCREASE -> frame.adjustMasterVolume(VOLUME_STEP);
case VOLUME_DECREASE -> frame.adjustMasterVolume(-VOLUME_STEP);
case NICKNAME_CHANGE -> frame.changeNickname(hotkey.argument);
case FILEBROWSER -> frame.browseCurrentChannel();
case SKIN_RELOAD -> frame.reloadSkin();
case BRING_TO_FRONT -> frame.bringToFront();
case SEND_TO_BACK -> frame.sendToBack();
default -> {
// Listed for completeness in the action catalogue, but not implemented here.
}
}
}
private void setMicMuted(List<ServerTab> targets, boolean muted) {
for (ServerTab tab : targets) tab.setMicMuted(muted);
frame.refreshAfterHotkey();
}
private void setDeafened(List<ServerTab> targets, boolean deaf) {
for (ServerTab tab : targets) tab.setDeafened(deaf);
frame.refreshAfterHotkey();
}
private void setAway(List<ServerTab> targets, boolean away, String message) {
for (ServerTab tab : targets) tab.setAway(away, message == null ? "" : message);
frame.refreshAfterHotkey();
}
private void setCommander(List<ServerTab> targets, boolean commander) {
for (ServerTab tab : targets) tab.setCommander(commander);
frame.refreshAfterHotkey();
}
private static boolean anyMicMuted(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isMicMuted);
}
private static boolean anyDeafened(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isDeafened);
}
private static boolean anyAway(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isAway);
}
private static boolean anyCommander(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isCommander);
}
/** @return the 1-based tab number in the argument, or 1 when it is not a number */
private static int parseIndex(String argument) {
try {
return Math.max(1, Integer.parseInt(argument.trim()));
} catch (RuntimeException e) {
return 1;
}
}
}