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:
@@ -75,8 +75,6 @@ public final class Settings {
|
||||
public double speechThreshold = 0.5;
|
||||
/** Keep voice activation running while in push-to-talk mode. */
|
||||
public boolean vadOverPtt = false;
|
||||
/** Push-to-talk key as an AWT virtual-key code; the frontend interprets it. Default: Ctrl (VK_CONTROL). */
|
||||
public int pushToTalkKey = 17;
|
||||
/** Opus target bitrate in bits/sec. */
|
||||
public int bitrate = 48000;
|
||||
/** Opus complexity 0..10. */
|
||||
@@ -173,7 +171,6 @@ public final class Settings {
|
||||
vadThresholdDb = parseD(props.getProperty("vadThresholdDb"), vadThresholdDb);
|
||||
speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold);
|
||||
vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt);
|
||||
pushToTalkKey = parseI(props.getProperty("pushToTalkKey"), pushToTalkKey);
|
||||
bitrate = parseI(props.getProperty("bitrate"), bitrate);
|
||||
complexity = parseI(props.getProperty("complexity"), complexity);
|
||||
vbr = parseB(props.getProperty("vbr"), vbr);
|
||||
@@ -211,7 +208,6 @@ public final class Settings {
|
||||
props.setProperty("vadThresholdDb", Double.toString(vadThresholdDb));
|
||||
props.setProperty("speechThreshold", Double.toString(speechThreshold));
|
||||
props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt));
|
||||
props.setProperty("pushToTalkKey", Integer.toString(pushToTalkKey));
|
||||
props.setProperty("bitrate", Integer.toString(bitrate));
|
||||
props.setProperty("complexity", Integer.toString(complexity));
|
||||
props.setProperty("vbr", Boolean.toString(vbr));
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ts3client.hotkey;
|
||||
|
||||
/**
|
||||
* A system-wide source of key and mouse-button events: it reports every press and
|
||||
* release on the machine, whether or not the client has focus, and without swallowing
|
||||
* the event on its way to the focused application.
|
||||
*
|
||||
* <p>Implementations are platform code and live outside the core; the core only ever
|
||||
* sees this interface.
|
||||
*/
|
||||
public interface GlobalInputHook extends AutoCloseable {
|
||||
|
||||
interface Listener {
|
||||
/**
|
||||
* @param key the key or button
|
||||
* @param pressed {@code true} for a press, {@code false} for a release
|
||||
*/
|
||||
void onInput(HotkeyKey key, boolean pressed);
|
||||
}
|
||||
|
||||
/** Starts delivering events on a background thread. */
|
||||
void start(Listener listener);
|
||||
|
||||
/** Whether events are actually being delivered; false when the platform refused. */
|
||||
boolean isRunning();
|
||||
|
||||
/** Why the hook is not running, for the options dialog to show; empty when it is. */
|
||||
String unavailableReason();
|
||||
|
||||
/**
|
||||
* The name this system's layout gives a key, or {@code null} to let the caller fall
|
||||
* back to {@link HotkeyKey#fallbackName()}.
|
||||
*/
|
||||
String keyName(HotkeyKey key);
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ts3client.hotkey;
|
||||
|
||||
/**
|
||||
* A single binding: which combination triggers which action, on which edge of the
|
||||
* keypress, and whether it reaches only the active server or every connected one.
|
||||
*/
|
||||
public final class Hotkey {
|
||||
|
||||
/** Which edge of the key press runs the action, as TS3's hotkey mode combo box offers. */
|
||||
public enum Trigger {
|
||||
/** Action triggers when the key is pressed down. */
|
||||
KEY_DOWN("On key down"),
|
||||
/** Action triggers when the key is released. */
|
||||
KEY_UP("On key up");
|
||||
|
||||
private final String label;
|
||||
|
||||
Trigger(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
public HotkeyAction action;
|
||||
public HotkeyCombo combo;
|
||||
public Trigger trigger = Trigger.KEY_DOWN;
|
||||
/** Apply to the active server only; when false the action reaches every connection. */
|
||||
public boolean activeServerOnly = true;
|
||||
/** Meaning depends on {@link HotkeyAction#argument()}; empty when the action takes none. */
|
||||
public String argument = "";
|
||||
public boolean enabled = true;
|
||||
|
||||
public Hotkey() {
|
||||
}
|
||||
|
||||
public Hotkey(HotkeyAction action, HotkeyCombo combo) {
|
||||
this.action = action;
|
||||
this.combo = combo;
|
||||
}
|
||||
|
||||
/**
|
||||
* The binding's place in the action tree, as TS3 spells it out:
|
||||
* {@code Sounds / Activate Soundpack / Default Sound Pack (Male)}.
|
||||
*/
|
||||
public String path() {
|
||||
if (action == null) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (!action.group().isEmpty()) sb.append(action.group()).append(" / ");
|
||||
sb.append(action.label());
|
||||
if (argument != null && !argument.isBlank()) sb.append(" / ").append(argument.trim());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return action != null && combo != null && !combo.isEmpty();
|
||||
}
|
||||
|
||||
/** Whether the action is a per-server one, for which {@link #activeServerOnly} matters. */
|
||||
public boolean isServerScoped() {
|
||||
return action != null && action.category() != HotkeyAction.Category.MISC;
|
||||
}
|
||||
|
||||
public Hotkey copy() {
|
||||
Hotkey h = new Hotkey(action, combo);
|
||||
h.trigger = trigger;
|
||||
h.activeServerOnly = activeServerOnly;
|
||||
h.argument = argument;
|
||||
h.enabled = enabled;
|
||||
return h;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package com.ts3client.hotkey;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The catalogue of hotkey actions, reverse-engineered from the official TeamSpeak 3
|
||||
* client (its hotkey dialog builds exactly this list, in this order).
|
||||
*
|
||||
* <p>Every entry keeps the identifiers TS3 itself uses — a numeric action type and a
|
||||
* parameter keyword — so bindings stay recognisable and could be imported from, or
|
||||
* exported to, the original client. {@link #supported()} tells whether this client
|
||||
* can actually carry the action out; the rest are listed but greyed out.
|
||||
*
|
||||
* <p>{@link #advanced()} mirrors the dialog's "Show Advanced Actions" checkbox: TS3
|
||||
* hides all but a handful of everyday actions until it is ticked.
|
||||
*/
|
||||
public enum HotkeyAction {
|
||||
|
||||
// ----- Server -----
|
||||
CONNECT_CURRENT_TAB(Category.SERVER, 0x0010, "Server (current tab)",
|
||||
"Connect to Server on Current Tab", false, Argument.BOOKMARK, true),
|
||||
CONNECT_NEW_TAB(Category.SERVER, 0x0010, "Server (new tab)",
|
||||
"Connect to Server in a New Tab", false, Argument.BOOKMARK, true),
|
||||
DISCONNECT_CURRENT(Category.SERVER, 0x0020, "Current Server",
|
||||
"Disconnect from Current Server", false, Argument.NONE, true),
|
||||
DISCONNECT_ALL(Category.SERVER, 0x0020, "All Servers",
|
||||
"Disconnect from All Servers", false, Argument.NONE, true),
|
||||
|
||||
MIC_ACTIVATE(Category.SERVER, 0x0030, "Activate",
|
||||
"Activate Microphone (current tab)", true, Argument.NONE, true),
|
||||
MIC_MUTE(Category.SERVER, 0x0030, "Mute",
|
||||
"Mute Microphone", true, Argument.NONE, true),
|
||||
MIC_UNMUTE(Category.SERVER, 0x0030, "Unmute",
|
||||
"Unmute Microphone", true, Argument.NONE, true),
|
||||
MIC_TOGGLE(Category.SERVER, 0x0030, "Toggle",
|
||||
"Toggle Microphone Mute", false, Argument.NONE, true),
|
||||
/** TS3's "local" mute is the hardware-level mute of the capture device itself. */
|
||||
MIC_LOCAL_UNMUTE(Category.SERVER, 0x0030, "ActivateLocalMute",
|
||||
"Disable Local Mic Mute", true, Argument.NONE, false),
|
||||
MIC_LOCAL_MUTE(Category.SERVER, 0x0030, "DeactivateLocalMute",
|
||||
"Enable Local Mic Mute", true, Argument.NONE, false),
|
||||
MIC_LOCAL_TOGGLE(Category.SERVER, 0x0030, "ToggleLocalMute",
|
||||
"Toggle Local Mic Mute", true, Argument.NONE, false),
|
||||
|
||||
SPEAKER_MUTE(Category.SERVER, 0x0040, "Mute",
|
||||
"Mute Speaker", true, Argument.NONE, true),
|
||||
SPEAKER_UNMUTE(Category.SERVER, 0x0040, "Unmute",
|
||||
"Unmute Speaker", true, Argument.NONE, true),
|
||||
SPEAKER_TOGGLE(Category.SERVER, 0x0040, "Toggle",
|
||||
"Toggle Speaker Mute", false, Argument.NONE, true),
|
||||
|
||||
AWAY_SET(Category.SERVER, 0x0050, "SetAway",
|
||||
"Set Away", true, Argument.NONE, true),
|
||||
AWAY_ONLINE(Category.SERVER, 0x0050, "SetOnline",
|
||||
"Set Online", true, Argument.NONE, true),
|
||||
AWAY_TOGGLE(Category.SERVER, 0x0050, "Toggle",
|
||||
"Toggle Away Status", false, Argument.NONE, true),
|
||||
AWAY_TOGGLE_WITH_MESSAGE(Category.SERVER, 0x0050, "Toggle With Message",
|
||||
"Toggle Away Status with Message", true, Argument.TEXT, true),
|
||||
|
||||
COMMANDER_ACTIVATE(Category.SERVER, 0x0100, "Activate",
|
||||
"Activate Channel Commander", true, Argument.NONE, true),
|
||||
COMMANDER_DEACTIVATE(Category.SERVER, 0x0100, "Deactivate",
|
||||
"Deactivate Channel Commander", true, Argument.NONE, true),
|
||||
COMMANDER_TOGGLE(Category.SERVER, 0x0100, "Toggle",
|
||||
"Toggle Channel Commander", true, Argument.NONE, true),
|
||||
|
||||
// ----- Self -----
|
||||
CAPTURE_PROFILE_ACTIVATE(Category.SELF, 0x0110, "Activate",
|
||||
"Activate Capture Profile", true, Argument.PROFILE, false),
|
||||
CAPTURE_PROFILE_DEACTIVATE(Category.SELF, 0x0110, "Deactivate",
|
||||
"Deactivate Capture Profile", true, Argument.PROFILE, false),
|
||||
CAPTURE_PROFILE_TOGGLE(Category.SELF, 0x0110, "Toggle",
|
||||
"Toggle Capture Profile", true, Argument.PROFILE, false),
|
||||
PLAYBACK_PROFILE_ACTIVATE(Category.SELF, 0x0120, "Activate",
|
||||
"Activate Playback Profile", true, Argument.PROFILE, false),
|
||||
PLAYBACK_PROFILE_DEACTIVATE(Category.SELF, 0x0120, "Deactivate",
|
||||
"Deactivate Playback Profile", true, Argument.PROFILE, false),
|
||||
PLAYBACK_PROFILE_TOGGLE(Category.SELF, 0x0120, "Toggle",
|
||||
"Toggle Playback Profile", true, Argument.PROFILE, false),
|
||||
HOTKEY_PROFILE_ACTIVATE(Category.SELF, 0x0130, "Activate",
|
||||
"Activate Hotkey Profile", true, Argument.PROFILE, false),
|
||||
HOTKEY_PROFILE_DEACTIVATE(Category.SELF, 0x0130, "Deactivate",
|
||||
"Deactivate Hotkey Profile", true, Argument.PROFILE, false),
|
||||
|
||||
PTT_ACTIVATE(Category.SELF, 0x0140, "Activate",
|
||||
"Activate Push-to-Talk", false, Argument.NONE, true, true),
|
||||
PTT_DEACTIVATE(Category.SELF, 0x0140, "Deactivate",
|
||||
"Deactivate Push-to-Talk", true, Argument.NONE, true),
|
||||
PTT_TOGGLE(Category.SELF, 0x0140, "Toggle",
|
||||
"Toggle Push-to-Talk", true, Argument.NONE, true),
|
||||
|
||||
CHANNEL_SWITCH(Category.SELF, 0x0150, "Strict Channel",
|
||||
"Switch to Channel", true, Argument.CHANNEL, true),
|
||||
CHANNEL_NEXT(Category.SELF, 0x0150, "Next Channel",
|
||||
"Switch to Next Channel (Global)", true, Argument.NONE, false),
|
||||
CHANNEL_PREVIOUS(Category.SELF, 0x0150, "Previous Channel",
|
||||
"Switch to Previous Channel (Global)", true, Argument.NONE, false),
|
||||
CHANNEL_LAST_VISITED(Category.SELF, 0x0150, "Last Visited Channel",
|
||||
"Switch to Last Visited Channel", true, Argument.NONE, false),
|
||||
CHANNEL_NEXT_FAMILY(Category.SELF, 0x0150, "Next Channel Family",
|
||||
"Switch to Next Channel (Channel Family)", true, Argument.NONE, false),
|
||||
CHANNEL_PREVIOUS_FAMILY(Category.SELF, 0x0150, "Previous Channel Family",
|
||||
"Switch to Previous Channel (Channel Family)", true, Argument.NONE, false),
|
||||
CHANNEL_NEXT_LEVEL(Category.SELF, 0x0150, "Next Channel Level",
|
||||
"Switch to Next Channel (Same Level)", true, Argument.NONE, false),
|
||||
CHANNEL_PREVIOUS_LEVEL(Category.SELF, 0x0150, "Previous Channel Level",
|
||||
"Switch to Previous Channel (Same Level)", true, Argument.NONE, false),
|
||||
|
||||
SERVER_TAB_SELECT(Category.SELF, 0x0160, "Strict Server",
|
||||
"Select Server Tab", true, Argument.TEXT, true),
|
||||
SERVER_TAB_NEXT(Category.SELF, 0x0160, "Next Server",
|
||||
"Select Next Server Tab", true, Argument.NONE, true),
|
||||
SERVER_TAB_PREVIOUS(Category.SELF, 0x0160, "Previous Server",
|
||||
"Select Previous Server Tab", true, Argument.NONE, true),
|
||||
|
||||
SOUNDPACK_ACTIVATE(Category.SELF, 0x0170, "Activate",
|
||||
"Activate Soundpack", true, Argument.PROFILE, false),
|
||||
SOUND_MUTE(Category.SELF, 0x0170, "Mute",
|
||||
"Mute Sounds", true, Argument.NONE, true),
|
||||
SOUND_UNMUTE(Category.SELF, 0x0170, "Unmute",
|
||||
"Unmute Sounds", true, Argument.NONE, true),
|
||||
SOUND_TOGGLE(Category.SELF, 0x0170, "Toggle",
|
||||
"Toggle Sound Mute", true, Argument.NONE, true),
|
||||
|
||||
WHISPER_PUSH_ACTIVATE(Category.SELF, 0x0180, "ActivatePushToWhisper",
|
||||
"Activate Push-To-Whisper", true, Argument.TEXT, false, true),
|
||||
WHISPER_PUSH_DEACTIVATE(Category.SELF, 0x0180, "DeactivatePushToWhisper",
|
||||
"Deactivate Push-To-Whisper", true, Argument.TEXT, false),
|
||||
WHISPER_REPLY_PUSH_ACTIVATE(Category.SELF, 0x0180, "ActivateReplyPushToWhisper",
|
||||
"Activate Push-To-Reply to a Whisper", true, Argument.NONE, false, true),
|
||||
WHISPER_REPLY_PUSH_DEACTIVATE(Category.SELF, 0x0180, "DeactivateReplyPushToWhisper",
|
||||
"Deactivate Push-To-Reply to a Whisper", true, Argument.NONE, false),
|
||||
WHISPERLIST_ACTIVATE(Category.SELF, 0x0180, "Activate",
|
||||
"Activate Whisperlist", true, Argument.TEXT, false),
|
||||
WHISPERLIST_DEACTIVATE(Category.SELF, 0x0180, "Deactivate",
|
||||
"Deactivate Whisperlist", true, Argument.TEXT, false),
|
||||
WHISPER_REPLY_ACTIVATE(Category.SELF, 0x0180, "ActivateReply",
|
||||
"Activate Reply to a Whisper", true, Argument.NONE, false),
|
||||
WHISPER_REPLY_DEACTIVATE(Category.SELF, 0x0180, "DeactivateReply",
|
||||
"Deactivate Reply to a Whisper", true, Argument.NONE, false),
|
||||
WHISPER_BLOCK_ACTIVATE(Category.SELF, 0x0180, "ActivateBlock",
|
||||
"Activate Block Incoming Whispers", true, Argument.NONE, false),
|
||||
WHISPER_BLOCK_DEACTIVATE(Category.SELF, 0x0180, "DeactivateBlock",
|
||||
"Deactivate Block Incoming Whispers", true, Argument.NONE, false),
|
||||
WHISPER_BLOCK_TOGGLE(Category.SELF, 0x0180, "ToggleBlock",
|
||||
"Toggle Block Incoming Whispers", true, Argument.NONE, false),
|
||||
|
||||
RECORDING_START(Category.SELF, 0x01c0, "Activate",
|
||||
"Start Recording", true, Argument.NONE, false),
|
||||
RECORDING_START_MULTITRACK(Category.SELF, 0x01c0, "ActivateMT",
|
||||
"Start Multitrack Recording", true, Argument.NONE, false),
|
||||
RECORDING_STOP(Category.SELF, 0x01c0, "Deactivate",
|
||||
"Stop Recording", true, Argument.NONE, false),
|
||||
|
||||
VOLUME_INCREASE(Category.SELF, 0x01e0, "Increase",
|
||||
"Increase Master Volume", true, Argument.NONE, true),
|
||||
VOLUME_DECREASE(Category.SELF, 0x01e0, "Decrease",
|
||||
"Decrease Master Volume", true, Argument.NONE, true),
|
||||
|
||||
PLUGIN_ACTIVATE(Category.SELF, 0x01f0, "Activate",
|
||||
"Activate Plugin", true, Argument.TEXT, false),
|
||||
PLUGIN_DEACTIVATE(Category.SELF, 0x01f0, "Deactivate",
|
||||
"Deactivate Plugin", true, Argument.TEXT, false),
|
||||
PLUGIN_TOGGLE(Category.SELF, 0x01f0, "Toggle",
|
||||
"Toggle Plugin", true, Argument.TEXT, false),
|
||||
PLUGIN_COMMAND(Category.SELF, 0x01f0, "Run",
|
||||
"Run Plugin Command", true, Argument.TEXT, false),
|
||||
PLUGIN_HOTKEY(Category.SELF, 0x01f0, "Hotkey",
|
||||
"Plugin Hotkey", true, Argument.TEXT, false),
|
||||
|
||||
SERVER_GROUP_ASSIGN(Category.SELF, 0x0270, "AssignSG",
|
||||
"Assign Server Group", true, Argument.TEXT, false),
|
||||
SERVER_GROUP_REVOKE(Category.SELF, 0x0270, "RevokeSG",
|
||||
"Revoke Server Group", true, Argument.TEXT, false),
|
||||
SERVER_GROUP_TOGGLE(Category.SELF, 0x0270, "ToggleSG",
|
||||
"Toggle Server Group", true, Argument.TEXT, false),
|
||||
|
||||
STYLESHEET_HELPER_ON(Category.SELF, 0x0240, "Activate",
|
||||
"Stylesheet Helper (on)", true, Argument.NONE, false),
|
||||
STYLESHEET_HELPER_OFF(Category.SELF, 0x0240, "Deactivate",
|
||||
"Stylesheet Helper (off)", true, Argument.NONE, false),
|
||||
|
||||
SOUND_3D_ACTIVATE(Category.SELF, 0x0250, "Activate",
|
||||
"Activate 3D Sound", true, Argument.NONE, false),
|
||||
SOUND_3D_DEACTIVATE(Category.SELF, 0x0250, "Deactivate",
|
||||
"Deactivate 3D Sound", true, Argument.NONE, false),
|
||||
SOUND_3D_TOGGLE(Category.SELF, 0x0250, "Toggle",
|
||||
"Toggle 3D Sound", true, Argument.NONE, false),
|
||||
|
||||
NICKNAME_CHANGE(Category.SELF, 0x01a0, "Rename",
|
||||
"Change Nickname", true, Argument.TEXT, true),
|
||||
|
||||
// ----- Misc -----
|
||||
TALK_POWER_GRANT_NEXT(Category.MISC, 0x01b0, "GrantNextTalkPower",
|
||||
"Grant Next User Talk Power", true, Argument.NONE, false),
|
||||
TALK_POWER_REQUEST(Category.MISC, 0x01b0, "RequestTalkPower",
|
||||
"Request Talk Power", true, Argument.NONE, false),
|
||||
TALK_POWER_REVOKE_ALL_GRANT_NEXT(Category.MISC, 0x01b0, "RevokeAllAndGrantNext",
|
||||
"Revoke All And Grant Next User Talk Power", true, Argument.NONE, false),
|
||||
|
||||
FILEBROWSER(Category.MISC, 0x01d0, "Filebrowser",
|
||||
"Open Filebrowser on Channel", true, Argument.NONE, true),
|
||||
AUTOCONNECT_DISABLE(Category.MISC, 0x0200, "Autoconnect",
|
||||
"Disable Autoconnect", true, Argument.NONE, false),
|
||||
SKIN_RELOAD(Category.MISC, 0x0210, "SkinReload",
|
||||
"Reload Skin", true, Argument.NONE, true),
|
||||
BRING_TO_FRONT(Category.MISC, 0x0220, "Bring2Front",
|
||||
"Bring Client to Front", true, Argument.NONE, true),
|
||||
SEND_TO_BACK(Category.MISC, 0x0230, "Send2Back",
|
||||
"Send Client to Background", true, Argument.NONE, true);
|
||||
|
||||
/** The three groups the TS3 hotkey dialog separates its list into. */
|
||||
public enum Category {
|
||||
SERVER("Server"),
|
||||
SELF("Self"),
|
||||
MISC("Misc");
|
||||
|
||||
private final String label;
|
||||
|
||||
Category(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The group an action's type belongs to in TeamSpeak's hotkey tree, taken from the
|
||||
* table its dialog builds. Types absent here are ungrouped: TS3 shows them as a
|
||||
* single item directly under their category.
|
||||
*/
|
||||
private static final Map<Integer, String> GROUPS = Map.ofEntries(
|
||||
Map.entry(0x0010, "Connect to Server"),
|
||||
Map.entry(0x0020, "Disconnect from Server"),
|
||||
Map.entry(0x0030, "Microphone"),
|
||||
Map.entry(0x0040, "Speaker"),
|
||||
Map.entry(0x0050, "Away Status"),
|
||||
Map.entry(0x0100, "Channel Commander"),
|
||||
Map.entry(0x0110, "Capture Profile"),
|
||||
Map.entry(0x0120, "Playback Profile"),
|
||||
Map.entry(0x0130, "Hotkey Profile"),
|
||||
Map.entry(0x0140, "Push-to-Talk"),
|
||||
Map.entry(0x0150, "Switch to Channel"),
|
||||
Map.entry(0x0160, "Select Server Tab"),
|
||||
Map.entry(0x0170, "Sounds"),
|
||||
Map.entry(0x0180, "Whisper"),
|
||||
Map.entry(0x01b0, "Talk Power"),
|
||||
Map.entry(0x01c0, "Recording"),
|
||||
Map.entry(0x01e0, "Master Volume"),
|
||||
Map.entry(0x01f0, "Plugins"),
|
||||
Map.entry(0x0250, "3D Sound"),
|
||||
Map.entry(0x0270, "Permissions"));
|
||||
|
||||
/** What the action's free-text parameter means, when it takes one. */
|
||||
public enum Argument {
|
||||
NONE,
|
||||
/** Label of a bookmark to connect to. */
|
||||
BOOKMARK,
|
||||
/** Channel path, "/"-separated. */
|
||||
CHANNEL,
|
||||
/** Name of a capture/playback/hotkey profile or sound pack. */
|
||||
PROFILE,
|
||||
/** Anything else: nickname, away message, tab number, plugin command. */
|
||||
TEXT
|
||||
}
|
||||
|
||||
private final Category category;
|
||||
private final int type;
|
||||
private final String parameter;
|
||||
private final String label;
|
||||
private final boolean advanced;
|
||||
private final Argument argument;
|
||||
private final boolean supported;
|
||||
private final boolean momentary;
|
||||
|
||||
HotkeyAction(Category category, int type, String parameter, String label,
|
||||
boolean advanced, Argument argument, boolean supported) {
|
||||
this(category, type, parameter, label, advanced, argument, supported, false);
|
||||
}
|
||||
|
||||
HotkeyAction(Category category, int type, String parameter, String label,
|
||||
boolean advanced, Argument argument, boolean supported, boolean momentary) {
|
||||
this.category = category;
|
||||
this.type = type;
|
||||
this.parameter = parameter;
|
||||
this.label = label;
|
||||
this.advanced = advanced;
|
||||
this.argument = argument;
|
||||
this.supported = supported;
|
||||
this.momentary = momentary;
|
||||
}
|
||||
|
||||
public Category category() {
|
||||
return category;
|
||||
}
|
||||
|
||||
/** TS3's numeric action type; actions sharing one differ only by {@link #parameter()}. */
|
||||
public int type() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node this action hangs under in the hotkey tree ("Sounds", "Microphone", …),
|
||||
* or an empty string for the actions TS3 lists on their own.
|
||||
*/
|
||||
public String group() {
|
||||
return GROUPS.getOrDefault(type, "");
|
||||
}
|
||||
|
||||
/** TS3's parameter keyword, e.g. {@code Toggle} or {@code ActivateReply}. */
|
||||
public String parameter() {
|
||||
return parameter;
|
||||
}
|
||||
|
||||
/** The name shown in the hotkey list. */
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/** Hidden until "Show advanced actions" is ticked, as in TS3. */
|
||||
public boolean advanced() {
|
||||
return advanced;
|
||||
}
|
||||
|
||||
public Argument argument() {
|
||||
return argument;
|
||||
}
|
||||
|
||||
/** Whether this client implements the action; unsupported ones cannot be bound. */
|
||||
public boolean supported() {
|
||||
return supported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the action lasts only while the hotkey is held (push-to-talk and the
|
||||
* push-to-whisper variants), in which case the trigger setting does not apply and
|
||||
* the release is reported as well.
|
||||
*/
|
||||
public boolean momentary() {
|
||||
return momentary;
|
||||
}
|
||||
|
||||
public static List<HotkeyAction> of(Category category, boolean includeAdvanced) {
|
||||
List<HotkeyAction> out = new ArrayList<>();
|
||||
for (HotkeyAction a : values()) {
|
||||
if (a.category == category && (includeAdvanced || !a.advanced)) out.add(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Looks an action up by its enum name, falling back to {@code null}. */
|
||||
public static HotkeyAction byName(String name) {
|
||||
for (HotkeyAction a : values()) {
|
||||
if (a.name().equals(name)) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.ts3client.hotkey;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* A key combination: one key or button, or several that have to be held together.
|
||||
* There is no distinguished modifier — as in TS3, any key can take that role, and the
|
||||
* combination fires once every one of its keys is down.
|
||||
*/
|
||||
public final class HotkeyCombo {
|
||||
|
||||
/** Recording order, which is also the order the combination is displayed in. */
|
||||
private final List<HotkeyKey> keys;
|
||||
|
||||
public HotkeyCombo(Collection<HotkeyKey> keys) {
|
||||
List<HotkeyKey> copy = new ArrayList<>();
|
||||
for (HotkeyKey k : keys) {
|
||||
if (k != null && !copy.contains(k)) copy.add(k);
|
||||
}
|
||||
this.keys = Collections.unmodifiableList(copy);
|
||||
}
|
||||
|
||||
public List<HotkeyKey> keys() {
|
||||
return keys;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return keys.isEmpty();
|
||||
}
|
||||
|
||||
public boolean contains(HotkeyKey key) {
|
||||
return keys.contains(key);
|
||||
}
|
||||
|
||||
/** Whether every key of the combination is currently held down. */
|
||||
public boolean isSatisfiedBy(Set<HotkeyKey> pressed) {
|
||||
return !keys.isEmpty() && pressed.containsAll(keys);
|
||||
}
|
||||
|
||||
/** Serialised form, e.g. {@code Keyboard:37+Keyboard:45}. */
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (HotkeyKey k : keys) {
|
||||
if (sb.length() > 0) sb.append('+');
|
||||
sb.append(k);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static HotkeyCombo parse(String text) {
|
||||
List<HotkeyKey> keys = new ArrayList<>();
|
||||
if (text != null && !text.isBlank()) {
|
||||
for (String part : text.split("\\+")) {
|
||||
HotkeyKey k = HotkeyKey.parse(part);
|
||||
if (k != null) keys.add(k);
|
||||
}
|
||||
}
|
||||
return new HotkeyCombo(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the combination for the user, e.g. {@code Ctrl+Mouse 4}.
|
||||
*
|
||||
* @param namer resolves a key to its name on this system, typically from the
|
||||
* keyboard layout; may return {@code null} to fall back
|
||||
*/
|
||||
public String display(Function<HotkeyKey, String> namer) {
|
||||
if (keys.isEmpty()) return "No hotkey assigned";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (HotkeyKey k : keys) {
|
||||
String name = namer == null ? null : namer.apply(k);
|
||||
if (name == null || name.isBlank()) name = k.fallbackName();
|
||||
if (sb.length() > 0) sb.append(" + ");
|
||||
sb.append(name);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
return o instanceof HotkeyCombo other && keys.equals(other.keys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return keys.hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.ts3client.hotkey;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Turns the raw stream of global key events into hotkey activations: it tracks which
|
||||
* keys are down, decides when a binding's combination becomes (or stops being)
|
||||
* satisfied, and reports that on the configured edge.
|
||||
*
|
||||
* <p>While a {@link Recorder} is installed the engine records combinations instead of
|
||||
* firing them, which is how the hotkey dialog captures a new binding without the old
|
||||
* ones going off.
|
||||
*/
|
||||
public final class HotkeyEngine implements GlobalInputHook.Listener {
|
||||
|
||||
/** Receives activations; called on the input hook's thread. */
|
||||
public interface Handler {
|
||||
/**
|
||||
* @param hotkey the binding that fired
|
||||
* @param active {@code true} on activation; a {@code false} call follows on release
|
||||
* for {@link HotkeyAction#momentary()} actions only
|
||||
*/
|
||||
void onHotkey(Hotkey hotkey, boolean active);
|
||||
}
|
||||
|
||||
/** Collects a combination as the user presses it, for the "set hotkey" button. */
|
||||
public interface Recorder {
|
||||
/** The keys held so far, in press order; called on every change. */
|
||||
void onRecording(HotkeyCombo combo);
|
||||
|
||||
/** The user let go of everything: this is the final combination. */
|
||||
void onRecorded(HotkeyCombo combo);
|
||||
}
|
||||
|
||||
private final Hotkeys hotkeys;
|
||||
private final Handler handler;
|
||||
|
||||
private final Set<HotkeyKey> pressed = new LinkedHashSet<>();
|
||||
/** Bindings whose combination is currently held, so a release can end them. */
|
||||
private final Set<Hotkey> active = new HashSet<>();
|
||||
|
||||
private volatile Recorder recorder;
|
||||
private final Set<HotkeyKey> recording = new LinkedHashSet<>();
|
||||
|
||||
public HotkeyEngine(Hotkeys hotkeys, Handler handler) {
|
||||
this.hotkeys = hotkeys;
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diverts every event into {@code recorder} until {@link #stopRecording()}; the
|
||||
* bindings themselves stay silent meanwhile.
|
||||
*/
|
||||
public void record(Recorder recorder) {
|
||||
synchronized (this) {
|
||||
recording.clear();
|
||||
releaseActive();
|
||||
pressed.clear();
|
||||
}
|
||||
this.recorder = recorder;
|
||||
}
|
||||
|
||||
public void stopRecording() {
|
||||
recorder = null;
|
||||
synchronized (this) {
|
||||
recording.clear();
|
||||
pressed.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInput(HotkeyKey key, boolean down) {
|
||||
Recorder rec = recorder;
|
||||
if (rec != null) {
|
||||
recordKey(rec, key, down);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Runnable> fired = new ArrayList<>();
|
||||
synchronized (this) {
|
||||
if (down) {
|
||||
if (!pressed.add(key)) return;
|
||||
} else if (!pressed.remove(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (hotkeys.all()) {
|
||||
for (Hotkey h : hotkeys.all()) {
|
||||
if (!h.enabled || !h.isValid() || !h.action.supported()) continue;
|
||||
if (!h.combo.contains(key)) continue;
|
||||
|
||||
boolean satisfied = h.combo.isSatisfiedBy(pressed);
|
||||
if (satisfied && active.add(h)) {
|
||||
if (h.action.momentary() || h.trigger == Hotkey.Trigger.KEY_DOWN) {
|
||||
fired.add(() -> handler.onHotkey(h, true));
|
||||
}
|
||||
} else if (!satisfied && active.remove(h)) {
|
||||
if (h.action.momentary()) {
|
||||
fired.add(() -> handler.onHotkey(h, false));
|
||||
} else if (h.trigger == Hotkey.Trigger.KEY_UP) {
|
||||
fired.add(() -> handler.onHotkey(h, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Runnable r : fired) r.run();
|
||||
}
|
||||
|
||||
private void recordKey(Recorder rec, HotkeyKey key, boolean down) {
|
||||
HotkeyCombo combo;
|
||||
boolean finished;
|
||||
synchronized (this) {
|
||||
if (down) {
|
||||
recording.add(key);
|
||||
pressed.add(key);
|
||||
} else {
|
||||
pressed.remove(key);
|
||||
}
|
||||
combo = new HotkeyCombo(recording);
|
||||
finished = !down && pressed.isEmpty() && !recording.isEmpty();
|
||||
if (finished) recording.clear();
|
||||
}
|
||||
if (finished) {
|
||||
rec.onRecorded(combo);
|
||||
} else {
|
||||
rec.onRecording(combo);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends every held binding, so nothing stays stuck when the hook stops. */
|
||||
public void releaseAll() {
|
||||
List<Runnable> fired = new ArrayList<>();
|
||||
synchronized (this) {
|
||||
for (Hotkey h : active) {
|
||||
if (h.action.momentary()) fired.add(() -> handler.onHotkey(h, false));
|
||||
}
|
||||
active.clear();
|
||||
pressed.clear();
|
||||
}
|
||||
for (Runnable r : fired) r.run();
|
||||
}
|
||||
|
||||
private void releaseActive() {
|
||||
for (Hotkey h : new ArrayList<>(active)) {
|
||||
if (h.action.momentary()) handler.onHotkey(h, false);
|
||||
}
|
||||
active.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.ts3client.hotkey;
|
||||
|
||||
/**
|
||||
* One physical key or mouse button, identified the way the X server numbers them:
|
||||
* keyboards by keycode (Linux evdev code + 8) and mice by button number, so a binding
|
||||
* is independent of the keyboard layout in force when it was recorded.
|
||||
*
|
||||
* @param device which input device the code belongs to
|
||||
* @param code X keycode for {@link Device#KEYBOARD}, X button number for {@link Device#MOUSE}
|
||||
*/
|
||||
public record HotkeyKey(HotkeyKey.Device device, int code) {
|
||||
|
||||
public enum Device {
|
||||
KEYBOARD("Keyboard"),
|
||||
MOUSE("Mouse");
|
||||
|
||||
private final String prefix;
|
||||
|
||||
Device(String prefix) {
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
String prefix() {
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
|
||||
public static HotkeyKey keyboard(int keycode) {
|
||||
return new HotkeyKey(Device.KEYBOARD, keycode);
|
||||
}
|
||||
|
||||
public static HotkeyKey mouse(int button) {
|
||||
return new HotkeyKey(Device.MOUSE, button);
|
||||
}
|
||||
|
||||
/** Serialised form, matching TS3's {@code Keyboard:9} / {@code Mouse:9} notation. */
|
||||
@Override
|
||||
public String toString() {
|
||||
return device.prefix() + ":" + code;
|
||||
}
|
||||
|
||||
public static HotkeyKey parse(String text) {
|
||||
if (text == null) return null;
|
||||
int colon = text.indexOf(':');
|
||||
if (colon <= 0) return null;
|
||||
String prefix = text.substring(0, colon).trim();
|
||||
int code;
|
||||
try {
|
||||
code = Integer.parseInt(text.substring(colon + 1).trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
for (Device d : Device.values()) {
|
||||
if (d.prefix().equalsIgnoreCase(prefix)) return new HotkeyKey(d, code);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A readable name that needs no help from the platform: mouse buttons carry the
|
||||
* numbering users know them by, keys fall back to their raw code.
|
||||
*/
|
||||
public String fallbackName() {
|
||||
if (device == Device.MOUSE) {
|
||||
return switch (code) {
|
||||
case 1 -> "Mouse Left";
|
||||
case 2 -> "Mouse Middle";
|
||||
case 3 -> "Mouse Right";
|
||||
case 4 -> "Wheel Up";
|
||||
case 5 -> "Wheel Down";
|
||||
case 6 -> "Wheel Left";
|
||||
case 7 -> "Wheel Right";
|
||||
// X hands the side buttons out as 8 and 9; everyone else calls them 4 and 5.
|
||||
case 8 -> "Mouse 4";
|
||||
case 9 -> "Mouse 5";
|
||||
default -> "Mouse " + code;
|
||||
};
|
||||
}
|
||||
return "Key " + code;
|
||||
}
|
||||
}
|
||||
109
ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java
Normal file
109
ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java
Normal file
@@ -0,0 +1,109 @@
|
||||
package com.ts3client.hotkey;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* The user's hotkey bindings, stored alongside the settings file.
|
||||
* Frontend-agnostic: no UI dependencies.
|
||||
*/
|
||||
public final class Hotkeys {
|
||||
|
||||
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
|
||||
private static final File FILE = new File(DIR, "hotkeys.properties");
|
||||
|
||||
private final List<Hotkey> entries = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
/** Live view; iterate under {@code synchronized (all())} when the engine may be running. */
|
||||
public List<Hotkey> all() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
public void add(Hotkey hotkey) {
|
||||
entries.add(hotkey);
|
||||
}
|
||||
|
||||
public void set(int index, Hotkey hotkey) {
|
||||
if (index >= 0 && index < entries.size()) entries.set(index, hotkey);
|
||||
}
|
||||
|
||||
public void remove(int index) {
|
||||
if (index >= 0 && index < entries.size()) entries.remove(index);
|
||||
}
|
||||
|
||||
/** Replaces every binding at once, e.g. when the options dialog is applied. */
|
||||
public void replaceAll(List<Hotkey> hotkeys) {
|
||||
synchronized (entries) {
|
||||
entries.clear();
|
||||
for (Hotkey h : hotkeys) {
|
||||
if (h != null && h.isValid()) entries.add(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Hotkeys load() {
|
||||
Hotkeys h = new Hotkeys();
|
||||
if (!FILE.isFile()) return h;
|
||||
Properties p = new Properties();
|
||||
try (FileInputStream in = new FileInputStream(FILE)) {
|
||||
p.load(in);
|
||||
} catch (Exception e) {
|
||||
return h;
|
||||
}
|
||||
int count = parseInt(p.getProperty("count"), 0);
|
||||
for (int i = 0; i < count; i++) {
|
||||
String prefix = "hotkey." + i + ".";
|
||||
HotkeyAction action = HotkeyAction.byName(p.getProperty(prefix + "action", ""));
|
||||
if (action == null) continue;
|
||||
Hotkey entry = new Hotkey(action, HotkeyCombo.parse(p.getProperty(prefix + "keys", "")));
|
||||
entry.trigger = "KEY_UP".equals(p.getProperty(prefix + "trigger"))
|
||||
? Hotkey.Trigger.KEY_UP : Hotkey.Trigger.KEY_DOWN;
|
||||
entry.activeServerOnly = !"false".equals(p.getProperty(prefix + "activeServerOnly"));
|
||||
entry.argument = p.getProperty(prefix + "argument", "");
|
||||
entry.enabled = !"false".equals(p.getProperty(prefix + "enabled"));
|
||||
if (entry.isValid()) h.entries.add(entry);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
public void save() {
|
||||
Properties p = new Properties();
|
||||
synchronized (entries) {
|
||||
p.setProperty("count", Integer.toString(entries.size()));
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
Hotkey e = entries.get(i);
|
||||
String prefix = "hotkey." + i + ".";
|
||||
p.setProperty(prefix + "action", e.action.name());
|
||||
p.setProperty(prefix + "keys", e.combo.toString());
|
||||
p.setProperty(prefix + "trigger", e.trigger.name());
|
||||
p.setProperty(prefix + "activeServerOnly", Boolean.toString(e.activeServerOnly));
|
||||
p.setProperty(prefix + "argument", e.argument == null ? "" : e.argument);
|
||||
p.setProperty(prefix + "enabled", Boolean.toString(e.enabled));
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!DIR.isDirectory()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
DIR.mkdirs();
|
||||
}
|
||||
try (FileOutputStream out = new FileOutputStream(FILE)) {
|
||||
p.store(out, "TS3J client hotkeys");
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseInt(String v, int def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return Integer.parseInt(v.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public final class SoundNotifier {
|
||||
private volatile SoundPlayer player;
|
||||
private volatile SoundPack pack;
|
||||
private volatile List<SoundPack> available = List.of();
|
||||
private volatile boolean soundsMuted;
|
||||
|
||||
public SoundNotifier(Settings settings) {
|
||||
this.settings = settings;
|
||||
@@ -55,6 +56,19 @@ public final class SoundNotifier {
|
||||
settings.soundPack = pack == null ? "" : pack.id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Silences every notification sound until turned back on — TS3's "Mute Sounds"
|
||||
* hotkey action. Unlike deafening, this is not tied to a server and even important
|
||||
* actions stay quiet.
|
||||
*/
|
||||
public void setMuted(boolean muted) {
|
||||
this.soundsMuted = muted;
|
||||
}
|
||||
|
||||
public boolean isMuted() {
|
||||
return soundsMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the sound for an action.
|
||||
*
|
||||
@@ -70,6 +84,7 @@ public final class SoundNotifier {
|
||||
* {@code clientname} or {@code channelname}
|
||||
*/
|
||||
public void fire(SoundEvent event, boolean muted, Map<String, String> variables) {
|
||||
if (soundsMuted) return;
|
||||
NotificationSettings notifications = settings.notifications;
|
||||
if (!notifications.isEnabled(event)) return;
|
||||
if (muted && !notifications.isImportant(event)) return;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.ts3client.hotkey;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class HotkeyEngineTest {
|
||||
|
||||
private static final HotkeyKey CTRL = HotkeyKey.keyboard(37);
|
||||
private static final HotkeyKey K = HotkeyKey.keyboard(45);
|
||||
private static final HotkeyKey MOUSE4 = HotkeyKey.mouse(8);
|
||||
|
||||
private final List<String> fired = new ArrayList<>();
|
||||
private final Hotkeys hotkeys = new Hotkeys();
|
||||
private final HotkeyEngine engine = new HotkeyEngine(hotkeys,
|
||||
(hotkey, active) -> fired.add(hotkey.action + (active ? ":on" : ":off")));
|
||||
|
||||
private Hotkey bind(HotkeyAction action, Hotkey.Trigger trigger, HotkeyKey... keys) {
|
||||
Hotkey h = new Hotkey(action, new HotkeyCombo(List.of(keys)));
|
||||
h.trigger = trigger;
|
||||
hotkeys.add(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
@Test
|
||||
void firesOnKeyDownOnlyWhenEveryKeyIsHeld() {
|
||||
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, CTRL, K);
|
||||
|
||||
engine.onInput(K, true);
|
||||
assertTrue(fired.isEmpty(), "one key of the combination is not enough");
|
||||
engine.onInput(CTRL, true);
|
||||
assertEquals(List.of("MIC_TOGGLE:on"), fired);
|
||||
|
||||
// Releasing and re-pressing arms it again, but only once per completion.
|
||||
engine.onInput(CTRL, false);
|
||||
engine.onInput(CTRL, true);
|
||||
assertEquals(List.of("MIC_TOGGLE:on", "MIC_TOGGLE:on"), fired);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyUpTriggerFiresOnRelease() {
|
||||
bind(HotkeyAction.SPEAKER_TOGGLE, Hotkey.Trigger.KEY_UP, MOUSE4);
|
||||
|
||||
engine.onInput(MOUSE4, true);
|
||||
assertTrue(fired.isEmpty());
|
||||
engine.onInput(MOUSE4, false);
|
||||
assertEquals(List.of("SPEAKER_TOGGLE:on"), fired);
|
||||
}
|
||||
|
||||
@Test
|
||||
void momentaryActionReportsPressAndRelease() {
|
||||
bind(HotkeyAction.PTT_ACTIVATE, Hotkey.Trigger.KEY_UP, MOUSE4);
|
||||
|
||||
engine.onInput(MOUSE4, true);
|
||||
engine.onInput(MOUSE4, false);
|
||||
// The trigger setting does not apply: push-to-talk always spans the hold.
|
||||
assertEquals(List.of("PTT_ACTIVATE:on", "PTT_ACTIVATE:off"), fired);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAndUnsupportedBindingsStaySilent() {
|
||||
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K).enabled = false;
|
||||
bind(HotkeyAction.RECORDING_START, Hotkey.Trigger.KEY_DOWN, MOUSE4);
|
||||
|
||||
engine.onInput(K, true);
|
||||
engine.onInput(MOUSE4, true);
|
||||
assertTrue(fired.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordingCapturesTheCombinationInsteadOfFiring() {
|
||||
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K);
|
||||
List<HotkeyCombo> recorded = new ArrayList<>();
|
||||
engine.record(new HotkeyEngine.Recorder() {
|
||||
@Override
|
||||
public void onRecording(HotkeyCombo combo) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecorded(HotkeyCombo combo) {
|
||||
recorded.add(combo);
|
||||
}
|
||||
});
|
||||
|
||||
engine.onInput(CTRL, true);
|
||||
engine.onInput(K, true);
|
||||
engine.onInput(K, false);
|
||||
engine.onInput(CTRL, false);
|
||||
|
||||
assertTrue(fired.isEmpty(), "bindings must not fire while recording");
|
||||
assertEquals(1, recorded.size());
|
||||
assertEquals(List.of(CTRL, K), recorded.get(0).keys());
|
||||
}
|
||||
|
||||
@Test
|
||||
void actionsCarryTeamSpeaksTreeGrouping() {
|
||||
assertEquals("Sounds", HotkeyAction.SOUNDPACK_ACTIVATE.group());
|
||||
assertEquals("Microphone", HotkeyAction.MIC_TOGGLE.group());
|
||||
// TS3 leaves a handful of actions ungrouped, listed straight under their category.
|
||||
assertEquals("", HotkeyAction.NICKNAME_CHANGE.group());
|
||||
assertEquals("", HotkeyAction.BRING_TO_FRONT.group());
|
||||
|
||||
Hotkey pack = new Hotkey(HotkeyAction.SOUNDPACK_ACTIVATE, new HotkeyCombo(List.of(K)));
|
||||
pack.argument = "Default Sound Pack (Male)";
|
||||
assertEquals("Sounds / Activate Soundpack / Default Sound Pack (Male)", pack.path());
|
||||
assertEquals("Bring Client to Front",
|
||||
new Hotkey(HotkeyAction.BRING_TO_FRONT, new HotkeyCombo(List.of(K))).path());
|
||||
}
|
||||
|
||||
@Test
|
||||
void combinationsSurviveARoundTripThroughText() {
|
||||
HotkeyCombo combo = new HotkeyCombo(List.of(CTRL, MOUSE4));
|
||||
assertEquals("Keyboard:37+Mouse:8", combo.toString());
|
||||
assertEquals(combo, HotkeyCombo.parse(combo.toString()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user