Show server and channel group icons in the tree

Download the icons a server advertises for its groups over the file
transfer channel, cache them under the config directory, and draw them as
a badge strip on each client's row, right-aligned against the tree's
visible edge like the TeamSpeak client does. Groups without a custom icon
fall back to the bundled default set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 07:06:23 +00:00
parent 0333b92ce5
commit 7c86a1f166
18 changed files with 597 additions and 38 deletions

View File

@@ -13,12 +13,14 @@ import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import javax.swing.JTabbedPane;
import javax.swing.Scrollable;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Dimension;
@@ -26,6 +28,7 @@ import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.List;
@@ -37,6 +40,14 @@ import java.util.List;
*/
public final class SettingsDialog extends JDialog {
/** Preferred widths of the form's field column; rows shrink with the dialog from there. */
private static final int FIELD_WIDTH = 240;
private static final int SLIDER_WIDTH = 200;
private static final int MIN_FIELD_WIDTH = 60;
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 VoiceOutput livePlayback;
@@ -114,13 +125,13 @@ public final class SettingsDialog extends JDialog {
pack();
setSize(new Dimension(480, 540));
setMinimumSize(new Dimension(420, 360));
setLocationRelativeTo(owner);
startMeter();
}
private JPanel buildDevicesTab() {
JPanel p = new JPanel(new GridBagLayout());
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
JPanel p = formPanel();
GridBagConstraints c = gbc();
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
@@ -136,6 +147,8 @@ public final class SettingsDialog extends JDialog {
+ "<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(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
@@ -143,6 +156,8 @@ public final class SettingsDialog extends JDialog {
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(p, c, row++, new JLabel("Microphone gain:"), inputGain);
addRow(p, c, row++, new JLabel("Playback volume:"), outputVol);
@@ -165,6 +180,7 @@ public final class SettingsDialog extends JDialog {
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
limitWidth(denoiseLevel, SLIDER_WIDTH);
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
@@ -214,8 +230,7 @@ public final class SettingsDialog extends JDialog {
}
private JPanel buildVoiceTab() {
JPanel p = new JPanel(new GridBagLayout());
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
JPanel p = formPanel();
GridBagConstraints c = gbc();
vadRadio = new JRadioButton("Voice Activation Detection");
@@ -295,18 +310,18 @@ public final class SettingsDialog extends JDialog {
p.add(vadOverPttCheck, c);
c.gridwidth = 1;
bitrateSlider = new JSlider(8, 128, settings.bitrate / 1000);
bitrateLabel = new JLabel(settings.bitrate / 1000 + " kbit/s");
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");
pushOpusLive();
});
JPanel brPanel = new JPanel(new BorderLayout(6, 0));
brPanel.add(bitrateSlider, BorderLayout.CENTER);
brPanel.add(bitrateLabel, BorderLayout.EAST);
addRow(p, c, row++, new JLabel("Opus bitrate:"), brPanel);
addRow(p, 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(p, c, row++, new JLabel("Opus complexity:"), complexitySlider);
@@ -389,6 +404,47 @@ public final class SettingsDialog extends JDialog {
}
}
/**
* The form panel used by both tabs. It follows the scroll pane's width instead of
* demanding its own preferred one, so rows stay inside the dialog.
*/
private static JPanel formPanel() {
JPanel p = new FormPanel();
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
return p;
}
private static final class FormPanel extends JPanel implements Scrollable {
FormPanel() {
super(new GridBagLayout());
}
@Override
public Dimension getPreferredScrollableViewportSize() {
return getPreferredSize();
}
@Override
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
return 16;
}
@Override
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
return visible.height;
}
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
@Override
public boolean getScrollableTracksViewportHeight() {
return false;
}
}
private static javax.swing.JScrollPane scrollable(JPanel content) {
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
@@ -399,13 +455,33 @@ public final class SettingsDialog extends JDialog {
}
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
return sliderWithLabel(slider, valueLabel, 48);
}
/**
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
* the longest value so the slider does not jump around as it is dragged.
*/
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
JPanel panel = new JPanel(new BorderLayout(6, 0));
limitWidth(slider, SLIDER_WIDTH);
panel.add(slider, BorderLayout.CENTER);
valueLabel.setPreferredSize(new Dimension(48, valueLabel.getPreferredSize().height));
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
panel.add(valueLabel, BorderLayout.EAST);
return panel;
}
/**
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
* would otherwise force the form past the dialog's edge, where the scroll pane (which
* never scrolls horizontally) simply clips them.
*/
private static void limitWidth(JComponent comp, int preferredWidth) {
int height = comp.getPreferredSize().height;
comp.setPreferredSize(new Dimension(preferredWidth, height));
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
}
private OpusParameters currentOpusParameters() {
return new OpusParameters(
bitrateSlider.getValue() * 1000,