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:
@@ -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.IdentityStore;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
import com.ts3client.sound.SoundPlayer;
|
||||
|
||||
@@ -31,9 +32,6 @@ import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.KeyEventDispatcher;
|
||||
import java.awt.KeyboardFocusManager;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.util.ArrayList;
|
||||
@@ -89,6 +87,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
|
||||
private boolean pttPressed;
|
||||
|
||||
/** Global hotkeys: the bindings, the matching engine and the platform input hook. */
|
||||
private final HotkeyService hotkeys;
|
||||
|
||||
/** Guards {@link #shutdown()} so the window listener and JVM hook don't both run it. */
|
||||
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
|
||||
private final Thread shutdownHook = new Thread(this::shutdown, "ts3j-shutdown");
|
||||
@@ -100,6 +101,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
this.sounds = new SoundNotifier(settings);
|
||||
this.soundPlayer = audio.createSoundPlayer(settings);
|
||||
this.sounds.setPlayer(soundPlayer);
|
||||
this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices);
|
||||
|
||||
setIconImage(Icons.app().getImage());
|
||||
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
|
||||
@@ -132,7 +134,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
first.chat().appendSystem("Use Connections → Connect to join a server.");
|
||||
first.chat().appendSystem(tray.status());
|
||||
|
||||
installPushToTalk();
|
||||
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
||||
statusTimer.start();
|
||||
setSize(880, 560);
|
||||
@@ -436,26 +437,151 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
tabUpdated(tab);
|
||||
}
|
||||
|
||||
// ---- push to talk ----
|
||||
// ---- hotkeys ----
|
||||
|
||||
private void installPushToTalk() {
|
||||
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(new KeyEventDispatcher() {
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent e) {
|
||||
if (settings.inputMode != Settings.InputMode.PUSH_TO_TALK) return false;
|
||||
ServerTab tab = micTab;
|
||||
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return false;
|
||||
if (e.getKeyCode() != settings.pushToTalkKey) return false;
|
||||
if (e.getID() == KeyEvent.KEY_PRESSED && !pttPressed) {
|
||||
pttPressed = true;
|
||||
tab.connection().getMicrophone().setPushToTalk(true);
|
||||
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
|
||||
pttPressed = false;
|
||||
tab.connection().getMicrophone().setPushToTalk(false);
|
||||
}
|
||||
return false;
|
||||
/**
|
||||
* Which connections a fired hotkey reaches: with "on active server" ticked only the
|
||||
* selected tab, otherwise every connected one.
|
||||
*/
|
||||
List<ServerTab> hotkeyTargets(boolean activeServerOnly) {
|
||||
List<ServerTab> out = new ArrayList<>();
|
||||
if (activeServerOnly) {
|
||||
if (selected != null && selected.isConnected()) out.add(selected);
|
||||
return out;
|
||||
}
|
||||
for (ServerTab tab : tabs) {
|
||||
if (tab.isConnected()) out.add(tab);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ServerTab selectedTab() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
List<ServerTab> allTabs() {
|
||||
return new ArrayList<>(tabs);
|
||||
}
|
||||
|
||||
/** Opens the microphone while a push-to-talk hotkey is held. */
|
||||
void setPushToTalk(boolean talking) {
|
||||
pttPressed = talking;
|
||||
ServerTab tab = micTab;
|
||||
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return;
|
||||
tab.connection().getMicrophone().setPushToTalk(talking);
|
||||
}
|
||||
|
||||
void moveMicrophoneToSelectedTab() {
|
||||
if (selected != null && selected.isConnected()) setMicTab(selected);
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
/** Connects to the bookmark with this label; no label means the first one. */
|
||||
void connectBookmark(String label, boolean newTab) {
|
||||
Bookmark match = null;
|
||||
for (Bookmark b : bookmarks.all()) {
|
||||
if (label == null || label.isBlank() || label.equalsIgnoreCase(b.label)) {
|
||||
match = b;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (match == null) return;
|
||||
if (newTab) selectTab(newTab());
|
||||
connectToBookmark(match);
|
||||
}
|
||||
|
||||
/** Selects the tab with this 1-based number, as the "Select Server Tab" action names it. */
|
||||
void selectTabNumber(int number) {
|
||||
if (number >= 1 && number <= tabs.size()) selectTab(tabs.get(number - 1));
|
||||
}
|
||||
|
||||
void stepTab(int delta) {
|
||||
if (tabs.isEmpty()) return;
|
||||
int index = Math.max(0, tabs.indexOf(selected));
|
||||
selectTab(tabs.get(Math.floorMod(index + delta, tabs.size())));
|
||||
}
|
||||
|
||||
void setSoundsMuted(boolean muted) {
|
||||
sounds.setMuted(muted);
|
||||
}
|
||||
|
||||
boolean areSoundsMuted() {
|
||||
return sounds.isMuted();
|
||||
}
|
||||
|
||||
void adjustMasterVolume(double delta) {
|
||||
settings.outputVolume = Math.max(0, Math.min(2.0, settings.outputVolume + delta));
|
||||
settings.save();
|
||||
applyOutputSettingsToAllTabs();
|
||||
updateStatusLabel();
|
||||
}
|
||||
|
||||
/** Applies a new nickname, or asks for one when the hotkey carries none. */
|
||||
void changeNickname(String nickname) {
|
||||
if (nickname == null || nickname.isBlank()) {
|
||||
changeNickname();
|
||||
return;
|
||||
}
|
||||
settings.nickname = nickname.trim();
|
||||
settings.save();
|
||||
for (ServerTab tab : tabs) tab.setNickname(settings.nickname);
|
||||
}
|
||||
|
||||
void browseCurrentChannel() {
|
||||
if (selected == null || !selected.isConnected()) return;
|
||||
ChannelNode channel = selected.currentChannel();
|
||||
if (channel != null) selected.browseFiles(channel);
|
||||
}
|
||||
|
||||
void reloadSkin() {
|
||||
IconTheme.get().reload(settings);
|
||||
}
|
||||
|
||||
void bringToFront() {
|
||||
setVisible(true);
|
||||
setExtendedState(getExtendedState() & ~JFrame.ICONIFIED);
|
||||
toFront();
|
||||
requestFocus();
|
||||
}
|
||||
|
||||
void sendToBack() {
|
||||
setExtendedState(getExtendedState() | JFrame.ICONIFIED);
|
||||
}
|
||||
|
||||
/**
|
||||
* The values a parameterised hotkey action can take, for the tree in the hotkey
|
||||
* dialog: the saved bookmarks, the installed sound packs and the channels of the
|
||||
* server on screen.
|
||||
*/
|
||||
private List<String> hotkeyArgumentChoices(com.ts3client.hotkey.HotkeyAction action) {
|
||||
List<String> out = new ArrayList<>();
|
||||
switch (action.argument()) {
|
||||
case BOOKMARK -> {
|
||||
for (Bookmark b : bookmarks.all()) {
|
||||
if (b.label != null && !b.label.isBlank()) out.add(b.label);
|
||||
}
|
||||
}
|
||||
case PROFILE -> {
|
||||
if (action == com.ts3client.hotkey.HotkeyAction.SOUNDPACK_ACTIVATE) {
|
||||
for (com.ts3client.sound.SoundPack pack : sounds.availablePacks()) out.add(pack.name());
|
||||
}
|
||||
}
|
||||
case CHANNEL -> {
|
||||
if (selected != null && selected.isConnected()) {
|
||||
out.addAll(selected.channelPaths());
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Repaints the chrome after a hotkey changed the local client's state. */
|
||||
void refreshAfterHotkey() {
|
||||
updateToolbar();
|
||||
updateTray();
|
||||
refreshTabs();
|
||||
}
|
||||
|
||||
// ---- actions ----
|
||||
@@ -627,6 +753,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
tab.shutdown();
|
||||
}
|
||||
soundPlayer.shutdown();
|
||||
hotkeys.dispose();
|
||||
if (tray != null) tray.dispose();
|
||||
}
|
||||
|
||||
@@ -646,7 +773,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
SettingsDialog dlg = new SettingsDialog(this, settings,
|
||||
micTab == null ? null : micTab.connection().getMicrophone(),
|
||||
selected == null ? null : selected.connection().getPlayback(),
|
||||
sounds,
|
||||
sounds, hotkeys,
|
||||
this::applyOutputSettingsToAllTabs);
|
||||
dlg.setVisible(true);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import javax.swing.JComponent;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.awt.Component;
|
||||
|
||||
/**
|
||||
@@ -319,6 +321,33 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
return c != null ? c.nickname : "Client " + clientId;
|
||||
}
|
||||
|
||||
/** Joins the channel at a "/"-separated path, as the hotkey action names it. */
|
||||
void joinChannelPath(String path) {
|
||||
if (!conn.isConnected() || path == null || path.isBlank()) return;
|
||||
ChannelNode target = conn.getModel().findChannelByPath(path);
|
||||
if (target != null) conn.joinChannel(target.id, null);
|
||||
}
|
||||
|
||||
/** Every channel of this server as a "/"-separated path, in the tree's own order. */
|
||||
List<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 ----
|
||||
|
||||
@Override
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.ts3client.audio.VoiceOutput;
|
||||
import com.ts3client.audio.desktop.AudioCapture;
|
||||
import com.ts3client.audio.desktop.AudioDevices;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.hotkey.Hotkey;
|
||||
import com.ts3client.hotkey.HotkeyAction;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
@@ -30,8 +32,6 @@ import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.KeyAdapter;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -53,11 +53,13 @@ public final class SettingsDialog extends JDialog {
|
||||
private final VoiceInput liveMic;
|
||||
private final VoiceOutput livePlayback;
|
||||
private final SoundNotifier sounds;
|
||||
private final HotkeyService hotkeys;
|
||||
private final Runnable onApply;
|
||||
|
||||
private NotificationsPanel notificationsPanel;
|
||||
private IconPackPanel iconPackPanel;
|
||||
private ClientVersionPanel clientVersionPanel;
|
||||
private HotkeysPanel hotkeysPanel;
|
||||
|
||||
private JComboBox<AudioDevices.Device> inputCombo;
|
||||
private JComboBox<AudioDevices.Device> outputCombo;
|
||||
@@ -79,7 +81,6 @@ public final class SettingsDialog extends JDialog {
|
||||
private JLabel speechLabel;
|
||||
private LevelMeter meter;
|
||||
private JButton pttKeyButton;
|
||||
private int pttKey;
|
||||
private JSlider bitrateSlider;
|
||||
private JLabel bitrateLabel;
|
||||
private JSlider complexitySlider;
|
||||
@@ -92,14 +93,14 @@ public final class SettingsDialog extends JDialog {
|
||||
|
||||
public SettingsDialog(Frame owner, Settings settings,
|
||||
VoiceInput liveMic, VoiceOutput livePlayback,
|
||||
SoundNotifier sounds, Runnable onApply) {
|
||||
SoundNotifier sounds, HotkeyService hotkeys, Runnable onApply) {
|
||||
super(owner, "Options", true);
|
||||
this.settings = settings;
|
||||
this.liveMic = liveMic;
|
||||
this.livePlayback = livePlayback;
|
||||
this.sounds = sounds;
|
||||
this.onApply = onApply;
|
||||
this.pttKey = settings.pushToTalkKey;
|
||||
this.hotkeys = hotkeys;
|
||||
|
||||
JTabbedPane tabs = new JTabbedPane();
|
||||
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
|
||||
@@ -108,6 +109,8 @@ public final class SettingsDialog extends JDialog {
|
||||
tabs.addTab("Notifications", notificationsPanel);
|
||||
iconPackPanel = new IconPackPanel(settings);
|
||||
tabs.addTab("Design", iconPackPanel);
|
||||
hotkeysPanel = new HotkeysPanel(hotkeys);
|
||||
tabs.addTab("Hotkeys", hotkeysPanel);
|
||||
clientVersionPanel = new ClientVersionPanel(settings);
|
||||
tabs.addTab("Client Version", scrollable(clientVersionPanel));
|
||||
|
||||
@@ -313,9 +316,10 @@ public final class SettingsDialog extends JDialog {
|
||||
});
|
||||
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
|
||||
|
||||
pttKeyButton = new JButton(keyName(pttKey));
|
||||
pttKeyButton.addActionListener(e -> capturePttKey());
|
||||
addRow(p, c, row++, new JLabel("Push-to-talk key:"), pttKeyButton);
|
||||
pttKeyButton = new JButton(pushToTalkHotkeyText());
|
||||
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
|
||||
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
|
||||
addRow(p, c, row++, new JLabel("Push-to-talk hotkey:"), pttKeyButton);
|
||||
|
||||
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
|
||||
c.gridx = 0;
|
||||
@@ -511,23 +515,31 @@ public final class SettingsDialog extends JDialog {
|
||||
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
|
||||
}
|
||||
|
||||
private void capturePttKey() {
|
||||
pttKeyButton.setText("Press a key…");
|
||||
pttKeyButton.requestFocusInWindow();
|
||||
KeyAdapter ka = new KeyAdapter() {
|
||||
@Override
|
||||
public void keyPressed(KeyEvent e) {
|
||||
pttKey = e.getKeyCode();
|
||||
pttKeyButton.setText(keyName(pttKey));
|
||||
pttKeyButton.removeKeyListener(this);
|
||||
}
|
||||
};
|
||||
pttKeyButton.addKeyListener(ka);
|
||||
private String pushToTalkHotkeyText() {
|
||||
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
|
||||
}
|
||||
|
||||
private static String keyName(int code) {
|
||||
String t = KeyEvent.getKeyText(code);
|
||||
return (t == null || t.isEmpty()) ? ("Key " + code) : t;
|
||||
/**
|
||||
* Push-to-talk is an ordinary hotkey, so this shortcut edits that binding — adding
|
||||
* it when there is none — rather than keeping a key of its own.
|
||||
*/
|
||||
private void editPushToTalkHotkey() {
|
||||
Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||
HotkeyDialog dlg = new HotkeyDialog(this, hotkeys,
|
||||
existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
|
||||
dlg.setVisible(true);
|
||||
if (!dlg.isConfirmed()) return;
|
||||
List<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() {
|
||||
@@ -546,7 +558,6 @@ public final class SettingsDialog extends JDialog {
|
||||
settings.vadThresholdDb = thresholdSlider.getValue();
|
||||
settings.speechThreshold = speechSlider.getValue() / 100.0;
|
||||
settings.vadOverPtt = vadOverPttCheck.isSelected();
|
||||
settings.pushToTalkKey = pttKey;
|
||||
settings.bitrate = bitrateSlider.getValue() * 1000;
|
||||
settings.complexity = complexitySlider.getValue();
|
||||
settings.vbr = vbrCheck.isSelected();
|
||||
@@ -555,6 +566,7 @@ public final class SettingsDialog extends JDialog {
|
||||
notificationsPanel.apply();
|
||||
iconPackPanel.apply();
|
||||
clientVersionPanel.apply();
|
||||
hotkeysPanel.apply();
|
||||
settings.save();
|
||||
|
||||
if (liveMic != null) {
|
||||
|
||||
Reference in New Issue
Block a user