Lay out the Options dialog like TeamSpeak 3's
A sidebar of pages with large icons replaces the tabs, and each page opens under a banner with its title, subtitle and icon. OK, Cancel and Apply sit below, and the dialog reopens on the last page used. The pages follow TS3's: Application (muted-microphone warning, reported client version), myTeamSpeak, Playback, Capture, Design, Hotkeys, Contacts, Chat and Notifications. Capture now holds everything from the microphone to the server: the device and gain, activation with the microphone test, signal processing and the encoder, laid out in one grid so its columns line up. Forms are grouped under section headings. Cancel now goes back to what was last applied, and closing the window cancels too, so a theme or icon pack being tried out is put back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -292,7 +292,7 @@ swing/ com.ts3client
|
|||||||
├── Spacers TS3 spacer-channel name parsing/rendering
|
├── Spacers TS3 spacer-channel name parsing/rendering
|
||||||
├── InfoPanel channel description / client group + details view
|
├── InfoPanel channel description / client group + details view
|
||||||
├── ChatPanel chat log + input
|
├── ChatPanel chat log + input
|
||||||
├── SettingsDialog the Options dialog; one panel per tab (DevicesPanel, …, ChatLogsPanel)
|
├── SettingsDialog the Options dialog: TS3-style page sidebar + banner; one panel per page (ApplicationPanel, …, ChatLogsPanel)
|
||||||
├── HotkeysPanel hotkey list (Options → Hotkeys)
|
├── HotkeysPanel hotkey list (Options → Hotkeys)
|
||||||
├── HotkeyDialog add/edit one hotkey: action, combination, trigger, scope
|
├── HotkeyDialog add/edit one hotkey: action, combination, trigger, scope
|
||||||
├── HotkeyService bindings + engine + input hook, for the dialogs
|
├── HotkeyService bindings + engine + input hook, for the dialogs
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
|
||||||
|
/** Options page for general behaviour: the muted-microphone warning and the reported client version. */
|
||||||
|
final class ApplicationPanel extends FormPanel {
|
||||||
|
|
||||||
|
private final Settings settings;
|
||||||
|
private final JCheckBox mutedWarning;
|
||||||
|
private final ClientVersionPanel clientVersion;
|
||||||
|
|
||||||
|
ApplicationPanel(Settings settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
GridBagConstraints c = gbc();
|
||||||
|
|
||||||
|
mutedWarning = new JCheckBox("Warn when talking while microphone is muted", settings.mutedTalkWarning);
|
||||||
|
clientVersion = new ClientVersionPanel(settings);
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
addSection(this, c, row++, "Misc");
|
||||||
|
addWide(this, c, row++, mutedWarning);
|
||||||
|
addSection(this, c, row++, "Client Version");
|
||||||
|
addWide(this, c, row++, clientVersion);
|
||||||
|
addFiller(this, c, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the page into the settings; the caller saves them. */
|
||||||
|
void apply() {
|
||||||
|
settings.mutedTalkWarning = mutedWarning.isSelected();
|
||||||
|
clientVersion.apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.audio.AudioDevice;
|
||||||
|
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** The device pickers of the Playback and Capture pages. */
|
||||||
|
final class AudioDevices {
|
||||||
|
|
||||||
|
private static final String HINT = "<html>Named devices are PipeWire's, and are routed through it "
|
||||||
|
+ "(so per-application volume and rerouting keep working).<br>"
|
||||||
|
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
|
||||||
|
|
||||||
|
private AudioDevices() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A picker over {@code devices} with {@code selectedId} chosen, or the first (default) one. */
|
||||||
|
static JComboBox<AudioDevice> combo(List<AudioDevice> devices, String selectedId) {
|
||||||
|
JComboBox<AudioDevice> combo = new JComboBox<>(devices.toArray(new AudioDevice[0]));
|
||||||
|
combo.setToolTipText(HINT);
|
||||||
|
if (combo.getItemCount() == 0) return combo;
|
||||||
|
combo.setSelectedIndex(0);
|
||||||
|
if (selectedId != null && !selectedId.isEmpty()) {
|
||||||
|
for (int i = 0; i < combo.getItemCount(); i++) {
|
||||||
|
if (selectedId.equals(combo.getItemAt(i).id())) {
|
||||||
|
combo.setSelectedIndex(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return combo;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String selectedId(JComboBox<AudioDevice> combo) {
|
||||||
|
AudioDevice d = (AudioDevice) combo.getSelectedItem();
|
||||||
|
return d == null ? "" : d.id();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.audio.AudioBackend;
|
||||||
|
import com.ts3client.audio.AudioDevice;
|
||||||
|
import com.ts3client.audio.VoiceInput;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options page for everything on the way from the microphone to the server: the capture
|
||||||
|
* device and gain, how transmission is activated (with the microphone test), the
|
||||||
|
* pre-processing, and the encoder. Changes preview live on the connected microphone and
|
||||||
|
* the test alike.
|
||||||
|
*/
|
||||||
|
final class CapturePanel extends FormPanel {
|
||||||
|
|
||||||
|
private final VoiceInput liveMic;
|
||||||
|
private final VoiceActivationSection activation;
|
||||||
|
private final EncoderSection encoder;
|
||||||
|
|
||||||
|
private final JComboBox<AudioDevice> inputCombo;
|
||||||
|
private final JSlider inputGain;
|
||||||
|
private final JCheckBox denoiseCheck;
|
||||||
|
private final JSlider denoiseLevel;
|
||||||
|
private final JCheckBox typingCheck;
|
||||||
|
private final JCheckBox agcCheck;
|
||||||
|
private final JSlider agcBoost;
|
||||||
|
|
||||||
|
CapturePanel(Settings settings, AudioBackend audio, VoiceInput liveMic, HotkeyService hotkeys,
|
||||||
|
HotkeysPanel hotkeysPanel, Supplier<Settings> audioSnapshot) {
|
||||||
|
this.liveMic = liveMic;
|
||||||
|
this.activation = new VoiceActivationSection(settings, audio, liveMic, hotkeys, hotkeysPanel, audioSnapshot);
|
||||||
|
this.encoder = new EncoderSection(settings, liveMic);
|
||||||
|
GridBagConstraints c = gbc();
|
||||||
|
|
||||||
|
inputCombo = AudioDevices.combo(audio.io().inputDevices(), settings.inputDevice);
|
||||||
|
limitWidth(inputCombo, FIELD_WIDTH);
|
||||||
|
inputCombo.addActionListener(e -> activation.restartTest());
|
||||||
|
|
||||||
|
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
|
||||||
|
JLabel gainLabel = new JLabel(inputGain.getValue() + "%");
|
||||||
|
inputGain.addChangeListener(e -> {
|
||||||
|
gainLabel.setText(inputGain.getValue() + "%");
|
||||||
|
applyLive(m -> m.setInputGain(inputGain.getValue() / 100.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
|
||||||
|
// TS3's four steps, 6/12/18/21 dB of suppression.
|
||||||
|
denoiseLevel = new JSlider(0, 3, Math.max(0, Math.min(3, settings.denoiserLevel)));
|
||||||
|
denoiseLevel.setMajorTickSpacing(1);
|
||||||
|
denoiseLevel.setPaintTicks(true);
|
||||||
|
limitWidth(denoiseLevel, SLIDER_WIDTH);
|
||||||
|
denoiseLevel.setToolTipText("6, 12, 18 or 21 dB");
|
||||||
|
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
|
||||||
|
agcCheck = new JCheckBox("Automatic gain control", settings.agc);
|
||||||
|
agcBoost = new JSlider(0, Settings.MAX_AGC_BOOST_DB, settings.agcBoostDb);
|
||||||
|
agcBoost.setMajorTickSpacing(5);
|
||||||
|
agcBoost.setPaintTicks(true);
|
||||||
|
limitWidth(agcBoost, SLIDER_WIDTH);
|
||||||
|
agcBoost.setToolTipText("<html>Lets AGC bring up a quiet or distant voice further, "
|
||||||
|
+ "at the cost of up to this many dB more background noise (0–20 dB).</html>");
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
addSection(this, c, row++, "Device");
|
||||||
|
addRow(this, c, row++, new JLabel("Capture device:"), inputCombo);
|
||||||
|
addRow(this, c, row++, new JLabel("Microphone gain:"), sliderWithLabel(inputGain, gainLabel));
|
||||||
|
addSection(this, c, row++, "Activation");
|
||||||
|
row = activation.layOut(this, c, row);
|
||||||
|
addSection(this, c, row++, "Digital Signal Processing");
|
||||||
|
addWide(this, c, row++, denoiseCheck);
|
||||||
|
addRow(this, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
|
||||||
|
addWide(this, c, row++, typingCheck);
|
||||||
|
addWide(this, c, row++, agcCheck);
|
||||||
|
addRow(this, c, row++, new JLabel("Boost quiet speech:"), agcBoost);
|
||||||
|
addSection(this, c, row++, "Encoder");
|
||||||
|
row = encoder.layOut(this, c, row);
|
||||||
|
addFiller(this, c, row);
|
||||||
|
|
||||||
|
Runnable syncProcessing = () -> {
|
||||||
|
denoiseLevel.setEnabled(denoiseCheck.isSelected());
|
||||||
|
agcBoost.setEnabled(agcCheck.isSelected());
|
||||||
|
applyLive(m -> {
|
||||||
|
m.setNoiseSuppression(denoiseCheck.isSelected());
|
||||||
|
m.setDenoiserLevel(denoiseLevel.getValue());
|
||||||
|
m.setTypingAttenuation(typingCheck.isSelected());
|
||||||
|
m.setAgc(agcCheck.isSelected());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
denoiseCheck.addActionListener(e -> syncProcessing.run());
|
||||||
|
typingCheck.addActionListener(e -> syncProcessing.run());
|
||||||
|
agcCheck.addActionListener(e -> syncProcessing.run());
|
||||||
|
denoiseLevel.addChangeListener(e -> applyLive(m -> m.setDenoiserLevel(denoiseLevel.getValue())));
|
||||||
|
agcBoost.addChangeListener(e -> applyLive(m -> m.setAgcBoost(agcBoost.getValue())));
|
||||||
|
syncProcessing.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the page into {@code target}, without touching anything else. */
|
||||||
|
void writeInto(Settings target) {
|
||||||
|
target.inputDevice = AudioDevices.selectedId(inputCombo);
|
||||||
|
target.inputVolume = inputGain.getValue() / 100.0;
|
||||||
|
target.denoise = denoiseCheck.isSelected();
|
||||||
|
target.denoiserLevel = denoiseLevel.getValue();
|
||||||
|
target.typingAttenuation = typingCheck.isSelected();
|
||||||
|
target.agc = agcCheck.isSelected();
|
||||||
|
target.agcBoostDb = agcBoost.getValue();
|
||||||
|
activation.writeInto(target);
|
||||||
|
encoder.writeInto(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a live change to the connected microphone and to the microphone test alike. */
|
||||||
|
void applyLive(Consumer<VoiceInput> change) {
|
||||||
|
if (liveMic != null) change.accept(liveMic);
|
||||||
|
activation.configureTest(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setTestOutputDevice(String deviceId) {
|
||||||
|
activation.setTestOutputDevice(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopTest() {
|
||||||
|
activation.stopTest();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import javax.swing.JPanel;
|
|||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.GridBagConstraints;
|
import java.awt.GridBagConstraints;
|
||||||
import java.awt.Insets;
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,10 +33,6 @@ final class ChatLogsPanel extends FormPanel {
|
|||||||
enabled.setSelected(settings.chatLogs);
|
enabled.setSelected(settings.chatLogs);
|
||||||
enabled.setToolTipText("Write every chat to disk and show its history when it opens again");
|
enabled.setToolTipText("Write every chat to disk and show its history when it opens again");
|
||||||
enabled.addActionListener(e -> updateEnabled());
|
enabled.addActionListener(e -> updateEnabled());
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = 0;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
add(enabled, c);
|
|
||||||
|
|
||||||
folder.setText(settings.chatLogDir);
|
folder.setText(settings.chatLogDir);
|
||||||
folder.putClientProperty("JTextField.placeholderText", "The TeamSpeak 3 client's folder");
|
folder.putClientProperty("JTextField.placeholderText", "The TeamSpeak 3 client's folder");
|
||||||
@@ -45,24 +40,26 @@ final class ChatLogsPanel extends FormPanel {
|
|||||||
+ AppDirs.chatLogs() + "</html>");
|
+ AppDirs.chatLogs() + "</html>");
|
||||||
limitWidth(folder, FIELD_WIDTH);
|
limitWidth(folder, FIELD_WIDTH);
|
||||||
browse.addActionListener(e -> browseForFolder());
|
browse.addActionListener(e -> browseForFolder());
|
||||||
JPanel row = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
JPanel folderRow = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
||||||
row.add(folder, BorderLayout.CENTER);
|
folderRow.add(folder, BorderLayout.CENTER);
|
||||||
row.add(browse, BorderLayout.EAST);
|
folderRow.add(browse, BorderLayout.EAST);
|
||||||
addRow(this, c, 1, new JLabel("Folder:"), row);
|
|
||||||
|
|
||||||
c.gridy = 2;
|
JLabel nextConnect = new JLabel("Takes effect on the next connect.");
|
||||||
c.gridx = 1;
|
nextConnect.setEnabled(false);
|
||||||
c.anchor = GridBagConstraints.NORTHWEST;
|
|
||||||
add(new JLabel("Takes effect on the next connect."), c);
|
|
||||||
|
|
||||||
pictures.setSelected(settings.descriptionPictures);
|
pictures.setSelected(settings.descriptionPictures);
|
||||||
pictures.setToolTipText("Off: only links to them, nothing fetched");
|
pictures.setToolTipText("Off: only links to them, nothing fetched");
|
||||||
c.gridy = 3;
|
|
||||||
c.gridx = 0;
|
int row = 0;
|
||||||
c.gridwidth = 2;
|
addSection(this, c, row++, "Logging");
|
||||||
c.weighty = 1;
|
addWide(this, c, row++, enabled);
|
||||||
c.insets = new Insets(UIScale.scale(12), c.insets.left, c.insets.bottom, c.insets.right);
|
addRow(this, c, row++, new JLabel("Folder:"), folderRow);
|
||||||
add(pictures, c);
|
c.gridx = 1;
|
||||||
|
c.gridy = row++;
|
||||||
|
add(nextConnect, c);
|
||||||
|
addSection(this, c, row++, "Descriptions");
|
||||||
|
addWide(this, c, row++, pictures);
|
||||||
|
addFiller(this, c, row);
|
||||||
updateEnabled();
|
updateEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
|
||||||
import com.formdev.flatlaf.util.UIScale;
|
import com.formdev.flatlaf.util.UIScale;
|
||||||
import com.ts3client.config.ClientVersion;
|
import com.ts3client.config.ClientVersion;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
@@ -20,7 +19,7 @@ import java.awt.GridBagLayout;
|
|||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options page for the version and operating system reported to the server, which
|
* The Application page's section for the version and operating system reported to the server, which
|
||||||
* is what other clients see next to the nickname ("3.6.2 on Windows").
|
* is what other clients see next to the nickname ("3.6.2 on Windows").
|
||||||
*
|
*
|
||||||
* <p>Servers verify TeamSpeak's signature over the version and platform and refuse
|
* <p>Servers verify TeamSpeak's signature over the version and platform and refuse
|
||||||
@@ -49,7 +48,6 @@ final class ClientVersionPanel extends JPanel {
|
|||||||
super(new BorderLayout(0, UIScale.scale(8)));
|
super(new BorderLayout(0, UIScale.scale(8)));
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
|
|
||||||
setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
|
|
||||||
add(buildForm(), BorderLayout.NORTH);
|
add(buildForm(), BorderLayout.NORTH);
|
||||||
|
|
||||||
load();
|
load();
|
||||||
@@ -111,12 +109,10 @@ final class ClientVersionPanel extends JPanel {
|
|||||||
c.gridx = 0;
|
c.gridx = 0;
|
||||||
c.gridy = row;
|
c.gridy = row;
|
||||||
c.gridwidth = 2;
|
c.gridwidth = 2;
|
||||||
p.add(new JLabel("<html><body style='width:" + HtmlStyles.px(320) + "'>The three fields belong together: the "
|
JLabel note = new JLabel("<html>Servers refuse made-up or outdated versions (\"client is modified\")."
|
||||||
+ "signature is TeamSpeak's, over that version and operating system. A server "
|
+ "<br>Takes effect on the next connect.</html>");
|
||||||
+ "answers \"client is modified\" and refuses the connection when it does not "
|
note.setEnabled(false);
|
||||||
+ "accept the version — made up, or older than the server's minimum — so prefer "
|
p.add(note, c);
|
||||||
+ "a recent one from the list. Takes effect the next time you connect."
|
|
||||||
+ "</body></html>"), c);
|
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
|
||||||
import com.formdev.flatlaf.util.UIScale;
|
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
import javax.swing.DefaultListCellRenderer;
|
import javax.swing.DefaultListCellRenderer;
|
||||||
@@ -13,46 +11,28 @@ import javax.swing.JLabel;
|
|||||||
import javax.swing.JList;
|
import javax.swing.JList;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
import java.awt.BorderLayout;
|
|
||||||
import java.awt.Component;
|
import java.awt.Component;
|
||||||
|
import java.awt.FlowLayout;
|
||||||
import java.awt.GridBagConstraints;
|
import java.awt.GridBagConstraints;
|
||||||
import java.awt.GridBagLayout;
|
|
||||||
import java.awt.Insets;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options page for the contact list: whether friends and blocked clients are
|
* Options page for the contact list: whether friends and blocked clients are
|
||||||
* coloured in the server tree, whose whispers get through (TeamSpeak's whisper
|
* coloured in the server tree, whose whispers get through (TeamSpeak's whisper
|
||||||
* options), and the defaults a new contact starts with.
|
* options), and the defaults a new contact starts with.
|
||||||
*/
|
*/
|
||||||
final class ContactsPanel extends JPanel {
|
final class ContactsPanel extends FormPanel {
|
||||||
|
|
||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private final JCheckBox colorContacts = new JCheckBox("Colour friends and blocked clients in the server tree");
|
private final JCheckBox colorContacts = new JCheckBox("Colour friends and blocked clients in the server tree");
|
||||||
private final JComboBox<Settings.WhisperPolicy> whisperPolicy = new JComboBox<>(Settings.WhisperPolicy.values());
|
private final JComboBox<Settings.WhisperPolicy> whisperPolicy = new JComboBox<>(Settings.WhisperPolicy.values());
|
||||||
|
|
||||||
ContactsPanel(Settings settings) {
|
ContactsPanel(Settings settings) {
|
||||||
super(new BorderLayout());
|
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
|
GridBagConstraints c = gbc();
|
||||||
|
|
||||||
JPanel p = new JPanel(new GridBagLayout());
|
|
||||||
GridBagConstraints c = new GridBagConstraints();
|
|
||||||
c.insets = UIScale.scale(new Insets(2, 2, 2, 2));
|
|
||||||
c.anchor = GridBagConstraints.WEST;
|
|
||||||
c.fill = GridBagConstraints.HORIZONTAL;
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
|
|
||||||
c.gridy = 0;
|
|
||||||
colorContacts.setSelected(settings.colorContacts);
|
colorContacts.setSelected(settings.colorContacts);
|
||||||
colorContacts.setToolTipText("Friends in green and blocked clients in red, as the TeamSpeak client shows them");
|
colorContacts.setToolTipText("Friends in green and blocked clients in red, as the TeamSpeak client shows them");
|
||||||
p.add(colorContacts, c);
|
|
||||||
|
|
||||||
c.gridy = 1;
|
|
||||||
c.insets = UIScale.scale(new Insets(12, 2, 2, 2));
|
|
||||||
p.add(new JLabel("Whispers: who is allowed to whisper to you"), c);
|
|
||||||
c.gridy = 2;
|
|
||||||
c.insets = UIScale.scale(new Insets(2, 2, 2, 2));
|
|
||||||
whisperPolicy.setSelectedItem(settings.whisperPolicy);
|
whisperPolicy.setSelectedItem(settings.whisperPolicy);
|
||||||
whisperPolicy.setRenderer(new DefaultListCellRenderer() {
|
whisperPolicy.setRenderer(new DefaultListCellRenderer() {
|
||||||
@Override
|
@Override
|
||||||
@@ -66,20 +46,24 @@ final class ContactsPanel extends JPanel {
|
|||||||
whisperPolicy.setToolTipText("<html>Use individual settings in contacts, else allow: a client not on your "
|
whisperPolicy.setToolTipText("<html>Use individual settings in contacts, else allow: a client not on your "
|
||||||
+ "contact list may whisper to you.<br>Use individual settings in contacts, else deny: only "
|
+ "contact list may whisper to you.<br>Use individual settings in contacts, else deny: only "
|
||||||
+ "contacts marked \"Allow whisper\" may.<br>Nobody: individual contact settings are ignored.</html>");
|
+ "contacts marked \"Allow whisper\" may.<br>Nobody: individual contact settings are ignored.</html>");
|
||||||
p.add(whisperPolicy, c);
|
|
||||||
|
|
||||||
c.gridy = 3;
|
|
||||||
c.insets = UIScale.scale(new Insets(12, 2, 2, 2));
|
|
||||||
c.fill = GridBagConstraints.NONE;
|
|
||||||
JButton defaults = new JButton("Contact defaults…");
|
JButton defaults = new JButton("Contact defaults…");
|
||||||
defaults.setToolTipText("Configure default settings when adding a client to the Contact Manager");
|
defaults.setToolTipText("Configure default settings when adding a client to the Contact Manager");
|
||||||
defaults.addActionListener(e -> {
|
defaults.addActionListener(e -> {
|
||||||
java.awt.Window owner = SwingUtilities.getWindowAncestor(this);
|
java.awt.Window owner = SwingUtilities.getWindowAncestor(this);
|
||||||
new ContactDefaultsDialog(owner instanceof JDialog ? (JDialog) owner : null, settings).setVisible(true);
|
new ContactDefaultsDialog(owner instanceof JDialog ? (JDialog) owner : null, settings).setVisible(true);
|
||||||
});
|
});
|
||||||
p.add(defaults, c);
|
JPanel defaultsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
||||||
|
defaultsRow.add(defaults);
|
||||||
|
|
||||||
add(p, BorderLayout.NORTH);
|
int row = 0;
|
||||||
|
addSection(this, c, row++, "Server Tree");
|
||||||
|
addWide(this, c, row++, colorContacts);
|
||||||
|
addSection(this, c, row++, "Whispers");
|
||||||
|
addRow(this, c, row++, new JLabel("Allow whispers from:"), whisperPolicy);
|
||||||
|
addSection(this, c, row++, "New Contacts");
|
||||||
|
addWide(this, c, row++, defaultsRow);
|
||||||
|
addFiller(this, c, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copies the page into the settings; the caller saves them. */
|
/** Copies the page into the settings; the caller saves them. */
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import com.formdev.flatlaf.util.UIScale;
|
|||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
import com.ts3client.gfx.IconPack;
|
import com.ts3client.gfx.IconPack;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
|
||||||
import javax.swing.DefaultListCellRenderer;
|
import javax.swing.DefaultListCellRenderer;
|
||||||
import javax.swing.DefaultListModel;
|
import javax.swing.DefaultListModel;
|
||||||
import javax.swing.ImageIcon;
|
import javax.swing.ImageIcon;
|
||||||
@@ -50,9 +49,10 @@ final class DesignPanel extends JPanel {
|
|||||||
private static final double[] SCALES = {0, 1, 1.25, 1.5, 1.75, 2, 2.5, 3};
|
private static final double[] SCALES = {0, 1, 1.25, 1.5, 1.75, 2, 2.5, 3};
|
||||||
|
|
||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private final String originalPackId;
|
/** What {@link #revert()} goes back to: as the dialog opened, or as last applied. */
|
||||||
private final String originalPackDir;
|
private String originalPackId;
|
||||||
private final Settings.Appearance originalAppearance;
|
private String originalPackDir;
|
||||||
|
private Settings.Appearance originalAppearance;
|
||||||
|
|
||||||
private final JComboBox<Settings.Appearance> appearanceCombo =
|
private final JComboBox<Settings.Appearance> appearanceCombo =
|
||||||
new JComboBox<>(Settings.Appearance.values());
|
new JComboBox<>(Settings.Appearance.values());
|
||||||
@@ -87,9 +87,12 @@ final class DesignPanel extends JPanel {
|
|||||||
Object selected = packCombo.getSelectedItem();
|
Object selected = packCombo.getSelectedItem();
|
||||||
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
||||||
settings.countryFlags = countryFlags.isSelected();
|
settings.countryFlags = countryFlags.isSelected();
|
||||||
|
originalPackId = settings.iconPack;
|
||||||
|
originalPackDir = settings.iconPackDir;
|
||||||
|
originalAppearance = settings.appearance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
|
/** Puts back the appearance and pack last applied, for a cancelled edit. */
|
||||||
void revert() {
|
void revert() {
|
||||||
settings.iconPack = originalPackId;
|
settings.iconPack = originalPackId;
|
||||||
settings.iconPackDir = originalPackDir;
|
settings.iconPackDir = originalPackDir;
|
||||||
@@ -105,7 +108,7 @@ final class DesignPanel extends JPanel {
|
|||||||
private JComponent buildHeader() {
|
private JComponent buildHeader() {
|
||||||
JPanel p = new JPanel(new GridBagLayout());
|
JPanel p = new JPanel(new GridBagLayout());
|
||||||
GridBagConstraints c = new GridBagConstraints();
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
c.insets = UIScale.scale(new Insets(2, 2, 2, 2));
|
c.insets = UIScale.scale(new Insets(4, 4, 4, 4));
|
||||||
c.anchor = GridBagConstraints.WEST;
|
c.anchor = GridBagConstraints.WEST;
|
||||||
c.fill = GridBagConstraints.HORIZONTAL;
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
|
||||||
@@ -134,30 +137,30 @@ final class DesignPanel extends JPanel {
|
|||||||
});
|
});
|
||||||
appearanceCombo.addActionListener(e -> onAppearanceSelected());
|
appearanceCombo.addActionListener(e -> onAppearanceSelected());
|
||||||
|
|
||||||
int row = 0;
|
|
||||||
addRow(p, c, row++, new JLabel("Theme:"), appearanceCombo);
|
|
||||||
if (DisplayScale.isManaged()) {
|
|
||||||
buildScaleCombo();
|
|
||||||
addRow(p, c, row++, new JLabel("Interface scale:"), scaleCombo);
|
|
||||||
}
|
|
||||||
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
|
|
||||||
c.gridx = 1;
|
|
||||||
c.gridy = row++;
|
|
||||||
packInfo.setEnabled(false);
|
|
||||||
p.add(packInfo, c);
|
|
||||||
|
|
||||||
JPanel dirRow = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
JPanel dirRow = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
||||||
JButton browse = new JButton("Browse…");
|
JButton browse = new JButton("Browse…");
|
||||||
browse.addActionListener(e -> browseForPackFolder());
|
browse.addActionListener(e -> browseForPackFolder());
|
||||||
dirRow.add(packDirField, BorderLayout.CENTER);
|
dirRow.add(packDirField, BorderLayout.CENTER);
|
||||||
dirRow.add(browse, BorderLayout.EAST);
|
dirRow.add(browse, BorderLayout.EAST);
|
||||||
packDirField.setToolTipText("Extra folder to look for icon packs in");
|
packDirField.setToolTipText("Extra folder to look for icon packs in");
|
||||||
addRow(p, c, row++, new JLabel("Extra pack folder:"), dirRow);
|
packInfo.setEnabled(false);
|
||||||
|
|
||||||
countryFlags.setSelected(settings.countryFlags);
|
countryFlags.setSelected(settings.countryFlags);
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
FormPanel.addSection(p, c, row++, "Appearance");
|
||||||
|
FormPanel.addRow(p, c, row++, new JLabel("Theme:"), appearanceCombo);
|
||||||
|
if (DisplayScale.isManaged()) {
|
||||||
|
buildScaleCombo();
|
||||||
|
FormPanel.addRow(p, c, row++, new JLabel("Interface scale:"), scaleCombo);
|
||||||
|
}
|
||||||
|
FormPanel.addSection(p, c, row++, "Channel Tree");
|
||||||
|
FormPanel.addWide(p, c, row++, countryFlags);
|
||||||
|
FormPanel.addSection(p, c, row++, "Icon Pack");
|
||||||
|
FormPanel.addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
|
||||||
c.gridx = 1;
|
c.gridx = 1;
|
||||||
c.gridy = row;
|
c.gridy = row++;
|
||||||
p.add(countryFlags, c);
|
p.add(packInfo, c);
|
||||||
|
FormPanel.addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow);
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +210,6 @@ final class DesignPanel extends JPanel {
|
|||||||
|
|
||||||
JScrollPane scroll = new JScrollPane(preview);
|
JScrollPane scroll = new JScrollPane(preview);
|
||||||
scroll.setPreferredSize(UIScale.scale(new Dimension(360, 200)));
|
scroll.setPreferredSize(UIScale.scale(new Dimension(360, 200)));
|
||||||
scroll.setBorder(BorderFactory.createTitledBorder("Icons in this pack"));
|
|
||||||
return scroll;
|
return scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,14 +267,4 @@ final class DesignPanel extends JPanel {
|
|||||||
settings.iconPackDir = packDirField.getText();
|
settings.iconPackDir = packDirField.getText();
|
||||||
reloadPacks();
|
reloadPacks();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row;
|
|
||||||
c.weightx = 0;
|
|
||||||
p.add(label, c);
|
|
||||||
c.gridx = 1;
|
|
||||||
c.weightx = 1;
|
|
||||||
p.add(field, c);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
package com.ts3client.ui;
|
|
||||||
|
|
||||||
import com.formdev.flatlaf.util.UIScale;
|
|
||||||
import com.ts3client.audio.AudioDevice;
|
|
||||||
import com.ts3client.audio.AudioIo;
|
|
||||||
import com.ts3client.audio.VoiceInput;
|
|
||||||
import com.ts3client.audio.VoiceOutput;
|
|
||||||
import com.ts3client.config.Settings;
|
|
||||||
|
|
||||||
import javax.swing.Box;
|
|
||||||
import javax.swing.JCheckBox;
|
|
||||||
import javax.swing.JComboBox;
|
|
||||||
import javax.swing.JLabel;
|
|
||||||
import javax.swing.JSlider;
|
|
||||||
import java.awt.GridBagConstraints;
|
|
||||||
import java.awt.Insets;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options dialog's "Playback / Capture" tab: device pickers, gain/volume, and the
|
|
||||||
* noise-reduction pre-processing options. Gain and pre-processing changes are pushed
|
|
||||||
* live via {@code applyLive}, which reaches both the connected microphone and the
|
|
||||||
* dialog's own microphone test.
|
|
||||||
*/
|
|
||||||
final class DevicesPanel extends FormPanel {
|
|
||||||
|
|
||||||
private final JComboBox<AudioDevice> inputCombo;
|
|
||||||
private final JComboBox<AudioDevice> outputCombo;
|
|
||||||
private final JSlider inputGain;
|
|
||||||
private final JSlider outputVol;
|
|
||||||
private final JCheckBox denoiseCheck;
|
|
||||||
private final JSlider denoiseLevel;
|
|
||||||
private final JCheckBox typingCheck;
|
|
||||||
private final JCheckBox agcCheck;
|
|
||||||
private final JSlider agcBoost;
|
|
||||||
private final JCheckBox mutedWarningCheck;
|
|
||||||
|
|
||||||
DevicesPanel(Settings settings, AudioIo io, VoiceOutput livePlayback,
|
|
||||||
Consumer<Consumer<VoiceInput>> applyLive,
|
|
||||||
Runnable onInputDeviceChanged, Consumer<String> onOutputDeviceChanged) {
|
|
||||||
GridBagConstraints c = gbc();
|
|
||||||
|
|
||||||
List<AudioDevice> ins = io.inputDevices();
|
|
||||||
List<AudioDevice> outs = io.outputDevices();
|
|
||||||
|
|
||||||
inputCombo = new JComboBox<>(ins.toArray(new AudioDevice[0]));
|
|
||||||
outputCombo = new JComboBox<>(outs.toArray(new AudioDevice[0]));
|
|
||||||
selectOrDefault(inputCombo, settings.inputDevice);
|
|
||||||
selectOrDefault(outputCombo, settings.outputDevice);
|
|
||||||
|
|
||||||
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
|
|
||||||
+ "(so per-application volume and rerouting keep working).<br>"
|
|
||||||
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
|
|
||||||
inputCombo.setToolTipText(deviceHint);
|
|
||||||
outputCombo.setToolTipText(deviceHint);
|
|
||||||
limitWidth(inputCombo, FIELD_WIDTH);
|
|
||||||
limitWidth(outputCombo, FIELD_WIDTH);
|
|
||||||
|
|
||||||
int row = 0;
|
|
||||||
addRow(this, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
|
|
||||||
addRow(this, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
|
|
||||||
|
|
||||||
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
|
|
||||||
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
|
|
||||||
limitWidth(inputGain, SLIDER_WIDTH);
|
|
||||||
limitWidth(outputVol, SLIDER_WIDTH);
|
|
||||||
addRow(this, c, row++, new JLabel("Microphone gain:"), inputGain);
|
|
||||||
addRow(this, c, row++, new JLabel("Playback volume:"), outputVol);
|
|
||||||
|
|
||||||
outputVol.addChangeListener(e -> {
|
|
||||||
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
|
|
||||||
});
|
|
||||||
inputGain.addChangeListener(e ->
|
|
||||||
applyLive.accept(m -> m.setInputGain(inputGain.getValue() / 100.0)));
|
|
||||||
inputCombo.addActionListener(e -> onInputDeviceChanged.run());
|
|
||||||
outputCombo.addActionListener(e -> onOutputDeviceChanged.accept(comboValue(outputCombo)));
|
|
||||||
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
c.insets = UIScale.scale(new Insets(14, 4, 2, 4));
|
|
||||||
add(new JLabel("Noise reduction"), c);
|
|
||||||
c.insets = UIScale.scale(new Insets(4, 4, 4, 4));
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
|
|
||||||
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
|
|
||||||
// TS3's four steps, 6/12/18/21 dB of suppression.
|
|
||||||
denoiseLevel = new JSlider(0, 3, Math.max(0, Math.min(3, settings.denoiserLevel)));
|
|
||||||
denoiseLevel.setMajorTickSpacing(1);
|
|
||||||
denoiseLevel.setPaintTicks(true);
|
|
||||||
limitWidth(denoiseLevel, SLIDER_WIDTH);
|
|
||||||
denoiseLevel.setToolTipText("How much steady noise to remove: 6, 12, 18 or 21 dB.");
|
|
||||||
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
|
|
||||||
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
|
|
||||||
+ "reduce the sounds made by typing.</html>");
|
|
||||||
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
|
|
||||||
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
|
|
||||||
+ "microphone loudness to a target level, boosting quiet mics and taming "
|
|
||||||
+ "loud ones.</html>");
|
|
||||||
agcBoost = new JSlider(0, Settings.MAX_AGC_BOOST_DB, settings.agcBoostDb);
|
|
||||||
agcBoost.setMajorTickSpacing(5);
|
|
||||||
agcBoost.setPaintTicks(true);
|
|
||||||
limitWidth(agcBoost, SLIDER_WIDTH);
|
|
||||||
agcBoost.setToolTipText("<html>Lets AGC bring up a quiet or distant voice further, "
|
|
||||||
+ "at the cost of up to this many dB more background noise (0–20 dB).</html>");
|
|
||||||
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
add(denoiseCheck, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
addRow(this, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
add(typingCheck, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
add(agcCheck, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
addRow(this, c, row++, new JLabel("Boost quiet speech:"), agcBoost);
|
|
||||||
|
|
||||||
Runnable syncNoise = () -> {
|
|
||||||
denoiseLevel.setEnabled(denoiseCheck.isSelected());
|
|
||||||
agcBoost.setEnabled(agcCheck.isSelected());
|
|
||||||
applyLive.accept(m -> {
|
|
||||||
m.setNoiseSuppression(denoiseCheck.isSelected());
|
|
||||||
m.setDenoiserLevel(denoiseLevel.getValue());
|
|
||||||
m.setTypingAttenuation(typingCheck.isSelected());
|
|
||||||
m.setAgc(agcCheck.isSelected());
|
|
||||||
});
|
|
||||||
};
|
|
||||||
denoiseCheck.addActionListener(e -> syncNoise.run());
|
|
||||||
typingCheck.addActionListener(e -> syncNoise.run());
|
|
||||||
agcCheck.addActionListener(e -> syncNoise.run());
|
|
||||||
denoiseLevel.addChangeListener(e ->
|
|
||||||
applyLive.accept(m -> m.setDenoiserLevel(denoiseLevel.getValue())));
|
|
||||||
agcBoost.addChangeListener(e ->
|
|
||||||
applyLive.accept(m -> m.setAgcBoost(agcBoost.getValue())));
|
|
||||||
syncNoise.run();
|
|
||||||
|
|
||||||
mutedWarningCheck = new JCheckBox("Beep when talking while muted", settings.mutedTalkWarning);
|
|
||||||
mutedWarningCheck.setToolTipText("Play a reminder sound when you speak into a muted microphone.");
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
c.insets = UIScale.scale(new Insets(14, 4, 4, 4));
|
|
||||||
add(mutedWarningCheck, c);
|
|
||||||
c.insets = UIScale.scale(new Insets(4, 4, 4, 4));
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row;
|
|
||||||
c.weighty = 1;
|
|
||||||
add(Box.createGlue(), c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Copies the form into {@code target}, without touching anything else. */
|
|
||||||
void writeInto(Settings target) {
|
|
||||||
target.inputDevice = comboValue(inputCombo);
|
|
||||||
target.outputDevice = comboValue(outputCombo);
|
|
||||||
target.inputVolume = inputGain.getValue() / 100.0;
|
|
||||||
target.outputVolume = outputVol.getValue() / 100.0;
|
|
||||||
target.denoise = denoiseCheck.isSelected();
|
|
||||||
target.denoiserLevel = denoiseLevel.getValue();
|
|
||||||
target.typingAttenuation = typingCheck.isSelected();
|
|
||||||
target.agc = agcCheck.isSelected();
|
|
||||||
target.agcBoostDb = agcBoost.getValue();
|
|
||||||
target.mutedTalkWarning = mutedWarningCheck.isSelected();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void selectOrDefault(JComboBox<AudioDevice> combo, String deviceId) {
|
|
||||||
if (deviceId != null && !deviceId.isEmpty()) {
|
|
||||||
for (int i = 0; i < combo.getItemCount(); i++) {
|
|
||||||
if (deviceId.equals(combo.getItemAt(i).id())) {
|
|
||||||
combo.setSelectedIndex(i);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
combo.setSelectedIndex(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String comboValue(JComboBox<AudioDevice> combo) {
|
|
||||||
AudioDevice d = (AudioDevice) combo.getSelectedItem();
|
|
||||||
return d == null ? "" : d.id();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.audio.OpusParameters;
|
||||||
|
import com.ts3client.audio.VoiceInput;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
|
||||||
|
import javax.swing.JCheckBox;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
|
||||||
|
/** The Capture page's Opus encoder controls, applied to the running encoder as they change. */
|
||||||
|
final class EncoderSection {
|
||||||
|
|
||||||
|
private static final int MIN_BITRATE_KBITS = 8;
|
||||||
|
private static final int MAX_BITRATE_KBITS = 160;
|
||||||
|
|
||||||
|
private final Settings settings;
|
||||||
|
private final VoiceInput liveMic;
|
||||||
|
|
||||||
|
private final JSlider bitrateSlider;
|
||||||
|
private final JSlider complexitySlider;
|
||||||
|
private final JCheckBox vbrCheck;
|
||||||
|
private final JCheckBox fecCheck;
|
||||||
|
private final JCheckBox musicCheck;
|
||||||
|
|
||||||
|
private final JLabel bitrateLabel;
|
||||||
|
|
||||||
|
EncoderSection(Settings settings, VoiceInput liveMic) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.liveMic = liveMic;
|
||||||
|
int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000));
|
||||||
|
bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits);
|
||||||
|
bitrateLabel = new JLabel(kbits + " kbit/s");
|
||||||
|
bitrateSlider.addChangeListener(e -> {
|
||||||
|
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
|
||||||
|
pushLive();
|
||||||
|
});
|
||||||
|
|
||||||
|
complexitySlider = new JSlider(0, 10, settings.complexity);
|
||||||
|
FormPanel.limitWidth(complexitySlider, FormPanel.SLIDER_WIDTH);
|
||||||
|
complexitySlider.addChangeListener(e -> pushLive());
|
||||||
|
|
||||||
|
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
|
||||||
|
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
|
||||||
|
musicCheck = new JCheckBox("Music codec (stereo, higher fidelity)", settings.music);
|
||||||
|
musicCheck.setToolTipText("<html>Transmits <b>OPUS_MUSIC</b>: stereo when the capture "
|
||||||
|
+ "device has two channels, and without the voice pre-processing "
|
||||||
|
+ "(noise removal, typing attenuation, AGC).<br>"
|
||||||
|
+ "Voice mode (<b>OPUS_VOICE</b>) is mono, as in the official client.</html>");
|
||||||
|
vbrCheck.addActionListener(e -> pushLive());
|
||||||
|
fecCheck.addActionListener(e -> pushLive());
|
||||||
|
musicCheck.addActionListener(e -> pushLive());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds the section's rows to {@code form} from {@code row} on; returns the next free row. */
|
||||||
|
int layOut(JPanel form, GridBagConstraints c, int row) {
|
||||||
|
FormPanel.addRow(form, c, row++, new JLabel("Bitrate:"), FormPanel.sliderWithLabel(bitrateSlider, bitrateLabel, 70));
|
||||||
|
FormPanel.addRow(form, c, row++, new JLabel("Complexity:"), complexitySlider);
|
||||||
|
FormPanel.addWide(form, c, row++, vbrCheck);
|
||||||
|
FormPanel.addWide(form, c, row++, fecCheck);
|
||||||
|
FormPanel.addWide(form, c, row++, musicCheck);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the form into {@code target}, without touching anything else. */
|
||||||
|
void writeInto(Settings target) {
|
||||||
|
target.bitrate = bitrateSlider.getValue() * 1000;
|
||||||
|
target.complexity = complexitySlider.getValue();
|
||||||
|
target.vbr = vbrCheck.isSelected();
|
||||||
|
target.fec = fecCheck.isSelected();
|
||||||
|
target.music = musicCheck.isSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pushLive() {
|
||||||
|
if (liveMic == null) return;
|
||||||
|
liveMic.setOpusParameters(new OpusParameters(
|
||||||
|
bitrateSlider.getValue() * 1000,
|
||||||
|
complexitySlider.getValue(),
|
||||||
|
vbrCheck.isSelected(),
|
||||||
|
fecCheck.isSelected(),
|
||||||
|
settings.packetLoss,
|
||||||
|
musicCheck.isSelected()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,17 @@ package com.ts3client.ui;
|
|||||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||||
import com.formdev.flatlaf.util.UIScale;
|
import com.formdev.flatlaf.util.UIScale;
|
||||||
|
|
||||||
|
import javax.swing.Box;
|
||||||
import javax.swing.JComponent;
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JSeparator;
|
||||||
import javax.swing.JSlider;
|
import javax.swing.JSlider;
|
||||||
import javax.swing.Scrollable;
|
import javax.swing.Scrollable;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Component;
|
import java.awt.Component;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
|
import java.awt.Font;
|
||||||
import java.awt.GridBagConstraints;
|
import java.awt.GridBagConstraints;
|
||||||
import java.awt.GridBagLayout;
|
import java.awt.GridBagLayout;
|
||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
@@ -28,8 +31,13 @@ class FormPanel extends JPanel implements Scrollable {
|
|||||||
static final int MIN_FIELD_WIDTH = 60;
|
static final int MIN_FIELD_WIDTH = 60;
|
||||||
|
|
||||||
FormPanel() {
|
FormPanel() {
|
||||||
|
this(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param padded {@code false} for a form embedded in another one, which pads it already */
|
||||||
|
FormPanel(boolean padded) {
|
||||||
super(new GridBagLayout());
|
super(new GridBagLayout());
|
||||||
setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
|
if (padded) setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -65,7 +73,71 @@ class FormPanel extends JPanel implements Scrollable {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
|
/** A group heading: the title in bold, followed by a rule to the right edge. */
|
||||||
|
static JComponent sectionHeader(String title) {
|
||||||
|
JPanel header = new JPanel(new GridBagLayout());
|
||||||
|
JLabel label = new JLabel(title) {
|
||||||
|
@Override
|
||||||
|
public void updateUI() {
|
||||||
|
super.updateUI();
|
||||||
|
setFont(getFont().deriveFont(Font.BOLD));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.insets = UIScale.scale(new Insets(0, 0, 0, 8));
|
||||||
|
header.add(label, c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
c.insets = new Insets(0, 0, 0, 0);
|
||||||
|
header.add(new JSeparator(), c);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a {@link #sectionHeader} spanning the grid at {@code row}, with space above it
|
||||||
|
* unless it is the first row; leaves {@code c} as it found it.
|
||||||
|
*/
|
||||||
|
static void addSection(JPanel p, GridBagConstraints c, int row, String title) {
|
||||||
|
Insets insets = c.insets;
|
||||||
|
int width = c.gridwidth;
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.gridwidth = 2;
|
||||||
|
c.weightx = 0;
|
||||||
|
c.insets = UIScale.scale(new Insets(row == 0 ? 0 : 14, 0, 2, 0));
|
||||||
|
p.add(sectionHeader(title), c);
|
||||||
|
c.insets = insets;
|
||||||
|
c.gridwidth = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a component spanning the whole grid at {@code row}, such as a check box. */
|
||||||
|
static void addWide(JPanel p, GridBagConstraints c, int row, Component comp) {
|
||||||
|
int width = c.gridwidth;
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
// No weight of its own: GridBag would hand part of it to the label column.
|
||||||
|
c.gridwidth = 2;
|
||||||
|
c.weightx = 0;
|
||||||
|
p.add(comp, c);
|
||||||
|
c.gridwidth = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes the rows above it to the top of a taller viewport, and gives the field column
|
||||||
|
* the spare width, which a form of only {@link #addWide} rows would otherwise centre.
|
||||||
|
*/
|
||||||
|
static void addFiller(JPanel p, GridBagConstraints c, int row) {
|
||||||
|
c.gridx = 1;
|
||||||
|
c.gridy = row;
|
||||||
|
c.gridwidth = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
c.weighty = 1;
|
||||||
|
p.add(Box.createGlue(), c);
|
||||||
|
c.weighty = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void addRow(JPanel p, GridBagConstraints c, int row, Component label, Component field) {
|
||||||
c.gridx = 0;
|
c.gridx = 0;
|
||||||
c.gridy = row;
|
c.gridy = row;
|
||||||
c.weightx = 0;
|
c.weightx = 0;
|
||||||
@@ -88,7 +160,7 @@ class FormPanel extends JPanel implements Scrollable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
|
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
|
||||||
return sliderWithLabel(slider, valueLabel, 48);
|
return sliderWithLabel(slider, valueLabel, 56);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +171,7 @@ class FormPanel extends JPanel implements Scrollable {
|
|||||||
JPanel panel = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
JPanel panel = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
||||||
limitWidth(slider, SLIDER_WIDTH);
|
limitWidth(slider, SLIDER_WIDTH);
|
||||||
panel.add(slider, BorderLayout.CENTER);
|
panel.add(slider, BorderLayout.CENTER);
|
||||||
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
|
valueLabel.setPreferredSize(new Dimension(UIScale.scale(labelWidth), valueLabel.getPreferredSize().height));
|
||||||
panel.add(valueLabel, BorderLayout.EAST);
|
panel.add(valueLabel, BorderLayout.EAST);
|
||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ public final class IconTheme {
|
|||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void removeListener(Runnable listener) {
|
||||||
|
listeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return the icon for a TeamSpeak icon key at tree-row size, or {@code null} if no pack has it */
|
/** @return the icon for a TeamSpeak icon key at tree-row size, or {@code null} if no pack has it */
|
||||||
public static ImageIcon icon(String key) {
|
public static ImageIcon icon(String key) {
|
||||||
return icon(key, UIScale.scale(SIZE));
|
return icon(key, UIScale.scale(SIZE));
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ final class NotificationsPanel extends JPanel {
|
|||||||
/** Edited copy, applied to the settings only when the dialog is confirmed. */
|
/** Edited copy, applied to the settings only when the dialog is confirmed. */
|
||||||
private final NotificationSettings working;
|
private final NotificationSettings working;
|
||||||
private final List<Row> rows = new ArrayList<>();
|
private final List<Row> rows = new ArrayList<>();
|
||||||
private final String originalPackId;
|
/** What {@link #revert()} goes back to: as the dialog opened, or as last applied. */
|
||||||
private final String originalPackDir;
|
private String originalPackId;
|
||||||
|
private String originalPackDir;
|
||||||
|
|
||||||
private final JComboBox<SoundPack> packCombo = new JComboBox<>();
|
private final JComboBox<SoundPack> packCombo = new JComboBox<>();
|
||||||
private final JLabel packInfo = new JLabel();
|
private final JLabel packInfo = new JLabel();
|
||||||
@@ -113,9 +114,11 @@ final class NotificationsPanel extends JPanel {
|
|||||||
settings.desktopNotifications = desktopNotifications.isSelected();
|
settings.desktopNotifications = desktopNotifications.isSelected();
|
||||||
sounds.setPack((SoundPack) packCombo.getSelectedItem());
|
sounds.setPack((SoundPack) packCombo.getSelectedItem());
|
||||||
if (rescan) sounds.reload();
|
if (rescan) sounds.reload();
|
||||||
|
originalPackId = settings.soundPack;
|
||||||
|
originalPackDir = settings.soundPackDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts back the pack (and folder) the dialog started with, for a cancelled edit. */
|
/** Puts back the pack (and folder) last applied, for a cancelled edit. */
|
||||||
void revert() {
|
void revert() {
|
||||||
settings.soundPackDir = originalPackDir;
|
settings.soundPackDir = originalPackDir;
|
||||||
settings.soundPack = originalPackId;
|
settings.soundPack = originalPackId;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||||
|
import com.formdev.flatlaf.util.UIScale;
|
||||||
|
|
||||||
|
import javax.swing.BoxLayout;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.UIManager;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Font;
|
||||||
|
import java.awt.GradientPaint;
|
||||||
|
import java.awt.Graphics;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heading over an Options page: its title and subtitle on an accent-tinted band, with
|
||||||
|
* the page's icon at the far end, after TS3's blue page banners.
|
||||||
|
*/
|
||||||
|
final class OptionsBanner extends JPanel {
|
||||||
|
|
||||||
|
private final JLabel title = new JLabel() {
|
||||||
|
@Override
|
||||||
|
public void updateUI() {
|
||||||
|
super.updateUI();
|
||||||
|
Font base = UIManager.getFont("Label.font");
|
||||||
|
if (base != null) setFont(base.deriveFont(Font.BOLD, base.getSize2D() * 1.4f));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private final JLabel subtitle = new JLabel();
|
||||||
|
private final JLabel icon = new JLabel();
|
||||||
|
private OptionsPage page;
|
||||||
|
|
||||||
|
OptionsBanner() {
|
||||||
|
super(new BorderLayout(UIScale.scale(12), 0));
|
||||||
|
setOpaque(false);
|
||||||
|
setBorder(new ScaledEmptyBorder(8, 12, 8, 12));
|
||||||
|
|
||||||
|
JPanel text = new JPanel();
|
||||||
|
text.setOpaque(false);
|
||||||
|
text.setLayout(new BoxLayout(text, BoxLayout.Y_AXIS));
|
||||||
|
text.add(title);
|
||||||
|
text.add(subtitle);
|
||||||
|
add(text, BorderLayout.CENTER);
|
||||||
|
add(icon, BorderLayout.EAST);
|
||||||
|
}
|
||||||
|
|
||||||
|
void show(OptionsPage page) {
|
||||||
|
this.page = page;
|
||||||
|
title.setText(page.title());
|
||||||
|
subtitle.setText(page.subtitle());
|
||||||
|
subtitle.setVisible(page.subtitle() != null && !page.subtitle().isEmpty());
|
||||||
|
refreshIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the icon up again from the active pack, after it changed. */
|
||||||
|
void refreshIcon() {
|
||||||
|
icon.setIcon(page == null ? null : page.icon(UIScale.scale(OptionsSidebar.ICON_SIZE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void paintComponent(Graphics g) {
|
||||||
|
Color accent = UIManager.getColor("Component.accentColor");
|
||||||
|
if (accent == null) accent = UIManager.getColor("List.selectionBackground");
|
||||||
|
Graphics2D g2 = (Graphics2D) g.create();
|
||||||
|
try {
|
||||||
|
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g2.setPaint(new GradientPaint(0, 0, withAlpha(accent, 70), 0, getHeight(), withAlpha(accent, 20)));
|
||||||
|
int arc = UIScale.scale(8);
|
||||||
|
g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc);
|
||||||
|
} finally {
|
||||||
|
g2.dispose();
|
||||||
|
}
|
||||||
|
super.paintComponent(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color withAlpha(Color c, int alpha) {
|
||||||
|
return new Color(c.getRed(), c.getGreen(), c.getBlue(), alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import javax.swing.ImageIcon;
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of the Options dialog, as the sidebar lists it and the banner introduces it.
|
||||||
|
*
|
||||||
|
* @param iconKeys TeamSpeak icon keys to try in turn, since packs differ in what they ship
|
||||||
|
*/
|
||||||
|
record OptionsPage(String title, String subtitle, List<String> iconKeys, JComponent content) {
|
||||||
|
|
||||||
|
OptionsPage(String title, String subtitle, JComponent content, String... iconKeys) {
|
||||||
|
this(title, subtitle, List.of(iconKeys), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the page's icon at {@code size} pixels, or {@code null} when the pack has none */
|
||||||
|
ImageIcon icon(int size) {
|
||||||
|
for (String key : iconKeys) {
|
||||||
|
ImageIcon icon = IconTheme.icon(key, size);
|
||||||
|
if (icon != null) return icon;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||||
|
import com.formdev.flatlaf.util.UIScale;
|
||||||
|
|
||||||
|
import javax.swing.DefaultListModel;
|
||||||
|
import javax.swing.Icon;
|
||||||
|
import javax.swing.ImageIcon;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JList;
|
||||||
|
import javax.swing.ListCellRenderer;
|
||||||
|
import javax.swing.ListSelectionModel;
|
||||||
|
import javax.swing.UIManager;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Graphics;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** The Options dialog's page list: large icons with their titles, one page per row, as in TS3. */
|
||||||
|
final class OptionsSidebar extends JList<OptionsPage> {
|
||||||
|
|
||||||
|
static final int ICON_SIZE = 32;
|
||||||
|
|
||||||
|
OptionsSidebar(List<OptionsPage> pages) {
|
||||||
|
super(model(pages));
|
||||||
|
setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||||
|
setCellRenderer(new Renderer());
|
||||||
|
setBorder(new ScaledEmptyBorder(4, 4, 4, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DefaultListModel<OptionsPage> model(List<OptionsPage> pages) {
|
||||||
|
DefaultListModel<OptionsPage> model = new DefaultListModel<>();
|
||||||
|
pages.forEach(model::addElement);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints its own rounded selection in the accent colour: the list's selection would turn
|
||||||
|
* grey whenever focus moves into the page, and the sidebar should always show where you are.
|
||||||
|
*/
|
||||||
|
private static final class Renderer extends JLabel implements ListCellRenderer<OptionsPage> {
|
||||||
|
|
||||||
|
private boolean selected;
|
||||||
|
|
||||||
|
Renderer() {
|
||||||
|
setOpaque(false);
|
||||||
|
setBorder(new ScaledEmptyBorder(6, 8, 6, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Component getListCellRendererComponent(JList<? extends OptionsPage> list, OptionsPage page,
|
||||||
|
int index, boolean isSelected, boolean focus) {
|
||||||
|
int size = UIScale.scale(ICON_SIZE);
|
||||||
|
ImageIcon icon = page.icon(size);
|
||||||
|
setIcon(icon != null ? icon : new BlankIcon(size));
|
||||||
|
setIconTextGap(UIScale.scale(10));
|
||||||
|
setText(page.title());
|
||||||
|
setFont(list.getFont());
|
||||||
|
selected = isSelected;
|
||||||
|
setForeground(isSelected ? selectionForeground() : list.getForeground());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void paintComponent(Graphics g) {
|
||||||
|
if (selected) {
|
||||||
|
Graphics2D g2 = (Graphics2D) g.create();
|
||||||
|
try {
|
||||||
|
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g2.setColor(accent());
|
||||||
|
int arc = UIScale.scale(8);
|
||||||
|
g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc);
|
||||||
|
} finally {
|
||||||
|
g2.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.paintComponent(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color accent() {
|
||||||
|
Color c = UIManager.getColor("Component.accentColor");
|
||||||
|
return c != null ? c : UIManager.getColor("List.selectionBackground");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color selectionForeground() {
|
||||||
|
Color c = UIManager.getColor("List.selectionForeground");
|
||||||
|
return c != null ? c : Color.WHITE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the titles lined up for a page whose icon the pack lacks. */
|
||||||
|
private record BlankIcon(int size) implements Icon {
|
||||||
|
@Override
|
||||||
|
public void paintIcon(Component c, Graphics g, int x, int y) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getIconWidth() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getIconHeight() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.audio.AudioDevice;
|
||||||
|
import com.ts3client.audio.AudioIo;
|
||||||
|
import com.ts3client.audio.VoiceOutput;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.sound.SoundEvent;
|
||||||
|
import com.ts3client.sound.SoundNotifier;
|
||||||
|
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JSlider;
|
||||||
|
import java.awt.FlowLayout;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options page for the playback device and voice volume. The volume previews live on the
|
||||||
|
* visible connection; a device change is handed to the microphone test's loopback.
|
||||||
|
*/
|
||||||
|
final class PlaybackPanel extends FormPanel {
|
||||||
|
|
||||||
|
private final JComboBox<AudioDevice> outputCombo;
|
||||||
|
private final JSlider outputVol;
|
||||||
|
|
||||||
|
PlaybackPanel(Settings settings, AudioIo io, VoiceOutput livePlayback, SoundNotifier sounds,
|
||||||
|
Consumer<String> onOutputDeviceChanged) {
|
||||||
|
GridBagConstraints c = gbc();
|
||||||
|
|
||||||
|
outputCombo = AudioDevices.combo(io.outputDevices(), settings.outputDevice);
|
||||||
|
limitWidth(outputCombo, FIELD_WIDTH);
|
||||||
|
outputCombo.addActionListener(e -> onOutputDeviceChanged.accept(AudioDevices.selectedId(outputCombo)));
|
||||||
|
|
||||||
|
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
|
||||||
|
JLabel volumeLabel = new JLabel(outputVol.getValue() + "%");
|
||||||
|
outputVol.addChangeListener(e -> {
|
||||||
|
volumeLabel.setText(outputVol.getValue() + "%");
|
||||||
|
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
JButton testSound = new JButton("Play Test Sound");
|
||||||
|
testSound.addActionListener(e -> sounds.preview(SoundEvent.SPECIAL_SOUND_TEST));
|
||||||
|
JPanel testRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
||||||
|
testRow.add(testSound);
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
addSection(this, c, row++, "Device");
|
||||||
|
addRow(this, c, row++, new JLabel("Playback device:"), outputCombo);
|
||||||
|
addSection(this, c, row++, "Volume");
|
||||||
|
addRow(this, c, row++, new JLabel("Voice volume:"), sliderWithLabel(outputVol, volumeLabel));
|
||||||
|
addWide(this, c, row++, testRow);
|
||||||
|
addFiller(this, c, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the form into {@code target}, without touching anything else. */
|
||||||
|
void writeInto(Settings target) {
|
||||||
|
target.outputDevice = AudioDevices.selectedId(outputCombo);
|
||||||
|
target.outputVolume = outputVol.getValue() / 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||||
import com.formdev.flatlaf.util.UIScale;
|
import com.formdev.flatlaf.util.UIScale;
|
||||||
import com.ts3client.audio.AudioBackend;
|
import com.ts3client.audio.AudioBackend;
|
||||||
import com.ts3client.audio.VoiceInput;
|
import com.ts3client.audio.VoiceInput;
|
||||||
@@ -8,35 +9,42 @@ import com.ts3client.config.Settings;
|
|||||||
import com.ts3client.sound.SoundNotifier;
|
import com.ts3client.sound.SoundNotifier;
|
||||||
|
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JDialog;
|
import javax.swing.JDialog;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.ScrollPaneConstants;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
import javax.swing.JTabbedPane;
|
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.CardLayout;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
|
import java.awt.FlowLayout;
|
||||||
import java.awt.Frame;
|
import java.awt.Frame;
|
||||||
import java.util.function.Consumer;
|
import java.awt.event.WindowAdapter;
|
||||||
|
import java.awt.event.WindowEvent;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options dialog: a tabbed coordinator over the individual settings pages. Each tab owns
|
* Options dialog, laid out like TS3's: a sidebar of pages on the left, the chosen page
|
||||||
* its own controls and reads/writes {@link Settings} on {@link #apply()}; this class wires
|
* under its banner on the right, OK/Cancel/Apply below. Each page owns its controls and
|
||||||
* the tabs that preview themselves live against the running audio subsystem (the device
|
* writes {@link Settings} when applied; this class wires the audio pages, which preview
|
||||||
* and voice-activation tabs, which share a single microphone test) and owns the
|
* themselves live against the running audio subsystem, and owns the apply plumbing.
|
||||||
* OK/Cancel/Apply plumbing.
|
|
||||||
*/
|
*/
|
||||||
public final class SettingsDialog extends JDialog {
|
public final class SettingsDialog extends JDialog {
|
||||||
|
|
||||||
|
/** The page the dialog was last left on, to reopen there. */
|
||||||
|
private static int lastPage;
|
||||||
|
|
||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private final VoiceInput liveMic;
|
|
||||||
private final VoiceOutput livePlayback;
|
private final VoiceOutput livePlayback;
|
||||||
private final Runnable onApply;
|
private final Runnable onApply;
|
||||||
|
|
||||||
private final DevicesPanel devicesPanel;
|
private final ApplicationPanel applicationPanel;
|
||||||
private final VoiceActivationPanel voiceActivationPanel;
|
private final PlaybackPanel playbackPanel;
|
||||||
|
private final CapturePanel capturePanel;
|
||||||
private final NotificationsPanel notificationsPanel;
|
private final NotificationsPanel notificationsPanel;
|
||||||
private final DesignPanel designPanel;
|
private final DesignPanel designPanel;
|
||||||
private final HotkeysPanel hotkeysPanel;
|
private final HotkeysPanel hotkeysPanel;
|
||||||
private final ClientVersionPanel clientVersionPanel;
|
|
||||||
private final ContactsPanel contactsPanel;
|
private final ContactsPanel contactsPanel;
|
||||||
private final ChatLogsPanel chatLogsPanel;
|
private final ChatLogsPanel chatLogsPanel;
|
||||||
|
|
||||||
@@ -46,87 +54,139 @@ public final class SettingsDialog extends JDialog {
|
|||||||
Runnable onApply) {
|
Runnable onApply) {
|
||||||
super(owner, "Options", true);
|
super(owner, "Options", true);
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.liveMic = liveMic;
|
|
||||||
this.livePlayback = livePlayback;
|
this.livePlayback = livePlayback;
|
||||||
this.onApply = onApply;
|
this.onApply = onApply;
|
||||||
|
|
||||||
|
applicationPanel = new ApplicationPanel(settings);
|
||||||
|
hotkeysPanel = new HotkeysPanel(hotkeys);
|
||||||
|
capturePanel = new CapturePanel(settings, audio, liveMic, hotkeys, hotkeysPanel, this::audioSnapshot);
|
||||||
|
playbackPanel = new PlaybackPanel(settings, audio.io(), livePlayback, sounds,
|
||||||
|
capturePanel::setTestOutputDevice);
|
||||||
notificationsPanel = new NotificationsPanel(settings, sounds);
|
notificationsPanel = new NotificationsPanel(settings, sounds);
|
||||||
designPanel = new DesignPanel(settings);
|
designPanel = new DesignPanel(settings);
|
||||||
hotkeysPanel = new HotkeysPanel(hotkeys);
|
|
||||||
clientVersionPanel = new ClientVersionPanel(settings);
|
|
||||||
contactsPanel = new ContactsPanel(settings);
|
contactsPanel = new ContactsPanel(settings);
|
||||||
chatLogsPanel = new ChatLogsPanel(settings);
|
chatLogsPanel = new ChatLogsPanel(settings);
|
||||||
devicesPanel = new DevicesPanel(settings, audio.io(), livePlayback,
|
|
||||||
this::applyLive, this::restartTest, this::setTestOutputDevice);
|
|
||||||
voiceActivationPanel = new VoiceActivationPanel(settings, audio, liveMic, hotkeys,
|
|
||||||
hotkeysPanel, this::audioSnapshot);
|
|
||||||
|
|
||||||
JTabbedPane tabs = new JTabbedPane();
|
List<OptionsPage> pages = List.of(
|
||||||
tabs.addTab("Playback / Capture", scrollable(devicesPanel));
|
new OptionsPage("Application", "General Application Options", scrollable(applicationPanel),
|
||||||
tabs.addTab("Voice Activation", scrollable(voiceActivationPanel));
|
"SETTINGS"),
|
||||||
tabs.addTab("Notifications", notificationsPanel);
|
new OptionsPage("myTeamSpeak", "Account and Synchronization", myTeamSpeak,
|
||||||
tabs.addTab("Design", designPanel);
|
"MYTS_ACCOUNT"),
|
||||||
tabs.addTab("Hotkeys", hotkeysPanel);
|
new OptionsPage("Playback", "Configure the Playback Sound System", scrollable(playbackPanel),
|
||||||
tabs.addTab("Contacts", contactsPanel);
|
"PLAYBACK"),
|
||||||
tabs.addTab("Chat", scrollable(chatLogsPanel));
|
new OptionsPage("Capture", "Configure the Capture Sound System", scrollable(capturePanel),
|
||||||
tabs.addTab("Client Version", scrollable(clientVersionPanel));
|
"CAPTURE"),
|
||||||
tabs.addTab("myTeamSpeak", myTeamSpeak);
|
new OptionsPage("Design", "Configure the Design of TeamSpeak", designPanel,
|
||||||
|
"GUISETUP", "CHANNEL_EDIT"),
|
||||||
|
new OptionsPage("Hotkeys", "Configure Hotkeys", hotkeysPanel,
|
||||||
|
"HOTKEYS"),
|
||||||
|
new OptionsPage("Contacts", "Configure Contacts and Whispers", scrollable(contactsPanel),
|
||||||
|
"CONTACT", "EDIT_FRIEND_FOE_STATUS"),
|
||||||
|
new OptionsPage("Chat", "Configure the Chat Tabs", scrollable(chatLogsPanel),
|
||||||
|
"CHANNEL_CHAT", "PLAYER_CHAT"),
|
||||||
|
new OptionsPage("Notifications", "Configure the Notifications", notificationsPanel,
|
||||||
|
"NOTIFICATIONS"));
|
||||||
|
|
||||||
JPanel buttons = new JPanel(new BorderLayout());
|
OptionsBanner banner = new OptionsBanner();
|
||||||
JPanel right = new JPanel();
|
CardLayout cards = new CardLayout();
|
||||||
|
JPanel content = new JPanel(cards);
|
||||||
|
for (OptionsPage page : pages) content.add(page.content(), page.title());
|
||||||
|
|
||||||
|
OptionsSidebar sidebar = new OptionsSidebar(pages);
|
||||||
|
sidebar.addListSelectionListener(e -> {
|
||||||
|
OptionsPage page = sidebar.getSelectedValue();
|
||||||
|
if (page == null) return;
|
||||||
|
banner.show(page);
|
||||||
|
cards.show(content, page.title());
|
||||||
|
lastPage = sidebar.getSelectedIndex();
|
||||||
|
});
|
||||||
|
sidebar.setSelectedIndex(Math.min(lastPage, pages.size() - 1));
|
||||||
|
JScrollPane sidebarScroll = new JScrollPane(sidebar,
|
||||||
|
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||||
|
|
||||||
|
JPanel page = new JPanel(new BorderLayout(0, UIScale.scale(4)));
|
||||||
|
page.add(banner, BorderLayout.NORTH);
|
||||||
|
page.add(content, BorderLayout.CENTER);
|
||||||
|
|
||||||
|
JPanel body = new JPanel(new BorderLayout(UIScale.scale(8), 0));
|
||||||
|
body.setBorder(new ScaledEmptyBorder(10, 10, 0, 10));
|
||||||
|
body.add(sidebarScroll, BorderLayout.WEST);
|
||||||
|
body.add(page, BorderLayout.CENTER);
|
||||||
|
|
||||||
|
getContentPane().setLayout(new BorderLayout());
|
||||||
|
getContentPane().add(body, BorderLayout.CENTER);
|
||||||
|
getContentPane().add(buildButtons(), BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
Runnable iconsChanged = () -> SwingUtilities.invokeLater(() -> {
|
||||||
|
sidebar.repaint();
|
||||||
|
banner.refreshIcon();
|
||||||
|
});
|
||||||
|
IconTheme.get().addListener(iconsChanged);
|
||||||
|
|
||||||
|
// The microphone test hears typing too, so typing attenuation can be tried out here.
|
||||||
|
Runnable testKeyPress = () -> SwingUtilities.invokeLater(
|
||||||
|
() -> capturePanel.applyLive(VoiceInput::keyPressed));
|
||||||
|
hotkeys.addKeyPressListener(testKeyPress);
|
||||||
|
|
||||||
|
Dialogs.closeOnEscape(this, this::cancel);
|
||||||
|
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
|
||||||
|
addWindowListener(new WindowAdapter() {
|
||||||
|
@Override
|
||||||
|
public void windowClosing(WindowEvent e) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void windowClosed(WindowEvent e) {
|
||||||
|
hotkeys.removeKeyPressListener(testKeyPress);
|
||||||
|
IconTheme.get().removeListener(iconsChanged);
|
||||||
|
capturePanel.stopTest();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pack();
|
||||||
|
setSize(UIScale.scale(new Dimension(900, 620)));
|
||||||
|
setMinimumSize(UIScale.scale(new Dimension(640, 440)));
|
||||||
|
setLocationRelativeTo(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JComponent buildButtons() {
|
||||||
JButton ok = new JButton("OK");
|
JButton ok = new JButton("OK");
|
||||||
JButton cancel = new JButton("Cancel");
|
JButton cancel = new JButton("Cancel");
|
||||||
|
JButton apply = new JButton("Apply");
|
||||||
ok.addActionListener(e -> {
|
ok.addActionListener(e -> {
|
||||||
apply();
|
apply();
|
||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
cancel.addActionListener(e -> cancel());
|
cancel.addActionListener(e -> cancel());
|
||||||
right.add(ok);
|
apply.addActionListener(e -> apply());
|
||||||
right.add(cancel);
|
|
||||||
buttons.add(right, BorderLayout.EAST);
|
|
||||||
|
|
||||||
getContentPane().setLayout(new BorderLayout());
|
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, UIScale.scale(6), 0));
|
||||||
getContentPane().add(tabs, BorderLayout.CENTER);
|
buttons.setBorder(new ScaledEmptyBorder(10, 10, 10, 10));
|
||||||
getContentPane().add(buttons, BorderLayout.SOUTH);
|
buttons.add(ok);
|
||||||
|
buttons.add(cancel);
|
||||||
// The microphone test hears typing too, so typing attenuation can be tried out here.
|
buttons.add(apply);
|
||||||
Runnable testKeyPress = () -> SwingUtilities.invokeLater(
|
getRootPane().setDefaultButton(ok);
|
||||||
() -> voiceActivationPanel.configureTest(VoiceInput::keyPressed));
|
return buttons;
|
||||||
hotkeys.addKeyPressListener(testKeyPress);
|
|
||||||
|
|
||||||
Dialogs.closeOnEscape(this, this::cancel);
|
|
||||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
|
||||||
addWindowListener(new java.awt.event.WindowAdapter() {
|
|
||||||
@Override
|
|
||||||
public void windowClosed(java.awt.event.WindowEvent e) {
|
|
||||||
hotkeys.removeKeyPressListener(testKeyPress);
|
|
||||||
voiceActivationPanel.stopTest();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pack();
|
|
||||||
setSize(UIScale.scale(new Dimension(480, 540)));
|
|
||||||
setMinimumSize(UIScale.scale(new Dimension(420, 360)));
|
|
||||||
setLocationRelativeTo(owner);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static javax.swing.JScrollPane scrollable(JPanel content) {
|
private static JScrollPane scrollable(JPanel content) {
|
||||||
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
|
JScrollPane sp = new JScrollPane(content,
|
||||||
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||||
javax.swing.ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||||
sp.setBorder(null);
|
sp.setBorder(null);
|
||||||
sp.getVerticalScrollBar().setUnitIncrement(16);
|
sp.getVerticalScrollBar().setUnitIncrement(16);
|
||||||
return sp;
|
return sp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copies the audio tabs into {@code target}, without touching anything else. */
|
/** Copies the audio pages into {@code target}, without touching anything else. */
|
||||||
private void writeAudioSettings(Settings target) {
|
private void writeAudioSettings(Settings target) {
|
||||||
devicesPanel.writeInto(target);
|
playbackPanel.writeInto(target);
|
||||||
voiceActivationPanel.writeInto(target);
|
capturePanel.writeInto(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The audio tabs as a standalone {@link Settings}, so the microphone test chain runs
|
* The audio pages as a standalone {@link Settings}, so the microphone test chain runs
|
||||||
* with the values currently on screen rather than the ones last saved.
|
* with the values currently on screen rather than the ones last saved.
|
||||||
*
|
*
|
||||||
* <p>The test always runs voice activation: it exists to tune the gate, and push-to-talk
|
* <p>The test always runs voice activation: it exists to tune the gate, and push-to-talk
|
||||||
@@ -143,15 +203,15 @@ public final class SettingsDialog extends JDialog {
|
|||||||
|
|
||||||
private void apply() {
|
private void apply() {
|
||||||
writeAudioSettings(settings);
|
writeAudioSettings(settings);
|
||||||
|
applicationPanel.apply();
|
||||||
notificationsPanel.apply();
|
notificationsPanel.apply();
|
||||||
designPanel.apply();
|
designPanel.apply();
|
||||||
clientVersionPanel.apply();
|
|
||||||
contactsPanel.apply();
|
contactsPanel.apply();
|
||||||
chatLogsPanel.apply();
|
chatLogsPanel.apply();
|
||||||
hotkeysPanel.apply();
|
hotkeysPanel.apply();
|
||||||
settings.save();
|
settings.save();
|
||||||
|
|
||||||
applyLive(m -> m.apply(settings));
|
capturePanel.applyLive(m -> m.apply(settings));
|
||||||
if (livePlayback != null) {
|
if (livePlayback != null) {
|
||||||
livePlayback.setMasterVolume(settings.outputVolume);
|
livePlayback.setMasterVolume(settings.outputVolume);
|
||||||
livePlayback.setOutputDevice(settings.outputDevice);
|
livePlayback.setOutputDevice(settings.outputDevice);
|
||||||
@@ -167,27 +227,7 @@ public final class SettingsDialog extends JDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void close() {
|
private void close() {
|
||||||
voiceActivationPanel.stopTest();
|
capturePanel.stopTest();
|
||||||
dispose();
|
dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a live change to the connected microphone and to the microphone test alike.
|
|
||||||
*
|
|
||||||
* <p>Guarded against a null {@code voiceActivationPanel}: the Devices tab applies its
|
|
||||||
* controls once as they are built, which happens before that tab exists.
|
|
||||||
*/
|
|
||||||
private void applyLive(Consumer<VoiceInput> change) {
|
|
||||||
if (liveMic != null) change.accept(liveMic);
|
|
||||||
if (voiceActivationPanel != null) voiceActivationPanel.configureTest(change);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Restarts the microphone test, if running, so a device change takes effect. */
|
|
||||||
private void restartTest() {
|
|
||||||
if (voiceActivationPanel != null) voiceActivationPanel.restartTest();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setTestOutputDevice(String deviceId) {
|
|
||||||
if (voiceActivationPanel != null) voiceActivationPanel.setTestOutputDevice(deviceId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
package com.ts3client.ui;
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||||
import com.formdev.flatlaf.util.UIScale;
|
import com.formdev.flatlaf.util.UIScale;
|
||||||
import com.ts3client.audio.AudioBackend;
|
import com.ts3client.audio.AudioBackend;
|
||||||
import com.ts3client.audio.InputLevel;
|
import com.ts3client.audio.InputLevel;
|
||||||
import com.ts3client.audio.OpusParameters;
|
|
||||||
import com.ts3client.audio.VoiceInput;
|
import com.ts3client.audio.VoiceInput;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
import com.ts3client.hotkey.Hotkey;
|
import com.ts3client.hotkey.Hotkey;
|
||||||
import com.ts3client.hotkey.HotkeyAction;
|
import com.ts3client.hotkey.HotkeyAction;
|
||||||
|
|
||||||
import javax.swing.Box;
|
|
||||||
import javax.swing.ButtonGroup;
|
import javax.swing.ButtonGroup;
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JCheckBox;
|
import javax.swing.JCheckBox;
|
||||||
import javax.swing.JComboBox;
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JRadioButton;
|
import javax.swing.JRadioButton;
|
||||||
import javax.swing.JSlider;
|
import javax.swing.JSlider;
|
||||||
import javax.swing.JToggleButton;
|
import javax.swing.JToggleButton;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
import java.awt.GridBagConstraints;
|
import java.awt.GridBagConstraints;
|
||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -26,19 +27,15 @@ import java.util.function.Consumer;
|
|||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options dialog's "Voice Activation" tab: input mode (VAD / push-to-talk / continuous),
|
* The Capture page's activation section: input mode (push-to-talk / continuous / voice
|
||||||
* the detection tuning with a live input meter, and the Opus encoder controls.
|
* activation), the detection tuning, and a live input meter.
|
||||||
*
|
*
|
||||||
* <p>Owns the microphone test (start/stop button, loopback, indicator): it runs a real
|
* <p>Owns the microphone test (start/stop button, loopback, indicator): it runs a real
|
||||||
* capture chain built from the form's current values, plus whatever the "Playback / Capture"
|
* capture chain built from what the Capture and Playback pages have on screen, via
|
||||||
* tab currently has on screen, via {@code audioSnapshot}.
|
* {@code audioSnapshot}.
|
||||||
*/
|
*/
|
||||||
final class VoiceActivationPanel extends FormPanel {
|
final class VoiceActivationSection {
|
||||||
|
|
||||||
private static final int MIN_BITRATE_KBITS = 8;
|
|
||||||
private static final int MAX_BITRATE_KBITS = 160;
|
|
||||||
|
|
||||||
private final Settings settings;
|
|
||||||
private final VoiceInput liveMic;
|
private final VoiceInput liveMic;
|
||||||
private final HotkeyService hotkeys;
|
private final HotkeyService hotkeys;
|
||||||
private final HotkeysPanel hotkeysPanel;
|
private final HotkeysPanel hotkeysPanel;
|
||||||
@@ -57,30 +54,26 @@ final class VoiceActivationPanel extends FormPanel {
|
|||||||
private JCheckBox loopbackCheck;
|
private JCheckBox loopbackCheck;
|
||||||
private JLabel talkIndicator;
|
private JLabel talkIndicator;
|
||||||
private final JButton pttKeyButton;
|
private final JButton pttKeyButton;
|
||||||
private final JSlider bitrateSlider;
|
|
||||||
private final JSlider complexitySlider;
|
|
||||||
private final JCheckBox vbrCheck;
|
|
||||||
private final JCheckBox fecCheck;
|
|
||||||
private final JCheckBox musicCheck;
|
|
||||||
|
|
||||||
VoiceActivationPanel(Settings settings, AudioBackend audio, VoiceInput liveMic, HotkeyService hotkeys,
|
private final JLabel thresholdLabel;
|
||||||
|
private final JLabel speechLabel;
|
||||||
|
private final JPanel testControls;
|
||||||
|
|
||||||
|
VoiceActivationSection(Settings settings, AudioBackend audio, VoiceInput liveMic, HotkeyService hotkeys,
|
||||||
HotkeysPanel hotkeysPanel, Supplier<Settings> audioSnapshot) {
|
HotkeysPanel hotkeysPanel, Supplier<Settings> audioSnapshot) {
|
||||||
this.settings = settings;
|
|
||||||
this.liveMic = liveMic;
|
this.liveMic = liveMic;
|
||||||
this.micTest = new MicrophoneTest(audio, this::onTestLevel, this::onTestTalking);
|
this.micTest = new MicrophoneTest(audio, this::onTestLevel, this::onTestTalking);
|
||||||
this.hotkeys = hotkeys;
|
this.hotkeys = hotkeys;
|
||||||
this.hotkeysPanel = hotkeysPanel;
|
this.hotkeysPanel = hotkeysPanel;
|
||||||
this.audioSnapshot = audioSnapshot;
|
this.audioSnapshot = audioSnapshot;
|
||||||
|
|
||||||
GridBagConstraints c = gbc();
|
|
||||||
|
|
||||||
vadRadio = new JRadioButton("Voice Activation Detection");
|
|
||||||
pttRadio = new JRadioButton("Push-To-Talk");
|
pttRadio = new JRadioButton("Push-To-Talk");
|
||||||
contRadio = new JRadioButton("Continuous");
|
contRadio = new JRadioButton("Continuous Transmission");
|
||||||
|
vadRadio = new JRadioButton("Voice Activity Detection");
|
||||||
ButtonGroup group = new ButtonGroup();
|
ButtonGroup group = new ButtonGroup();
|
||||||
group.add(vadRadio);
|
|
||||||
group.add(pttRadio);
|
group.add(pttRadio);
|
||||||
group.add(contRadio);
|
group.add(contRadio);
|
||||||
|
group.add(vadRadio);
|
||||||
switch (settings.inputMode) {
|
switch (settings.inputMode) {
|
||||||
case PUSH_TO_TALK:
|
case PUSH_TO_TALK:
|
||||||
pttRadio.setSelected(true);
|
pttRadio.setSelected(true);
|
||||||
@@ -92,31 +85,13 @@ final class VoiceActivationPanel extends FormPanel {
|
|||||||
vadRadio.setSelected(true);
|
vadRadio.setSelected(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
int row = 0;
|
pttKeyButton = new JButton(pushToTalkHotkeyText());
|
||||||
c.gridx = 0;
|
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
|
||||||
c.gridy = row++;
|
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
|
||||||
c.gridwidth = 2;
|
|
||||||
add(vadRadio, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
add(pttRadio, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
add(contRadio, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
meter = new LevelMeter();
|
vadOverPttCheck = new JCheckBox("Add Voice Activity Detection", settings.vadOverPtt);
|
||||||
meter.setThreshold(settings.vadThresholdDb);
|
vadOverPttCheck.addActionListener(e ->
|
||||||
c.gridx = 0;
|
micTest.configure(m -> m.setVadOverPtt(vadOverPttCheck.isSelected())));
|
||||||
c.gridy = row;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
c.insets = UIScale.scale(new Insets(10, 4, 2, 4));
|
|
||||||
add(new JLabel("Input level:"), c);
|
|
||||||
c.gridy = ++row;
|
|
||||||
add(meter, c);
|
|
||||||
c.insets = UIScale.scale(new Insets(4, 4, 4, 4));
|
|
||||||
c.gridy = ++row;
|
|
||||||
add(buildTestControls(), c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
row++;
|
|
||||||
|
|
||||||
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
|
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
|
||||||
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
|
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
|
||||||
@@ -124,74 +99,26 @@ final class VoiceActivationPanel extends FormPanel {
|
|||||||
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
|
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
|
||||||
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
|
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
|
||||||
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
|
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
|
||||||
addRow(this, c, row++, new JLabel("Detection:"), vadModeCombo);
|
|
||||||
|
|
||||||
thresholdSlider = new JSlider((int) InputLevel.MIN_DB, (int) InputLevel.MAX_DB,
|
thresholdSlider = new JSlider((int) InputLevel.MIN_DB, (int) InputLevel.MAX_DB,
|
||||||
(int) Math.round(settings.vadThresholdDb));
|
(int) Math.round(settings.vadThresholdDb));
|
||||||
JLabel thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
|
thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
|
||||||
|
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
|
||||||
|
speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
|
||||||
|
|
||||||
|
meter = new LevelMeter();
|
||||||
|
meter.setThreshold(settings.vadThresholdDb);
|
||||||
|
testControls = buildTestControls();
|
||||||
|
|
||||||
thresholdSlider.addChangeListener(e -> {
|
thresholdSlider.addChangeListener(e -> {
|
||||||
meter.setThreshold(thresholdSlider.getValue());
|
meter.setThreshold(thresholdSlider.getValue());
|
||||||
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
|
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
|
||||||
applyLive(m -> m.setThresholdDb(thresholdSlider.getValue()));
|
applyLive(m -> m.setThresholdDb(thresholdSlider.getValue()));
|
||||||
});
|
});
|
||||||
addRow(this, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
|
|
||||||
|
|
||||||
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
|
|
||||||
JLabel speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
|
|
||||||
speechSlider.addChangeListener(e -> {
|
speechSlider.addChangeListener(e -> {
|
||||||
speechLabel.setText(speechSlider.getValue() + "%");
|
speechLabel.setText(speechSlider.getValue() + "%");
|
||||||
applyLive(m -> m.setSpeechThreshold(speechSlider.getValue() / 100.0));
|
applyLive(m -> m.setSpeechThreshold(speechSlider.getValue() / 100.0));
|
||||||
});
|
});
|
||||||
addRow(this, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
|
|
||||||
|
|
||||||
pttKeyButton = new JButton(pushToTalkHotkeyText());
|
|
||||||
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
|
|
||||||
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
|
|
||||||
addRow(this, c, row++, new JLabel("Push-to-talk hotkey:"), pttKeyButton);
|
|
||||||
|
|
||||||
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
|
|
||||||
vadOverPttCheck.addActionListener(e ->
|
|
||||||
micTest.configure(m -> m.setVadOverPtt(vadOverPttCheck.isSelected())));
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
add(vadOverPttCheck, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000));
|
|
||||||
bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits);
|
|
||||||
JLabel bitrateLabel = new JLabel(kbits + " kbit/s");
|
|
||||||
bitrateSlider.addChangeListener(e -> {
|
|
||||||
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
|
|
||||||
pushOpusLive();
|
|
||||||
});
|
|
||||||
addRow(this, c, row++, new JLabel("Opus bitrate:"),
|
|
||||||
sliderWithLabel(bitrateSlider, bitrateLabel, 70));
|
|
||||||
|
|
||||||
complexitySlider = new JSlider(0, 10, settings.complexity);
|
|
||||||
limitWidth(complexitySlider, SLIDER_WIDTH);
|
|
||||||
complexitySlider.addChangeListener(e -> pushOpusLive());
|
|
||||||
addRow(this, c, row++, new JLabel("Opus complexity:"), complexitySlider);
|
|
||||||
|
|
||||||
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
|
|
||||||
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
|
|
||||||
musicCheck = new JCheckBox("Music codec (stereo, higher fidelity)", settings.music);
|
|
||||||
musicCheck.setToolTipText("<html>Transmits <b>OPUS_MUSIC</b>: stereo when the capture "
|
|
||||||
+ "device has two channels, and without the voice pre-processing "
|
|
||||||
+ "(noise removal, typing attenuation, AGC).<br>"
|
|
||||||
+ "Voice mode (<b>OPUS_VOICE</b>) is mono, as in the official client.</html>");
|
|
||||||
vbrCheck.addActionListener(e -> pushOpusLive());
|
|
||||||
fecCheck.addActionListener(e -> pushOpusLive());
|
|
||||||
musicCheck.addActionListener(e -> pushOpusLive());
|
|
||||||
c.gridx = 0;
|
|
||||||
c.gridy = row++;
|
|
||||||
c.gridwidth = 2;
|
|
||||||
add(vbrCheck, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
add(fecCheck, c);
|
|
||||||
c.gridy = row++;
|
|
||||||
add(musicCheck, c);
|
|
||||||
c.gridwidth = 1;
|
|
||||||
|
|
||||||
Runnable syncEnabled = () -> {
|
Runnable syncEnabled = () -> {
|
||||||
boolean vad = vadRadio.isSelected();
|
boolean vad = vadRadio.isSelected();
|
||||||
@@ -222,11 +149,33 @@ final class VoiceActivationPanel extends FormPanel {
|
|||||||
vadModeCombo.addActionListener(e -> syncEnabled.run());
|
vadModeCombo.addActionListener(e -> syncEnabled.run());
|
||||||
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
|
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
|
||||||
syncEnabled.run();
|
syncEnabled.run();
|
||||||
|
}
|
||||||
|
|
||||||
c.gridx = 0;
|
/** Adds the section's rows to {@code form} from {@code row} on; returns the next free row. */
|
||||||
c.gridy = row;
|
int layOut(JPanel form, GridBagConstraints c, int row) {
|
||||||
c.weighty = 1;
|
FormPanel.addRow(form, c, row++, pttRadio, pttKeyButton);
|
||||||
add(Box.createGlue(), c);
|
FormPanel.addWide(form, c, row++, indented(vadOverPttCheck));
|
||||||
|
FormPanel.addWide(form, c, row++, contRadio);
|
||||||
|
FormPanel.addRow(form, c, row++, vadRadio, vadModeCombo);
|
||||||
|
FormPanel.addRow(form, c, row++, indented(new JLabel("Volume gate:")),
|
||||||
|
FormPanel.sliderWithLabel(thresholdSlider, thresholdLabel));
|
||||||
|
FormPanel.addRow(form, c, row++, indented(new JLabel("Speech threshold:")),
|
||||||
|
FormPanel.sliderWithLabel(speechSlider, speechLabel));
|
||||||
|
|
||||||
|
Insets insets = c.insets;
|
||||||
|
c.insets = UIScale.scale(new Insets(10, 4, 4, 4));
|
||||||
|
FormPanel.addWide(form, c, row++, meter);
|
||||||
|
c.insets = insets;
|
||||||
|
FormPanel.addWide(form, c, row++, testControls);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lines a dependent control up under the text of the radio button it belongs to. */
|
||||||
|
private static JComponent indented(JComponent comp) {
|
||||||
|
JPanel p = new JPanel(new BorderLayout());
|
||||||
|
p.setBorder(new ScaledEmptyBorder(0, 22, 0, 0));
|
||||||
|
p.add(comp, BorderLayout.CENTER);
|
||||||
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copies the form into {@code target}, without touching anything else. */
|
/** Copies the form into {@code target}, without touching anything else. */
|
||||||
@@ -238,24 +187,19 @@ final class VoiceActivationPanel extends FormPanel {
|
|||||||
target.vadThresholdDb = thresholdSlider.getValue();
|
target.vadThresholdDb = thresholdSlider.getValue();
|
||||||
target.speechThreshold = speechSlider.getValue() / 100.0;
|
target.speechThreshold = speechSlider.getValue() / 100.0;
|
||||||
target.vadOverPtt = vadOverPttCheck.isSelected();
|
target.vadOverPtt = vadOverPttCheck.isSelected();
|
||||||
target.bitrate = bitrateSlider.getValue() * 1000;
|
|
||||||
target.complexity = complexitySlider.getValue();
|
|
||||||
target.vbr = vbrCheck.isSelected();
|
|
||||||
target.fec = fecCheck.isSelected();
|
|
||||||
target.music = musicCheck.isSelected();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies a change to the microphone test, if it is running; used by the Devices tab too. */
|
/** Applies a change to the microphone test, if it is running. */
|
||||||
void configureTest(Consumer<VoiceInput> change) {
|
void configureTest(Consumer<VoiceInput> change) {
|
||||||
micTest.configure(change);
|
micTest.configure(change);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The playback device to loop the test through; used by the Devices tab's output picker. */
|
/** The playback device to loop the test through. */
|
||||||
void setTestOutputDevice(String deviceId) {
|
void setTestOutputDevice(String deviceId) {
|
||||||
micTest.setOutputDevice(deviceId);
|
micTest.setOutputDevice(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restarts the test chain, if running, so a device change on the Devices tab takes effect. */
|
/** Restarts the test chain, if running, so a device change takes effect. */
|
||||||
void restartTest() {
|
void restartTest() {
|
||||||
if (micTest.isRunning()) {
|
if (micTest.isRunning()) {
|
||||||
boolean loopback = loopbackCheck.isSelected();
|
boolean loopback = loopbackCheck.isSelected();
|
||||||
@@ -294,21 +238,6 @@ final class VoiceActivationPanel extends FormPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private OpusParameters currentOpusParameters() {
|
|
||||||
return new OpusParameters(
|
|
||||||
bitrateSlider.getValue() * 1000,
|
|
||||||
complexitySlider.getValue(),
|
|
||||||
vbrCheck.isSelected(),
|
|
||||||
fecCheck.isSelected(),
|
|
||||||
settings.packetLoss,
|
|
||||||
musicCheck.isSelected());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies the current Opus controls to the running encoder immediately. */
|
|
||||||
private void pushOpusLive() {
|
|
||||||
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String pushToTalkHotkeyText() {
|
private String pushToTalkHotkeyText() {
|
||||||
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||||
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
|
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
|
||||||
@@ -320,7 +249,7 @@ final class VoiceActivationPanel extends FormPanel {
|
|||||||
*/
|
*/
|
||||||
private void editPushToTalkHotkey() {
|
private void editPushToTalkHotkey() {
|
||||||
Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||||
HotkeyDialog dlg = new HotkeyDialog(javax.swing.SwingUtilities.getWindowAncestor(this),
|
HotkeyDialog dlg = new HotkeyDialog(javax.swing.SwingUtilities.getWindowAncestor(pttKeyButton),
|
||||||
hotkeys, existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
|
hotkeys, existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
|
||||||
dlg.setVisible(true);
|
dlg.setVisible(true);
|
||||||
if (!dlg.isConfirmed()) return;
|
if (!dlg.isConfirmed()) return;
|
||||||
Reference in New Issue
Block a user