diff --git a/ts3-client/README.md b/ts3-client/README.md index 08a4046..98cf320 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -80,6 +80,25 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. - **Status bar** shows the server name, user count and live ping. - Change your nickname, mute/deafen from the toolbar. +### Hotkeys +- **Global hotkeys**, as in TeamSpeak 3: single keys or combinations (any key can act + as the modifier) and mouse buttons including *Mouse 4* / *Mouse 5*, working + system-wide rather than only while the window has focus. Configure them in + **Options → Hotkeys**. +- Per binding you choose whether it **triggers on key down or on key up**, and whether + it applies **on the active server** only or to every connected one (checkbox off). + Push-to-talk style actions ignore the trigger setting and simply last while held. +- The action list is TeamSpeak's own, reverse-engineered from its hotkey dialog: + three categories (*Server*, *Self*, *Misc*) and a **"Show advanced actions"** + checkbox that reveals the rest, exactly as the original hides all but the everyday + actions. Actions this client does not implement are listed but greyed out. +- Push-to-talk is one of those hotkeys; the button in **Options → Voice Activation** + edits that binding. +- Capture uses X11's RECORD extension, which needs no privileges and does not swallow + the keystroke. On Wayland the X server never sees keys aimed at other applications, + so the client falls back to reading `/dev/input/event*` — for which your user has to + be in the `input` group. The Hotkeys tab says which backend is in use, or why none is. + ### Notification sounds - **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini` mapping actions (`CONNECTION_CONNECTED`, `CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS`, …) @@ -151,6 +170,12 @@ core/ com.ts3client ├── gfx │ ├── IconPack an icon pack zip/folder + its settings.ini mapping │ └── IconPacks discovery of installed packs +├── hotkey +│ ├── HotkeyAction catalogue of TeamSpeak's hotkey actions (RE'd from its binary) +│ ├── Hotkey/HotkeyCombo one binding: keys, trigger edge, server scope, argument +│ ├── Hotkeys persisted bindings (~/.ts3jclient/hotkeys.properties) +│ ├── GlobalInputHook platform hook interface: system-wide key/button events +│ └── HotkeyEngine held-key tracking, combination matching, recording ├── sound │ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids) │ ├── SoundPack a pack folder + its settings.ini mapping @@ -164,14 +189,18 @@ core/ com.ts3client ├── ChannelNode/ClientEntry view models └── ConnectionListener frontend callbacks -desktop/ com.ts3client.audio.desktop +desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop ├── Opus Panama (FFM) binding to native libopus ├── OpusEncoder/OpusDecoder thin codec wrappers ├── AudioDevices device enumeration + line opening (48 kHz/16-bit) ├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode ├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing ├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line -└── JavaSoundAudioBackend wires the above into the core AudioBackend +├── JavaSoundAudioBackend wires the above into the core AudioBackend +└── hotkey.desktop + ├── XRecordInputHook global key/button capture via X11's RECORD extension + ├── EvdevInputHook /dev/input fallback for Wayland sessions + └── DesktopInputHooks picks the backend that suits the session swing/ com.ts3client ├── Main entry point (look & feel, settings, backend injection) @@ -182,6 +211,10 @@ swing/ com.ts3client ├── InfoPanel channel description / client group + details view ├── ChatPanel chat log + input ├── SettingsDialog audio + VAD options with live meter + ├── HotkeysPanel hotkey list (Options → Hotkeys) + ├── HotkeyDialog add/edit one hotkey: action, combination, trigger, scope + ├── HotkeyService bindings + engine + input hook, for the dialogs + ├── HotkeyActions carries a fired hotkey out on the client ├── NotificationsPanel sound pack + per-action sound/important configuration ├── IconPackPanel icon pack chooser + icon viewer (Options → Design) ├── ConnectDialog connect form @@ -200,6 +233,9 @@ swing/ com.ts3client dependency-free and reusable in the core library. - Whisper is received/played but not yet **sendable** from the UI. - Playback decodes streams as mono; stereo music-bot audio is down-mixed. -- Push-to-talk is captured via Swing key events, so it only works while the app - window has focus (no global hotkey). +- Hotkey actions TeamSpeak offers but this client does not perform yet (listed but + greyed out in the dialog): capture/playback/hotkey profiles and sound packs, + whisper and push-to-whisper, recording, plugins, server groups, talk power, 3D + sound, hardware ("local") microphone mute and the channel-traversal variants + beyond "Switch to Channel". - No file transfer, avatars, or server/channel administration UI yet. diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index a322b2a..fcc6fec 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -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)); diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/GlobalInputHook.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/GlobalInputHook.java new file mode 100644 index 0000000..8ded28c --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/GlobalInputHook.java @@ -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. + * + *

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(); +} diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkey.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkey.java new file mode 100644 index 0000000..7307ffd --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkey.java @@ -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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java new file mode 100644 index 0000000..4e905a3 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyAction.java @@ -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). + * + *

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. + * + *

{@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 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 of(Category category, boolean includeAdvanced) { + List 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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyCombo.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyCombo.java new file mode 100644 index 0000000..742238a --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyCombo.java @@ -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 keys; + + public HotkeyCombo(Collection keys) { + List copy = new ArrayList<>(); + for (HotkeyKey k : keys) { + if (k != null && !copy.contains(k)) copy.add(k); + } + this.keys = Collections.unmodifiableList(copy); + } + + public List 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 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 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 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(); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyEngine.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyEngine.java new file mode 100644 index 0000000..8bcb2d3 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyEngine.java @@ -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. + * + *

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 pressed = new LinkedHashSet<>(); + /** Bindings whose combination is currently held, so a release can end them. */ + private final Set active = new HashSet<>(); + + private volatile Recorder recorder; + private final Set 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 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 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(); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java new file mode 100644 index 0000000..d60fbd2 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/HotkeyKey.java @@ -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; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java b/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java new file mode 100644 index 0000000..756a4cd --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java @@ -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 entries = Collections.synchronizedList(new ArrayList<>()); + + /** Live view; iterate under {@code synchronized (all())} when the engine may be running. */ + public List 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 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; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java index 6730942..bb1e6ef 100644 --- a/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java +++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java @@ -24,6 +24,7 @@ public final class SoundNotifier { private volatile SoundPlayer player; private volatile SoundPack pack; private volatile List 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 variables) { + if (soundsMuted) return; NotificationSettings notifications = settings.notifications; if (!notifications.isEnabled(event)) return; if (muted && !notifications.isImportant(event)) return; diff --git a/ts3-client/core/src/test/java/com/ts3client/hotkey/HotkeyEngineTest.java b/ts3-client/core/src/test/java/com/ts3client/hotkey/HotkeyEngineTest.java new file mode 100644 index 0000000..68c9908 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/hotkey/HotkeyEngineTest.java @@ -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 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 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())); + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java new file mode 100644 index 0000000..14a87cc --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/DesktopInputHooks.java @@ -0,0 +1,79 @@ +package com.ts3client.hotkey.desktop; + +import com.ts3client.hotkey.GlobalInputHook; +import com.ts3client.hotkey.HotkeyKey; + +import java.util.ArrayList; +import java.util.List; + +/** + * Picks the global input hook that suits the running session and starts it. + * + *

On X11, RECORD is the polite choice: no extra privileges, no devices to open. On + * Wayland the X server sees only what is aimed at X clients, so reading evdev is the + * only way to catch every key — and when that is not permitted either, the returned + * hook is simply not running and says why. + */ +public final class DesktopInputHooks { + + private DesktopInputHooks() { + } + + /** @return a hook, started when it could be; check {@link GlobalInputHook#isRunning()} */ + public static GlobalInputHook start(GlobalInputHook.Listener listener) { + List reasons = new ArrayList<>(); + for (GlobalInputHook hook : candidates()) { + hook.start(listener); + if (hook.isRunning()) return hook; + reasons.add(hook.getClass().getSimpleName() + ": " + hook.unavailableReason()); + hook.close(); + } + return new Unavailable(reasons.isEmpty() + ? "no global input backend for this session" + : String.join("; ", reasons)); + } + + private static List candidates() { + List hooks = new ArrayList<>(); + if (isWayland()) { + if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook()); + if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook()); + } else { + if (XRecordInputHook.isSupported()) hooks.add(new XRecordInputHook()); + if (EvdevInputHook.isSupported()) hooks.add(new EvdevInputHook()); + } + return hooks; + } + + private static boolean isWayland() { + return System.getenv("WAYLAND_DISPLAY") != null + || "wayland".equalsIgnoreCase(System.getenv("XDG_SESSION_TYPE")); + } + + /** Stands in when no backend could start, so callers need no null checks. */ + private record Unavailable(String reason) implements GlobalInputHook { + + @Override + public void start(Listener listener) { + } + + @Override + public boolean isRunning() { + return false; + } + + @Override + public String unavailableReason() { + return reason; + } + + @Override + public String keyName(HotkeyKey key) { + return X11KeyNamer.name(key); + } + + @Override + public void close() { + } + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/EvdevInputHook.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/EvdevInputHook.java new file mode 100644 index 0000000..b3f4e8a --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/EvdevInputHook.java @@ -0,0 +1,151 @@ +package com.ts3client.hotkey.desktop; + +import com.ts3client.hotkey.GlobalInputHook; +import com.ts3client.hotkey.HotkeyKey; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; + +/** + * System-wide input hook that reads the kernel's evdev devices directly, for sessions + * where {@link XRecordInputHook} cannot see everything — Wayland above all, where the X + * server is only told about events aimed at X clients. + * + *

It needs read access to {@code /dev/input/event*}, which normally means membership + * of the {@code input} group; without it the hook reports itself unavailable and the + * client carries on without global hotkeys. + * + *

Key codes are reported as X keycodes (the kernel code plus 8) and buttons as X + * button numbers, so bindings mean the same thing whichever backend recorded them. + */ +public final class EvdevInputHook implements GlobalInputHook { + + private static final File INPUT_DIR = new File("/dev/input"); + /** {@code struct input_event} on 64-bit Linux: two 8-byte time fields, then type/code/value. */ + private static final int EVENT_SIZE = 24; + private static final int EV_KEY = 1; + /** Kernel key codes below this are keyboard keys; from here up they are BTN_* buttons. */ + private static final int BTN_MISC = 0x100; + private static final int BTN_LEFT = 0x110; + + private final List streams = new ArrayList<>(); + private final List threads = new ArrayList<>(); + private volatile Listener listener; + private volatile boolean running; + private volatile String unavailable = "not started"; + + public static boolean isSupported() { + File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event")); + if (devices == null) return false; + for (File device : devices) { + if (device.canRead()) return true; + } + return false; + } + + @Override + public void start(Listener listener) { + this.listener = listener; + File[] devices = INPUT_DIR.listFiles((dir, name) -> name.startsWith("event")); + if (devices == null || devices.length == 0) { + unavailable = "no /dev/input devices"; + return; + } + for (File device : devices) { + try { + InputStream in = new FileInputStream(device); + streams.add(in); + Thread t = new Thread(() -> read(in), "ts3j-hotkeys-evdev-" + device.getName()); + t.setDaemon(true); + threads.add(t); + } catch (Exception ignored) { + // Devices we may not read are simply skipped. + } + } + if (streams.isEmpty()) { + unavailable = "no readable /dev/input device — add your user to the \"input\" group"; + return; + } + running = true; + unavailable = ""; + for (Thread t : threads) t.start(); + } + + private void read(InputStream in) { + byte[] buffer = new byte[EVENT_SIZE * 16]; + ByteBuffer view = ByteBuffer.wrap(buffer).order(ByteOrder.nativeOrder()); + while (running) { + try { + int read = in.read(buffer); + if (read < 0) return; + for (int offset = 0; offset + EVENT_SIZE <= read; offset += EVENT_SIZE) { + int type = view.getShort(offset + 16) & 0xffff; + int code = view.getShort(offset + 18) & 0xffff; + int value = view.getInt(offset + 20); + // 2 is auto-repeat, which must not look like a fresh press. + if (type == EV_KEY && value != 2) dispatch(code, value == 1); + } + } catch (Exception e) { + return; + } + } + } + + private void dispatch(int code, boolean pressed) { + Listener l = listener; + if (l == null) return; + HotkeyKey key = code < BTN_MISC ? HotkeyKey.keyboard(code + 8) : mouse(code); + if (key != null) l.onInput(key, pressed); + } + + /** Maps the kernel's BTN_* codes onto the X button numbering. */ + private static HotkeyKey mouse(int code) { + return switch (code) { + case BTN_LEFT -> HotkeyKey.mouse(1); + case BTN_LEFT + 1 -> HotkeyKey.mouse(3); // BTN_RIGHT + case BTN_LEFT + 2 -> HotkeyKey.mouse(2); // BTN_MIDDLE + case BTN_LEFT + 3 -> HotkeyKey.mouse(8); // BTN_SIDE, "mouse 4" + case BTN_LEFT + 4 -> HotkeyKey.mouse(9); // BTN_EXTRA, "mouse 5" + case BTN_LEFT + 5 -> HotkeyKey.mouse(10); // BTN_FORWARD + case BTN_LEFT + 6 -> HotkeyKey.mouse(11); // BTN_BACK + case BTN_LEFT + 7 -> HotkeyKey.mouse(12); // BTN_TASK + default -> null; // joysticks, lid switches, … + }; + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public String unavailableReason() { + return running ? "" : unavailable; + } + + @Override + public String keyName(HotkeyKey key) { + // The kernel knows scancodes, not layouts; let X name the key when it can. + return X11KeyNamer.name(key); + } + + @Override + public void close() { + running = false; + listener = null; + // Closing the descriptor is what breaks the readers out of their blocking read. + for (InputStream in : streams) { + try { + in.close(); + } catch (Exception ignored) { + } + } + streams.clear(); + threads.clear(); + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/KeyNames.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/KeyNames.java new file mode 100644 index 0000000..77b2921 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/KeyNames.java @@ -0,0 +1,47 @@ +package com.ts3client.hotkey.desktop; + +import java.util.Map; + +/** Turns X keysym names into the labels users expect on a hotkey button. */ +final class KeyNames { + + private KeyNames() { + } + + private static final Map SPECIAL = Map.ofEntries( + Map.entry("Control_L", "Left Ctrl"), + Map.entry("Control_R", "Right Ctrl"), + Map.entry("Shift_L", "Left Shift"), + Map.entry("Shift_R", "Right Shift"), + Map.entry("Alt_L", "Left Alt"), + Map.entry("Alt_R", "Right Alt"), + Map.entry("ISO_Level3_Shift", "AltGr"), + Map.entry("Super_L", "Left Super"), + Map.entry("Super_R", "Right Super"), + Map.entry("Meta_L", "Left Meta"), + Map.entry("Meta_R", "Right Meta"), + Map.entry("Prior", "Page Up"), + Map.entry("Next", "Page Down"), + Map.entry("Return", "Enter"), + Map.entry("space", "Space"), + Map.entry("BackSpace", "Backspace"), + Map.entry("Escape", "Esc"), + Map.entry("Caps_Lock", "Caps Lock"), + Map.entry("Num_Lock", "Num Lock"), + Map.entry("Scroll_Lock", "Scroll Lock"), + Map.entry("Menu", "Menu"), + Map.entry("Print", "Print Screen")); + + /** + * @param keysym the X name of the keysym, e.g. {@code a}, {@code Control_L}, {@code KP_Add} + * @return a display name, or {@code null} when there is nothing sensible to show + */ + static String pretty(String keysym) { + if (keysym == null || keysym.isBlank()) return null; + String special = SPECIAL.get(keysym); + if (special != null) return special; + if (keysym.startsWith("KP_")) return "Numpad " + pretty(keysym.substring(3)); + if (keysym.length() == 1) return keysym.toUpperCase(); + return keysym.replace('_', ' '); + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/X11KeyNamer.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/X11KeyNamer.java new file mode 100644 index 0000000..7ff49d3 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/X11KeyNamer.java @@ -0,0 +1,47 @@ +package com.ts3client.hotkey.desktop; + +import com.ts3client.hotkey.HotkeyKey; + +import java.lang.foreign.MemorySegment; +import java.util.HashMap; +import java.util.Map; + +/** + * Names keycodes using the X keyboard layout, so a hotkey button shows "A" rather than + * "Key 38". Bindings themselves stay layout-independent; only the label comes from here. + * + *

Keeps one long-lived display connection of its own, opened on first use, so it can + * be asked from any thread without racing the input hooks' own connections. + */ +final class X11KeyNamer { + + private X11KeyNamer() { + } + + private static final Map CACHE = new HashMap<>(); + private static MemorySegment display; + private static boolean tried; + + static synchronized String name(HotkeyKey key) { + if (key.device() != HotkeyKey.Device.KEYBOARD) return null; + if (!tried) { + tried = true; + try { + if (Xlib.isCoreAvailable() && System.getenv("DISPLAY") != null) { + display = Xlib.openDisplay(); + } + } catch (Throwable ignored) { + display = null; + } + } + if (display == null || display.equals(MemorySegment.NULL)) return null; + return CACHE.computeIfAbsent(key.code(), code -> { + try { + long sym = Xlib.keysym(display, code); + return sym == 0 ? null : KeyNames.pretty(Xlib.keysymName(sym)); + } catch (Throwable t) { + return null; + } + }); + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XRecordInputHook.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XRecordInputHook.java new file mode 100644 index 0000000..44b527a --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/XRecordInputHook.java @@ -0,0 +1,212 @@ +package com.ts3client.hotkey.desktop; + +import com.ts3client.hotkey.GlobalInputHook; +import com.ts3client.hotkey.HotkeyKey; + +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; + +/** + * System-wide input hook built on X11's RECORD extension: it taps the core key and + * button events the server delivers to every client, so hotkeys work no matter which + * window has focus, and the keystroke still reaches that window untouched. + * + *

Two connections are needed, as RECORD demands: a control one that creates and + * later tears the context down, and a data one that blocks inside + * {@code XRecordEnableContext} handing us events. + * + *

Under Wayland the X server only ever sees events aimed at X clients, so this hook + * is not truly global there; {@link EvdevInputHook} is the way out. + */ +public final class XRecordInputHook implements GlobalInputHook { + + private static final MethodHandle CALLBACK; + + static { + try { + CALLBACK = MethodHandles.lookup().findStatic(XRecordInputHook.class, "onRecorded", + MethodType.methodType(void.class, MemorySegment.class, MemorySegment.class)); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + + /** The running hook, for the static upcall to find its way back. */ + private static volatile XRecordInputHook current; + + private Arena arena; + private MemorySegment control = MemorySegment.NULL; + private MemorySegment data = MemorySegment.NULL; + private long context; + private Thread thread; + private volatile Listener listener; + private volatile boolean running; + private volatile String unavailable = "not started"; + + public static boolean isSupported() { + return Xlib.isAvailable() && System.getenv("DISPLAY") != null; + } + + @Override + public void start(Listener listener) { + this.listener = listener; + try { + open(); + } catch (Throwable t) { + unavailable = describe(t); + closeQuietly(); + } + } + + private void open() { + arena = Arena.ofShared(); + control = Xlib.openDisplay(); + if (control.equals(MemorySegment.NULL)) { + throw new IllegalStateException("cannot open the X display"); + } + if (!Xlib.queryRecordVersion(control, arena)) { + throw new IllegalStateException("the X server has no RECORD extension"); + } + + MemorySegment range = Xlib.allocRange(); + if (range.equals(MemorySegment.NULL)) throw new IllegalStateException("XRecordAllocRange failed"); + MemorySegment r = range.reinterpret(64); + r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_FIRST, (byte) Xlib.KEY_PRESS); + r.set(ValueLayout.JAVA_BYTE, Xlib.RANGE_DEVICE_EVENTS_LAST, (byte) Xlib.BUTTON_RELEASE); + + MemorySegment clients = arena.allocate(ValueLayout.JAVA_LONG); + clients.set(ValueLayout.JAVA_LONG, 0, Xlib.ALL_CLIENTS); + MemorySegment ranges = arena.allocate(ValueLayout.ADDRESS); + ranges.set(ValueLayout.ADDRESS, 0, range); + + context = Xlib.createContext(control, clients, 1, ranges, 1); + if (context == 0) throw new IllegalStateException("XRecordCreateContext failed"); + // The context id is allocated client-side and the request is not a round trip: + // without this the second connection can enable a context the server has yet to + // create, which it answers with BadContext. + Xlib.sync(control); + + data = Xlib.openDisplay(); + if (data.equals(MemorySegment.NULL)) { + throw new IllegalStateException("cannot open the second X display connection"); + } + + MemorySegment stub = java.lang.foreign.Linker.nativeLinker() + .upcallStub(CALLBACK, Xlib.INTERCEPT_PROC, arena); + + current = this; + running = true; + unavailable = ""; + thread = new Thread(() -> { + try { + // Blocks until close() disables the context from the control connection. + Xlib.enableContext(data, context, stub); + } catch (Throwable ignored) { + } finally { + running = false; + } + }, "ts3j-hotkeys-xrecord"); + thread.setDaemon(true); + thread.start(); + } + + /** Upcall target: one recorded protocol datum. */ + @SuppressWarnings("unused") + private static void onRecorded(MemorySegment closure, MemorySegment recorded) { + XRecordInputHook hook = current; + MemorySegment d = recorded.reinterpret(48); + try { + if (hook == null || d.get(ValueLayout.JAVA_INT, Xlib.INTERCEPT_CATEGORY) != Xlib.FROM_SERVER) { + return; + } + long length = d.get(ValueLayout.JAVA_LONG, Xlib.INTERCEPT_DATA_LEN); + MemorySegment event = d.get(ValueLayout.ADDRESS, Xlib.INTERCEPT_DATA); + // data_len counts 4-byte units; a core event is always 32 bytes. + if (event.equals(MemorySegment.NULL) || length < 2) return; + event = event.reinterpret(32); + hook.dispatch(event.get(ValueLayout.JAVA_BYTE, 0) & 0x7f, + event.get(ValueLayout.JAVA_BYTE, 1) & 0xff); + } catch (Throwable ignored) { + } finally { + Xlib.freeData(recorded); + } + } + + private void dispatch(int type, int detail) { + Listener l = listener; + if (l == null) return; + switch (type) { + case Xlib.KEY_PRESS -> l.onInput(HotkeyKey.keyboard(detail), true); + case Xlib.KEY_RELEASE -> l.onInput(HotkeyKey.keyboard(detail), false); + case Xlib.BUTTON_PRESS -> l.onInput(HotkeyKey.mouse(detail), true); + case Xlib.BUTTON_RELEASE -> l.onInput(HotkeyKey.mouse(detail), false); + default -> { + } + } + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public String unavailableReason() { + return running ? "" : unavailable; + } + + @Override + public String keyName(HotkeyKey key) { + return X11KeyNamer.name(key); + } + + @Override + public void close() { + listener = null; + running = false; + try { + if (!control.equals(MemorySegment.NULL) && context != 0) { + Xlib.disableContext(control, context); + Xlib.flush(control); + } + if (thread != null) thread.join(1000); + } catch (Throwable ignored) { + } + closeQuietly(); + } + + private synchronized void closeQuietly() { + if (current == this) current = null; + try { + if (!control.equals(MemorySegment.NULL) && context != 0) Xlib.freeContext(control, context); + } catch (Throwable ignored) { + } + context = 0; + for (MemorySegment display : new MemorySegment[]{data, control}) { + try { + if (!display.equals(MemorySegment.NULL)) Xlib.closeDisplay(display); + } catch (Throwable ignored) { + } + } + data = control = MemorySegment.NULL; + // The arena owns the upcall stub: freeing it while the recording thread could + // still call into it would take the JVM down, so a stuck thread keeps it alive. + if (arena != null && (thread == null || !thread.isAlive())) { + try { + arena.close(); + } catch (Throwable ignored) { + } + arena = null; + } + thread = null; + } + + private static String describe(Throwable t) { + String message = t.getMessage(); + return (message == null || message.isBlank()) ? t.getClass().getSimpleName() : message; + } +} diff --git a/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java new file mode 100644 index 0000000..550c764 --- /dev/null +++ b/ts3-client/desktop/src/main/java/com/ts3client/hotkey/desktop/Xlib.java @@ -0,0 +1,207 @@ +package com.ts3client.hotkey.desktop; + +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; +import java.nio.charset.StandardCharsets; + +/** + * Raw binding to {@code libX11} and the RECORD extension in {@code libXtst}, with just + * the calls {@link XRecordInputHook} needs. + * + *

RECORD lets a client watch the core input events the server delivers to everyone + * else without intercepting them, which is exactly what a global hotkey needs: the + * keystroke still reaches the focused application. + * + *

Loading is lazy and failure is expected — with no X11 around, {@link #isAvailable()} + * returns {@code false} and the caller falls back to another backend. + */ +final class Xlib { + + private Xlib() { + } + + static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT; + static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG; + private static final java.lang.foreign.AddressLayout PTR = ValueLayout.ADDRESS; + + /** Core event types (X.h) as they appear in the recorded protocol data. */ + static final int KEY_PRESS = 2; + static final int KEY_RELEASE = 3; + static final int BUTTON_PRESS = 4; + static final int BUTTON_RELEASE = 5; + + /** {@code XRecordAllClients}: record every client, present and future. */ + static final long ALL_CLIENTS = 3L; + + /** {@code XRecordInterceptData.category} values we care about. */ + static final int FROM_SERVER = 0; + + /** + * {@code XRecordRange}: only {@code device_events} is filled in. Its two bytes sit + * after the request/reply and delivered-event ranges, at a fixed LP64 offset. + */ + static final long RANGE_DEVICE_EVENTS_FIRST = 18; + static final long RANGE_DEVICE_EVENTS_LAST = 19; + + /** {@code XRecordInterceptData}: the protocol bytes and the reason we were called. */ + static final long INTERCEPT_CATEGORY = 24; + static final long INTERCEPT_DATA = 32; + static final long INTERCEPT_DATA_LEN = 40; + + private static final Linker LINKER = Linker.nativeLinker(); + private static final String[] X11_NAMES = {"libX11.so.6", "libX11.so"}; + private static final String[] XTST_NAMES = {"libXtst.so.6", "libXtst.so"}; + + /** libX11 alone: enough to open a display and name keys. */ + private static final class Core { + static final SymbolLookup LIB = load(X11_NAMES, "libX11"); + + static final MethodHandle X_OPEN_DISPLAY = + downcall(LIB, "XOpenDisplay", FunctionDescriptor.of(PTR, PTR)); + static final MethodHandle X_CLOSE_DISPLAY = + downcall(LIB, "XCloseDisplay", FunctionDescriptor.of(INT, PTR)); + static final MethodHandle X_FLUSH = + downcall(LIB, "XFlush", FunctionDescriptor.of(INT, PTR)); + static final MethodHandle X_SYNC = + downcall(LIB, "XSync", FunctionDescriptor.of(INT, PTR, INT)); + static final MethodHandle XKB_KEYCODE_TO_KEYSYM = + downcall(LIB, "XkbKeycodeToKeysym", FunctionDescriptor.of(LONG, PTR, INT, INT, INT)); + static final MethodHandle X_KEYSYM_TO_STRING = + downcall(LIB, "XKeysymToString", FunctionDescriptor.of(PTR, LONG)); + } + + /** The RECORD extension, which ships separately in libXtst. */ + private static final class Record { + static final SymbolLookup LIB = load(XTST_NAMES, "libXtst"); + + static final MethodHandle QUERY_VERSION = + downcall(LIB, "XRecordQueryVersion", FunctionDescriptor.of(INT, PTR, PTR, PTR)); + static final MethodHandle ALLOC_RANGE = + downcall(LIB, "XRecordAllocRange", FunctionDescriptor.of(PTR)); + static final MethodHandle CREATE_CONTEXT = + downcall(LIB, "XRecordCreateContext", + FunctionDescriptor.of(LONG, PTR, INT, PTR, INT, PTR, INT)); + static final MethodHandle ENABLE_CONTEXT = + downcall(LIB, "XRecordEnableContext", FunctionDescriptor.of(INT, PTR, LONG, PTR, PTR)); + static final MethodHandle DISABLE_CONTEXT = + downcall(LIB, "XRecordDisableContext", FunctionDescriptor.of(INT, PTR, LONG)); + static final MethodHandle FREE_CONTEXT = + downcall(LIB, "XRecordFreeContext", FunctionDescriptor.of(INT, PTR, LONG)); + static final MethodHandle FREE_DATA = + downcall(LIB, "XRecordFreeData", FunctionDescriptor.ofVoid(PTR)); + } + + private static SymbolLookup load(String[] names, String what) { + IllegalArgumentException last = null; + for (String name : names) { + try { + return SymbolLookup.libraryLookup(name, Arena.global()); + } catch (IllegalArgumentException e) { + last = e; + } + } + throw (last != null) ? last : new IllegalArgumentException(what + " not found"); + } + + private static MethodHandle downcall(SymbolLookup lookup, String symbol, + FunctionDescriptor descriptor) { + return LINKER.downcallHandle( + lookup.find(symbol).orElseThrow(() -> + new UnsatisfiedLinkError("unresolved symbol " + symbol)), + descriptor); + } + + /** The C signature of {@code XRecordInterceptProc}. */ + static final FunctionDescriptor INTERCEPT_PROC = FunctionDescriptor.ofVoid(PTR, PTR); + + /** Whether libX11 loaded, which is all that naming keys needs. */ + static boolean isCoreAvailable() { + try { + return Core.LIB != null; + } catch (Throwable t) { + return false; + } + } + + static boolean isAvailable() { + try { + return Core.LIB != null && Record.LIB != null; + } catch (Throwable t) { + return false; + } + } + + static MemorySegment openDisplay() { + return (MemorySegment) call(Core.X_OPEN_DISPLAY, MemorySegment.NULL); + } + + static void closeDisplay(MemorySegment display) { + call(Core.X_CLOSE_DISPLAY, display); + } + + static void flush(MemorySegment display) { + call(Core.X_FLUSH, display); + } + + /** Flushes and waits for the server to have processed everything sent so far. */ + static void sync(MemorySegment display) { + call(Core.X_SYNC, display, 0); + } + + /** @return whether the server has the RECORD extension */ + static boolean queryRecordVersion(MemorySegment display, Arena arena) { + MemorySegment major = arena.allocate(INT); + MemorySegment minor = arena.allocate(INT); + return (int) call(Record.QUERY_VERSION, display, major, minor) != 0; + } + + static MemorySegment allocRange() { + return (MemorySegment) call(Record.ALLOC_RANGE); + } + + static long createContext(MemorySegment display, MemorySegment clients, int clientCount, + MemorySegment ranges, int rangeCount) { + return (long) call(Record.CREATE_CONTEXT, display, 0, clients, clientCount, ranges, rangeCount); + } + + static int enableContext(MemorySegment display, long context, MemorySegment callback) { + return (int) call(Record.ENABLE_CONTEXT, display, context, callback, MemorySegment.NULL); + } + + static void disableContext(MemorySegment display, long context) { + call(Record.DISABLE_CONTEXT, display, context); + } + + static void freeContext(MemorySegment display, long context) { + call(Record.FREE_CONTEXT, display, context); + } + + static void freeData(MemorySegment data) { + call(Record.FREE_DATA, data); + } + + /** The unshifted keysym of a keycode in the first group, or 0 when unbound. */ + static long keysym(MemorySegment display, int keycode) { + return (long) call(Core.XKB_KEYCODE_TO_KEYSYM, display, keycode, 0, 0); + } + + /** The keysym's X name ("a", "Control_L", "F5"), or {@code null}. */ + static String keysymName(long keysym) { + MemorySegment name = (MemorySegment) call(Core.X_KEYSYM_TO_STRING, keysym); + if (name == null || name.equals(MemorySegment.NULL)) return null; + return name.reinterpret(Long.MAX_VALUE).getString(0, StandardCharsets.US_ASCII); + } + + private static Object call(MethodHandle handle, Object... args) { + try { + return handle.invokeWithArguments(args); + } catch (Throwable t) { + throw new IllegalStateException("X11 call failed", t); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java new file mode 100644 index 0000000..a6575c2 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyActions.java @@ -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. + * + *

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 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 targets, boolean muted) { + for (ServerTab tab : targets) tab.setMicMuted(muted); + frame.refreshAfterHotkey(); + } + + private void setDeafened(List targets, boolean deaf) { + for (ServerTab tab : targets) tab.setDeafened(deaf); + frame.refreshAfterHotkey(); + } + + private void setAway(List targets, boolean away, String message) { + for (ServerTab tab : targets) tab.setAway(away, message == null ? "" : message); + frame.refreshAfterHotkey(); + } + + private void setCommander(List targets, boolean commander) { + for (ServerTab tab : targets) tab.setCommander(commander); + frame.refreshAfterHotkey(); + } + + private static boolean anyMicMuted(List tabs) { + return tabs.stream().anyMatch(ServerTab::isMicMuted); + } + + private static boolean anyDeafened(List tabs) { + return tabs.stream().anyMatch(ServerTab::isDeafened); + } + + private static boolean anyAway(List tabs) { + return tabs.stream().anyMatch(ServerTab::isAway); + } + + private static boolean anyCommander(List 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; + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyArguments.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyArguments.java new file mode 100644 index 0000000..c456b72 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyArguments.java @@ -0,0 +1,16 @@ +package com.ts3client.ui; + +import com.ts3client.hotkey.HotkeyAction; + +import java.util.List; + +/** + * Supplies the concrete values an action's parameter can take — the bookmarks, sound + * packs and channels the hotkey tree hangs under an action as its leaves, so a binding + * reads as "Sounds / Activate Soundpack / Default Sound Pack (Male)". + */ +interface HotkeyArguments { + + /** @return the choices for this action, or an empty list when it is free-form */ + List choices(HotkeyAction action); +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyDialog.java new file mode 100644 index 0000000..58b817a --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyDialog.java @@ -0,0 +1,385 @@ +package com.ts3client.ui; + +import com.ts3client.hotkey.Hotkey; +import com.ts3client.hotkey.HotkeyAction; +import com.ts3client.hotkey.HotkeyCombo; +import com.ts3client.hotkey.HotkeyEngine; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import javax.swing.tree.TreeSelectionModel; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Window; +import java.util.List; + +/** + * Adds or edits one hotkey, following the official client's dialog: pick the action + * from the tree — category, action group, action, and where the action takes one, the + * concrete bookmark, sound pack or channel — press the key combination to bind, and + * choose the edge it triggers on and whether it applies to the active server only. + */ +final class HotkeyDialog extends JDialog { + + /** + * A tree node: a category or group heading ({@code action == null}), an action, or + * one of the values an action's parameter can take ({@code argument != null}). + */ + private record Node(HotkeyAction action, String argument, String label) { + + static Node heading(String label) { + return new Node(null, null, label); + } + + static Node of(HotkeyAction action) { + return new Node(action, null, action.label()); + } + + static Node value(HotkeyAction action, String argument) { + return new Node(action, argument, argument); + } + + boolean isHeading() { + return action == null; + } + + @Override + public String toString() { + return label; + } + } + + private final HotkeyService service; + private final Hotkey hotkey; + + private final DefaultMutableTreeNode root = new DefaultMutableTreeNode("Actions"); + private final DefaultTreeModel treeModel = new DefaultTreeModel(root); + private final JTree tree = new JTree(treeModel); + private final JCheckBox advancedCheck = new JCheckBox("Show advanced actions"); + private final JButton keyButton = new JButton(); + private final JComboBox triggerCombo = new JComboBox<>(Hotkey.Trigger.values()); + private final JCheckBox activeServerCheck = new JCheckBox("On active server"); + private final JLabel argumentLabel = new JLabel(); + private final JTextField argumentField = new JTextField(); + private final JLabel hint = new JLabel(); + + private HotkeyCombo combo; + private boolean recording; + private boolean confirmed; + + HotkeyDialog(Window owner, HotkeyService service, Hotkey existing) { + // Any window may open this: the options dialog's hotkey tab as much as a frame. + super(owner, existing == null ? "Add hotkey" : "Edit hotkey", ModalityType.APPLICATION_MODAL); + this.service = service; + this.hotkey = existing == null ? new Hotkey() : existing.copy(); + this.combo = hotkey.combo; + + advancedCheck.setSelected(hotkey.action != null && hotkey.action.advanced()); + advancedCheck.addActionListener(e -> rebuildTree()); + tree.setRootVisible(false); + tree.setShowsRootHandles(true); + tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); + tree.setCellRenderer(new NodeRenderer()); + tree.addTreeSelectionListener(e -> selectionChanged()); + rebuildTree(); + + keyButton.addActionListener(e -> startRecording()); + triggerCombo.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean selected, boolean focused) { + super.getListCellRendererComponent(list, value, index, selected, focused); + if (value instanceof Hotkey.Trigger t) setText(t.label()); + return this; + } + }); + triggerCombo.setSelectedItem(hotkey.trigger); + activeServerCheck.setSelected(hotkey.activeServerOnly); + argumentField.setText(hotkey.argument == null ? "" : hotkey.argument); + hint.setFont(hint.getFont().deriveFont(Font.ITALIC, hint.getFont().getSize2D() - 1f)); + + getContentPane().setLayout(new BorderLayout(8, 8)); + ((JComponent) getContentPane()).setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + getContentPane().add(buildActionPane(), BorderLayout.CENTER); + getContentPane().add(buildForm(), BorderLayout.SOUTH); + + updateKeyButton(); + updateForAction(); + Dialogs.closeOnEscape(this, this::cancel); + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + setSize(new Dimension(470, 560)); + setLocationRelativeTo(owner); + } + + private JPanel buildActionPane() { + JPanel p = new JPanel(new BorderLayout(0, 4)); + p.add(new JLabel("Action:"), BorderLayout.NORTH); + p.add(new JScrollPane(tree), BorderLayout.CENTER); + p.add(advancedCheck, BorderLayout.SOUTH); + return p; + } + + private JPanel buildForm() { + JPanel p = new JPanel(new GridBagLayout()); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(3, 3, 3, 3); + c.anchor = GridBagConstraints.WEST; + c.fill = GridBagConstraints.HORIZONTAL; + + int row = 0; + addRow(p, c, row++, new JLabel("Hotkey:"), keyButton); + addRow(p, c, row++, argumentLabel, argumentField); + addRow(p, c, row++, new JLabel("Trigger:"), triggerCombo); + + c.gridx = 1; + c.gridy = row++; + p.add(activeServerCheck, c); + c.gridx = 0; + c.gridy = row++; + c.gridwidth = 2; + p.add(hint, c); + + JPanel buttons = new JPanel(); + JButton ok = new JButton("OK"); + JButton cancel = new JButton("Cancel"); + ok.addActionListener(e -> confirm()); + cancel.addActionListener(e -> cancel()); + buttons.add(Box.createHorizontalGlue()); + buttons.add(ok); + buttons.add(cancel); + c.gridy = row; + p.add(buttons, c); + getRootPane().setDefaultButton(ok); + return p; + } + + private static void addRow(JPanel p, GridBagConstraints c, int row, JComponent left, JComponent right) { + c.gridwidth = 1; + c.gridx = 0; + c.gridy = row; + c.weightx = 0; + p.add(left, c); + c.gridx = 1; + c.weightx = 1; + p.add(right, c); + } + + /** + * Builds category → group → action → value, collapsing the groups TS3 leaves + * unnamed so their actions sit directly under the category. + */ + private void rebuildTree() { + boolean advanced = advancedCheck.isSelected(); + root.removeAllChildren(); + for (HotkeyAction.Category category : HotkeyAction.Category.values()) { + List actions = HotkeyAction.of(category, advanced); + if (actions.isEmpty()) continue; + DefaultMutableTreeNode categoryNode = + new DefaultMutableTreeNode(Node.heading(category.label())); + DefaultMutableTreeNode groupNode = null; + String groupName = null; + for (HotkeyAction action : actions) { + DefaultMutableTreeNode parent = categoryNode; + if (!action.group().isEmpty()) { + if (groupNode == null || !action.group().equals(groupName)) { + groupName = action.group(); + groupNode = new DefaultMutableTreeNode(Node.heading(groupName)); + categoryNode.add(groupNode); + } + parent = groupNode; + } + DefaultMutableTreeNode actionNode = new DefaultMutableTreeNode(Node.of(action)); + parent.add(actionNode); + for (String value : service.argumentChoices(action)) { + actionNode.add(new DefaultMutableTreeNode(Node.value(action, value))); + } + } + root.add(categoryNode); + } + treeModel.reload(); + // Categories open, groups closed: the whole action set at a glance, as in TS3. + for (int i = 0; i < root.getChildCount(); i++) { + tree.expandPath(new TreePath(((DefaultMutableTreeNode) root.getChildAt(i)).getPath())); + } + selectCurrent(); + } + + /** Reveals and selects the node matching the binding being edited. */ + private void selectCurrent() { + if (hotkey.action == null) return; + DefaultMutableTreeNode match = null; + var nodes = root.depthFirstEnumeration(); + while (nodes.hasMoreElements()) { + DefaultMutableTreeNode node = (DefaultMutableTreeNode) nodes.nextElement(); + if (!(node.getUserObject() instanceof Node n) || n.action() != hotkey.action) continue; + boolean sameArgument = n.argument() != null && n.argument().equals(hotkey.argument); + if (sameArgument) { + match = node; + break; + } + if (n.argument() == null && match == null) match = node; + } + if (match == null) return; + TreePath path = new TreePath(match.getPath()); + tree.setSelectionPath(path); + SwingUtilities.invokeLater(() -> tree.scrollPathToVisible(path)); + } + + private void selectionChanged() { + if (!(tree.getLastSelectedPathComponent() instanceof DefaultMutableTreeNode node) + || !(node.getUserObject() instanceof Node selected)) { + return; + } + if (selected.isHeading()) { + // Headings only structure the tree; keep the action that was chosen before. + tree.clearSelection(); + selectCurrent(); + return; + } + hotkey.action = selected.action(); + if (selected.argument() != null) argumentField.setText(selected.argument()); + updateForAction(); + } + + /** Syncs the form to the selected action: parameter row, trigger, scope and hint. */ + private void updateForAction() { + HotkeyAction action = hotkey.action; + boolean takesArgument = action != null && action.argument() != HotkeyAction.Argument.NONE; + argumentLabel.setText(takesArgument ? argumentLabel(action) : ""); + argumentLabel.setVisible(takesArgument); + argumentField.setVisible(takesArgument); + + boolean momentary = action != null && action.momentary(); + triggerCombo.setEnabled(!momentary); + activeServerCheck.setEnabled(action != null && action.category() != HotkeyAction.Category.MISC); + + if (action != null && !action.supported()) { + hint.setText("This action is part of TeamSpeak's hotkey set but is not implemented yet."); + } else if (momentary) { + hint.setText("Held down: the action lasts as long as the hotkey is pressed."); + } else { + hint.setText(service.isRunning() ? " " : service.status()); + } + } + + private static String argumentLabel(HotkeyAction action) { + return switch (action.argument()) { + case BOOKMARK -> "Bookmark:"; + case CHANNEL -> "Channel path:"; + case PROFILE -> "Profile:"; + default -> "Parameter:"; + }; + } + + private void startRecording() { + if (recording) return; + if (!service.isRunning()) { + hint.setText(service.status()); + return; + } + recording = true; + keyButton.setText("Press hotkey combination…"); + service.record(new HotkeyEngine.Recorder() { + @Override + public void onRecording(HotkeyCombo partial) { + SwingUtilities.invokeLater(() -> keyButton.setText(service.display(partial) + "…")); + } + + @Override + public void onRecorded(HotkeyCombo recorded) { + SwingUtilities.invokeLater(() -> { + combo = recorded; + stopRecording(); + }); + } + }); + } + + private void stopRecording() { + if (!recording) return; + recording = false; + service.stopRecording(); + updateKeyButton(); + } + + private void updateKeyButton() { + keyButton.setText(combo == null || combo.isEmpty() + ? "No hotkey assigned" : service.display(combo)); + } + + private void confirm() { + stopRecording(); + if (hotkey.action == null || combo == null || combo.isEmpty()) { + hint.setText("Pick an action and press a key combination first."); + return; + } + if (!hotkey.action.supported()) { + hint.setText("This action is not implemented yet — pick another one."); + return; + } + hotkey.combo = combo; + hotkey.trigger = (Hotkey.Trigger) triggerCombo.getSelectedItem(); + hotkey.activeServerOnly = activeServerCheck.isSelected(); + hotkey.argument = argumentField.getText().trim(); + confirmed = true; + dispose(); + } + + private void cancel() { + stopRecording(); + dispose(); + } + + boolean isConfirmed() { + return confirmed; + } + + Hotkey result() { + return hotkey; + } + + /** Draws headings in bold and greys out the actions this client cannot perform. */ + private static final class NodeRenderer extends DefaultTreeCellRenderer { + @Override + public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, + boolean expanded, boolean leaf, int row, + boolean focused) { + super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, focused); + setIcon(null); + if (!(value instanceof DefaultMutableTreeNode node) + || !(node.getUserObject() instanceof Node n)) { + return this; + } + if (n.isHeading()) { + setFont(getFont().deriveFont(Font.BOLD)); + setToolTipText(null); + } else { + setEnabled(n.action().supported()); + setToolTipText(n.action().supported() ? null : "Not implemented by this client"); + } + return this; + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyService.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyService.java new file mode 100644 index 0000000..10ae8e6 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeyService.java @@ -0,0 +1,94 @@ +package com.ts3client.ui; + +import com.ts3client.hotkey.GlobalInputHook; +import com.ts3client.hotkey.Hotkey; +import com.ts3client.hotkey.HotkeyAction; +import com.ts3client.hotkey.HotkeyCombo; +import com.ts3client.hotkey.HotkeyEngine; +import com.ts3client.hotkey.HotkeyKey; +import com.ts3client.hotkey.Hotkeys; +import com.ts3client.hotkey.desktop.DesktopInputHooks; + +import java.util.List; + +/** + * Owns the hotkey machinery for the UI: the stored bindings, the matching engine and + * the platform input hook they are fed from. Everything the dialogs need — recording a + * combination, naming keys, telling the user why hotkeys are dead — goes through here. + */ +final class HotkeyService { + + private final Hotkeys hotkeys = Hotkeys.load(); + private final HotkeyEngine engine; + private final GlobalInputHook hook; + private final HotkeyArguments arguments; + + HotkeyService(HotkeyEngine.Handler handler, HotkeyArguments arguments) { + this.arguments = arguments; + engine = new HotkeyEngine(hotkeys, handler); + hook = DesktopInputHooks.start(engine); + } + + /** The values this action's parameter can take right now, for the action tree. */ + List argumentChoices(HotkeyAction action) { + if (arguments == null || action.argument() == HotkeyAction.Argument.NONE) return List.of(); + try { + return arguments.choices(action); + } catch (RuntimeException e) { + return List.of(); + } + } + + List all() { + return hotkeys.all(); + } + + void replaceAll(List updated) { + engine.releaseAll(); + hotkeys.replaceAll(updated); + hotkeys.save(); + } + + /** The binding for an action with no argument, or {@code null} when unbound. */ + Hotkey find(HotkeyAction action) { + synchronized (hotkeys.all()) { + for (Hotkey h : hotkeys.all()) { + if (h.action == action) return h; + } + } + return null; + } + + boolean isRunning() { + return hook.isRunning(); + } + + /** One line for the options dialog: either working, or why it is not. */ + String status() { + return hook.isRunning() + ? "Global hotkeys are active." + : "Global hotkeys are unavailable (" + hook.unavailableReason() + ")."; + } + + String display(HotkeyCombo combo) { + return combo == null ? "No hotkey assigned" : combo.display(this::keyName); + } + + private String keyName(HotkeyKey key) { + return hook.keyName(key); + } + + /** Captures the next combination instead of firing bindings; see {@link #stopRecording()}. */ + void record(HotkeyEngine.Recorder recorder) { + engine.record(recorder); + } + + void stopRecording() { + engine.stopRecording(); + } + + void dispose() { + engine.releaseAll(); + hook.close(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeysPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeysPanel.java new file mode 100644 index 0000000..70e3134 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/HotkeysPanel.java @@ -0,0 +1,170 @@ +package com.ts3client.ui; + +import com.ts3client.hotkey.Hotkey; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.ListSelectionModel; +import javax.swing.SwingUtilities; +import javax.swing.table.AbstractTableModel; +import java.awt.BorderLayout; +import java.awt.Font; +import java.awt.Window; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; + +/** + * The options dialog's hotkey tab: the list of bindings with the buttons to add, edit + * and remove them. Edits happen on a working copy and only reach the running engine + * when the dialog is applied. + */ +final class HotkeysPanel extends JPanel { + + private static final String[] COLUMNS = {"Action", "Hotkey", "Trigger", "Active server", "On"}; + + private final HotkeyService service; + private final List working = new ArrayList<>(); + private final Model model = new Model(); + private final JTable table = new JTable(model); + + HotkeysPanel(HotkeyService service) { + super(new BorderLayout(6, 6)); + this.service = service; + setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + + synchronized (service.all()) { + for (Hotkey h : service.all()) working.add(h.copy()); + } + + table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + table.setRowHeight(table.getRowHeight() + 4); + table.getColumnModel().getColumn(0).setPreferredWidth(240); + table.getColumnModel().getColumn(1).setPreferredWidth(150); + table.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) edit(); + } + }); + + JButton add = new JButton("Add…"); + JButton edit = new JButton("Edit…"); + JButton remove = new JButton("Remove"); + add.addActionListener(e -> add()); + edit.addActionListener(e -> edit()); + remove.addActionListener(e -> remove()); + + JPanel buttons = new JPanel(); + buttons.add(add); + buttons.add(edit); + buttons.add(remove); + + JLabel status = new JLabel(service.status()); + status.setFont(status.getFont().deriveFont(Font.ITALIC, status.getFont().getSize2D() - 1f)); + + add(new JScrollPane(table), BorderLayout.CENTER); + JPanel south = new JPanel(new BorderLayout()); + south.add(buttons, BorderLayout.WEST); + south.add(status, BorderLayout.SOUTH); + add(south, BorderLayout.SOUTH); + } + + private void add() { + HotkeyDialog dlg = new HotkeyDialog(owner(), service, null); + dlg.setVisible(true); + if (!dlg.isConfirmed()) return; + working.add(dlg.result()); + model.fireTableDataChanged(); + } + + private void edit() { + int row = table.getSelectedRow(); + if (row < 0) return; + HotkeyDialog dlg = new HotkeyDialog(owner(), service, working.get(row)); + dlg.setVisible(true); + if (!dlg.isConfirmed()) return; + working.set(row, dlg.result()); + model.fireTableRowsUpdated(row, row); + } + + private void remove() { + int row = table.getSelectedRow(); + if (row < 0) return; + working.remove(row); + model.fireTableDataChanged(); + } + + private Window owner() { + return SwingUtilities.getWindowAncestor(this); + } + + /** Commits the edited list to the engine and to disk. */ + void apply() { + service.replaceAll(working); + } + + /** Picks the stored bindings up again after something else changed them. */ + void reload() { + working.clear(); + synchronized (service.all()) { + for (Hotkey h : service.all()) working.add(h.copy()); + } + model.fireTableDataChanged(); + } + + private final class Model extends AbstractTableModel { + + @Override + public int getRowCount() { + return working.size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Class getColumnClass(int column) { + return column >= 3 ? Boolean.class : String.class; + } + + @Override + public boolean isCellEditable(int row, int column) { + return column == 4 || (column == 3 && working.get(row).isServerScoped()); + } + + @Override + public Object getValueAt(int row, int column) { + Hotkey h = working.get(row); + return switch (column) { + case 0 -> h.path(); + case 1 -> service.display(h.combo); + case 2 -> h.action.momentary() ? "While held" : h.trigger.label(); + case 3 -> h.isServerScoped() && h.activeServerOnly; + default -> h.enabled; + }; + } + + @Override + public void setValueAt(Object value, int row, int column) { + Hotkey h = working.get(row); + if (column == 3) { + h.activeServerOnly = Boolean.TRUE.equals(value); + } else if (column == 4) { + h.enabled = Boolean.TRUE.equals(value); + } + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 5bca4c1..e5d13eb 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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 hotkeyTargets(boolean activeServerOnly) { + List 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 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 hotkeyArgumentChoices(com.ts3client.hotkey.HotkeyAction action) { + List 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); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index 026464a..bd4c4b1 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -15,6 +15,8 @@ import javax.swing.JComponent; import javax.swing.JOptionPane; import javax.swing.JSplitPane; import javax.swing.SwingUtilities; +import java.util.ArrayList; +import java.util.List; import java.awt.Component; /** @@ -319,6 +321,33 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { return c != null ? c.nickname : "Client " + clientId; } + /** Joins the channel at a "/"-separated path, as the hotkey action names it. */ + void joinChannelPath(String path) { + if (!conn.isConnected() || path == null || path.isBlank()) return; + ChannelNode target = conn.getModel().findChannelByPath(path); + if (target != null) conn.joinChannel(target.id, null); + } + + /** Every channel of this server as a "/"-separated path, in the tree's own order. */ + List channelPaths() { + List out = new ArrayList<>(); + if (!conn.isConnected()) return out; + for (ChannelNode root : conn.getModel().buildTree()) collectPaths(root, out); + return out; + } + + private void collectPaths(ChannelNode channel, List out) { + out.add(conn.getModel().channelPath(channel.id)); + for (ChannelNode child : channel.children) collectPaths(child, out); + } + + /** The channel we are in, or null when not connected. */ + ChannelNode currentChannel() { + if (!conn.isConnected()) return null; + ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); + return self == null ? null : conn.getModel().getChannel(self.channelId); + } + // ---- ServerTreePanel.Actions ---- @Override diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java index 911e216..647005d 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java @@ -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 inputCombo; private JComboBox 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 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) {