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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user