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:
101
ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java
Normal file
101
ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java
Normal file
@@ -0,0 +1,101 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.IconRepository;
|
||||
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.ImageIcon;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Image;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Turns the {@link IconRepository}'s icon bytes into Swing icons for one server's
|
||||
* groups, caching the decoded images. Icons that still have to be downloaded simply
|
||||
* do not render yet; the repository repaints the view once they arrive.
|
||||
*/
|
||||
public final class GroupIcons {
|
||||
|
||||
/** Tree rows are 16×16 like TeamSpeak's own icon set. */
|
||||
private static final int SIZE = 16;
|
||||
|
||||
private final IconRepository repository;
|
||||
private final Map<Long, ImageIcon> decoded = new ConcurrentHashMap<>();
|
||||
|
||||
public GroupIcons(IconRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/** @return the group's icon, or {@code null} if it has none or it is not loaded yet */
|
||||
public ImageIcon iconOf(Group group) {
|
||||
if (group == null || group.iconId == 0) return null;
|
||||
return icon(group.iconId);
|
||||
}
|
||||
|
||||
public ImageIcon icon(long iconId) {
|
||||
ImageIcon cached = decoded.get(iconId);
|
||||
if (cached != null) return cached;
|
||||
|
||||
byte[] data = repository.get(iconId);
|
||||
if (data == null) return null;
|
||||
|
||||
ImageIcon icon = new ImageIcon(data);
|
||||
if (icon.getIconWidth() <= 0) return null;
|
||||
if (icon.getIconWidth() != SIZE || icon.getIconHeight() != SIZE) {
|
||||
icon = new ImageIcon(icon.getImage().getScaledInstance(SIZE, SIZE, Image.SCALE_SMOOTH));
|
||||
}
|
||||
decoded.put(iconId, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
/** Collects the icons of a client's server groups plus its channel group, in display order. */
|
||||
public List<Icon> iconsOf(List<Group> serverGroups, Group channelGroup) {
|
||||
List<Icon> icons = new ArrayList<>();
|
||||
for (Group g : serverGroups) {
|
||||
ImageIcon icon = iconOf(g);
|
||||
if (icon != null) icons.add(icon);
|
||||
}
|
||||
ImageIcon channelIcon = iconOf(channelGroup);
|
||||
if (channelIcon != null) icons.add(channelIcon);
|
||||
return icons;
|
||||
}
|
||||
|
||||
/** Lays several icons out in a row, so a single tree cell can show a badge strip. */
|
||||
public static final class Row implements Icon {
|
||||
|
||||
private static final int GAP = 2;
|
||||
|
||||
private final List<Icon> icons;
|
||||
|
||||
public Row(List<Icon> icons) {
|
||||
this.icons = icons;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void paintIcon(Component c, Graphics g, int x, int y) {
|
||||
int offset = x;
|
||||
for (Icon icon : icons) {
|
||||
icon.paintIcon(c, g, offset, y + (getIconHeight() - icon.getIconHeight()) / 2);
|
||||
offset += icon.getIconWidth() + GAP;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIconWidth() {
|
||||
int width = 0;
|
||||
for (Icon icon : icons) width += icon.getIconWidth() + GAP;
|
||||
return Math.max(0, width - GAP);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIconHeight() {
|
||||
int height = 0;
|
||||
for (Icon icon : icons) height = Math.max(height, icon.getIconHeight());
|
||||
return height;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.IconRepository;
|
||||
import com.ts3client.net.ServerModel;
|
||||
import com.ts3client.text.BBCode;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JEditorPane;
|
||||
import javax.swing.JScrollPane;
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -67,15 +70,27 @@ public final class InfoPanel extends JScrollPane {
|
||||
setHtml(sb.toString());
|
||||
}
|
||||
|
||||
public void showClient(ClientEntry cl, ServerModel model) {
|
||||
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
|
||||
|
||||
List<String> serverGroups = model.serverGroupNames(cl.serverGroupIds);
|
||||
row(sb, "Server groups", serverGroups.isEmpty() ? "—" : esc(String.join(", ", serverGroups)));
|
||||
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
|
||||
if (serverGroups.isEmpty()) {
|
||||
List<String> names = model.serverGroupNames(cl.serverGroupIds);
|
||||
row(sb, "Server groups", names.isEmpty() ? "—" : esc(String.join(", ", names)));
|
||||
} else {
|
||||
StringBuilder groups = new StringBuilder();
|
||||
for (Group g : serverGroups) {
|
||||
if (groups.length() > 0) groups.append(", ");
|
||||
groups.append(iconTag(g, icons)).append(esc(g.name));
|
||||
}
|
||||
row(sb, "Server groups", groups.toString());
|
||||
}
|
||||
|
||||
String channelGroup = model.channelGroupName(cl.channelGroupId);
|
||||
row(sb, "Channel group", channelGroup != null ? esc(channelGroup) : "#" + cl.channelGroupId);
|
||||
Group channelGroup = model.channelGroup(cl.channelGroupId);
|
||||
row(sb, "Channel group", channelGroup != null
|
||||
? iconTag(channelGroup, icons) + esc(channelGroup.name)
|
||||
: "#" + cl.channelGroupId);
|
||||
|
||||
if (cl.talkPower != 0) row(sb, "Talk power", Integer.toString(cl.talkPower));
|
||||
if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
|
||||
@@ -97,6 +112,18 @@ public final class InfoPanel extends JScrollPane {
|
||||
pane.setCaretPosition(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* An inline {@code <img>} for a group's icon, or nothing while the icon is missing
|
||||
* or still downloading. Swing's HTML renderer only loads images by URL, so the icon
|
||||
* is served from the repository's on-disk cache.
|
||||
*/
|
||||
private static String iconTag(Group group, IconRepository icons) {
|
||||
if (group.iconId == 0) return "";
|
||||
File file = icons.file(group.iconId);
|
||||
if (file == null) return "";
|
||||
return "<img src='" + file.toURI() + "' width='14' height='14'> ";
|
||||
}
|
||||
|
||||
private static String heading(String text) {
|
||||
return "<div style='font-weight:bold;font-size:13px;margin-bottom:4px'>" + text + "</div>";
|
||||
}
|
||||
|
||||
@@ -314,6 +314,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
if (tabs.size() == 1) return; // always keep one view around
|
||||
|
||||
tabs.remove(tab);
|
||||
tab.dispose();
|
||||
tabPane.removeTab(tab);
|
||||
if (micTab == tab) micTab = null;
|
||||
if (selected == tab) {
|
||||
|
||||
@@ -31,6 +31,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
private final IdentityStore identities;
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
private final GroupIcons groupIcons;
|
||||
private final ServerTreePanel treePanel;
|
||||
private final ChatPanel chatPanel;
|
||||
private final InfoPanel infoPanel = new InfoPanel();
|
||||
@@ -56,7 +57,8 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
this.settings = settings;
|
||||
this.identities = identities;
|
||||
this.conn = new TeamspeakConnection(settings, audio, this);
|
||||
this.treePanel = new ServerTreePanel(conn.getModel(), this);
|
||||
this.groupIcons = new GroupIcons(conn.getIcons());
|
||||
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, this);
|
||||
this.chatPanel = new ChatPanel();
|
||||
|
||||
chatPanel.setSendHandler(this::onSendChat);
|
||||
@@ -189,6 +191,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
if (conn.isConnected()) conn.disconnect();
|
||||
}
|
||||
|
||||
/** Releases the background resources of a tab that is being thrown away. */
|
||||
void dispose() {
|
||||
conn.getIcons().shutdown();
|
||||
}
|
||||
|
||||
/** Synchronous teardown for shutdown paths, so the server sees us leave. */
|
||||
void shutdown() {
|
||||
if (conn.isConnected()) conn.disconnectBlocking("Leaving");
|
||||
@@ -361,7 +368,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
if (sel instanceof ChannelNode) {
|
||||
infoPanel.showChannel((ChannelNode) sel);
|
||||
} else if (sel instanceof ClientEntry) {
|
||||
infoPanel.showClient((ClientEntry) sel, conn.getModel());
|
||||
infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons());
|
||||
} else {
|
||||
infoPanel.clear();
|
||||
}
|
||||
@@ -425,6 +432,14 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
SwingUtilities.invokeLater(this::renderInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIconsUpdated() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
treePanel.refreshRowSizes();
|
||||
renderInfo();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
|
||||
switch (scope) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.ts3client.net.ServerModel;
|
||||
import com.ts3client.text.TsLink;
|
||||
|
||||
import javax.swing.DropMode;
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JScrollPane;
|
||||
@@ -77,11 +78,13 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
|
||||
private final ServerModel model;
|
||||
private final GroupIcons groupIcons;
|
||||
private final Actions actions;
|
||||
private int selfClientId = -1;
|
||||
|
||||
public ServerTreePanel(ServerModel model, Actions actions) {
|
||||
public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) {
|
||||
this.model = model;
|
||||
this.groupIcons = groupIcons;
|
||||
this.actions = actions;
|
||||
root.setUserObject("Not connected");
|
||||
this.tree = new DropIndicatorTree(treeModel);
|
||||
@@ -364,11 +367,47 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- group icon strip ----
|
||||
|
||||
/** Gap kept between a row's label and the right-aligned icon strip. */
|
||||
private static final int BADGE_GAP = 8;
|
||||
/** Inset of the strip from the visible right edge. */
|
||||
private static final int BADGE_MARGIN = 4;
|
||||
|
||||
/**
|
||||
* Paints every visible client's group icons flush with the right edge of the
|
||||
* viewport, the way TeamSpeak lines them up. Drawing them here rather than in the
|
||||
* cell renderer keeps the rows' measured widths (and thus the selection highlight)
|
||||
* tied to the label alone.
|
||||
*/
|
||||
private void paintBadges(Graphics g, JTree tree) {
|
||||
Rectangle visible = tree.getVisibleRect();
|
||||
int right = visible.x + visible.width - BADGE_MARGIN;
|
||||
for (int row = 0; row < tree.getRowCount(); row++) {
|
||||
Rectangle bounds = tree.getRowBounds(row);
|
||||
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
|
||||
if (bounds.y > visible.y + visible.height) break;
|
||||
|
||||
TreePath path = tree.getPathForRow(row);
|
||||
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
if (!(obj instanceof ClientEntry)) continue;
|
||||
ClientEntry cl = (ClientEntry) obj;
|
||||
|
||||
List<Icon> icons = groupIcons.iconsOf(
|
||||
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
|
||||
if (icons.isEmpty()) continue;
|
||||
|
||||
GroupIcons.Row strip = new GroupIcons.Row(icons);
|
||||
int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth());
|
||||
strip.paintIcon(tree, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws where the drop will land: an insertion line between rows, or an outline
|
||||
* around the row that will receive the dragged node.
|
||||
*/
|
||||
private static final class DropIndicatorTree extends JTree {
|
||||
private final class DropIndicatorTree extends JTree {
|
||||
/** Set while a drop would move a client into this channel row. */
|
||||
private TreePath highlight;
|
||||
|
||||
@@ -385,6 +424,7 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
@Override
|
||||
protected void paintComponent(Graphics g) {
|
||||
super.paintComponent(g);
|
||||
paintBadges(g, this);
|
||||
JTree.DropLocation loc = getDropLocation();
|
||||
if (loc == null || loc.getPath() == null) return;
|
||||
|
||||
@@ -477,7 +517,26 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
tree.repaint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-measures every visible row, for changes that alter a row's width (a group
|
||||
* icon that finished downloading). A plain repaint would keep the cached widths
|
||||
* and clip the new icons.
|
||||
*/
|
||||
public void refreshRowSizes() {
|
||||
for (int i = tree.getRowCount() - 1; i >= 0; i--) {
|
||||
TreePath path = tree.getPathForRow(i);
|
||||
if (path != null) {
|
||||
treeModel.nodeChanged((DefaultMutableTreeNode) path.getLastPathComponent());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a tree row: the status icon and the label. The group icon strip is painted
|
||||
* separately, right-aligned, by {@link #paintBadges}.
|
||||
*/
|
||||
private final class Renderer extends DefaultTreeCellRenderer {
|
||||
|
||||
@Override
|
||||
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
|
||||
boolean expanded, boolean leaf, int row,
|
||||
@@ -504,9 +563,15 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
}
|
||||
} else if (obj instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) obj;
|
||||
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
|
||||
String label = cl.nickname;
|
||||
if (primaryGroup != null) label += " [" + primaryGroup + "]";
|
||||
boolean hasIcons = !groupIcons.iconsOf(
|
||||
model.serverGroupsOf(cl.serverGroupIds),
|
||||
model.channelGroup(cl.channelGroupId)).isEmpty();
|
||||
if (!hasIcons) {
|
||||
// No icons (yet): fall back to naming the primary group inline.
|
||||
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
|
||||
if (primaryGroup != null) label += " [" + primaryGroup + "]";
|
||||
}
|
||||
setText(label);
|
||||
setIcon(iconFor(cl));
|
||||
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user