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() + ""); 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(); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVersionPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVersionPanel.java index fa002af..e37441d 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVersionPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVersionPanel.java @@ -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"). * *

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("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." - + ""), c); + JLabel note = new JLabel("Servers refuse made-up or outdated versions (\"client is modified\")." + + "
Takes effect on the next connect."); + note.setEnabled(false); + p.add(note, c); return p; } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsPanel.java index 76d5d04..f0d2175 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ContactsPanel.java @@ -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 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("Use individual settings in contacts, else allow: a client not on your " + "contact list may whisper to you.
Use individual settings in contacts, else deny: only " + "contacts marked \"Allow whisper\" may.
Nobody: individual contact settings are ignored."); - 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. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java index 28094b7..0c44a8f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DesignPanel.java @@ -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 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); - } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java deleted file mode 100644 index 303db6c..0000000 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DevicesPanel.java +++ /dev/null @@ -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 inputCombo; - private final JComboBox 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> applyLive, - Runnable onInputDeviceChanged, Consumer onOutputDeviceChanged) { - GridBagConstraints c = gbc(); - - List ins = io.inputDevices(); - List 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 = "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."; - 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("Typing attenuation tries to detect and " - + "reduce the sounds made by typing."); - agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc); - agcCheck.setToolTipText("Automatic gain control normalises your " - + "microphone loudness to a target level, boosting quiet mics and taming " - + "loud ones."); - 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)."); - - 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 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 combo) { - AudioDevice d = (AudioDevice) combo.getSelectedItem(); - return d == null ? "" : d.id(); - } -} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/EncoderSection.java b/ts3-client/swing/src/main/java/com/ts3client/ui/EncoderSection.java new file mode 100644 index 0000000..68c88cc --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/EncoderSection.java @@ -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("Transmits OPUS_MUSIC: stereo when the capture " + + "device has two channels, and without the voice pre-processing " + + "(noise removal, typing attenuation, AGC).
" + + "Voice mode (OPUS_VOICE) is mono, as in the official client."); + 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())); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java index 26a5d91..380d13d 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java @@ -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; } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java index f0029f4..8150bae 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java @@ -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)); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java index 2abfc4d..400fb90 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java @@ -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 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 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; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/OptionsBanner.java b/ts3-client/swing/src/main/java/com/ts3client/ui/OptionsBanner.java new file mode 100644 index 0000000..8a52a43 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/OptionsBanner.java @@ -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); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/OptionsPage.java b/ts3-client/swing/src/main/java/com/ts3client/ui/OptionsPage.java new file mode 100644 index 0000000..bf4af81 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/OptionsPage.java @@ -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 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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/OptionsSidebar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/OptionsSidebar.java new file mode 100644 index 0000000..2dea9ef --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/OptionsSidebar.java @@ -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 { + + static final int ICON_SIZE = 32; + + OptionsSidebar(List pages) { + super(model(pages)); + setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + setCellRenderer(new Renderer()); + setBorder(new ScaledEmptyBorder(4, 4, 4, 4)); + } + + private static DefaultListModel model(List pages) { + DefaultListModel 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 { + + private boolean selected; + + Renderer() { + setOpaque(false); + setBorder(new ScaledEmptyBorder(6, 8, 6, 16)); + } + + @Override + public Component getListCellRendererComponent(JList 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; + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/PlaybackPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/PlaybackPanel.java new file mode 100644 index 0000000..a27914b --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/PlaybackPanel.java @@ -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 outputCombo; + private final JSlider outputVol; + + PlaybackPanel(Settings settings, AudioIo io, VoiceOutput livePlayback, SoundNotifier sounds, + Consumer 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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java index 38f538f..9569cd6 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java @@ -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 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. * *

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. - * - *

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 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); - } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationSection.java similarity index 67% rename from ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationPanel.java rename to ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationSection.java index d37d641..bb982b3 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/VoiceActivationSection.java @@ -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. * *

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 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("Automatic: intelligent speech detection.
" + "Volume Gate: transmit when loud enough.
" + "Hybrid: loud enough and detected as speech."); - 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("Transmits OPUS_MUSIC: stereo when the capture " - + "device has two channels, and without the voice pre-processing " - + "(noise removal, typing attenuation, AGC).
" - + "Voice mode (OPUS_VOICE) is mono, as in the official client."); - 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 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;