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:
@@ -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 java.awt.BorderLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.Insets;
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
@@ -34,10 +33,6 @@ final class ChatLogsPanel extends FormPanel {
|
||||
enabled.setSelected(settings.chatLogs);
|
||||
enabled.setToolTipText("Write every chat to disk and show its history when it opens again");
|
||||
enabled.addActionListener(e -> updateEnabled());
|
||||
c.gridx = 0;
|
||||
c.gridy = 0;
|
||||
c.gridwidth = 2;
|
||||
add(enabled, c);
|
||||
|
||||
folder.setText(settings.chatLogDir);
|
||||
folder.putClientProperty("JTextField.placeholderText", "The TeamSpeak 3 client's folder");
|
||||
@@ -45,24 +40,26 @@ final class ChatLogsPanel extends FormPanel {
|
||||
+ AppDirs.chatLogs() + "</html>");
|
||||
limitWidth(folder, FIELD_WIDTH);
|
||||
browse.addActionListener(e -> browseForFolder());
|
||||
JPanel row = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
||||
row.add(folder, BorderLayout.CENTER);
|
||||
row.add(browse, BorderLayout.EAST);
|
||||
addRow(this, c, 1, new JLabel("Folder:"), row);
|
||||
JPanel folderRow = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
||||
folderRow.add(folder, BorderLayout.CENTER);
|
||||
folderRow.add(browse, BorderLayout.EAST);
|
||||
|
||||
c.gridy = 2;
|
||||
c.gridx = 1;
|
||||
c.anchor = GridBagConstraints.NORTHWEST;
|
||||
add(new JLabel("Takes effect on the next connect."), c);
|
||||
JLabel nextConnect = new JLabel("Takes effect on the next connect.");
|
||||
nextConnect.setEnabled(false);
|
||||
|
||||
pictures.setSelected(settings.descriptionPictures);
|
||||
pictures.setToolTipText("Off: only links to them, nothing fetched");
|
||||
c.gridy = 3;
|
||||
c.gridx = 0;
|
||||
c.gridwidth = 2;
|
||||
c.weighty = 1;
|
||||
c.insets = new Insets(UIScale.scale(12), c.insets.left, c.insets.bottom, c.insets.right);
|
||||
add(pictures, c);
|
||||
|
||||
int row = 0;
|
||||
addSection(this, c, row++, "Logging");
|
||||
addWide(this, c, row++, enabled);
|
||||
addRow(this, c, row++, new JLabel("Folder:"), folderRow);
|
||||
c.gridx = 1;
|
||||
c.gridy = row++;
|
||||
add(nextConnect, c);
|
||||
addSection(this, c, row++, "Descriptions");
|
||||
addWide(this, c, row++, pictures);
|
||||
addFiller(this, c, row);
|
||||
updateEnabled();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.config.ClientVersion;
|
||||
import com.ts3client.config.Settings;
|
||||
@@ -20,7 +19,7 @@ import java.awt.GridBagLayout;
|
||||
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").
|
||||
*
|
||||
* <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)));
|
||||
this.settings = settings;
|
||||
|
||||
setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
|
||||
add(buildForm(), BorderLayout.NORTH);
|
||||
|
||||
load();
|
||||
@@ -111,12 +109,10 @@ final class ClientVersionPanel extends JPanel {
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.gridwidth = 2;
|
||||
p.add(new JLabel("<html><body style='width:" + HtmlStyles.px(320) + "'>The three fields belong together: the "
|
||||
+ "signature is TeamSpeak's, over that version and operating system. A server "
|
||||
+ "answers \"client is modified\" and refuses the connection when it does not "
|
||||
+ "accept the version — made up, or older than the server's minimum — so prefer "
|
||||
+ "a recent one from the list. Takes effect the next time you connect."
|
||||
+ "</body></html>"), c);
|
||||
JLabel note = new JLabel("<html>Servers refuse made-up or outdated versions (\"client is modified\")."
|
||||
+ "<br>Takes effect on the next connect.</html>");
|
||||
note.setEnabled(false);
|
||||
p.add(note, c);
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
@@ -13,46 +11,28 @@ import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.FlowLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
|
||||
/**
|
||||
* Options page for the contact list: whether friends and blocked clients are
|
||||
* coloured in the server tree, whose whispers get through (TeamSpeak's whisper
|
||||
* 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 JCheckBox colorContacts = new JCheckBox("Colour friends and blocked clients in the server tree");
|
||||
private final JComboBox<Settings.WhisperPolicy> whisperPolicy = new JComboBox<>(Settings.WhisperPolicy.values());
|
||||
|
||||
ContactsPanel(Settings settings) {
|
||||
super(new BorderLayout());
|
||||
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.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.setRenderer(new DefaultListCellRenderer() {
|
||||
@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 "
|
||||
+ "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>");
|
||||
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…");
|
||||
defaults.setToolTipText("Configure default settings when adding a client to the Contact Manager");
|
||||
defaults.addActionListener(e -> {
|
||||
java.awt.Window owner = SwingUtilities.getWindowAncestor(this);
|
||||
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. */
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.gfx.IconPack;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
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 final Settings settings;
|
||||
private final String originalPackId;
|
||||
private final String originalPackDir;
|
||||
private final Settings.Appearance originalAppearance;
|
||||
/** What {@link #revert()} goes back to: as the dialog opened, or as last applied. */
|
||||
private String originalPackId;
|
||||
private String originalPackDir;
|
||||
private Settings.Appearance originalAppearance;
|
||||
|
||||
private final JComboBox<Settings.Appearance> appearanceCombo =
|
||||
new JComboBox<>(Settings.Appearance.values());
|
||||
@@ -87,9 +87,12 @@ final class DesignPanel extends JPanel {
|
||||
Object selected = packCombo.getSelectedItem();
|
||||
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
||||
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() {
|
||||
settings.iconPack = originalPackId;
|
||||
settings.iconPackDir = originalPackDir;
|
||||
@@ -105,7 +108,7 @@ final class DesignPanel extends JPanel {
|
||||
private JComponent buildHeader() {
|
||||
JPanel p = new JPanel(new GridBagLayout());
|
||||
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.fill = GridBagConstraints.HORIZONTAL;
|
||||
|
||||
@@ -134,30 +137,30 @@ final class DesignPanel extends JPanel {
|
||||
});
|
||||
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));
|
||||
JButton browse = new JButton("Browse…");
|
||||
browse.addActionListener(e -> browseForPackFolder());
|
||||
dirRow.add(packDirField, BorderLayout.CENTER);
|
||||
dirRow.add(browse, BorderLayout.EAST);
|
||||
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);
|
||||
|
||||
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.gridy = row;
|
||||
p.add(countryFlags, c);
|
||||
c.gridy = row++;
|
||||
p.add(packInfo, c);
|
||||
FormPanel.addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -207,7 +210,6 @@ final class DesignPanel extends JPanel {
|
||||
|
||||
JScrollPane scroll = new JScrollPane(preview);
|
||||
scroll.setPreferredSize(UIScale.scale(new Dimension(360, 200)));
|
||||
scroll.setBorder(BorderFactory.createTitledBorder("Icons in this pack"));
|
||||
return scroll;
|
||||
}
|
||||
|
||||
@@ -265,14 +267,4 @@ final class DesignPanel extends JPanel {
|
||||
settings.iconPackDir = packDirField.getText();
|
||||
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.UIScale;
|
||||
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSeparator;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.Scrollable;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
@@ -28,8 +31,13 @@ class FormPanel extends JPanel implements Scrollable {
|
||||
static final int MIN_FIELD_WIDTH = 60;
|
||||
|
||||
FormPanel() {
|
||||
this(true);
|
||||
}
|
||||
|
||||
/** @param padded {@code false} for a form embedded in another one, which pads it already */
|
||||
FormPanel(boolean padded) {
|
||||
super(new GridBagLayout());
|
||||
setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
|
||||
if (padded) setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -65,7 +73,71 @@ class FormPanel extends JPanel implements Scrollable {
|
||||
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.gridy = row;
|
||||
c.weightx = 0;
|
||||
@@ -88,7 +160,7 @@ class FormPanel extends JPanel implements Scrollable {
|
||||
}
|
||||
|
||||
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));
|
||||
limitWidth(slider, SLIDER_WIDTH);
|
||||
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);
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,10 @@ public final class IconTheme {
|
||||
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 */
|
||||
public static ImageIcon icon(String key) {
|
||||
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. */
|
||||
private final NotificationSettings working;
|
||||
private final List<Row> rows = new ArrayList<>();
|
||||
private final String originalPackId;
|
||||
private final String originalPackDir;
|
||||
/** What {@link #revert()} goes back to: as the dialog opened, or as last applied. */
|
||||
private String originalPackId;
|
||||
private String originalPackDir;
|
||||
|
||||
private final JComboBox<SoundPack> packCombo = new JComboBox<>();
|
||||
private final JLabel packInfo = new JLabel();
|
||||
@@ -113,9 +114,11 @@ final class NotificationsPanel extends JPanel {
|
||||
settings.desktopNotifications = desktopNotifications.isSelected();
|
||||
sounds.setPack((SoundPack) packCombo.getSelectedItem());
|
||||
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() {
|
||||
settings.soundPackDir = originalPackDir;
|
||||
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;
|
||||
|
||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.audio.AudioBackend;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
@@ -8,35 +9,42 @@ import com.ts3client.config.Settings;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.ScrollPaneConstants;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.JTabbedPane;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FlowLayout;
|
||||
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
|
||||
* its own controls and reads/writes {@link Settings} on {@link #apply()}; this class wires
|
||||
* the tabs that preview themselves live against the running audio subsystem (the device
|
||||
* and voice-activation tabs, which share a single microphone test) and owns the
|
||||
* OK/Cancel/Apply plumbing.
|
||||
* Options dialog, laid out like TS3's: a sidebar of pages on the left, the chosen page
|
||||
* under its banner on the right, OK/Cancel/Apply below. Each page owns its controls and
|
||||
* writes {@link Settings} when applied; this class wires the audio pages, which preview
|
||||
* themselves live against the running audio subsystem, and owns the apply plumbing.
|
||||
*/
|
||||
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 VoiceInput liveMic;
|
||||
private final VoiceOutput livePlayback;
|
||||
private final Runnable onApply;
|
||||
|
||||
private final DevicesPanel devicesPanel;
|
||||
private final VoiceActivationPanel voiceActivationPanel;
|
||||
private final ApplicationPanel applicationPanel;
|
||||
private final PlaybackPanel playbackPanel;
|
||||
private final CapturePanel capturePanel;
|
||||
private final NotificationsPanel notificationsPanel;
|
||||
private final DesignPanel designPanel;
|
||||
private final HotkeysPanel hotkeysPanel;
|
||||
private final ClientVersionPanel clientVersionPanel;
|
||||
private final ContactsPanel contactsPanel;
|
||||
private final ChatLogsPanel chatLogsPanel;
|
||||
|
||||
@@ -46,87 +54,139 @@ public final class SettingsDialog extends JDialog {
|
||||
Runnable onApply) {
|
||||
super(owner, "Options", true);
|
||||
this.settings = settings;
|
||||
this.liveMic = liveMic;
|
||||
this.livePlayback = livePlayback;
|
||||
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);
|
||||
designPanel = new DesignPanel(settings);
|
||||
hotkeysPanel = new HotkeysPanel(hotkeys);
|
||||
clientVersionPanel = new ClientVersionPanel(settings);
|
||||
contactsPanel = new ContactsPanel(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();
|
||||
tabs.addTab("Playback / Capture", scrollable(devicesPanel));
|
||||
tabs.addTab("Voice Activation", scrollable(voiceActivationPanel));
|
||||
tabs.addTab("Notifications", notificationsPanel);
|
||||
tabs.addTab("Design", designPanel);
|
||||
tabs.addTab("Hotkeys", hotkeysPanel);
|
||||
tabs.addTab("Contacts", contactsPanel);
|
||||
tabs.addTab("Chat", scrollable(chatLogsPanel));
|
||||
tabs.addTab("Client Version", scrollable(clientVersionPanel));
|
||||
tabs.addTab("myTeamSpeak", myTeamSpeak);
|
||||
List<OptionsPage> pages = List.of(
|
||||
new OptionsPage("Application", "General Application Options", scrollable(applicationPanel),
|
||||
"SETTINGS"),
|
||||
new OptionsPage("myTeamSpeak", "Account and Synchronization", myTeamSpeak,
|
||||
"MYTS_ACCOUNT"),
|
||||
new OptionsPage("Playback", "Configure the Playback Sound System", scrollable(playbackPanel),
|
||||
"PLAYBACK"),
|
||||
new OptionsPage("Capture", "Configure the Capture Sound System", scrollable(capturePanel),
|
||||
"CAPTURE"),
|
||||
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());
|
||||
JPanel right = new JPanel();
|
||||
OptionsBanner banner = new OptionsBanner();
|
||||
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 cancel = new JButton("Cancel");
|
||||
JButton apply = new JButton("Apply");
|
||||
ok.addActionListener(e -> {
|
||||
apply();
|
||||
close();
|
||||
});
|
||||
cancel.addActionListener(e -> cancel());
|
||||
right.add(ok);
|
||||
right.add(cancel);
|
||||
buttons.add(right, BorderLayout.EAST);
|
||||
apply.addActionListener(e -> apply());
|
||||
|
||||
getContentPane().setLayout(new BorderLayout());
|
||||
getContentPane().add(tabs, BorderLayout.CENTER);
|
||||
getContentPane().add(buttons, BorderLayout.SOUTH);
|
||||
|
||||
// The microphone test hears typing too, so typing attenuation can be tried out here.
|
||||
Runnable testKeyPress = () -> SwingUtilities.invokeLater(
|
||||
() -> voiceActivationPanel.configureTest(VoiceInput::keyPressed));
|
||||
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);
|
||||
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, UIScale.scale(6), 0));
|
||||
buttons.setBorder(new ScaledEmptyBorder(10, 10, 10, 10));
|
||||
buttons.add(ok);
|
||||
buttons.add(cancel);
|
||||
buttons.add(apply);
|
||||
getRootPane().setDefaultButton(ok);
|
||||
return buttons;
|
||||
}
|
||||
|
||||
private static javax.swing.JScrollPane scrollable(JPanel content) {
|
||||
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
|
||||
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||
javax.swing.ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
private static JScrollPane scrollable(JPanel content) {
|
||||
JScrollPane sp = new JScrollPane(content,
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
sp.setBorder(null);
|
||||
sp.getVerticalScrollBar().setUnitIncrement(16);
|
||||
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) {
|
||||
devicesPanel.writeInto(target);
|
||||
voiceActivationPanel.writeInto(target);
|
||||
playbackPanel.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.
|
||||
*
|
||||
* <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() {
|
||||
writeAudioSettings(settings);
|
||||
applicationPanel.apply();
|
||||
notificationsPanel.apply();
|
||||
designPanel.apply();
|
||||
clientVersionPanel.apply();
|
||||
contactsPanel.apply();
|
||||
chatLogsPanel.apply();
|
||||
hotkeysPanel.apply();
|
||||
settings.save();
|
||||
|
||||
applyLive(m -> m.apply(settings));
|
||||
capturePanel.applyLive(m -> m.apply(settings));
|
||||
if (livePlayback != null) {
|
||||
livePlayback.setMasterVolume(settings.outputVolume);
|
||||
livePlayback.setOutputDevice(settings.outputDevice);
|
||||
@@ -167,27 +227,7 @@ public final class SettingsDialog extends JDialog {
|
||||
}
|
||||
|
||||
private void close() {
|
||||
voiceActivationPanel.stopTest();
|
||||
capturePanel.stopTest();
|
||||
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;
|
||||
|
||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.audio.AudioBackend;
|
||||
import com.ts3client.audio.InputLevel;
|
||||
import com.ts3client.audio.OpusParameters;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.hotkey.Hotkey;
|
||||
import com.ts3client.hotkey.HotkeyAction;
|
||||
|
||||
import javax.swing.Box;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.JToggleButton;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.Insets;
|
||||
import java.util.List;
|
||||
@@ -26,19 +27,15 @@ import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Options dialog's "Voice Activation" tab: input mode (VAD / push-to-talk / continuous),
|
||||
* the detection tuning with a live input meter, and the Opus encoder controls.
|
||||
* The Capture page's activation section: input mode (push-to-talk / continuous / voice
|
||||
* activation), the detection tuning, and a live input meter.
|
||||
*
|
||||
* <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"
|
||||
* tab currently has on screen, via {@code audioSnapshot}.
|
||||
* capture chain built from what the Capture and Playback pages have on screen, via
|
||||
* {@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 HotkeyService hotkeys;
|
||||
private final HotkeysPanel hotkeysPanel;
|
||||
@@ -57,30 +54,26 @@ final class VoiceActivationPanel extends FormPanel {
|
||||
private JCheckBox loopbackCheck;
|
||||
private JLabel talkIndicator;
|
||||
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) {
|
||||
this.settings = settings;
|
||||
this.liveMic = liveMic;
|
||||
this.micTest = new MicrophoneTest(audio, this::onTestLevel, this::onTestTalking);
|
||||
this.hotkeys = hotkeys;
|
||||
this.hotkeysPanel = hotkeysPanel;
|
||||
this.audioSnapshot = audioSnapshot;
|
||||
|
||||
GridBagConstraints c = gbc();
|
||||
|
||||
vadRadio = new JRadioButton("Voice Activation Detection");
|
||||
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();
|
||||
group.add(vadRadio);
|
||||
group.add(pttRadio);
|
||||
group.add(contRadio);
|
||||
group.add(vadRadio);
|
||||
switch (settings.inputMode) {
|
||||
case PUSH_TO_TALK:
|
||||
pttRadio.setSelected(true);
|
||||
@@ -92,31 +85,13 @@ final class VoiceActivationPanel extends FormPanel {
|
||||
vadRadio.setSelected(true);
|
||||
}
|
||||
|
||||
int row = 0;
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
add(vadRadio, c);
|
||||
c.gridy = row++;
|
||||
add(pttRadio, c);
|
||||
c.gridy = row++;
|
||||
add(contRadio, c);
|
||||
c.gridwidth = 1;
|
||||
pttKeyButton = new JButton(pushToTalkHotkeyText());
|
||||
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
|
||||
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
|
||||
|
||||
meter = new LevelMeter();
|
||||
meter.setThreshold(settings.vadThresholdDb);
|
||||
c.gridx = 0;
|
||||
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++;
|
||||
vadOverPttCheck = new JCheckBox("Add Voice Activity Detection", settings.vadOverPtt);
|
||||
vadOverPttCheck.addActionListener(e ->
|
||||
micTest.configure(m -> m.setVadOverPtt(vadOverPttCheck.isSelected())));
|
||||
|
||||
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
|
||||
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
|
||||
@@ -124,74 +99,26 @@ final class VoiceActivationPanel extends FormPanel {
|
||||
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
|
||||
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
|
||||
+ "<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,
|
||||
(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 -> {
|
||||
meter.setThreshold(thresholdSlider.getValue());
|
||||
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
|
||||
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 -> {
|
||||
speechLabel.setText(speechSlider.getValue() + "%");
|
||||
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 = () -> {
|
||||
boolean vad = vadRadio.isSelected();
|
||||
@@ -222,11 +149,33 @@ final class VoiceActivationPanel extends FormPanel {
|
||||
vadModeCombo.addActionListener(e -> syncEnabled.run());
|
||||
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
|
||||
syncEnabled.run();
|
||||
}
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weighty = 1;
|
||||
add(Box.createGlue(), c);
|
||||
/** 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++, pttRadio, pttKeyButton);
|
||||
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. */
|
||||
@@ -238,24 +187,19 @@ final class VoiceActivationPanel extends FormPanel {
|
||||
target.vadThresholdDb = thresholdSlider.getValue();
|
||||
target.speechThreshold = speechSlider.getValue() / 100.0;
|
||||
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) {
|
||||
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) {
|
||||
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() {
|
||||
if (micTest.isRunning()) {
|
||||
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() {
|
||||
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
|
||||
@@ -320,7 +249,7 @@ final class VoiceActivationPanel extends FormPanel {
|
||||
*/
|
||||
private void editPushToTalkHotkey() {
|
||||
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);
|
||||
dlg.setVisible(true);
|
||||
if (!dlg.isConfirmed()) return;
|
||||
Reference in New Issue
Block a user