Add the Edit Channel dialog

Recovers the official client's CreateChannelDialog layout so the widget
set, labels and tab order match: name/icon/password/topic/description
above Standard, Audio, Permissions and Advanced tabs.

Core gains ChannelSettings (diffed against the original so channeledit
only carries changed properties) and ChannelAdmin (channelinfo/edit,
channel permissions, the server icon store). TeamspeakConnection now
owns the event executor so it can drain queued notify events before
reading a command's result, which channelpermlist needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 15:11:49 +00:00
parent b29f518d59
commit d8a56566d4
19 changed files with 2138 additions and 3 deletions

View File

@@ -0,0 +1,187 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelSettings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
/**
* The channel editor's "Advanced" tab: the phonetic name, the delete delay of a temporary
* channel, voice encryption, and the two client limits.
*/
final class ChannelAdvancedPanel extends JPanel implements ChannelEditDialog.Tab {
/** TeamSpeak's ceiling for {@code channel_delete_delay}, in seconds. */
private static final int MAX_DELETE_DELAY = 604800;
private final JTextField phoneticName = new JTextField();
private final JSpinner deleteDelay =
Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, MAX_DELETE_DELAY, 1)));
private final JButton deleteDelayMax = new JButton("max");
private final JCheckBox encrypted = new JCheckBox("Voice Data encrypted");
private final JRadioButton maxUsersUnlimited = new JRadioButton("Unlimited");
private final JRadioButton maxUsersLimited = new JRadioButton("Limited");
private final JSpinner maxUsers = Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
private final JRadioButton familyInherited = new JRadioButton("Inherited");
private final JRadioButton familyUnlimited = new JRadioButton("Unlimited");
private final JRadioButton familyLimited = new JRadioButton("Limited");
private final JSpinner familyMaxUsers =
Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
ChannelAdvancedPanel() {
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
phoneticName.setToolTipText("Set phonetic nickname used for this channel by text-to-speech.");
deleteDelay.setToolTipText("<html><p>Delay in seconds after temporary channels are deleted"
+ " after the last user left the channel.</p></html>");
deleteDelayMax.setToolTipText("Set delete delay to maximum value");
deleteDelayMax.addActionListener(a -> deleteDelay.setValue(MAX_DELETE_DELAY));
encrypted.setToolTipText("Voice data in this channel will be encrypted.");
ButtonGroup users = new ButtonGroup();
users.add(maxUsersUnlimited);
users.add(maxUsersLimited);
maxUsersUnlimited.setToolTipText("Unlimited number of clients can join this channel.");
maxUsersLimited.setToolTipText("Limit number of clients in this channel.");
maxUsersUnlimited.addActionListener(a -> syncLimits());
maxUsersLimited.addActionListener(a -> syncLimits());
ButtonGroup family = new ButtonGroup();
family.add(familyInherited);
family.add(familyUnlimited);
family.add(familyLimited);
familyInherited.setToolTipText("Inherit the client limit by parent channel.");
familyUnlimited.setToolTipText("The channel subtree can be joined by unlimited number of clients.");
familyLimited.setToolTipText("Limit the number of clients in the channel subtree.");
for (JRadioButton radio : new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}) {
radio.addActionListener(a -> syncLimits());
}
JPanel other = otherSettings();
other.setAlignmentX(0f);
add(other);
add(Box.createVerticalStrut(6));
JPanel limits = new JPanel();
limits.setLayout(new BoxLayout(limits, BoxLayout.X_AXIS));
limits.add(limitBox("Max Users", new JRadioButton[]{maxUsersUnlimited, maxUsersLimited}, maxUsers));
limits.add(Box.createHorizontalStrut(8));
limits.add(limitBox("Family Max Users",
new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}, familyMaxUsers));
limits.setAlignmentX(0f);
add(limits);
add(Box.createVerticalGlue());
}
/** The delete delay only means anything for a channel that disappears on its own. */
void setChannelType(ChannelSettings.Type type) {
boolean temporary = type == ChannelSettings.Type.TEMPORARY;
deleteDelay.setEnabled(temporary);
deleteDelayMax.setEnabled(temporary);
}
@Override
public void read(ChannelSettings settings) {
phoneticName.setText(settings.phoneticName);
deleteDelay.setValue(Math.min(MAX_DELETE_DELAY, Math.max(0, settings.deleteDelay)));
encrypted.setSelected(settings.encrypted);
if (settings.maxClientsUnlimited) maxUsersUnlimited.setSelected(true);
else maxUsersLimited.setSelected(true);
maxUsers.setValue(settings.maxClients);
if (settings.familyInherited) familyInherited.setSelected(true);
else if (settings.familyUnlimited) familyUnlimited.setSelected(true);
else familyLimited.setSelected(true);
familyMaxUsers.setValue(settings.maxFamilyClients);
setChannelType(settings.type);
syncLimits();
}
@Override
public void write(ChannelSettings settings) {
settings.phoneticName = phoneticName.getText();
settings.deleteDelay = (Integer) deleteDelay.getValue();
settings.encrypted = encrypted.isSelected();
settings.maxClientsUnlimited = maxUsersUnlimited.isSelected();
settings.maxClients = (Integer) maxUsers.getValue();
settings.familyInherited = familyInherited.isSelected();
settings.familyUnlimited = familyUnlimited.isSelected();
settings.maxFamilyClients = (Integer) familyMaxUsers.getValue();
}
private void syncLimits() {
maxUsers.setEnabled(maxUsersLimited.isSelected());
familyMaxUsers.setEnabled(familyLimited.isSelected());
}
private JPanel otherSettings() {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(BorderFactory.createTitledBorder("Other Settings"));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 4, 3, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
c.gridx = 0;
c.gridy = 0;
panel.add(new JLabel("Phonetic Name:"), c);
c.gridx = 1;
c.weightx = 1;
panel.add(phoneticName, c);
c.gridx = 0;
c.gridy = 1;
c.weightx = 0;
panel.add(new JLabel("Delete delay:"), c);
JPanel delay = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
delay.add(deleteDelay);
delay.add(deleteDelayMax);
c.gridx = 1;
c.weightx = 1;
panel.add(delay, c);
c.gridx = 1;
c.gridy = 2;
panel.add(encrypted, c);
c.gridx = 0;
c.gridy = 3;
c.weighty = 1;
panel.add(Box.createGlue(), c);
return panel;
}
private JPanel limitBox(String title, JRadioButton[] choices, JSpinner spinner) {
JPanel box = new JPanel();
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
box.setBorder(BorderFactory.createTitledBorder(title));
for (JRadioButton radio : choices) {
radio.setAlignmentX(0f);
box.add(radio);
}
spinner.setAlignmentX(0f);
box.add(Box.createVerticalStrut(4));
box.add(spinner);
box.add(Box.createVerticalGlue());
return box;
}
}

View File

@@ -0,0 +1,166 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelSettings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import java.awt.BorderLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
/**
* The channel editor's "Audio" tab: the codec preset, or the codec and quality picked by
* hand, with the bandwidth one talking client then costs.
*/
final class ChannelAudioPanel extends JPanel implements ChannelEditDialog.Tab {
/** Codec names in TeamSpeak's own id order, which is what the combo box index is. */
private static final String[] CODEC_NAMES = {
"Speex Narrowband", "Speex Wideband", "Speex Ultra-Wideband",
"CELT Mono", "Opus Voice", "Opus Music"};
/**
* Bandwidth per talking client in KiB/s at quality 0 and 10, per codec, as published by
* TeamSpeak. The steps in between are interpolated: the client shows a figure per
* quality step, and only the two ends of each codec's range are documented.
*/
private static final double[][] BANDWIDTH_RANGE = {
{2.49, 5.22}, {2.69, 7.37}, {2.73, 7.57}, {6.10, 13.92}, {2.73, 7.71}, {3.08, 11.87}};
private static final int CODEC_OPUS_VOICE = 4;
private static final int CODEC_OPUS_MUSIC = 5;
private final JRadioButton voiceMobile = new JRadioButton("Voice Mobile");
private final JRadioButton voiceDesktop = new JRadioButton("Voice Desktop");
private final JRadioButton music = new JRadioButton("Music");
private final JRadioButton custom = new JRadioButton("Custom");
private final JComboBox<String> codec = new JComboBox<>(CODEC_NAMES);
private final JSlider quality = new JSlider(0, 10, 6);
private final JLabel qualityValue = new JLabel("6");
private final JLabel bandwidth = new JLabel();
private final JPanel customSettings = new JPanel(new GridBagLayout());
ChannelAudioPanel() {
super(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
ButtonGroup presets = new ButtonGroup();
presets.add(voiceMobile);
presets.add(voiceDesktop);
presets.add(music);
presets.add(custom);
voiceMobile.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 4));
voiceDesktop.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 6));
music.addActionListener(a -> applyPreset(CODEC_OPUS_MUSIC, 6));
custom.addActionListener(a -> setCustomEnabled(true));
codec.addActionListener(a -> updateBandwidth());
quality.setMajorTickSpacing(1);
quality.setPaintTicks(true);
quality.setSnapToTicks(true);
quality.addChangeListener(e -> updateBandwidth());
add(presetBox(), BorderLayout.WEST);
add(customBox(), BorderLayout.CENTER);
add(bandwidthRow(), BorderLayout.SOUTH);
}
@Override
public void read(ChannelSettings settings) {
codec.setSelectedIndex(Math.max(0, Math.min(CODEC_NAMES.length - 1, settings.codec)));
quality.setValue(Math.max(0, Math.min(10, settings.codecQuality)));
if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 4) voiceMobile.setSelected(true);
else if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 6) voiceDesktop.setSelected(true);
else if (settings.codec == CODEC_OPUS_MUSIC && settings.codecQuality == 6) music.setSelected(true);
else custom.setSelected(true);
setCustomEnabled(custom.isSelected());
updateBandwidth();
}
@Override
public void write(ChannelSettings settings) {
settings.codec = codec.getSelectedIndex();
settings.codecQuality = quality.getValue();
}
private void applyPreset(int codecId, int qualityValue) {
codec.setSelectedIndex(codecId);
quality.setValue(qualityValue);
setCustomEnabled(false);
updateBandwidth();
}
private void setCustomEnabled(boolean enabled) {
customSettings.setEnabled(enabled);
for (java.awt.Component child : customSettings.getComponents()) child.setEnabled(enabled);
}
private void updateBandwidth() {
qualityValue.setText(Integer.toString(quality.getValue()));
int index = Math.max(0, Math.min(BANDWIDTH_RANGE.length - 1, codec.getSelectedIndex()));
double[] range = BANDWIDTH_RANGE[index];
double value = range[0] + (range[1] - range[0]) * quality.getValue() / 10.0;
bandwidth.setText(String.format("%.2f KiB/s", value));
}
private JPanel presetBox() {
JPanel box = new JPanel();
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
box.setBorder(BorderFactory.createTitledBorder("Presets"));
for (JRadioButton radio : new JRadioButton[]{voiceMobile, voiceDesktop, music, custom}) {
radio.setAlignmentX(0f);
box.add(radio);
}
box.add(Box.createVerticalGlue());
return box;
}
private JPanel customBox() {
customSettings.setBorder(BorderFactory.createTitledBorder("Custom Settings"));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
c.gridx = 0;
c.gridy = 0;
customSettings.add(new JLabel("Codec:"), c);
c.gridx = 1;
c.weightx = 1;
c.gridwidth = 2;
customSettings.add(codec, c);
c.gridwidth = 1;
c.gridx = 0;
c.gridy = 1;
c.weightx = 0;
customSettings.add(new JLabel("Quality:"), c);
c.gridx = 1;
c.weightx = 1;
customSettings.add(quality, c);
c.gridx = 2;
c.weightx = 0;
customSettings.add(qualityValue, c);
c.gridx = 0;
c.gridy = 2;
c.weighty = 1;
customSettings.add(Box.createVerticalGlue(), c);
return customSettings;
}
private JPanel bandwidthRow() {
JPanel row = new JPanel(new BorderLayout(6, 0));
row.add(new JLabel("Bandwidth usage:"), BorderLayout.WEST);
row.add(bandwidth, BorderLayout.CENTER);
return row;
}
}

View File

@@ -0,0 +1,374 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTabbedPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Window;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.Map;
/**
* TeamSpeak's channel editor: the channel's name, icon, password, topic and description
* above four tabs holding everything else.
*
* <p>The channel's properties are not in the tree model — the tree only carries what the
* server pushes for display — so the dialog opens empty and fills itself from a
* {@code channelinfo}, with its own permissions arriving separately. Saving sends only what
* was actually changed, because a {@code channeledit} carrying one property the client may
* not modify is refused as a whole.
*/
final class ChannelEditDialog extends JDialog {
/** A tab that maps a part of the channel's settings onto its controls. */
interface Tab {
void read(ChannelSettings settings);
void write(ChannelSettings settings);
}
/** How long, and how often, to keep looking for a channel icon that is still downloading. */
private static final int ICON_RETRY_MS = 500;
private static final int ICON_RETRIES = 20;
/** What the password field shows for an existing password, which is never readable. */
private static final String PASSWORD_PLACEHOLDER = "••••••••";
private static final String NAME_TOOLTIP = "<html>Name of this channel displayed in the tree."
+ "<table>"
+ "<tr><td style='white-space:nowrap'>Syntax: \"<strong>[?Spacer#]Text</strong>\"</td></tr>"
+ "<tr><td style='white-space:nowrap'>Where \"?\" stands for an alignment"
+ " (r=right, c=center, l=left),</td></tr>"
+ "<tr><td style='white-space:nowrap'>\"*\" will repeat the text to fill the whole line.</td></tr>"
+ "<tr><td style='white-space:nowrap'>Change \"#\" to get a unique channel name.</td></tr>"
+ "<tr><td style='white-space:nowrap'>Use one of the three-character-blocks as text for a"
+ " special spacer: \"---\", \"...\", \"-.-\", \"___\", \"-..\"</td></tr>"
+ "</table></html>";
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final ChannelNode channel;
private final JTextField name = new JTextField();
private final JPasswordField password = new JPasswordField();
private final JTextField topic = new JTextField();
private final JTextArea description = new JTextArea(5, 40);
private final JButton iconButton = new JButton();
private final JButton ok = new JButton("OK");
private final ChannelStandardPanel standardPanel;
private final ChannelAudioPanel audioPanel = new ChannelAudioPanel();
private final ChannelPermissionsPanel permissionsPanel = new ChannelPermissionsPanel();
private final ChannelAdvancedPanel advancedPanel = new ChannelAdvancedPanel();
/** The tabs backed by {@link ChannelSettings}; the permissions tab has its own source. */
private final List<Tab> settingsTabs;
/** The channel as the server last described it; the baseline every change is measured against. */
private ChannelSettings original;
private long iconId;
private boolean passwordEdited;
ChannelEditDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, ChannelNode channel) {
super(owner, "Edit Channel: " + channel.name, ModalityType.APPLICATION_MODAL);
this.conn = conn;
this.groupIcons = groupIcons;
this.channel = channel;
this.standardPanel = new ChannelStandardPanel(conn.getModel().siblingsOf(channel.id));
standardPanel.onTypeChanged(advancedPanel::setChannelType);
settingsTabs = List.of(standardPanel, audioPanel, advancedPanel);
JTabbedPane tabs = new JTabbedPane();
tabs.addTab("Standard", standardPanel);
tabs.addTab("Audio", audioPanel);
tabs.addTab("Permissions", permissionsPanel);
tabs.addTab("Advanced", advancedPanel);
getContentPane().setLayout(new BorderLayout(0, 6));
getContentPane().add(header(), BorderLayout.NORTH);
getContentPane().add(tabs, BorderLayout.CENTER);
getContentPane().add(buttons(), BorderLayout.SOUTH);
setEnabledForLoading(false);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(560, 620));
setMinimumSize(new Dimension(480, 520));
setLocationRelativeTo(owner);
load();
}
// ---- layout ----
private JPanel header() {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 4, 3, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
name.setToolTipText(NAME_TOOLTIP);
password.setToolTipText("Optional password for this channel.");
topic.setToolTipText("Optional topic for this channel, displayed in the info area on the right.");
password.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
passwordEdited = true;
}
@Override
public void removeUpdate(DocumentEvent e) {
passwordEdited = true;
}
@Override
public void changedUpdate(DocumentEvent e) {
passwordEdited = true;
}
});
c.gridx = 0;
c.gridy = 0;
panel.add(new JLabel("Name:"), c);
c.gridx = 1;
c.weightx = 1;
panel.add(name, c);
c.gridx = 2;
c.weightx = 0;
panel.add(iconField(), c);
c.gridx = 0;
c.gridy = 1;
panel.add(new JLabel("Password:"), c);
c.gridx = 1;
c.gridwidth = 2;
c.weightx = 1;
panel.add(password, c);
c.gridwidth = 1;
c.gridx = 0;
c.gridy = 2;
c.weightx = 0;
panel.add(new JLabel("Topic:"), c);
c.gridx = 1;
c.gridwidth = 2;
c.weightx = 1;
panel.add(topic, c);
c.gridwidth = 1;
c.gridx = 0;
c.gridy = 3;
c.weightx = 0;
panel.add(new JLabel("Description:"), c);
JButton popOut = new JButton("Edit…");
popOut.setToolTipText("Tear off description editor");
popOut.addActionListener(a -> editDescription());
c.gridx = 1;
c.gridwidth = 2;
c.fill = GridBagConstraints.NONE;
panel.add(popOut, c);
description.setLineWrap(true);
description.setWrapStyleWord(true);
c.gridx = 0;
c.gridy = 4;
c.gridwidth = 3;
c.weightx = 1;
c.fill = GridBagConstraints.BOTH;
panel.add(new JScrollPane(description), c);
return panel;
}
private JPanel iconField() {
iconButton.setToolTipText("Set channel icon.");
iconButton.setPreferredSize(new Dimension(26, 24));
iconButton.setMargin(new Insets(1, 1, 1, 1));
iconButton.addActionListener(a -> chooseIcon());
iconButton.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger()) showIconMenu(e);
}
@Override
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger()) showIconMenu(e);
}
});
JPanel row = new JPanel(new BorderLayout(4, 0));
row.add(new JLabel("Icon:"), BorderLayout.WEST);
row.add(iconButton, BorderLayout.CENTER);
return row;
}
private JPanel buttons() {
JButton cancel = new JButton("Cancel");
ok.addActionListener(a -> save());
cancel.addActionListener(a -> dispose());
JPanel panel = new JPanel(new BorderLayout());
panel.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
JPanel right = new JPanel();
right.add(ok);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(ok);
return panel;
}
// ---- loading ----
private void load() {
conn.requestChannelSettings(channel.id, (settings, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
JOptionPane.showMessageDialog(this, "Could not read the channel: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
dispose();
return;
}
apply(settings);
}));
conn.requestChannelPermissions(channel.id, (permissions, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) permissionsPanel.showUnavailable("No permission to view channel permissions.");
else permissionsPanel.read(permissions);
}));
}
private void apply(ChannelSettings settings) {
original = settings;
name.setText(settings.name);
topic.setText(settings.topic);
description.setText(settings.description);
description.setCaretPosition(0);
if (settings.hasPassword) password.setText(PASSWORD_PLACEHOLDER);
passwordEdited = false;
setIconId(settings.iconId);
for (Tab tab : settingsTabs) tab.read(settings);
setEnabledForLoading(true);
name.requestFocusInWindow();
}
private void setEnabledForLoading(boolean loaded) {
ok.setEnabled(loaded);
name.setEnabled(loaded);
password.setEnabled(loaded);
topic.setEnabled(loaded);
description.setEnabled(loaded);
iconButton.setEnabled(loaded);
}
// ---- icon ----
private void setIconId(long id) {
setIconId(id, ICON_RETRIES);
}
/**
* @param retries how many more times to look for an icon that is still downloading;
* one that never arrives (deleted, or not ours to read) simply stays blank
*/
private void setIconId(long id, int retries) {
iconId = id;
ImageIcon icon = groupIcons.icon(id);
iconButton.setIcon(icon);
if (icon != null || id == 0 || retries <= 0) return;
Timer retry = new Timer(ICON_RETRY_MS, null);
retry.setRepeats(false);
retry.addActionListener(e -> {
if (iconId == id) setIconId(id, retries - 1);
});
retry.start();
}
private void chooseIcon() {
IconChooserDialog chooser = new IconChooserDialog(this, conn, groupIcons);
chooser.setVisible(true);
if (chooser.isAccepted()) setIconId(chooser.getIconId());
}
private void showIconMenu(MouseEvent e) {
JPopupMenu menu = new JPopupMenu();
JMenuItem edit = new JMenuItem("Edit Icon");
edit.addActionListener(a -> chooseIcon());
JMenuItem remove = new JMenuItem("Remove Icon");
remove.setEnabled(iconId != 0);
remove.addActionListener(a -> setIconId(0));
menu.add(edit);
menu.add(remove);
menu.show(e.getComponent(), e.getX(), e.getY());
}
// ---- description ----
private void editDescription() {
DescriptionEditorDialog editor = new DescriptionEditorDialog(this, description.getText());
editor.setVisible(true);
if (editor.isAccepted()) description.setText(editor.getDescription());
}
// ---- saving ----
private void save() {
if (original == null) return;
ChannelSettings edited = original.copy();
edited.name = name.getText().trim();
edited.topic = topic.getText();
edited.description = description.getText();
edited.password = passwordEdited ? new String(password.getPassword()) : null;
edited.iconId = iconId;
for (Tab tab : settingsTabs) tab.write(edited);
if (edited.name.isEmpty()) {
JOptionPane.showMessageDialog(this, "The channel needs a name.",
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
Map<String, String> changes = edited.changesFrom(original);
Map<String, Integer> setPermissions = permissionsPanel.changed();
List<String> clearedPermissions = permissionsPanel.cleared();
if (changes.isEmpty() && setPermissions.isEmpty() && clearedPermissions.isEmpty()) {
dispose();
return;
}
ok.setEnabled(false);
conn.applyChannelEdit(channel.id, changes, setPermissions, clearedPermissions,
error -> SwingUtilities.invokeLater(() -> {
if (error == null) {
dispose();
return;
}
ok.setEnabled(true);
JOptionPane.showMessageDialog(this, "Could not save the channel: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
}));
}
}

View File

@@ -20,6 +20,10 @@ final class ChannelMenu {
join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join);
menu.addSeparator();
JMenuItem edit = new JMenuItem("Edit Channel", Icons.of("CHANNEL_EDIT"));
edit.addActionListener(a -> actions.editChannel(channel));
menu.add(edit);
menu.addSeparator();
addSubscriptionItems(menu, channel, actions);
JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER"));
files.addActionListener(a -> actions.browseFiles(channel));

View File

@@ -0,0 +1,148 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import java.awt.Color;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* The channel editor's "Permissions" tab: the needed-power permissions set on the channel
* itself, which is the subset of channel permissions TeamSpeak surfaces here.
*
* <p>These are not channel properties but permissions, so they are read with
* {@code channelpermlist} and written with {@code channeladdperm}. A power left at zero is
* not a permission the channel carries: clearing a spinner back to zero removes the
* permission again so the channel inherits it, which is how the official client behaves.
*/
final class ChannelPermissionsPanel extends JPanel {
/** The regular powers, in the order the official dialog lists them. */
private static final String[][] REGULAR = {
{"i_channel_needed_join_power", "Join:"},
{"i_channel_needed_subscribe_power", "Subscribe:"},
{"i_channel_needed_description_view_power", "Desc. View:"},
{"i_channel_needed_modify_power", "Modify:"},
{"i_channel_needed_delete_power", "Delete:"}};
private static final String[][] FILE_TRANSFER = {
{"i_ft_needed_file_browse_power", "Browse:"},
{"i_ft_needed_file_upload_power", "Upload:"},
{"i_ft_needed_file_download_power", "Download:"},
{"i_ft_needed_file_rename_power", "Rename:"},
{"i_ft_needed_directory_create_power", "Dir. Create:"}};
private final Map<String, JSpinner> spinners = new LinkedHashMap<>();
/** The values the server reported, to tell an edited power from an untouched one. */
private final Map<String, Integer> original = new LinkedHashMap<>();
private final JLabel status = new JLabel(" ");
ChannelPermissionsPanel() {
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JPanel groups = new JPanel();
groups.setLayout(new BoxLayout(groups, BoxLayout.X_AXIS));
groups.add(group("Regular Needed Powers", REGULAR));
groups.add(Box.createHorizontalStrut(8));
groups.add(group("File Transfer Needed Powers", FILE_TRANSFER));
groups.setAlignmentX(0f);
add(groups);
status.setAlignmentX(0f);
status.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2));
add(status);
add(Box.createVerticalGlue());
setEditable(false);
}
/** Fills the spinners from a {@code channelpermlist} result and enables editing. */
void read(Map<String, Integer> permissions) {
original.clear();
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
int value = permissions.getOrDefault(entry.getKey(), 0);
original.put(entry.getKey(), value);
entry.getValue().setValue(value);
}
setEditable(true);
setStatus(" ", false);
}
/** Greys the tab out with a reason, e.g. when the server refuses to show the permissions. */
void showUnavailable(String message) {
setEditable(false);
setStatus(message, true);
}
/** The permissions whose power was changed to a non-zero value. */
Map<String, Integer> changed() {
Map<String, Integer> changed = new LinkedHashMap<>();
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
int value = (Integer) entry.getValue().getValue();
if (value != 0 && value != original.getOrDefault(entry.getKey(), 0)) {
changed.put(entry.getKey(), value);
}
}
return changed;
}
/** The permissions cleared back to zero, which are removed from the channel. */
List<String> cleared() {
List<String> cleared = new ArrayList<>();
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
int value = (Integer) entry.getValue().getValue();
if (value == 0 && original.getOrDefault(entry.getKey(), 0) != 0) cleared.add(entry.getKey());
}
return cleared;
}
void setStatus(String message, boolean error) {
status.setText(message == null || message.isEmpty() ? " " : message);
status.setForeground(error ? Color.RED.darker() : Theme.CHAT_SYSTEM);
}
private void setEditable(boolean editable) {
for (JSpinner spinner : spinners.values()) spinner.setEnabled(editable);
}
private JPanel group(String title, String[][] permissions) {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(BorderFactory.createTitledBorder(title));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 4, 3, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
int row = 0;
for (String[] permission : permissions) {
JSpinner spinner = Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1)));
spinners.put(permission[0], spinner);
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
panel.add(new JLabel(permission[1]), c);
c.gridx = 1;
c.weightx = 1;
c.fill = GridBagConstraints.NONE;
panel.add(spinner, c);
c.fill = GridBagConstraints.HORIZONTAL;
row++;
}
// Keeps the rows at the top of the group instead of centred in whatever height
// the tab happens to give it.
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
panel.add(Box.createGlue(), c);
return panel;
}
}

View File

@@ -0,0 +1,195 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.DefaultComboBoxModel;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSeparator;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.util.List;
import java.util.function.Consumer;
/**
* The channel editor's "Standard" tab: how long the channel lives, whether it is the
* server's default one, where it sorts among its siblings, and its moderation setting.
*/
final class ChannelStandardPanel extends JPanel implements ChannelEditDialog.Tab {
private final JRadioButton temporary = new JRadioButton("Temporary");
private final JRadioButton semiPermanent = new JRadioButton("Semi-Permanent");
private final JRadioButton permanent = new JRadioButton("Permanent");
private final JCheckBox defaultChannel = new JCheckBox("Default Channel");
private final JComboBox<SortEntry> sortAfter = new JComboBox<>();
private final JSpinner talkPower = new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1));
private Consumer<ChannelSettings.Type> typeListener;
/**
* @param siblings the channels this one shares a parent with, in tree order and
* excluding the channel being edited
*/
ChannelStandardPanel(List<ChannelNode> siblings) {
super(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
temporary.setToolTipText("Channel will be deleted when the last user left.");
semiPermanent.setToolTipText("Channel exists until server is restarted.");
permanent.setToolTipText("Channel will exist until manually deleted.");
ButtonGroup types = new ButtonGroup();
types.add(temporary);
types.add(semiPermanent);
types.add(permanent);
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
radio.addActionListener(a -> fireTypeChanged());
}
defaultChannel.setToolTipText("<html>The default channel is the place where new clients join on login."
+ "<br>There can be only one default channel for the whole server.</html>");
DefaultComboBoxModel<SortEntry> order = new DefaultComboBoxModel<>();
order.addElement(new SortEntry(0, "(first)"));
for (ChannelNode sibling : siblings) order.addElement(new SortEntry(sibling.id, sibling.name));
sortAfter.setModel(order);
sortAfter.setToolTipText("Channel will be sorted below this channel.");
sortAfter.setPreferredSize(new Dimension(200, sortAfter.getPreferredSize().height));
Spinners.compact(talkPower);
add(typeBox(), BorderLayout.WEST);
add(rightColumn(), BorderLayout.CENTER);
}
/** Notified whenever the channel type changes, so the delete delay can follow it. */
void onTypeChanged(Consumer<ChannelSettings.Type> listener) {
this.typeListener = listener;
}
@Override
public void read(ChannelSettings settings) {
switch (settings.type) {
case TEMPORARY:
temporary.setSelected(true);
break;
case SEMI_PERMANENT:
semiPermanent.setSelected(true);
break;
default:
permanent.setSelected(true);
break;
}
defaultChannel.setSelected(settings.defaultChannel);
// The default channel cannot simply stop being one; another has to take over.
defaultChannel.setEnabled(!settings.defaultChannel);
select(settings.order);
talkPower.setValue(settings.neededTalkPower);
fireTypeChanged();
}
@Override
public void write(ChannelSettings settings) {
settings.type = selectedType();
settings.defaultChannel = defaultChannel.isSelected();
SortEntry entry = (SortEntry) sortAfter.getSelectedItem();
settings.order = entry == null ? 0 : entry.channelId;
settings.neededTalkPower = (Integer) talkPower.getValue();
}
private ChannelSettings.Type selectedType() {
if (temporary.isSelected()) return ChannelSettings.Type.TEMPORARY;
if (semiPermanent.isSelected()) return ChannelSettings.Type.SEMI_PERMANENT;
return ChannelSettings.Type.PERMANENT;
}
private void fireTypeChanged() {
if (typeListener != null) typeListener.accept(selectedType());
}
private void select(int channelId) {
for (int i = 0; i < sortAfter.getItemCount(); i++) {
if (sortAfter.getItemAt(i).channelId == channelId) {
sortAfter.setSelectedIndex(i);
return;
}
}
sortAfter.setSelectedIndex(0);
}
private JPanel typeBox() {
JPanel box = new JPanel();
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
box.setBorder(BorderFactory.createTitledBorder("Channel Type"));
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
radio.setAlignmentX(0f);
box.add(radio);
}
JSeparator line = new JSeparator();
line.setAlignmentX(0f);
line.setMaximumSize(new Dimension(Integer.MAX_VALUE, 8));
box.add(Box.createVerticalStrut(4));
box.add(line);
box.add(Box.createVerticalStrut(4));
defaultChannel.setAlignmentX(0f);
box.add(defaultChannel);
box.add(Box.createVerticalGlue());
return box;
}
private JPanel rightColumn() {
JPanel sort = new JPanel(new BorderLayout());
sort.setBorder(BorderFactory.createTitledBorder("Sort This Channel After:"));
sort.add(sortAfter, BorderLayout.CENTER);
JPanel moderation = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
moderation.setBorder(BorderFactory.createTitledBorder("Moderation"));
JLabel label = new JLabel("Needed Talk Power:");
label.setToolTipText("Talk Power required to speak in this channel.");
moderation.add(label);
moderation.add(talkPower);
// Both groups keep their natural height; the filler below soaks up the rest, so
// they sit at the top instead of stretching over the whole tab.
JPanel column = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.gridx = 0;
c.fill = GridBagConstraints.HORIZONTAL;
c.weightx = 1;
c.insets = new Insets(0, 0, 6, 0);
column.add(sort, c);
column.add(moderation, c);
c.weighty = 1;
c.fill = GridBagConstraints.BOTH;
column.add(Box.createGlue(), c);
return column;
}
/** One entry of the "sort after" list: a sibling channel, or the top of the list. */
private static final class SortEntry {
final int channelId;
final String label;
SortEntry(int channelId, String label) {
this.channelId = channelId;
this.label = label;
}
@Override
public String toString() {
return label;
}
}
}

View File

@@ -0,0 +1,171 @@
package com.ts3client.ui;
import com.ts3client.text.BBCode;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JColorChooser;
import javax.swing.JDialog;
import javax.swing.JEditorPane;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Window;
/**
* The description editor TeamSpeak tears off the channel dialog: the raw BBCode plus
* the handful of formatting buttons it offers (bold, italic, underline, colour) and a
* preview that renders the text the way the info panel will.
*
* <p>Formatting works on the selection, wrapping it in the tag pair; with nothing
* selected the pair is inserted and the caret placed between the two halves, so typing
* continues inside it.
*/
final class DescriptionEditorDialog extends JDialog {
private static final String EDIT_CARD = "edit";
private static final String PREVIEW_CARD = "preview";
private final JTextArea area = new JTextArea();
private final JEditorPane preview = new JEditorPane("text/html", "");
private final CardLayout cards = new CardLayout();
private final JPanel body = new JPanel(cards);
private final JToggleButton previewButton = new JToggleButton("Preview");
private boolean accepted;
DescriptionEditorDialog(Window owner, String description) {
super(owner, "Channel Description", ModalityType.APPLICATION_MODAL);
area.setText(description);
area.setLineWrap(true);
area.setWrapStyleWord(true);
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
area.setCaretPosition(0);
preview.setEditable(false);
preview.setBackground(Theme.CHAT_BG);
preview.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
body.add(new JScrollPane(area), EDIT_CARD);
body.add(new JScrollPane(preview), PREVIEW_CARD);
JLabel hint = new JLabel("Press Button to \"Preview\" the changes in channel info.");
hint.setForeground(Theme.CHAT_SYSTEM);
hint.setBorder(BorderFactory.createEmptyBorder(4, 6, 0, 6));
getContentPane().setLayout(new BorderLayout());
getContentPane().add(buildToolbar(), BorderLayout.NORTH);
getContentPane().add(body, BorderLayout.CENTER);
JPanel south = new JPanel(new BorderLayout());
south.add(hint, BorderLayout.NORTH);
south.add(buildButtons(), BorderLayout.SOUTH);
getContentPane().add(south, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(520, 380));
setMinimumSize(new Dimension(360, 260));
setLocationRelativeTo(owner);
}
/** @return whether the user confirmed; {@link #getDescription()} then holds the new text */
boolean isAccepted() {
return accepted;
}
String getDescription() {
return area.getText();
}
private JToolBar buildToolbar() {
JToolBar bar = new JToolBar();
bar.setFloatable(false);
bar.add(tagButton("B", "Bold", Font.BOLD, "[b]", "[/b]"));
bar.add(tagButton("I", "Italic", Font.ITALIC, "[i]", "[/i]"));
JButton underline = tagButton("<html><u>U</u></html>", "Underline", Font.PLAIN, "[u]", "[/u]");
bar.add(underline);
JButton color = new JButton("Color");
color.setToolTipText("Color");
color.addActionListener(a -> chooseColor());
bar.add(square(color));
bar.add(Box.createHorizontalGlue());
previewButton.setToolTipText("Show the description as the info panel renders it");
previewButton.addActionListener(a -> showPreview(previewButton.isSelected()));
previewButton.setMaximumSize(previewButton.getPreferredSize());
bar.add(previewButton);
return bar;
}
private JPanel buildButtons() {
JButton ok = new JButton("OK");
JButton cancel = new JButton("Cancel");
ok.addActionListener(a -> {
accepted = true;
dispose();
});
cancel.addActionListener(a -> dispose());
JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel();
right.add(ok);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(ok);
return panel;
}
private JButton tagButton(String text, String tip, int style, String open, String close) {
JButton button = new JButton(text);
button.setToolTipText(tip);
button.setFont(button.getFont().deriveFont(style));
button.addActionListener(a -> wrapSelection(open, close));
return square(button);
}
/**
* Pins a toolbar button to its natural size: the tool bar lays its children out along a
* box, which would otherwise let one of them soak up all the free width.
*/
private static JButton square(JButton button) {
Dimension size = new Dimension(Math.max(28, button.getPreferredSize().width),
button.getPreferredSize().height);
button.setPreferredSize(size);
button.setMaximumSize(size);
return button;
}
private void chooseColor() {
Color chosen = JColorChooser.showDialog(this, "Color", Color.BLACK);
if (chosen == null) return;
wrapSelection(String.format("[color=#%02x%02x%02x]",
chosen.getRed(), chosen.getGreen(), chosen.getBlue()), "[/color]");
}
/** Wraps the selection (or the caret) in a BBCode tag pair and returns focus to the text. */
private void wrapSelection(String open, String close) {
if (previewButton.isSelected()) showPreview(false);
int start = area.getSelectionStart();
int end = area.getSelectionEnd();
String selected = area.getSelectedText();
area.replaceRange(open + (selected == null ? "" : selected) + close, start, end);
area.setCaretPosition(start + open.length() + (selected == null ? 0 : selected.length()));
area.requestFocusInWindow();
}
private void showPreview(boolean on) {
previewButton.setSelected(on);
if (on) {
preview.setText("<html><body style=\"font-family:sans-serif;font-size:9pt\">"
+ BBCode.toHtml(area.getText()) + "</body></html>");
preview.setCaretPosition(0);
}
cards.show(body, on ? PREVIEW_CARD : EDIT_CARD);
}
}

View File

@@ -0,0 +1,270 @@
package com.ts3client.ui;
import com.ts3client.net.IconRepository;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.DefaultListModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Window;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.File;
/**
* TeamSpeak's icon viewer: the icons uploaded to this virtual server on one side (which
* can be added to and removed from here) and the ones the icon pack ships on the other.
*
* <p>Server icons arrive through the {@link IconRepository}, which downloads them in the
* background, so the lists are simply repainted until every icon has turned up.
*/
final class IconChooserDialog extends JDialog {
/** How often the lists are repainted while icons are still being downloaded. */
private static final int REFRESH_MS = 300;
/** Give up repainting once nothing has arrived for this long. */
private static final int REFRESH_TIMEOUT_MS = 20_000;
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final DefaultListModel<Long> remoteModel = new DefaultListModel<>();
private final JList<Long> remoteList = new JList<>(remoteModel);
private final JList<Long> localList = new JList<>(new DefaultListModel<>());
private final JButton deleteButton = new JButton("Delete");
private final JButton selectButton = new JButton("Select");
private final JLabel status = new JLabel(" ");
private long chosenIconId;
private boolean accepted;
IconChooserDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons) {
super(owner, "Icons", ModalityType.APPLICATION_MODAL);
this.conn = conn;
this.groupIcons = groupIcons;
configure(remoteList);
configure(localList);
DefaultListModel<Long> localModel = (DefaultListModel<Long>) localList.getModel();
for (Long id : IconRepository.BUNDLED_IDS) localModel.addElement(id);
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
group("Remote", remoteList, remoteButtons()),
group("Local", localList, localFooter()));
split.setResizeWeight(0.6);
status.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8));
status.setForeground(Theme.CHAT_SYSTEM);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(split, BorderLayout.CENTER);
JPanel south = new JPanel(new BorderLayout());
south.add(status, BorderLayout.NORTH);
south.add(buttons(), BorderLayout.SOUTH);
getContentPane().add(south, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(520, 360));
setLocationRelativeTo(owner);
loadRemoteIcons();
startRefreshing();
}
/** @return whether an icon was picked; {@link #getIconId()} then holds it */
boolean isAccepted() {
return accepted;
}
long getIconId() {
return chosenIconId;
}
private void configure(JList<Long> list) {
list.setLayoutOrientation(JList.HORIZONTAL_WRAP);
list.setVisibleRowCount(-1);
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
list.setFixedCellWidth(32);
list.setFixedCellHeight(32);
list.setCellRenderer(new IconCellRenderer());
list.addListSelectionListener(e -> onSelectionChanged(list));
list.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2 && list.getSelectedValue() != null) choose(list.getSelectedValue());
}
});
}
/** Keeps one list's selection exclusive: picking on one side clears the other. */
private void onSelectionChanged(JList<Long> list) {
JList<Long> other = list == remoteList ? localList : remoteList;
if (list.getSelectedValue() != null) other.clearSelection();
deleteButton.setEnabled(remoteList.getSelectedValue() != null);
selectButton.setEnabled(selectedIcon() != 0);
}
private long selectedIcon() {
Long remote = remoteList.getSelectedValue();
if (remote != null) return remote;
Long local = localList.getSelectedValue();
return local == null ? 0 : local;
}
private JPanel group(String title, JList<Long> list, Component footer) {
JPanel panel = new JPanel(new BorderLayout(0, 4));
panel.setBorder(BorderFactory.createTitledBorder(title));
panel.add(new JScrollPane(list), BorderLayout.CENTER);
panel.add(footer, BorderLayout.SOUTH);
return panel;
}
private Component remoteButtons() {
JButton upload = new JButton("Upload");
upload.addActionListener(a -> uploadIcon());
deleteButton.setEnabled(false);
deleteButton.addActionListener(a -> deleteSelectedIcon());
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 4, 0));
row.add(upload);
row.add(deleteButton);
return row;
}
private Component localFooter() {
com.ts3client.gfx.IconPack active = IconTheme.get().activePack();
JLabel pack = new JLabel("Icon Pack: " + (active == null ? "none" : active.name()));
pack.setBorder(BorderFactory.createEmptyBorder(0, 4, 0, 4));
return pack;
}
private JPanel buttons() {
selectButton.setEnabled(false);
selectButton.addActionListener(a -> choose(selectedIcon()));
JButton cancel = new JButton("Cancel");
cancel.addActionListener(a -> dispose());
JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel();
right.add(selectButton);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(selectButton);
return panel;
}
private void choose(long iconId) {
if (iconId == 0) return;
chosenIconId = iconId;
accepted = true;
dispose();
}
private void loadRemoteIcons() {
status.setText("Loading icons…");
conn.requestServerIcons((ids, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
status.setText("Could not list the server's icons: " + error);
return;
}
status.setText(" ");
remoteModel.clear();
for (Long id : ids) remoteModel.addElement(id);
}));
}
private void uploadIcon() {
JFileChooser chooser = new JFileChooser();
chooser.setDialogTitle("Select an image to upload");
chooser.setFileFilter(new FileNameExtensionFilter("Images", "png", "jpg", "jpeg", "gif", "bmp", "svg"));
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return;
File file = chooser.getSelectedFile();
status.setText("Uploading " + file.getName() + "");
conn.uploadIcon(file, (iconId, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
status.setText(" ");
JOptionPane.showMessageDialog(this, "Error uploading icon: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
loadRemoteIcons();
startRefreshing();
}));
}
private void deleteSelectedIcon() {
Long id = remoteList.getSelectedValue();
if (id == null) return;
int answer = JOptionPane.showConfirmDialog(this,
"Permanently delete this icon from the server?", "Confirmation",
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
if (answer != JOptionPane.YES_OPTION) return;
conn.deleteIcon(id, error -> SwingUtilities.invokeLater(() -> {
if (error != null) {
JOptionPane.showMessageDialog(this, "Failed to delete remote icon file: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
remoteModel.removeElement(id);
}));
}
/**
* Repaints while the repository is still fetching icons; it hands them over silently,
* so the only way to show them as they land is to keep asking.
*/
private void startRefreshing() {
Timer timer = new Timer(REFRESH_MS, null);
long deadline = System.currentTimeMillis() + REFRESH_TIMEOUT_MS;
timer.addActionListener(e -> {
remoteList.repaint();
localList.repaint();
if (!isDisplayable() || System.currentTimeMillis() > deadline || allIconsLoaded()) timer.stop();
});
timer.start();
}
private boolean allIconsLoaded() {
for (int i = 0; i < remoteModel.size(); i++) {
if (groupIcons.icon(remoteModel.get(i)) == null) return false;
}
return true;
}
/** Draws one icon, blank until the repository has it. */
private final class IconCellRenderer extends javax.swing.DefaultListCellRenderer {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focused) {
super.getListCellRendererComponent(list, "", index, selected, focused);
long id = value instanceof Long ? (Long) value : 0;
ImageIcon icon = groupIcons.icon(id);
setIcon(icon);
setHorizontalAlignment(CENTER);
setToolTipText("Icon " + id);
return this;
}
}
/** Convenience for callers that only need the picked id. */
static long pick(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, long current) {
IconChooserDialog dialog = new IconChooserDialog(owner, conn, groupIcons);
dialog.setVisible(true);
return dialog.isAccepted() ? dialog.getIconId() : current;
}
}

View File

@@ -205,6 +205,12 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
}
@Override
public void editChannel(ChannelNode channel) {
if (!conn.isConnected()) return;
new ChannelEditDialog(host, conn, groupIcons, channel).setVisible(true);
}
@Override
public void browseFiles(ChannelNode channel) {
if (!conn.canTransferFiles()) return;

View File

@@ -44,6 +44,9 @@ public final class ServerTreePanel extends JScrollPane {
/** Moves a client into the channel we are currently in. */
void moveClientToOwnChannel(ClientEntry client);
/** Opens the channel editor for a channel. */
void editChannel(ChannelNode channel);
/** Open the file repository browser for a channel. */
void browseFiles(ChannelNode channel);

View File

@@ -0,0 +1,27 @@
package com.ts3client.ui;
import javax.swing.JSpinner;
import java.awt.Dimension;
/** Shared shaping for the numeric spinners the channel editor is full of. */
final class Spinners {
/** Width that fits a five-digit power without swallowing the rest of a form row. */
private static final int WIDTH = 80;
private Spinners() {
}
/**
* Keeps a spinner to a sensible width and drops the grouping separator: these hold
* permission powers and client counts, which TeamSpeak shows as plain numbers.
*/
static JSpinner compact(JSpinner spinner) {
JSpinner.NumberEditor editor = new JSpinner.NumberEditor(spinner, "#");
spinner.setEditor(editor);
Dimension size = new Dimension(WIDTH, spinner.getPreferredSize().height);
spinner.setPreferredSize(size);
spinner.setMaximumSize(size);
return spinner;
}
}