diff --git a/ts3-client/README.md b/ts3-client/README.md
index 00bc6a4..6172397 100644
--- a/ts3-client/README.md
+++ b/ts3-client/README.md
@@ -292,7 +292,7 @@ swing/ com.ts3client
├── Spacers TS3 spacer-channel name parsing/rendering
├── InfoPanel channel description / client group + details view
├── 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)
├── HotkeyDialog add/edit one hotkey: action, combination, trigger, scope
├── HotkeyService bindings + engine + input hook, for the dialogs
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ApplicationPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ApplicationPanel.java
new file mode 100644
index 0000000..a5ef683
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ApplicationPanel.java
@@ -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();
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/AudioDevices.java b/ts3-client/swing/src/main/java/com/ts3client/ui/AudioDevices.java
new file mode 100644
index 0000000..fd23112
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/AudioDevices.java
@@ -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 = "Named devices are PipeWire's, and are routed through it "
+ + "(so per-application volume and rerouting keep working).
"
+ + "ALSA: entries talk to the sound card directly, taking it exclusively.";
+
+ private AudioDevices() {
+ }
+
+ /** A picker over {@code devices} with {@code selectedId} chosen, or the first (default) one. */
+ static JComboBox combo(List devices, String selectedId) {
+ JComboBox 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 combo) {
+ AudioDevice d = (AudioDevice) combo.getSelectedItem();
+ return d == null ? "" : d.id();
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/CapturePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/CapturePanel.java
new file mode 100644
index 0000000..11fcf6d
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/CapturePanel.java
@@ -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 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 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("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).");
+
+ 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 change) {
+ if (liveMic != null) change.accept(liveMic);
+ activation.configureTest(change);
+ }
+
+ void setTestOutputDevice(String deviceId) {
+ activation.setTestOutputDevice(deviceId);
+ }
+
+ void stopTest() {
+ activation.stopTest();
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatLogsPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatLogsPanel.java
index 91e032b..a4bd5ce 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatLogsPanel.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatLogsPanel.java
@@ -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() + "