Global hotkeys, in TeamSpeak's own shape
Bindings are captured system-wide rather than only while the window has focus: X11's RECORD extension where the X server sees every key, and a /dev/input reader as the Wayland fallback. Any key can act as a modifier, mouse buttons included, as TS3 allows. The action catalogue, its three categories and the "advanced actions" split are reverse-engineered from the original client; actions this client cannot perform are listed but greyed out. Push-to-talk becomes one of these hotkeys, so the old focus-bound pushToTalkKey setting is gone and the Voice Activation button edits that binding instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- **Status bar** shows the server name, user count and live ping.
|
||||||
- Change your nickname, mute/deafen from the toolbar.
|
- 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
|
### Notification sounds
|
||||||
- **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini`
|
- **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`, …)
|
mapping actions (`CONNECTION_CONNECTED`, `CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS`, …)
|
||||||
@@ -151,6 +170,12 @@ core/ com.ts3client
|
|||||||
├── gfx
|
├── gfx
|
||||||
│ ├── IconPack an icon pack zip/folder + its settings.ini mapping
|
│ ├── IconPack an icon pack zip/folder + its settings.ini mapping
|
||||||
│ └── IconPacks discovery of installed packs
|
│ └── 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
|
├── sound
|
||||||
│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids)
|
│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids)
|
||||||
│ ├── SoundPack a pack folder + its settings.ini mapping
|
│ ├── SoundPack a pack folder + its settings.ini mapping
|
||||||
@@ -164,14 +189,18 @@ core/ com.ts3client
|
|||||||
├── ChannelNode/ClientEntry view models
|
├── ChannelNode/ClientEntry view models
|
||||||
└── ConnectionListener frontend callbacks
|
└── ConnectionListener frontend callbacks
|
||||||
|
|
||||||
desktop/ com.ts3client.audio.desktop
|
desktop/ com.ts3client.audio.desktop + com.ts3client.hotkey.desktop
|
||||||
├── Opus Panama (FFM) binding to native libopus
|
├── Opus Panama (FFM) binding to native libopus
|
||||||
├── OpusEncoder/OpusDecoder thin codec wrappers
|
├── OpusEncoder/OpusDecoder thin codec wrappers
|
||||||
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
|
├── AudioDevices device enumeration + line opening (48 kHz/16-bit)
|
||||||
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
|
├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode
|
||||||
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing
|
├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing
|
||||||
├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line
|
├── 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
|
swing/ com.ts3client
|
||||||
├── Main entry point (look & feel, settings, backend injection)
|
├── Main entry point (look & feel, settings, backend injection)
|
||||||
@@ -182,6 +211,10 @@ swing/ com.ts3client
|
|||||||
├── InfoPanel channel description / client group + details view
|
├── InfoPanel channel description / client group + details view
|
||||||
├── ChatPanel chat log + input
|
├── ChatPanel chat log + input
|
||||||
├── SettingsDialog audio + VAD options with live meter
|
├── 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
|
├── NotificationsPanel sound pack + per-action sound/important configuration
|
||||||
├── IconPackPanel icon pack chooser + icon viewer (Options → Design)
|
├── IconPackPanel icon pack chooser + icon viewer (Options → Design)
|
||||||
├── ConnectDialog connect form
|
├── ConnectDialog connect form
|
||||||
@@ -200,6 +233,9 @@ swing/ com.ts3client
|
|||||||
dependency-free and reusable in the core library.
|
dependency-free and reusable in the core library.
|
||||||
- Whisper is received/played but not yet **sendable** from the UI.
|
- Whisper is received/played but not yet **sendable** from the UI.
|
||||||
- Playback decodes streams as mono; stereo music-bot audio is down-mixed.
|
- 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
|
- Hotkey actions TeamSpeak offers but this client does not perform yet (listed but
|
||||||
window has focus (no global hotkey).
|
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.
|
- No file transfer, avatars, or server/channel administration UI yet.
|
||||||
|
|||||||
@@ -75,8 +75,6 @@ public final class Settings {
|
|||||||
public double speechThreshold = 0.5;
|
public double speechThreshold = 0.5;
|
||||||
/** Keep voice activation running while in push-to-talk mode. */
|
/** Keep voice activation running while in push-to-talk mode. */
|
||||||
public boolean vadOverPtt = false;
|
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. */
|
/** Opus target bitrate in bits/sec. */
|
||||||
public int bitrate = 48000;
|
public int bitrate = 48000;
|
||||||
/** Opus complexity 0..10. */
|
/** Opus complexity 0..10. */
|
||||||
@@ -173,7 +171,6 @@ public final class Settings {
|
|||||||
vadThresholdDb = parseD(props.getProperty("vadThresholdDb"), vadThresholdDb);
|
vadThresholdDb = parseD(props.getProperty("vadThresholdDb"), vadThresholdDb);
|
||||||
speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold);
|
speechThreshold = parseD(props.getProperty("speechThreshold"), speechThreshold);
|
||||||
vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt);
|
vadOverPtt = parseB(props.getProperty("vadOverPtt"), vadOverPtt);
|
||||||
pushToTalkKey = parseI(props.getProperty("pushToTalkKey"), pushToTalkKey);
|
|
||||||
bitrate = parseI(props.getProperty("bitrate"), bitrate);
|
bitrate = parseI(props.getProperty("bitrate"), bitrate);
|
||||||
complexity = parseI(props.getProperty("complexity"), complexity);
|
complexity = parseI(props.getProperty("complexity"), complexity);
|
||||||
vbr = parseB(props.getProperty("vbr"), vbr);
|
vbr = parseB(props.getProperty("vbr"), vbr);
|
||||||
@@ -211,7 +208,6 @@ public final class Settings {
|
|||||||
props.setProperty("vadThresholdDb", Double.toString(vadThresholdDb));
|
props.setProperty("vadThresholdDb", Double.toString(vadThresholdDb));
|
||||||
props.setProperty("speechThreshold", Double.toString(speechThreshold));
|
props.setProperty("speechThreshold", Double.toString(speechThreshold));
|
||||||
props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt));
|
props.setProperty("vadOverPtt", Boolean.toString(vadOverPtt));
|
||||||
props.setProperty("pushToTalkKey", Integer.toString(pushToTalkKey));
|
|
||||||
props.setProperty("bitrate", Integer.toString(bitrate));
|
props.setProperty("bitrate", Integer.toString(bitrate));
|
||||||
props.setProperty("complexity", Integer.toString(complexity));
|
props.setProperty("complexity", Integer.toString(complexity));
|
||||||
props.setProperty("vbr", Boolean.toString(vbr));
|
props.setProperty("vbr", Boolean.toString(vbr));
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A system-wide source of key and mouse-button events: it reports every press and
|
||||||
|
* release on the machine, whether or not the client has focus, and without swallowing
|
||||||
|
* the event on its way to the focused application.
|
||||||
|
*
|
||||||
|
* <p>Implementations are platform code and live outside the core; the core only ever
|
||||||
|
* sees this interface.
|
||||||
|
*/
|
||||||
|
public interface GlobalInputHook extends AutoCloseable {
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
/**
|
||||||
|
* @param key the key or button
|
||||||
|
* @param pressed {@code true} for a press, {@code false} for a release
|
||||||
|
*/
|
||||||
|
void onInput(HotkeyKey key, boolean pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts delivering events on a background thread. */
|
||||||
|
void start(Listener listener);
|
||||||
|
|
||||||
|
/** Whether events are actually being delivered; false when the platform refused. */
|
||||||
|
boolean isRunning();
|
||||||
|
|
||||||
|
/** Why the hook is not running, for the options dialog to show; empty when it is. */
|
||||||
|
String unavailableReason();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name this system's layout gives a key, or {@code null} to let the caller fall
|
||||||
|
* back to {@link HotkeyKey#fallbackName()}.
|
||||||
|
*/
|
||||||
|
String keyName(HotkeyKey key);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single binding: which combination triggers which action, on which edge of the
|
||||||
|
* keypress, and whether it reaches only the active server or every connected one.
|
||||||
|
*/
|
||||||
|
public final class Hotkey {
|
||||||
|
|
||||||
|
/** Which edge of the key press runs the action, as TS3's hotkey mode combo box offers. */
|
||||||
|
public enum Trigger {
|
||||||
|
/** Action triggers when the key is pressed down. */
|
||||||
|
KEY_DOWN("On key down"),
|
||||||
|
/** Action triggers when the key is released. */
|
||||||
|
KEY_UP("On key up");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
Trigger(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public HotkeyAction action;
|
||||||
|
public HotkeyCombo combo;
|
||||||
|
public Trigger trigger = Trigger.KEY_DOWN;
|
||||||
|
/** Apply to the active server only; when false the action reaches every connection. */
|
||||||
|
public boolean activeServerOnly = true;
|
||||||
|
/** Meaning depends on {@link HotkeyAction#argument()}; empty when the action takes none. */
|
||||||
|
public String argument = "";
|
||||||
|
public boolean enabled = true;
|
||||||
|
|
||||||
|
public Hotkey() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Hotkey(HotkeyAction action, HotkeyCombo combo) {
|
||||||
|
this.action = action;
|
||||||
|
this.combo = combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The binding's place in the action tree, as TS3 spells it out:
|
||||||
|
* {@code Sounds / Activate Soundpack / Default Sound Pack (Male)}.
|
||||||
|
*/
|
||||||
|
public String path() {
|
||||||
|
if (action == null) return "";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (!action.group().isEmpty()) sb.append(action.group()).append(" / ");
|
||||||
|
sb.append(action.label());
|
||||||
|
if (argument != null && !argument.isBlank()) sb.append(" / ").append(argument.trim());
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid() {
|
||||||
|
return action != null && combo != null && !combo.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the action is a per-server one, for which {@link #activeServerOnly} matters. */
|
||||||
|
public boolean isServerScoped() {
|
||||||
|
return action != null && action.category() != HotkeyAction.Category.MISC;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Hotkey copy() {
|
||||||
|
Hotkey h = new Hotkey(action, combo);
|
||||||
|
h.trigger = trigger;
|
||||||
|
h.activeServerOnly = activeServerOnly;
|
||||||
|
h.argument = argument;
|
||||||
|
h.enabled = enabled;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue of hotkey actions, reverse-engineered from the official TeamSpeak 3
|
||||||
|
* client (its hotkey dialog builds exactly this list, in this order).
|
||||||
|
*
|
||||||
|
* <p>Every entry keeps the identifiers TS3 itself uses — a numeric action type and a
|
||||||
|
* parameter keyword — so bindings stay recognisable and could be imported from, or
|
||||||
|
* exported to, the original client. {@link #supported()} tells whether this client
|
||||||
|
* can actually carry the action out; the rest are listed but greyed out.
|
||||||
|
*
|
||||||
|
* <p>{@link #advanced()} mirrors the dialog's "Show Advanced Actions" checkbox: TS3
|
||||||
|
* hides all but a handful of everyday actions until it is ticked.
|
||||||
|
*/
|
||||||
|
public enum HotkeyAction {
|
||||||
|
|
||||||
|
// ----- Server -----
|
||||||
|
CONNECT_CURRENT_TAB(Category.SERVER, 0x0010, "Server (current tab)",
|
||||||
|
"Connect to Server on Current Tab", false, Argument.BOOKMARK, true),
|
||||||
|
CONNECT_NEW_TAB(Category.SERVER, 0x0010, "Server (new tab)",
|
||||||
|
"Connect to Server in a New Tab", false, Argument.BOOKMARK, true),
|
||||||
|
DISCONNECT_CURRENT(Category.SERVER, 0x0020, "Current Server",
|
||||||
|
"Disconnect from Current Server", false, Argument.NONE, true),
|
||||||
|
DISCONNECT_ALL(Category.SERVER, 0x0020, "All Servers",
|
||||||
|
"Disconnect from All Servers", false, Argument.NONE, true),
|
||||||
|
|
||||||
|
MIC_ACTIVATE(Category.SERVER, 0x0030, "Activate",
|
||||||
|
"Activate Microphone (current tab)", true, Argument.NONE, true),
|
||||||
|
MIC_MUTE(Category.SERVER, 0x0030, "Mute",
|
||||||
|
"Mute Microphone", true, Argument.NONE, true),
|
||||||
|
MIC_UNMUTE(Category.SERVER, 0x0030, "Unmute",
|
||||||
|
"Unmute Microphone", true, Argument.NONE, true),
|
||||||
|
MIC_TOGGLE(Category.SERVER, 0x0030, "Toggle",
|
||||||
|
"Toggle Microphone Mute", false, Argument.NONE, true),
|
||||||
|
/** TS3's "local" mute is the hardware-level mute of the capture device itself. */
|
||||||
|
MIC_LOCAL_UNMUTE(Category.SERVER, 0x0030, "ActivateLocalMute",
|
||||||
|
"Disable Local Mic Mute", true, Argument.NONE, false),
|
||||||
|
MIC_LOCAL_MUTE(Category.SERVER, 0x0030, "DeactivateLocalMute",
|
||||||
|
"Enable Local Mic Mute", true, Argument.NONE, false),
|
||||||
|
MIC_LOCAL_TOGGLE(Category.SERVER, 0x0030, "ToggleLocalMute",
|
||||||
|
"Toggle Local Mic Mute", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
SPEAKER_MUTE(Category.SERVER, 0x0040, "Mute",
|
||||||
|
"Mute Speaker", true, Argument.NONE, true),
|
||||||
|
SPEAKER_UNMUTE(Category.SERVER, 0x0040, "Unmute",
|
||||||
|
"Unmute Speaker", true, Argument.NONE, true),
|
||||||
|
SPEAKER_TOGGLE(Category.SERVER, 0x0040, "Toggle",
|
||||||
|
"Toggle Speaker Mute", false, Argument.NONE, true),
|
||||||
|
|
||||||
|
AWAY_SET(Category.SERVER, 0x0050, "SetAway",
|
||||||
|
"Set Away", true, Argument.NONE, true),
|
||||||
|
AWAY_ONLINE(Category.SERVER, 0x0050, "SetOnline",
|
||||||
|
"Set Online", true, Argument.NONE, true),
|
||||||
|
AWAY_TOGGLE(Category.SERVER, 0x0050, "Toggle",
|
||||||
|
"Toggle Away Status", false, Argument.NONE, true),
|
||||||
|
AWAY_TOGGLE_WITH_MESSAGE(Category.SERVER, 0x0050, "Toggle With Message",
|
||||||
|
"Toggle Away Status with Message", true, Argument.TEXT, true),
|
||||||
|
|
||||||
|
COMMANDER_ACTIVATE(Category.SERVER, 0x0100, "Activate",
|
||||||
|
"Activate Channel Commander", true, Argument.NONE, true),
|
||||||
|
COMMANDER_DEACTIVATE(Category.SERVER, 0x0100, "Deactivate",
|
||||||
|
"Deactivate Channel Commander", true, Argument.NONE, true),
|
||||||
|
COMMANDER_TOGGLE(Category.SERVER, 0x0100, "Toggle",
|
||||||
|
"Toggle Channel Commander", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
// ----- Self -----
|
||||||
|
CAPTURE_PROFILE_ACTIVATE(Category.SELF, 0x0110, "Activate",
|
||||||
|
"Activate Capture Profile", true, Argument.PROFILE, false),
|
||||||
|
CAPTURE_PROFILE_DEACTIVATE(Category.SELF, 0x0110, "Deactivate",
|
||||||
|
"Deactivate Capture Profile", true, Argument.PROFILE, false),
|
||||||
|
CAPTURE_PROFILE_TOGGLE(Category.SELF, 0x0110, "Toggle",
|
||||||
|
"Toggle Capture Profile", true, Argument.PROFILE, false),
|
||||||
|
PLAYBACK_PROFILE_ACTIVATE(Category.SELF, 0x0120, "Activate",
|
||||||
|
"Activate Playback Profile", true, Argument.PROFILE, false),
|
||||||
|
PLAYBACK_PROFILE_DEACTIVATE(Category.SELF, 0x0120, "Deactivate",
|
||||||
|
"Deactivate Playback Profile", true, Argument.PROFILE, false),
|
||||||
|
PLAYBACK_PROFILE_TOGGLE(Category.SELF, 0x0120, "Toggle",
|
||||||
|
"Toggle Playback Profile", true, Argument.PROFILE, false),
|
||||||
|
HOTKEY_PROFILE_ACTIVATE(Category.SELF, 0x0130, "Activate",
|
||||||
|
"Activate Hotkey Profile", true, Argument.PROFILE, false),
|
||||||
|
HOTKEY_PROFILE_DEACTIVATE(Category.SELF, 0x0130, "Deactivate",
|
||||||
|
"Deactivate Hotkey Profile", true, Argument.PROFILE, false),
|
||||||
|
|
||||||
|
PTT_ACTIVATE(Category.SELF, 0x0140, "Activate",
|
||||||
|
"Activate Push-to-Talk", false, Argument.NONE, true, true),
|
||||||
|
PTT_DEACTIVATE(Category.SELF, 0x0140, "Deactivate",
|
||||||
|
"Deactivate Push-to-Talk", true, Argument.NONE, true),
|
||||||
|
PTT_TOGGLE(Category.SELF, 0x0140, "Toggle",
|
||||||
|
"Toggle Push-to-Talk", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
CHANNEL_SWITCH(Category.SELF, 0x0150, "Strict Channel",
|
||||||
|
"Switch to Channel", true, Argument.CHANNEL, true),
|
||||||
|
CHANNEL_NEXT(Category.SELF, 0x0150, "Next Channel",
|
||||||
|
"Switch to Next Channel (Global)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_PREVIOUS(Category.SELF, 0x0150, "Previous Channel",
|
||||||
|
"Switch to Previous Channel (Global)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_LAST_VISITED(Category.SELF, 0x0150, "Last Visited Channel",
|
||||||
|
"Switch to Last Visited Channel", true, Argument.NONE, false),
|
||||||
|
CHANNEL_NEXT_FAMILY(Category.SELF, 0x0150, "Next Channel Family",
|
||||||
|
"Switch to Next Channel (Channel Family)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_PREVIOUS_FAMILY(Category.SELF, 0x0150, "Previous Channel Family",
|
||||||
|
"Switch to Previous Channel (Channel Family)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_NEXT_LEVEL(Category.SELF, 0x0150, "Next Channel Level",
|
||||||
|
"Switch to Next Channel (Same Level)", true, Argument.NONE, false),
|
||||||
|
CHANNEL_PREVIOUS_LEVEL(Category.SELF, 0x0150, "Previous Channel Level",
|
||||||
|
"Switch to Previous Channel (Same Level)", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
SERVER_TAB_SELECT(Category.SELF, 0x0160, "Strict Server",
|
||||||
|
"Select Server Tab", true, Argument.TEXT, true),
|
||||||
|
SERVER_TAB_NEXT(Category.SELF, 0x0160, "Next Server",
|
||||||
|
"Select Next Server Tab", true, Argument.NONE, true),
|
||||||
|
SERVER_TAB_PREVIOUS(Category.SELF, 0x0160, "Previous Server",
|
||||||
|
"Select Previous Server Tab", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
SOUNDPACK_ACTIVATE(Category.SELF, 0x0170, "Activate",
|
||||||
|
"Activate Soundpack", true, Argument.PROFILE, false),
|
||||||
|
SOUND_MUTE(Category.SELF, 0x0170, "Mute",
|
||||||
|
"Mute Sounds", true, Argument.NONE, true),
|
||||||
|
SOUND_UNMUTE(Category.SELF, 0x0170, "Unmute",
|
||||||
|
"Unmute Sounds", true, Argument.NONE, true),
|
||||||
|
SOUND_TOGGLE(Category.SELF, 0x0170, "Toggle",
|
||||||
|
"Toggle Sound Mute", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
WHISPER_PUSH_ACTIVATE(Category.SELF, 0x0180, "ActivatePushToWhisper",
|
||||||
|
"Activate Push-To-Whisper", true, Argument.TEXT, false, true),
|
||||||
|
WHISPER_PUSH_DEACTIVATE(Category.SELF, 0x0180, "DeactivatePushToWhisper",
|
||||||
|
"Deactivate Push-To-Whisper", true, Argument.TEXT, false),
|
||||||
|
WHISPER_REPLY_PUSH_ACTIVATE(Category.SELF, 0x0180, "ActivateReplyPushToWhisper",
|
||||||
|
"Activate Push-To-Reply to a Whisper", true, Argument.NONE, false, true),
|
||||||
|
WHISPER_REPLY_PUSH_DEACTIVATE(Category.SELF, 0x0180, "DeactivateReplyPushToWhisper",
|
||||||
|
"Deactivate Push-To-Reply to a Whisper", true, Argument.NONE, false),
|
||||||
|
WHISPERLIST_ACTIVATE(Category.SELF, 0x0180, "Activate",
|
||||||
|
"Activate Whisperlist", true, Argument.TEXT, false),
|
||||||
|
WHISPERLIST_DEACTIVATE(Category.SELF, 0x0180, "Deactivate",
|
||||||
|
"Deactivate Whisperlist", true, Argument.TEXT, false),
|
||||||
|
WHISPER_REPLY_ACTIVATE(Category.SELF, 0x0180, "ActivateReply",
|
||||||
|
"Activate Reply to a Whisper", true, Argument.NONE, false),
|
||||||
|
WHISPER_REPLY_DEACTIVATE(Category.SELF, 0x0180, "DeactivateReply",
|
||||||
|
"Deactivate Reply to a Whisper", true, Argument.NONE, false),
|
||||||
|
WHISPER_BLOCK_ACTIVATE(Category.SELF, 0x0180, "ActivateBlock",
|
||||||
|
"Activate Block Incoming Whispers", true, Argument.NONE, false),
|
||||||
|
WHISPER_BLOCK_DEACTIVATE(Category.SELF, 0x0180, "DeactivateBlock",
|
||||||
|
"Deactivate Block Incoming Whispers", true, Argument.NONE, false),
|
||||||
|
WHISPER_BLOCK_TOGGLE(Category.SELF, 0x0180, "ToggleBlock",
|
||||||
|
"Toggle Block Incoming Whispers", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
RECORDING_START(Category.SELF, 0x01c0, "Activate",
|
||||||
|
"Start Recording", true, Argument.NONE, false),
|
||||||
|
RECORDING_START_MULTITRACK(Category.SELF, 0x01c0, "ActivateMT",
|
||||||
|
"Start Multitrack Recording", true, Argument.NONE, false),
|
||||||
|
RECORDING_STOP(Category.SELF, 0x01c0, "Deactivate",
|
||||||
|
"Stop Recording", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
VOLUME_INCREASE(Category.SELF, 0x01e0, "Increase",
|
||||||
|
"Increase Master Volume", true, Argument.NONE, true),
|
||||||
|
VOLUME_DECREASE(Category.SELF, 0x01e0, "Decrease",
|
||||||
|
"Decrease Master Volume", true, Argument.NONE, true),
|
||||||
|
|
||||||
|
PLUGIN_ACTIVATE(Category.SELF, 0x01f0, "Activate",
|
||||||
|
"Activate Plugin", true, Argument.TEXT, false),
|
||||||
|
PLUGIN_DEACTIVATE(Category.SELF, 0x01f0, "Deactivate",
|
||||||
|
"Deactivate Plugin", true, Argument.TEXT, false),
|
||||||
|
PLUGIN_TOGGLE(Category.SELF, 0x01f0, "Toggle",
|
||||||
|
"Toggle Plugin", true, Argument.TEXT, false),
|
||||||
|
PLUGIN_COMMAND(Category.SELF, 0x01f0, "Run",
|
||||||
|
"Run Plugin Command", true, Argument.TEXT, false),
|
||||||
|
PLUGIN_HOTKEY(Category.SELF, 0x01f0, "Hotkey",
|
||||||
|
"Plugin Hotkey", true, Argument.TEXT, false),
|
||||||
|
|
||||||
|
SERVER_GROUP_ASSIGN(Category.SELF, 0x0270, "AssignSG",
|
||||||
|
"Assign Server Group", true, Argument.TEXT, false),
|
||||||
|
SERVER_GROUP_REVOKE(Category.SELF, 0x0270, "RevokeSG",
|
||||||
|
"Revoke Server Group", true, Argument.TEXT, false),
|
||||||
|
SERVER_GROUP_TOGGLE(Category.SELF, 0x0270, "ToggleSG",
|
||||||
|
"Toggle Server Group", true, Argument.TEXT, false),
|
||||||
|
|
||||||
|
STYLESHEET_HELPER_ON(Category.SELF, 0x0240, "Activate",
|
||||||
|
"Stylesheet Helper (on)", true, Argument.NONE, false),
|
||||||
|
STYLESHEET_HELPER_OFF(Category.SELF, 0x0240, "Deactivate",
|
||||||
|
"Stylesheet Helper (off)", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
SOUND_3D_ACTIVATE(Category.SELF, 0x0250, "Activate",
|
||||||
|
"Activate 3D Sound", true, Argument.NONE, false),
|
||||||
|
SOUND_3D_DEACTIVATE(Category.SELF, 0x0250, "Deactivate",
|
||||||
|
"Deactivate 3D Sound", true, Argument.NONE, false),
|
||||||
|
SOUND_3D_TOGGLE(Category.SELF, 0x0250, "Toggle",
|
||||||
|
"Toggle 3D Sound", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
NICKNAME_CHANGE(Category.SELF, 0x01a0, "Rename",
|
||||||
|
"Change Nickname", true, Argument.TEXT, true),
|
||||||
|
|
||||||
|
// ----- Misc -----
|
||||||
|
TALK_POWER_GRANT_NEXT(Category.MISC, 0x01b0, "GrantNextTalkPower",
|
||||||
|
"Grant Next User Talk Power", true, Argument.NONE, false),
|
||||||
|
TALK_POWER_REQUEST(Category.MISC, 0x01b0, "RequestTalkPower",
|
||||||
|
"Request Talk Power", true, Argument.NONE, false),
|
||||||
|
TALK_POWER_REVOKE_ALL_GRANT_NEXT(Category.MISC, 0x01b0, "RevokeAllAndGrantNext",
|
||||||
|
"Revoke All And Grant Next User Talk Power", true, Argument.NONE, false),
|
||||||
|
|
||||||
|
FILEBROWSER(Category.MISC, 0x01d0, "Filebrowser",
|
||||||
|
"Open Filebrowser on Channel", true, Argument.NONE, true),
|
||||||
|
AUTOCONNECT_DISABLE(Category.MISC, 0x0200, "Autoconnect",
|
||||||
|
"Disable Autoconnect", true, Argument.NONE, false),
|
||||||
|
SKIN_RELOAD(Category.MISC, 0x0210, "SkinReload",
|
||||||
|
"Reload Skin", true, Argument.NONE, true),
|
||||||
|
BRING_TO_FRONT(Category.MISC, 0x0220, "Bring2Front",
|
||||||
|
"Bring Client to Front", true, Argument.NONE, true),
|
||||||
|
SEND_TO_BACK(Category.MISC, 0x0230, "Send2Back",
|
||||||
|
"Send Client to Background", true, Argument.NONE, true);
|
||||||
|
|
||||||
|
/** The three groups the TS3 hotkey dialog separates its list into. */
|
||||||
|
public enum Category {
|
||||||
|
SERVER("Server"),
|
||||||
|
SELF("Self"),
|
||||||
|
MISC("Misc");
|
||||||
|
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
Category(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The group an action's type belongs to in TeamSpeak's hotkey tree, taken from the
|
||||||
|
* table its dialog builds. Types absent here are ungrouped: TS3 shows them as a
|
||||||
|
* single item directly under their category.
|
||||||
|
*/
|
||||||
|
private static final Map<Integer, String> GROUPS = Map.ofEntries(
|
||||||
|
Map.entry(0x0010, "Connect to Server"),
|
||||||
|
Map.entry(0x0020, "Disconnect from Server"),
|
||||||
|
Map.entry(0x0030, "Microphone"),
|
||||||
|
Map.entry(0x0040, "Speaker"),
|
||||||
|
Map.entry(0x0050, "Away Status"),
|
||||||
|
Map.entry(0x0100, "Channel Commander"),
|
||||||
|
Map.entry(0x0110, "Capture Profile"),
|
||||||
|
Map.entry(0x0120, "Playback Profile"),
|
||||||
|
Map.entry(0x0130, "Hotkey Profile"),
|
||||||
|
Map.entry(0x0140, "Push-to-Talk"),
|
||||||
|
Map.entry(0x0150, "Switch to Channel"),
|
||||||
|
Map.entry(0x0160, "Select Server Tab"),
|
||||||
|
Map.entry(0x0170, "Sounds"),
|
||||||
|
Map.entry(0x0180, "Whisper"),
|
||||||
|
Map.entry(0x01b0, "Talk Power"),
|
||||||
|
Map.entry(0x01c0, "Recording"),
|
||||||
|
Map.entry(0x01e0, "Master Volume"),
|
||||||
|
Map.entry(0x01f0, "Plugins"),
|
||||||
|
Map.entry(0x0250, "3D Sound"),
|
||||||
|
Map.entry(0x0270, "Permissions"));
|
||||||
|
|
||||||
|
/** What the action's free-text parameter means, when it takes one. */
|
||||||
|
public enum Argument {
|
||||||
|
NONE,
|
||||||
|
/** Label of a bookmark to connect to. */
|
||||||
|
BOOKMARK,
|
||||||
|
/** Channel path, "/"-separated. */
|
||||||
|
CHANNEL,
|
||||||
|
/** Name of a capture/playback/hotkey profile or sound pack. */
|
||||||
|
PROFILE,
|
||||||
|
/** Anything else: nickname, away message, tab number, plugin command. */
|
||||||
|
TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Category category;
|
||||||
|
private final int type;
|
||||||
|
private final String parameter;
|
||||||
|
private final String label;
|
||||||
|
private final boolean advanced;
|
||||||
|
private final Argument argument;
|
||||||
|
private final boolean supported;
|
||||||
|
private final boolean momentary;
|
||||||
|
|
||||||
|
HotkeyAction(Category category, int type, String parameter, String label,
|
||||||
|
boolean advanced, Argument argument, boolean supported) {
|
||||||
|
this(category, type, parameter, label, advanced, argument, supported, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
HotkeyAction(Category category, int type, String parameter, String label,
|
||||||
|
boolean advanced, Argument argument, boolean supported, boolean momentary) {
|
||||||
|
this.category = category;
|
||||||
|
this.type = type;
|
||||||
|
this.parameter = parameter;
|
||||||
|
this.label = label;
|
||||||
|
this.advanced = advanced;
|
||||||
|
this.argument = argument;
|
||||||
|
this.supported = supported;
|
||||||
|
this.momentary = momentary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Category category() {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TS3's numeric action type; actions sharing one differ only by {@link #parameter()}. */
|
||||||
|
public int type() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The node this action hangs under in the hotkey tree ("Sounds", "Microphone", …),
|
||||||
|
* or an empty string for the actions TS3 lists on their own.
|
||||||
|
*/
|
||||||
|
public String group() {
|
||||||
|
return GROUPS.getOrDefault(type, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TS3's parameter keyword, e.g. {@code Toggle} or {@code ActivateReply}. */
|
||||||
|
public String parameter() {
|
||||||
|
return parameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name shown in the hotkey list. */
|
||||||
|
public String label() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hidden until "Show advanced actions" is ticked, as in TS3. */
|
||||||
|
public boolean advanced() {
|
||||||
|
return advanced;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Argument argument() {
|
||||||
|
return argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this client implements the action; unsupported ones cannot be bound. */
|
||||||
|
public boolean supported() {
|
||||||
|
return supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the action lasts only while the hotkey is held (push-to-talk and the
|
||||||
|
* push-to-whisper variants), in which case the trigger setting does not apply and
|
||||||
|
* the release is reported as well.
|
||||||
|
*/
|
||||||
|
public boolean momentary() {
|
||||||
|
return momentary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<HotkeyAction> of(Category category, boolean includeAdvanced) {
|
||||||
|
List<HotkeyAction> out = new ArrayList<>();
|
||||||
|
for (HotkeyAction a : values()) {
|
||||||
|
if (a.category == category && (includeAdvanced || !a.advanced)) out.add(a);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks an action up by its enum name, falling back to {@code null}. */
|
||||||
|
public static HotkeyAction byName(String name) {
|
||||||
|
for (HotkeyAction a : values()) {
|
||||||
|
if (a.name().equals(name)) return a;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key combination: one key or button, or several that have to be held together.
|
||||||
|
* There is no distinguished modifier — as in TS3, any key can take that role, and the
|
||||||
|
* combination fires once every one of its keys is down.
|
||||||
|
*/
|
||||||
|
public final class HotkeyCombo {
|
||||||
|
|
||||||
|
/** Recording order, which is also the order the combination is displayed in. */
|
||||||
|
private final List<HotkeyKey> keys;
|
||||||
|
|
||||||
|
public HotkeyCombo(Collection<HotkeyKey> keys) {
|
||||||
|
List<HotkeyKey> copy = new ArrayList<>();
|
||||||
|
for (HotkeyKey k : keys) {
|
||||||
|
if (k != null && !copy.contains(k)) copy.add(k);
|
||||||
|
}
|
||||||
|
this.keys = Collections.unmodifiableList(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<HotkeyKey> keys() {
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return keys.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean contains(HotkeyKey key) {
|
||||||
|
return keys.contains(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether every key of the combination is currently held down. */
|
||||||
|
public boolean isSatisfiedBy(Set<HotkeyKey> pressed) {
|
||||||
|
return !keys.isEmpty() && pressed.containsAll(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialised form, e.g. {@code Keyboard:37+Keyboard:45}. */
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (HotkeyKey k : keys) {
|
||||||
|
if (sb.length() > 0) sb.append('+');
|
||||||
|
sb.append(k);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HotkeyCombo parse(String text) {
|
||||||
|
List<HotkeyKey> keys = new ArrayList<>();
|
||||||
|
if (text != null && !text.isBlank()) {
|
||||||
|
for (String part : text.split("\\+")) {
|
||||||
|
HotkeyKey k = HotkeyKey.parse(part);
|
||||||
|
if (k != null) keys.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new HotkeyCombo(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the combination for the user, e.g. {@code Ctrl+Mouse 4}.
|
||||||
|
*
|
||||||
|
* @param namer resolves a key to its name on this system, typically from the
|
||||||
|
* keyboard layout; may return {@code null} to fall back
|
||||||
|
*/
|
||||||
|
public String display(Function<HotkeyKey, String> namer) {
|
||||||
|
if (keys.isEmpty()) return "No hotkey assigned";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (HotkeyKey k : keys) {
|
||||||
|
String name = namer == null ? null : namer.apply(k);
|
||||||
|
if (name == null || name.isBlank()) name = k.fallbackName();
|
||||||
|
if (sb.length() > 0) sb.append(" + ");
|
||||||
|
sb.append(name);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
return o instanceof HotkeyCombo other && keys.equals(other.keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return keys.hashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the raw stream of global key events into hotkey activations: it tracks which
|
||||||
|
* keys are down, decides when a binding's combination becomes (or stops being)
|
||||||
|
* satisfied, and reports that on the configured edge.
|
||||||
|
*
|
||||||
|
* <p>While a {@link Recorder} is installed the engine records combinations instead of
|
||||||
|
* firing them, which is how the hotkey dialog captures a new binding without the old
|
||||||
|
* ones going off.
|
||||||
|
*/
|
||||||
|
public final class HotkeyEngine implements GlobalInputHook.Listener {
|
||||||
|
|
||||||
|
/** Receives activations; called on the input hook's thread. */
|
||||||
|
public interface Handler {
|
||||||
|
/**
|
||||||
|
* @param hotkey the binding that fired
|
||||||
|
* @param active {@code true} on activation; a {@code false} call follows on release
|
||||||
|
* for {@link HotkeyAction#momentary()} actions only
|
||||||
|
*/
|
||||||
|
void onHotkey(Hotkey hotkey, boolean active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collects a combination as the user presses it, for the "set hotkey" button. */
|
||||||
|
public interface Recorder {
|
||||||
|
/** The keys held so far, in press order; called on every change. */
|
||||||
|
void onRecording(HotkeyCombo combo);
|
||||||
|
|
||||||
|
/** The user let go of everything: this is the final combination. */
|
||||||
|
void onRecorded(HotkeyCombo combo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Hotkeys hotkeys;
|
||||||
|
private final Handler handler;
|
||||||
|
|
||||||
|
private final Set<HotkeyKey> pressed = new LinkedHashSet<>();
|
||||||
|
/** Bindings whose combination is currently held, so a release can end them. */
|
||||||
|
private final Set<Hotkey> active = new HashSet<>();
|
||||||
|
|
||||||
|
private volatile Recorder recorder;
|
||||||
|
private final Set<HotkeyKey> recording = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
public HotkeyEngine(Hotkeys hotkeys, Handler handler) {
|
||||||
|
this.hotkeys = hotkeys;
|
||||||
|
this.handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diverts every event into {@code recorder} until {@link #stopRecording()}; the
|
||||||
|
* bindings themselves stay silent meanwhile.
|
||||||
|
*/
|
||||||
|
public void record(Recorder recorder) {
|
||||||
|
synchronized (this) {
|
||||||
|
recording.clear();
|
||||||
|
releaseActive();
|
||||||
|
pressed.clear();
|
||||||
|
}
|
||||||
|
this.recorder = recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopRecording() {
|
||||||
|
recorder = null;
|
||||||
|
synchronized (this) {
|
||||||
|
recording.clear();
|
||||||
|
pressed.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onInput(HotkeyKey key, boolean down) {
|
||||||
|
Recorder rec = recorder;
|
||||||
|
if (rec != null) {
|
||||||
|
recordKey(rec, key, down);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Runnable> fired = new ArrayList<>();
|
||||||
|
synchronized (this) {
|
||||||
|
if (down) {
|
||||||
|
if (!pressed.add(key)) return;
|
||||||
|
} else if (!pressed.remove(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized (hotkeys.all()) {
|
||||||
|
for (Hotkey h : hotkeys.all()) {
|
||||||
|
if (!h.enabled || !h.isValid() || !h.action.supported()) continue;
|
||||||
|
if (!h.combo.contains(key)) continue;
|
||||||
|
|
||||||
|
boolean satisfied = h.combo.isSatisfiedBy(pressed);
|
||||||
|
if (satisfied && active.add(h)) {
|
||||||
|
if (h.action.momentary() || h.trigger == Hotkey.Trigger.KEY_DOWN) {
|
||||||
|
fired.add(() -> handler.onHotkey(h, true));
|
||||||
|
}
|
||||||
|
} else if (!satisfied && active.remove(h)) {
|
||||||
|
if (h.action.momentary()) {
|
||||||
|
fired.add(() -> handler.onHotkey(h, false));
|
||||||
|
} else if (h.trigger == Hotkey.Trigger.KEY_UP) {
|
||||||
|
fired.add(() -> handler.onHotkey(h, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Runnable r : fired) r.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordKey(Recorder rec, HotkeyKey key, boolean down) {
|
||||||
|
HotkeyCombo combo;
|
||||||
|
boolean finished;
|
||||||
|
synchronized (this) {
|
||||||
|
if (down) {
|
||||||
|
recording.add(key);
|
||||||
|
pressed.add(key);
|
||||||
|
} else {
|
||||||
|
pressed.remove(key);
|
||||||
|
}
|
||||||
|
combo = new HotkeyCombo(recording);
|
||||||
|
finished = !down && pressed.isEmpty() && !recording.isEmpty();
|
||||||
|
if (finished) recording.clear();
|
||||||
|
}
|
||||||
|
if (finished) {
|
||||||
|
rec.onRecorded(combo);
|
||||||
|
} else {
|
||||||
|
rec.onRecording(combo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends every held binding, so nothing stays stuck when the hook stops. */
|
||||||
|
public void releaseAll() {
|
||||||
|
List<Runnable> fired = new ArrayList<>();
|
||||||
|
synchronized (this) {
|
||||||
|
for (Hotkey h : active) {
|
||||||
|
if (h.action.momentary()) fired.add(() -> handler.onHotkey(h, false));
|
||||||
|
}
|
||||||
|
active.clear();
|
||||||
|
pressed.clear();
|
||||||
|
}
|
||||||
|
for (Runnable r : fired) r.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseActive() {
|
||||||
|
for (Hotkey h : new ArrayList<>(active)) {
|
||||||
|
if (h.action.momentary()) handler.onHotkey(h, false);
|
||||||
|
}
|
||||||
|
active.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One physical key or mouse button, identified the way the X server numbers them:
|
||||||
|
* keyboards by keycode (Linux evdev code + 8) and mice by button number, so a binding
|
||||||
|
* is independent of the keyboard layout in force when it was recorded.
|
||||||
|
*
|
||||||
|
* @param device which input device the code belongs to
|
||||||
|
* @param code X keycode for {@link Device#KEYBOARD}, X button number for {@link Device#MOUSE}
|
||||||
|
*/
|
||||||
|
public record HotkeyKey(HotkeyKey.Device device, int code) {
|
||||||
|
|
||||||
|
public enum Device {
|
||||||
|
KEYBOARD("Keyboard"),
|
||||||
|
MOUSE("Mouse");
|
||||||
|
|
||||||
|
private final String prefix;
|
||||||
|
|
||||||
|
Device(String prefix) {
|
||||||
|
this.prefix = prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
String prefix() {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HotkeyKey keyboard(int keycode) {
|
||||||
|
return new HotkeyKey(Device.KEYBOARD, keycode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HotkeyKey mouse(int button) {
|
||||||
|
return new HotkeyKey(Device.MOUSE, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialised form, matching TS3's {@code Keyboard:9} / {@code Mouse:9} notation. */
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return device.prefix() + ":" + code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HotkeyKey parse(String text) {
|
||||||
|
if (text == null) return null;
|
||||||
|
int colon = text.indexOf(':');
|
||||||
|
if (colon <= 0) return null;
|
||||||
|
String prefix = text.substring(0, colon).trim();
|
||||||
|
int code;
|
||||||
|
try {
|
||||||
|
code = Integer.parseInt(text.substring(colon + 1).trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Device d : Device.values()) {
|
||||||
|
if (d.prefix().equalsIgnoreCase(prefix)) return new HotkeyKey(d, code);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A readable name that needs no help from the platform: mouse buttons carry the
|
||||||
|
* numbering users know them by, keys fall back to their raw code.
|
||||||
|
*/
|
||||||
|
public String fallbackName() {
|
||||||
|
if (device == Device.MOUSE) {
|
||||||
|
return switch (code) {
|
||||||
|
case 1 -> "Mouse Left";
|
||||||
|
case 2 -> "Mouse Middle";
|
||||||
|
case 3 -> "Mouse Right";
|
||||||
|
case 4 -> "Wheel Up";
|
||||||
|
case 5 -> "Wheel Down";
|
||||||
|
case 6 -> "Wheel Left";
|
||||||
|
case 7 -> "Wheel Right";
|
||||||
|
// X hands the side buttons out as 8 and 9; everyone else calls them 4 and 5.
|
||||||
|
case 8 -> "Mouse 4";
|
||||||
|
case 9 -> "Mouse 5";
|
||||||
|
default -> "Mouse " + code;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return "Key " + code;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java
Normal file
109
ts3-client/core/src/main/java/com/ts3client/hotkey/Hotkeys.java
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's hotkey bindings, stored alongside the settings file.
|
||||||
|
* Frontend-agnostic: no UI dependencies.
|
||||||
|
*/
|
||||||
|
public final class Hotkeys {
|
||||||
|
|
||||||
|
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
|
||||||
|
private static final File FILE = new File(DIR, "hotkeys.properties");
|
||||||
|
|
||||||
|
private final List<Hotkey> entries = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
|
||||||
|
/** Live view; iterate under {@code synchronized (all())} when the engine may be running. */
|
||||||
|
public List<Hotkey> all() {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void add(Hotkey hotkey) {
|
||||||
|
entries.add(hotkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set(int index, Hotkey hotkey) {
|
||||||
|
if (index >= 0 && index < entries.size()) entries.set(index, hotkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void remove(int index) {
|
||||||
|
if (index >= 0 && index < entries.size()) entries.remove(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces every binding at once, e.g. when the options dialog is applied. */
|
||||||
|
public void replaceAll(List<Hotkey> hotkeys) {
|
||||||
|
synchronized (entries) {
|
||||||
|
entries.clear();
|
||||||
|
for (Hotkey h : hotkeys) {
|
||||||
|
if (h != null && h.isValid()) entries.add(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Hotkeys load() {
|
||||||
|
Hotkeys h = new Hotkeys();
|
||||||
|
if (!FILE.isFile()) return h;
|
||||||
|
Properties p = new Properties();
|
||||||
|
try (FileInputStream in = new FileInputStream(FILE)) {
|
||||||
|
p.load(in);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
int count = parseInt(p.getProperty("count"), 0);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
String prefix = "hotkey." + i + ".";
|
||||||
|
HotkeyAction action = HotkeyAction.byName(p.getProperty(prefix + "action", ""));
|
||||||
|
if (action == null) continue;
|
||||||
|
Hotkey entry = new Hotkey(action, HotkeyCombo.parse(p.getProperty(prefix + "keys", "")));
|
||||||
|
entry.trigger = "KEY_UP".equals(p.getProperty(prefix + "trigger"))
|
||||||
|
? Hotkey.Trigger.KEY_UP : Hotkey.Trigger.KEY_DOWN;
|
||||||
|
entry.activeServerOnly = !"false".equals(p.getProperty(prefix + "activeServerOnly"));
|
||||||
|
entry.argument = p.getProperty(prefix + "argument", "");
|
||||||
|
entry.enabled = !"false".equals(p.getProperty(prefix + "enabled"));
|
||||||
|
if (entry.isValid()) h.entries.add(entry);
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save() {
|
||||||
|
Properties p = new Properties();
|
||||||
|
synchronized (entries) {
|
||||||
|
p.setProperty("count", Integer.toString(entries.size()));
|
||||||
|
for (int i = 0; i < entries.size(); i++) {
|
||||||
|
Hotkey e = entries.get(i);
|
||||||
|
String prefix = "hotkey." + i + ".";
|
||||||
|
p.setProperty(prefix + "action", e.action.name());
|
||||||
|
p.setProperty(prefix + "keys", e.combo.toString());
|
||||||
|
p.setProperty(prefix + "trigger", e.trigger.name());
|
||||||
|
p.setProperty(prefix + "activeServerOnly", Boolean.toString(e.activeServerOnly));
|
||||||
|
p.setProperty(prefix + "argument", e.argument == null ? "" : e.argument);
|
||||||
|
p.setProperty(prefix + "enabled", Boolean.toString(e.enabled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!DIR.isDirectory()) {
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
DIR.mkdirs();
|
||||||
|
}
|
||||||
|
try (FileOutputStream out = new FileOutputStream(FILE)) {
|
||||||
|
p.store(out, "TS3J client hotkeys");
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parseInt(String v, int def) {
|
||||||
|
if (v == null) return def;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(v.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ public final class SoundNotifier {
|
|||||||
private volatile SoundPlayer player;
|
private volatile SoundPlayer player;
|
||||||
private volatile SoundPack pack;
|
private volatile SoundPack pack;
|
||||||
private volatile List<SoundPack> available = List.of();
|
private volatile List<SoundPack> available = List.of();
|
||||||
|
private volatile boolean soundsMuted;
|
||||||
|
|
||||||
public SoundNotifier(Settings settings) {
|
public SoundNotifier(Settings settings) {
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
@@ -55,6 +56,19 @@ public final class SoundNotifier {
|
|||||||
settings.soundPack = pack == null ? "" : pack.id();
|
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.
|
* Plays the sound for an action.
|
||||||
*
|
*
|
||||||
@@ -70,6 +84,7 @@ public final class SoundNotifier {
|
|||||||
* {@code clientname} or {@code channelname}
|
* {@code clientname} or {@code channelname}
|
||||||
*/
|
*/
|
||||||
public void fire(SoundEvent event, boolean muted, Map<String, String> variables) {
|
public void fire(SoundEvent event, boolean muted, Map<String, String> variables) {
|
||||||
|
if (soundsMuted) return;
|
||||||
NotificationSettings notifications = settings.notifications;
|
NotificationSettings notifications = settings.notifications;
|
||||||
if (!notifications.isEnabled(event)) return;
|
if (!notifications.isEnabled(event)) return;
|
||||||
if (muted && !notifications.isImportant(event)) return;
|
if (muted && !notifications.isImportant(event)) return;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.ts3client.hotkey;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class HotkeyEngineTest {
|
||||||
|
|
||||||
|
private static final HotkeyKey CTRL = HotkeyKey.keyboard(37);
|
||||||
|
private static final HotkeyKey K = HotkeyKey.keyboard(45);
|
||||||
|
private static final HotkeyKey MOUSE4 = HotkeyKey.mouse(8);
|
||||||
|
|
||||||
|
private final List<String> fired = new ArrayList<>();
|
||||||
|
private final Hotkeys hotkeys = new Hotkeys();
|
||||||
|
private final HotkeyEngine engine = new HotkeyEngine(hotkeys,
|
||||||
|
(hotkey, active) -> fired.add(hotkey.action + (active ? ":on" : ":off")));
|
||||||
|
|
||||||
|
private Hotkey bind(HotkeyAction action, Hotkey.Trigger trigger, HotkeyKey... keys) {
|
||||||
|
Hotkey h = new Hotkey(action, new HotkeyCombo(List.of(keys)));
|
||||||
|
h.trigger = trigger;
|
||||||
|
hotkeys.add(h);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firesOnKeyDownOnlyWhenEveryKeyIsHeld() {
|
||||||
|
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, CTRL, K);
|
||||||
|
|
||||||
|
engine.onInput(K, true);
|
||||||
|
assertTrue(fired.isEmpty(), "one key of the combination is not enough");
|
||||||
|
engine.onInput(CTRL, true);
|
||||||
|
assertEquals(List.of("MIC_TOGGLE:on"), fired);
|
||||||
|
|
||||||
|
// Releasing and re-pressing arms it again, but only once per completion.
|
||||||
|
engine.onInput(CTRL, false);
|
||||||
|
engine.onInput(CTRL, true);
|
||||||
|
assertEquals(List.of("MIC_TOGGLE:on", "MIC_TOGGLE:on"), fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keyUpTriggerFiresOnRelease() {
|
||||||
|
bind(HotkeyAction.SPEAKER_TOGGLE, Hotkey.Trigger.KEY_UP, MOUSE4);
|
||||||
|
|
||||||
|
engine.onInput(MOUSE4, true);
|
||||||
|
assertTrue(fired.isEmpty());
|
||||||
|
engine.onInput(MOUSE4, false);
|
||||||
|
assertEquals(List.of("SPEAKER_TOGGLE:on"), fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void momentaryActionReportsPressAndRelease() {
|
||||||
|
bind(HotkeyAction.PTT_ACTIVATE, Hotkey.Trigger.KEY_UP, MOUSE4);
|
||||||
|
|
||||||
|
engine.onInput(MOUSE4, true);
|
||||||
|
engine.onInput(MOUSE4, false);
|
||||||
|
// The trigger setting does not apply: push-to-talk always spans the hold.
|
||||||
|
assertEquals(List.of("PTT_ACTIVATE:on", "PTT_ACTIVATE:off"), fired);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledAndUnsupportedBindingsStaySilent() {
|
||||||
|
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K).enabled = false;
|
||||||
|
bind(HotkeyAction.RECORDING_START, Hotkey.Trigger.KEY_DOWN, MOUSE4);
|
||||||
|
|
||||||
|
engine.onInput(K, true);
|
||||||
|
engine.onInput(MOUSE4, true);
|
||||||
|
assertTrue(fired.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordingCapturesTheCombinationInsteadOfFiring() {
|
||||||
|
bind(HotkeyAction.MIC_TOGGLE, Hotkey.Trigger.KEY_DOWN, K);
|
||||||
|
List<HotkeyCombo> recorded = new ArrayList<>();
|
||||||
|
engine.record(new HotkeyEngine.Recorder() {
|
||||||
|
@Override
|
||||||
|
public void onRecording(HotkeyCombo combo) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRecorded(HotkeyCombo combo) {
|
||||||
|
recorded.add(combo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.onInput(CTRL, true);
|
||||||
|
engine.onInput(K, true);
|
||||||
|
engine.onInput(K, false);
|
||||||
|
engine.onInput(CTRL, false);
|
||||||
|
|
||||||
|
assertTrue(fired.isEmpty(), "bindings must not fire while recording");
|
||||||
|
assertEquals(1, recorded.size());
|
||||||
|
assertEquals(List.of(CTRL, K), recorded.get(0).keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void actionsCarryTeamSpeaksTreeGrouping() {
|
||||||
|
assertEquals("Sounds", HotkeyAction.SOUNDPACK_ACTIVATE.group());
|
||||||
|
assertEquals("Microphone", HotkeyAction.MIC_TOGGLE.group());
|
||||||
|
// TS3 leaves a handful of actions ungrouped, listed straight under their category.
|
||||||
|
assertEquals("", HotkeyAction.NICKNAME_CHANGE.group());
|
||||||
|
assertEquals("", HotkeyAction.BRING_TO_FRONT.group());
|
||||||
|
|
||||||
|
Hotkey pack = new Hotkey(HotkeyAction.SOUNDPACK_ACTIVATE, new HotkeyCombo(List.of(K)));
|
||||||
|
pack.argument = "Default Sound Pack (Male)";
|
||||||
|
assertEquals("Sounds / Activate Soundpack / Default Sound Pack (Male)", pack.path());
|
||||||
|
assertEquals("Bring Client to Front",
|
||||||
|
new Hotkey(HotkeyAction.BRING_TO_FRONT, new HotkeyCombo(List.of(K))).path());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinationsSurviveARoundTripThroughText() {
|
||||||
|
HotkeyCombo combo = new HotkeyCombo(List.of(CTRL, MOUSE4));
|
||||||
|
assertEquals("Keyboard:37+Mouse:8", combo.toString());
|
||||||
|
assertEquals(combo, HotkeyCombo.parse(combo.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<String> 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<GlobalInputHook> candidates() {
|
||||||
|
List<GlobalInputHook> 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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<InputStream> streams = new ArrayList<>();
|
||||||
|
private final List<Thread> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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('_', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<Integer, String> 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.hotkey.Hotkey;
|
||||||
|
import com.ts3client.hotkey.HotkeyEngine;
|
||||||
|
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries a fired hotkey out on the client.
|
||||||
|
*
|
||||||
|
* <p>Activations arrive on the input hook's thread, so everything is handed to the
|
||||||
|
* event dispatch thread first. Which connections an action reaches is the binding's
|
||||||
|
* "on active server" flag: set, only the selected tab; clear, every connected one.
|
||||||
|
*/
|
||||||
|
final class HotkeyActions implements HotkeyEngine.Handler {
|
||||||
|
|
||||||
|
/** How much one press of the master-volume hotkeys moves the slider. */
|
||||||
|
private static final double VOLUME_STEP = 0.05;
|
||||||
|
|
||||||
|
private final MainFrame frame;
|
||||||
|
/** Latched push-to-talk, driven by the "Toggle Push-to-Talk" action. */
|
||||||
|
private boolean pttLatched;
|
||||||
|
|
||||||
|
HotkeyActions(MainFrame frame) {
|
||||||
|
this.frame = frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onHotkey(Hotkey hotkey, boolean active) {
|
||||||
|
SwingUtilities.invokeLater(() -> perform(hotkey, active));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void perform(Hotkey hotkey, boolean active) {
|
||||||
|
List<ServerTab> targets = frame.hotkeyTargets(hotkey.activeServerOnly);
|
||||||
|
switch (hotkey.action) {
|
||||||
|
case CONNECT_CURRENT_TAB -> frame.connectBookmark(hotkey.argument, false);
|
||||||
|
case CONNECT_NEW_TAB -> frame.connectBookmark(hotkey.argument, true);
|
||||||
|
case DISCONNECT_CURRENT -> {
|
||||||
|
ServerTab tab = frame.selectedTab();
|
||||||
|
if (tab != null) tab.disconnect();
|
||||||
|
}
|
||||||
|
case DISCONNECT_ALL -> {
|
||||||
|
for (ServerTab tab : frame.allTabs()) tab.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
case MIC_ACTIVATE -> frame.moveMicrophoneToSelectedTab();
|
||||||
|
case MIC_MUTE -> setMicMuted(targets, true);
|
||||||
|
case MIC_UNMUTE -> setMicMuted(targets, false);
|
||||||
|
case MIC_TOGGLE -> setMicMuted(targets, !anyMicMuted(targets));
|
||||||
|
|
||||||
|
case SPEAKER_MUTE -> setDeafened(targets, true);
|
||||||
|
case SPEAKER_UNMUTE -> setDeafened(targets, false);
|
||||||
|
case SPEAKER_TOGGLE -> setDeafened(targets, !anyDeafened(targets));
|
||||||
|
|
||||||
|
case AWAY_SET -> setAway(targets, true, "");
|
||||||
|
case AWAY_ONLINE -> setAway(targets, false, "");
|
||||||
|
case AWAY_TOGGLE -> setAway(targets, !anyAway(targets), "");
|
||||||
|
case AWAY_TOGGLE_WITH_MESSAGE -> setAway(targets, !anyAway(targets), hotkey.argument);
|
||||||
|
|
||||||
|
case COMMANDER_ACTIVATE -> setCommander(targets, true);
|
||||||
|
case COMMANDER_DEACTIVATE -> setCommander(targets, false);
|
||||||
|
case COMMANDER_TOGGLE -> setCommander(targets, !anyCommander(targets));
|
||||||
|
|
||||||
|
// Momentary: the engine reports the release too, so the key simply holds it open.
|
||||||
|
case PTT_ACTIVATE -> frame.setPushToTalk(active || pttLatched);
|
||||||
|
case PTT_DEACTIVATE -> {
|
||||||
|
pttLatched = false;
|
||||||
|
frame.setPushToTalk(false);
|
||||||
|
}
|
||||||
|
case PTT_TOGGLE -> {
|
||||||
|
pttLatched = !pttLatched;
|
||||||
|
frame.setPushToTalk(pttLatched);
|
||||||
|
}
|
||||||
|
|
||||||
|
case CHANNEL_SWITCH -> {
|
||||||
|
for (ServerTab tab : targets) tab.joinChannelPath(hotkey.argument);
|
||||||
|
}
|
||||||
|
case SERVER_TAB_SELECT -> frame.selectTabNumber(parseIndex(hotkey.argument));
|
||||||
|
case SERVER_TAB_NEXT -> frame.stepTab(1);
|
||||||
|
case SERVER_TAB_PREVIOUS -> frame.stepTab(-1);
|
||||||
|
|
||||||
|
case SOUND_MUTE -> frame.setSoundsMuted(true);
|
||||||
|
case SOUND_UNMUTE -> frame.setSoundsMuted(false);
|
||||||
|
case SOUND_TOGGLE -> frame.setSoundsMuted(!frame.areSoundsMuted());
|
||||||
|
|
||||||
|
case VOLUME_INCREASE -> frame.adjustMasterVolume(VOLUME_STEP);
|
||||||
|
case VOLUME_DECREASE -> frame.adjustMasterVolume(-VOLUME_STEP);
|
||||||
|
|
||||||
|
case NICKNAME_CHANGE -> frame.changeNickname(hotkey.argument);
|
||||||
|
|
||||||
|
case FILEBROWSER -> frame.browseCurrentChannel();
|
||||||
|
case SKIN_RELOAD -> frame.reloadSkin();
|
||||||
|
case BRING_TO_FRONT -> frame.bringToFront();
|
||||||
|
case SEND_TO_BACK -> frame.sendToBack();
|
||||||
|
|
||||||
|
default -> {
|
||||||
|
// Listed for completeness in the action catalogue, but not implemented here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setMicMuted(List<ServerTab> targets, boolean muted) {
|
||||||
|
for (ServerTab tab : targets) tab.setMicMuted(muted);
|
||||||
|
frame.refreshAfterHotkey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setDeafened(List<ServerTab> targets, boolean deaf) {
|
||||||
|
for (ServerTab tab : targets) tab.setDeafened(deaf);
|
||||||
|
frame.refreshAfterHotkey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setAway(List<ServerTab> targets, boolean away, String message) {
|
||||||
|
for (ServerTab tab : targets) tab.setAway(away, message == null ? "" : message);
|
||||||
|
frame.refreshAfterHotkey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setCommander(List<ServerTab> targets, boolean commander) {
|
||||||
|
for (ServerTab tab : targets) tab.setCommander(commander);
|
||||||
|
frame.refreshAfterHotkey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean anyMicMuted(List<ServerTab> tabs) {
|
||||||
|
return tabs.stream().anyMatch(ServerTab::isMicMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean anyDeafened(List<ServerTab> tabs) {
|
||||||
|
return tabs.stream().anyMatch(ServerTab::isDeafened);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean anyAway(List<ServerTab> tabs) {
|
||||||
|
return tabs.stream().anyMatch(ServerTab::isAway);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean anyCommander(List<ServerTab> tabs) {
|
||||||
|
return tabs.stream().anyMatch(ServerTab::isCommander);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the 1-based tab number in the argument, or 1 when it is not a number */
|
||||||
|
private static int parseIndex(String argument) {
|
||||||
|
try {
|
||||||
|
return Math.max(1, Integer.parseInt(argument.trim()));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> choices(HotkeyAction action);
|
||||||
|
}
|
||||||
@@ -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<Hotkey.Trigger> 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<HotkeyAction> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<Hotkey> all() {
|
||||||
|
return hotkeys.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
void replaceAll(List<Hotkey> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Hotkey> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import com.ts3client.config.Bookmark;
|
|||||||
import com.ts3client.config.Bookmarks;
|
import com.ts3client.config.Bookmarks;
|
||||||
import com.ts3client.config.IdentityStore;
|
import com.ts3client.config.IdentityStore;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.net.ChannelNode;
|
||||||
import com.ts3client.sound.SoundNotifier;
|
import com.ts3client.sound.SoundNotifier;
|
||||||
import com.ts3client.sound.SoundPlayer;
|
import com.ts3client.sound.SoundPlayer;
|
||||||
|
|
||||||
@@ -31,9 +32,6 @@ import javax.swing.SwingUtilities;
|
|||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.GridLayout;
|
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.WindowAdapter;
|
||||||
import java.awt.event.WindowEvent;
|
import java.awt.event.WindowEvent;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -89,6 +87,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
|
|
||||||
private boolean pttPressed;
|
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. */
|
/** Guards {@link #shutdown()} so the window listener and JVM hook don't both run it. */
|
||||||
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
|
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
|
||||||
private final Thread shutdownHook = new Thread(this::shutdown, "ts3j-shutdown");
|
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.sounds = new SoundNotifier(settings);
|
||||||
this.soundPlayer = audio.createSoundPlayer(settings);
|
this.soundPlayer = audio.createSoundPlayer(settings);
|
||||||
this.sounds.setPlayer(soundPlayer);
|
this.sounds.setPlayer(soundPlayer);
|
||||||
|
this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices);
|
||||||
|
|
||||||
setIconImage(Icons.app().getImage());
|
setIconImage(Icons.app().getImage());
|
||||||
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
|
// 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("Use Connections → Connect to join a server.");
|
||||||
first.chat().appendSystem(tray.status());
|
first.chat().appendSystem(tray.status());
|
||||||
|
|
||||||
installPushToTalk();
|
|
||||||
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
||||||
statusTimer.start();
|
statusTimer.start();
|
||||||
setSize(880, 560);
|
setSize(880, 560);
|
||||||
@@ -436,26 +437,151 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
tabUpdated(tab);
|
tabUpdated(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- push to talk ----
|
// ---- hotkeys ----
|
||||||
|
|
||||||
private void installPushToTalk() {
|
/**
|
||||||
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(new KeyEventDispatcher() {
|
* Which connections a fired hotkey reaches: with "on active server" ticked only the
|
||||||
@Override
|
* selected tab, otherwise every connected one.
|
||||||
public boolean dispatchKeyEvent(KeyEvent e) {
|
*/
|
||||||
if (settings.inputMode != Settings.InputMode.PUSH_TO_TALK) return false;
|
List<ServerTab> hotkeyTargets(boolean activeServerOnly) {
|
||||||
|
List<ServerTab> out = new ArrayList<>();
|
||||||
|
if (activeServerOnly) {
|
||||||
|
if (selected != null && selected.isConnected()) out.add(selected);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (ServerTab tab : tabs) {
|
||||||
|
if (tab.isConnected()) out.add(tab);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerTab selectedTab() {
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ServerTab> allTabs() {
|
||||||
|
return new ArrayList<>(tabs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the microphone while a push-to-talk hotkey is held. */
|
||||||
|
void setPushToTalk(boolean talking) {
|
||||||
|
pttPressed = talking;
|
||||||
ServerTab tab = micTab;
|
ServerTab tab = micTab;
|
||||||
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return false;
|
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return;
|
||||||
if (e.getKeyCode() != settings.pushToTalkKey) return false;
|
tab.connection().getMicrophone().setPushToTalk(talking);
|
||||||
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;
|
|
||||||
|
void moveMicrophoneToSelectedTab() {
|
||||||
|
if (selected != null && selected.isConnected()) setMicTab(selected);
|
||||||
|
updateToolbar();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
/** Connects to the bookmark with this label; no label means the first one. */
|
||||||
|
void connectBookmark(String label, boolean newTab) {
|
||||||
|
Bookmark match = null;
|
||||||
|
for (Bookmark b : bookmarks.all()) {
|
||||||
|
if (label == null || label.isBlank() || label.equalsIgnoreCase(b.label)) {
|
||||||
|
match = b;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match == null) return;
|
||||||
|
if (newTab) selectTab(newTab());
|
||||||
|
connectToBookmark(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selects the tab with this 1-based number, as the "Select Server Tab" action names it. */
|
||||||
|
void selectTabNumber(int number) {
|
||||||
|
if (number >= 1 && number <= tabs.size()) selectTab(tabs.get(number - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void stepTab(int delta) {
|
||||||
|
if (tabs.isEmpty()) return;
|
||||||
|
int index = Math.max(0, tabs.indexOf(selected));
|
||||||
|
selectTab(tabs.get(Math.floorMod(index + delta, tabs.size())));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSoundsMuted(boolean muted) {
|
||||||
|
sounds.setMuted(muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean areSoundsMuted() {
|
||||||
|
return sounds.isMuted();
|
||||||
|
}
|
||||||
|
|
||||||
|
void adjustMasterVolume(double delta) {
|
||||||
|
settings.outputVolume = Math.max(0, Math.min(2.0, settings.outputVolume + delta));
|
||||||
|
settings.save();
|
||||||
|
applyOutputSettingsToAllTabs();
|
||||||
|
updateStatusLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a new nickname, or asks for one when the hotkey carries none. */
|
||||||
|
void changeNickname(String nickname) {
|
||||||
|
if (nickname == null || nickname.isBlank()) {
|
||||||
|
changeNickname();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.nickname = nickname.trim();
|
||||||
|
settings.save();
|
||||||
|
for (ServerTab tab : tabs) tab.setNickname(settings.nickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
void browseCurrentChannel() {
|
||||||
|
if (selected == null || !selected.isConnected()) return;
|
||||||
|
ChannelNode channel = selected.currentChannel();
|
||||||
|
if (channel != null) selected.browseFiles(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reloadSkin() {
|
||||||
|
IconTheme.get().reload(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bringToFront() {
|
||||||
|
setVisible(true);
|
||||||
|
setExtendedState(getExtendedState() & ~JFrame.ICONIFIED);
|
||||||
|
toFront();
|
||||||
|
requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendToBack() {
|
||||||
|
setExtendedState(getExtendedState() | JFrame.ICONIFIED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The values a parameterised hotkey action can take, for the tree in the hotkey
|
||||||
|
* dialog: the saved bookmarks, the installed sound packs and the channels of the
|
||||||
|
* server on screen.
|
||||||
|
*/
|
||||||
|
private List<String> hotkeyArgumentChoices(com.ts3client.hotkey.HotkeyAction action) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
switch (action.argument()) {
|
||||||
|
case BOOKMARK -> {
|
||||||
|
for (Bookmark b : bookmarks.all()) {
|
||||||
|
if (b.label != null && !b.label.isBlank()) out.add(b.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case PROFILE -> {
|
||||||
|
if (action == com.ts3client.hotkey.HotkeyAction.SOUNDPACK_ACTIVATE) {
|
||||||
|
for (com.ts3client.sound.SoundPack pack : sounds.availablePacks()) out.add(pack.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case CHANNEL -> {
|
||||||
|
if (selected != null && selected.isConnected()) {
|
||||||
|
out.addAll(selected.channelPaths());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repaints the chrome after a hotkey changed the local client's state. */
|
||||||
|
void refreshAfterHotkey() {
|
||||||
|
updateToolbar();
|
||||||
|
updateTray();
|
||||||
|
refreshTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- actions ----
|
// ---- actions ----
|
||||||
@@ -627,6 +753,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
tab.shutdown();
|
tab.shutdown();
|
||||||
}
|
}
|
||||||
soundPlayer.shutdown();
|
soundPlayer.shutdown();
|
||||||
|
hotkeys.dispose();
|
||||||
if (tray != null) tray.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,
|
SettingsDialog dlg = new SettingsDialog(this, settings,
|
||||||
micTab == null ? null : micTab.connection().getMicrophone(),
|
micTab == null ? null : micTab.connection().getMicrophone(),
|
||||||
selected == null ? null : selected.connection().getPlayback(),
|
selected == null ? null : selected.connection().getPlayback(),
|
||||||
sounds,
|
sounds, hotkeys,
|
||||||
this::applyOutputSettingsToAllTabs);
|
this::applyOutputSettingsToAllTabs);
|
||||||
dlg.setVisible(true);
|
dlg.setVisible(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import javax.swing.JComponent;
|
|||||||
import javax.swing.JOptionPane;
|
import javax.swing.JOptionPane;
|
||||||
import javax.swing.JSplitPane;
|
import javax.swing.JSplitPane;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.awt.Component;
|
import java.awt.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -319,6 +321,33 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
|||||||
return c != null ? c.nickname : "Client " + clientId;
|
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<String> channelPaths() {
|
||||||
|
List<String> 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<String> 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 ----
|
// ---- ServerTreePanel.Actions ----
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.ts3client.audio.VoiceOutput;
|
|||||||
import com.ts3client.audio.desktop.AudioCapture;
|
import com.ts3client.audio.desktop.AudioCapture;
|
||||||
import com.ts3client.audio.desktop.AudioDevices;
|
import com.ts3client.audio.desktop.AudioDevices;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.hotkey.Hotkey;
|
||||||
|
import com.ts3client.hotkey.HotkeyAction;
|
||||||
import com.ts3client.sound.SoundNotifier;
|
import com.ts3client.sound.SoundNotifier;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
@@ -30,8 +32,6 @@ import java.awt.GridBagConstraints;
|
|||||||
import java.awt.GridBagLayout;
|
import java.awt.GridBagLayout;
|
||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
import java.awt.Rectangle;
|
import java.awt.Rectangle;
|
||||||
import java.awt.event.KeyAdapter;
|
|
||||||
import java.awt.event.KeyEvent;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,11 +53,13 @@ public final class SettingsDialog extends JDialog {
|
|||||||
private final VoiceInput liveMic;
|
private final VoiceInput liveMic;
|
||||||
private final VoiceOutput livePlayback;
|
private final VoiceOutput livePlayback;
|
||||||
private final SoundNotifier sounds;
|
private final SoundNotifier sounds;
|
||||||
|
private final HotkeyService hotkeys;
|
||||||
private final Runnable onApply;
|
private final Runnable onApply;
|
||||||
|
|
||||||
private NotificationsPanel notificationsPanel;
|
private NotificationsPanel notificationsPanel;
|
||||||
private IconPackPanel iconPackPanel;
|
private IconPackPanel iconPackPanel;
|
||||||
private ClientVersionPanel clientVersionPanel;
|
private ClientVersionPanel clientVersionPanel;
|
||||||
|
private HotkeysPanel hotkeysPanel;
|
||||||
|
|
||||||
private JComboBox<AudioDevices.Device> inputCombo;
|
private JComboBox<AudioDevices.Device> inputCombo;
|
||||||
private JComboBox<AudioDevices.Device> outputCombo;
|
private JComboBox<AudioDevices.Device> outputCombo;
|
||||||
@@ -79,7 +81,6 @@ public final class SettingsDialog extends JDialog {
|
|||||||
private JLabel speechLabel;
|
private JLabel speechLabel;
|
||||||
private LevelMeter meter;
|
private LevelMeter meter;
|
||||||
private JButton pttKeyButton;
|
private JButton pttKeyButton;
|
||||||
private int pttKey;
|
|
||||||
private JSlider bitrateSlider;
|
private JSlider bitrateSlider;
|
||||||
private JLabel bitrateLabel;
|
private JLabel bitrateLabel;
|
||||||
private JSlider complexitySlider;
|
private JSlider complexitySlider;
|
||||||
@@ -92,14 +93,14 @@ public final class SettingsDialog extends JDialog {
|
|||||||
|
|
||||||
public SettingsDialog(Frame owner, Settings settings,
|
public SettingsDialog(Frame owner, Settings settings,
|
||||||
VoiceInput liveMic, VoiceOutput livePlayback,
|
VoiceInput liveMic, VoiceOutput livePlayback,
|
||||||
SoundNotifier sounds, Runnable onApply) {
|
SoundNotifier sounds, HotkeyService hotkeys, Runnable onApply) {
|
||||||
super(owner, "Options", true);
|
super(owner, "Options", true);
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.liveMic = liveMic;
|
this.liveMic = liveMic;
|
||||||
this.livePlayback = livePlayback;
|
this.livePlayback = livePlayback;
|
||||||
this.sounds = sounds;
|
this.sounds = sounds;
|
||||||
this.onApply = onApply;
|
this.onApply = onApply;
|
||||||
this.pttKey = settings.pushToTalkKey;
|
this.hotkeys = hotkeys;
|
||||||
|
|
||||||
JTabbedPane tabs = new JTabbedPane();
|
JTabbedPane tabs = new JTabbedPane();
|
||||||
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
|
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
|
||||||
@@ -108,6 +109,8 @@ public final class SettingsDialog extends JDialog {
|
|||||||
tabs.addTab("Notifications", notificationsPanel);
|
tabs.addTab("Notifications", notificationsPanel);
|
||||||
iconPackPanel = new IconPackPanel(settings);
|
iconPackPanel = new IconPackPanel(settings);
|
||||||
tabs.addTab("Design", iconPackPanel);
|
tabs.addTab("Design", iconPackPanel);
|
||||||
|
hotkeysPanel = new HotkeysPanel(hotkeys);
|
||||||
|
tabs.addTab("Hotkeys", hotkeysPanel);
|
||||||
clientVersionPanel = new ClientVersionPanel(settings);
|
clientVersionPanel = new ClientVersionPanel(settings);
|
||||||
tabs.addTab("Client Version", scrollable(clientVersionPanel));
|
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));
|
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
|
||||||
|
|
||||||
pttKeyButton = new JButton(keyName(pttKey));
|
pttKeyButton = new JButton(pushToTalkHotkeyText());
|
||||||
pttKeyButton.addActionListener(e -> capturePttKey());
|
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
|
||||||
addRow(p, c, row++, new JLabel("Push-to-talk key:"), pttKeyButton);
|
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);
|
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
|
||||||
c.gridx = 0;
|
c.gridx = 0;
|
||||||
@@ -511,23 +515,31 @@ public final class SettingsDialog extends JDialog {
|
|||||||
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
|
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void capturePttKey() {
|
private String pushToTalkHotkeyText() {
|
||||||
pttKeyButton.setText("Press a key…");
|
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||||
pttKeyButton.requestFocusInWindow();
|
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
|
||||||
KeyAdapter ka = new KeyAdapter() {
|
|
||||||
@Override
|
|
||||||
public void keyPressed(KeyEvent e) {
|
|
||||||
pttKey = e.getKeyCode();
|
|
||||||
pttKeyButton.setText(keyName(pttKey));
|
|
||||||
pttKeyButton.removeKeyListener(this);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
pttKeyButton.addKeyListener(ka);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String keyName(int code) {
|
/**
|
||||||
String t = KeyEvent.getKeyText(code);
|
* Push-to-talk is an ordinary hotkey, so this shortcut edits that binding — adding
|
||||||
return (t == null || t.isEmpty()) ? ("Key " + code) : t;
|
* 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<Hotkey> 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() {
|
private void apply() {
|
||||||
@@ -546,7 +558,6 @@ public final class SettingsDialog extends JDialog {
|
|||||||
settings.vadThresholdDb = thresholdSlider.getValue();
|
settings.vadThresholdDb = thresholdSlider.getValue();
|
||||||
settings.speechThreshold = speechSlider.getValue() / 100.0;
|
settings.speechThreshold = speechSlider.getValue() / 100.0;
|
||||||
settings.vadOverPtt = vadOverPttCheck.isSelected();
|
settings.vadOverPtt = vadOverPttCheck.isSelected();
|
||||||
settings.pushToTalkKey = pttKey;
|
|
||||||
settings.bitrate = bitrateSlider.getValue() * 1000;
|
settings.bitrate = bitrateSlider.getValue() * 1000;
|
||||||
settings.complexity = complexitySlider.getValue();
|
settings.complexity = complexitySlider.getValue();
|
||||||
settings.vbr = vbrCheck.isSelected();
|
settings.vbr = vbrCheck.isSelected();
|
||||||
@@ -555,6 +566,7 @@ public final class SettingsDialog extends JDialog {
|
|||||||
notificationsPanel.apply();
|
notificationsPanel.apply();
|
||||||
iconPackPanel.apply();
|
iconPackPanel.apply();
|
||||||
clientVersionPanel.apply();
|
clientVersionPanel.apply();
|
||||||
|
hotkeysPanel.apply();
|
||||||
settings.save();
|
settings.save();
|
||||||
|
|
||||||
if (liveMic != null) {
|
if (liveMic != null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user