diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java b/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java index b03dad9..2419013 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java @@ -58,6 +58,31 @@ final class ChannelAdmin { return ChannelSettings.from(channelId, info.getMap()); } + /** + * Creates a channel below {@code parentId} (0 being the top level). + * + * @param properties the new channel's properties, as {@link ChannelSettings#creationParameters} + * @return the new channel's id + */ + int create(int parentId, Map properties) throws Exception { + SingleCommand cmd = new SingleCommand("channelcreate", ProtocolRole.CLIENT); + cmd.add(new CommandSingleParameter("cpid", Integer.toString(parentId))); + for (Map.Entry property : properties.entrySet()) { + cmd.add(new CommandSingleParameter(property.getKey(), property.getValue())); + } + for (SingleCommand answer : conn.socket().executeCommand(cmd).get()) { + int cid = parseInt(answer.toMap().get("cid"), -1); + if (cid > 0) return cid; + } + // Some servers acknowledge the command without naming the channel; the + // notifychannelcreated they also send does name it, so read it off the model + // once that event has been handled. + conn.awaitEventsProcessed(); + ChannelNode created = conn.getModel().findChannelByName(parentId, properties.get("channel_name")); + if (created == null) throw new IllegalStateException("The server did not report the new channel"); + return created.id; + } + /** Applies {@code changes} (property name to value) with a single {@code channeledit}. */ void edit(int channelId, Map changes) throws Exception { if (changes.isEmpty()) return; diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java b/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java index 0f388e8..539a012 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java @@ -143,6 +143,32 @@ public final class ChannelSettings { return changes; } + /** + * The {@code channelcreate} parameters for a channel that does not exist yet. + * + *

Everything a fresh channel would get anyway is left out, because TeamSpeak checks + * a create permission per property that is present — a client allowed to create plain + * channels but not, say, ones with a topic must not send an empty topic. What the + * server cannot infer is always sent: the name, the channel type (whose absent flags + * would silently mean temporary), the codec, and the client limits when they are not + * unlimited. + */ + public Map creationParameters() { + Map params = changesFrom(new ChannelSettings()); + params.put("channel_name", name); + params.put("channel_flag_permanent", flag(type == Type.PERMANENT)); + params.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT)); + params.put("channel_flag_temporary", flag(type == Type.TEMPORARY)); + params.put("channel_codec", Integer.toString(codec)); + params.put("channel_codec_quality", Integer.toString(codecQuality)); + if (!maxClientsUnlimited) params.put("channel_maxclients", Integer.toString(maxClients)); + if (!familyInherited && !familyUnlimited) { + params.put("channel_maxfamilyclients", Integer.toString(maxFamilyClients)); + } + if (password == null || password.isEmpty()) params.remove("channel_password"); + return params; + } + private static void putIfChanged(Map into, String key, String value, String was) { if (!value.equals(was)) into.put(key, value); } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index c31d52a..91673ca 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -218,17 +218,34 @@ public final class ServerModel { public synchronized List siblingsOf(int channelId) { ChannelNode channel = channels.get(channelId); if (channel == null) return new ArrayList<>(); - List siblings = new ArrayList<>(); - for (ChannelNode c : channels.values()) { - if (c.parentId == channel.parentId) siblings.add(c); - } // Ordered with the channel still in place: the order links form a chain, and // pulling a link out of it first would strand everything below. - sortSiblings(siblings); + List siblings = childrenOf(channel.parentId); siblings.remove(channel); return siblings; } + /** + * The channels directly below {@code parentId} (0 being the top level), in the order + * they appear in the tree — what a new channel can be sorted after. + */ + public synchronized List childrenOf(int parentId) { + List children = new ArrayList<>(); + for (ChannelNode c : channels.values()) { + if (c.parentId == parentId) children.add(c); + } + sortSiblings(children); + return children; + } + + /** The channel of that exact name directly below {@code parentId}, or {@code null}. */ + public synchronized ChannelNode findChannelByName(int parentId, String name) { + for (ChannelNode c : channels.values()) { + if (c.parentId == parentId && c.name.equals(name)) return c; + } + return null; + } + /** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */ public synchronized String channelPath(int id) { ChannelNode c = channels.get(id); diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index 55d8e60..e592a58 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -923,6 +923,29 @@ public final class TeamspeakConnection implements TS3Listener { }); } + /** + * Creates a channel below {@code parentId} (0 being the top level) and gives it the + * channel permissions it was created with. + * + * @param callback given the new channel's id, or the failure message. A channel whose + * permissions could not be set still exists, and is reported as an error + * saying so. + */ + public void createChannel(int parentId, Map properties, + Map permissions, + BiConsumer callback) { + run("ts3j-channel-create", callback, () -> { + int channelId = channels.create(parentId, properties); + try { + channels.writePermissions(channelId, permissions, List.of()); + } catch (Exception e) { + throw new IllegalStateException("The channel was created, but its permissions" + + " could not be set: " + rootMessage(e)); + } + return channelId; + }); + } + /** The ids of the icons uploaded to this virtual server. */ public void requestServerIcons(BiConsumer, String> callback) { run("ts3j-icon-list", callback, channels::listIcons); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAdvancedPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAdvancedPanel.java index 6aa1242..11e549a 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAdvancedPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAdvancedPanel.java @@ -23,7 +23,7 @@ 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 { +final class ChannelAdvancedPanel extends JPanel implements ChannelDialog.Tab { /** TeamSpeak's ceiling for {@code channel_delete_delay}, in seconds. */ private static final int MAX_DELETE_DELAY = 604800; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAudioPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAudioPanel.java index 2801dce..d7ccf79 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAudioPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAudioPanel.java @@ -20,7 +20,7 @@ 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 { +final class ChannelAudioPanel extends JPanel implements ChannelDialog.Tab { /** Codec names in TeamSpeak's own id order, which is what the combo box index is. */ private static final String[] CODEC_NAMES = { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelEditDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelDialog.java similarity index 76% rename from ts3-client/swing/src/main/java/com/ts3client/ui/ChannelEditDialog.java rename to ts3-client/swing/src/main/java/com/ts3client/ui/ChannelDialog.java index 423457c..bc8d063 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelEditDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelDialog.java @@ -35,15 +35,20 @@ import java.util.Map; /** * TeamSpeak's channel editor: the channel's name, icon, password, topic and description - * above four tabs holding everything else. + * above four tabs holding everything else. The official client uses the same dialog to + * create a channel and to edit one, and so does this. * - *

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

When editing, 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. + * + *

When creating, there is nothing to read: the dialog starts from the defaults its + * caller chose and sends a {@code channelcreate} carrying whatever deviates from a plain + * new channel, for the same reason. */ -final class ChannelEditDialog extends JDialog { +final class ChannelDialog extends JDialog { /** A tab that maps a part of the channel's settings onto its controls. */ interface Tab { @@ -66,13 +71,19 @@ final class ChannelEditDialog extends JDialog { + " (r=right, c=center, l=left)," + "\"*\" will repeat the text to fill the whole line." + "Change \"#\" to get a unique channel name." + + "[lSpacer0] a left aligned text" + + "[cSpacer1] a centered text" + + "[rSpacer2] a right aligned text" + "Use one of the three-character-blocks as text for a" + " special spacer: \"---\", \"...\", \"-.-\", \"___\", \"-..\"" + ""; private final TeamspeakConnection conn; private final GroupIcons groupIcons; + /** The channel being edited, or {@code null} when a new one is being created. */ private final ChannelNode channel; + /** Where a new channel goes; 0 is the top level. Unused when editing. */ + private final int parentId; private final JTextField name = new JTextField(); private final JPasswordField password = new JPasswordField(); @@ -94,12 +105,33 @@ final class ChannelEditDialog extends JDialog { private long iconId; private boolean passwordEdited; - ChannelEditDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, ChannelNode channel) { - super(owner, "Edit Channel: " + channel.name, ModalityType.APPLICATION_MODAL); + /** The editor for an existing channel. */ + static ChannelDialog toEdit(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, + ChannelNode channel) { + return new ChannelDialog(owner, conn, groupIcons, "Edit Channel: " + channel.name, + channel, channel.parentId, null); + } + + /** + * The editor for a channel that does not exist yet. + * + * @param parentId the channel it goes below, 0 being the top level + * @param defaults what the dialog opens with, e.g. the channel type its caller prefers + */ + static ChannelDialog toCreate(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, + String title, int parentId, ChannelSettings defaults) { + return new ChannelDialog(owner, conn, groupIcons, title, null, parentId, defaults); + } + + private ChannelDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, String title, + ChannelNode channel, int parentId, ChannelSettings defaults) { + super(owner, title, ModalityType.APPLICATION_MODAL); this.conn = conn; this.groupIcons = groupIcons; this.channel = channel; - this.standardPanel = new ChannelStandardPanel(conn.getModel().siblingsOf(channel.id)); + this.parentId = parentId; + this.standardPanel = new ChannelStandardPanel(channel != null + ? conn.getModel().siblingsOf(channel.id) : conn.getModel().childrenOf(parentId)); standardPanel.onTypeChanged(advancedPanel::setChannelType); settingsTabs = List.of(standardPanel, audioPanel, advancedPanel); @@ -122,7 +154,8 @@ final class ChannelEditDialog extends JDialog { setMinimumSize(new Dimension(480, 520)); setLocationRelativeTo(owner); - load(); + if (channel != null) load(); + else prepareNew(defaults); } // ---- layout ---- @@ -261,6 +294,16 @@ final class ChannelEditDialog extends JDialog { })); } + /** + * Opens the dialog on a channel that does not exist yet. Its permissions start empty + * and editable — they are applied to the channel once the server has created it. + */ + private void prepareNew(ChannelSettings defaults) { + permissionsPanel.read(Map.of()); + apply(defaults); + name.selectAll(); + } + private void apply(ChannelSettings settings) { original = settings; name.setText(settings.name); @@ -351,6 +394,14 @@ final class ChannelEditDialog extends JDialog { return; } + if (channel == null) { + ok.setEnabled(false); + conn.createChannel(parentId, edited.creationParameters(), permissionsPanel.changed(), + (channelId, error) -> SwingUtilities.invokeLater( + () -> finish(error, "Could not create the channel: "))); + return; + } + Map changes = edited.changesFrom(original); Map setPermissions = permissionsPanel.changed(); List clearedPermissions = permissionsPanel.cleared(); @@ -361,14 +412,16 @@ final class ChannelEditDialog extends JDialog { 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); - })); + error -> SwingUtilities.invokeLater(() -> finish(error, "Could not save the channel: "))); + } + + /** Closes the dialog, or reports why the server would not have it and lets the user retry. */ + private void finish(String error, String errorPrefix) { + if (error == null) { + dispose(); + return; + } + ok.setEnabled(true); + JOptionPane.showMessageDialog(this, errorPrefix + error, "Error", JOptionPane.ERROR_MESSAGE); } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java index d06b0b9..a1e4292 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java @@ -1,6 +1,7 @@ package com.ts3client.ui; import com.ts3client.net.ChannelNode; +import com.ts3client.net.ChannelSettings; import javax.swing.JMenuItem; import javax.swing.JPopupMenu; @@ -20,6 +21,14 @@ final class ChannelMenu { join.addActionListener(a -> actions.joinChannel(channel.id)); menu.add(join); menu.addSeparator(); + // Both start out temporary: a channel made from another channel's menu is + // usually a throwaway one, and a permanent channel is a deliberate choice. + JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE")); + create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.TEMPORARY)); + menu.add(create); + JMenuItem createSub = new JMenuItem("Create Sub-Channel", Icons.of("CHANNEL_CREATE_SUB")); + createSub.addActionListener(a -> actions.createChannel(channel, ChannelSettings.Type.TEMPORARY)); + menu.add(createSub); JMenuItem edit = new JMenuItem("Edit Channel", Icons.of("CHANNEL_EDIT")); edit.addActionListener(a -> actions.editChannel(channel)); menu.add(edit); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelStandardPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelStandardPanel.java index 9c445e1..3f4aea7 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelStandardPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelStandardPanel.java @@ -29,7 +29,7 @@ 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 { +final class ChannelStandardPanel extends JPanel implements ChannelDialog.Tab { private final JRadioButton temporary = new JRadioButton("Temporary"); private final JRadioButton semiPermanent = new JRadioButton("Semi-Permanent"); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerMenu.java new file mode 100644 index 0000000..7b0a39a --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerMenu.java @@ -0,0 +1,28 @@ +package com.ts3client.ui; + +import com.ts3client.net.ChannelSettings; + +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; + +/** + * The context menu for the server itself — the tree's root row. + */ +final class ServerMenu { + + private ServerMenu() { + } + + static JPopupMenu build(ServerTreePanel.Actions actions) { + JPopupMenu menu = new JPopupMenu(); + JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE")); + create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.PERMANENT)); + menu.add(create); + JMenuItem spacer = new JMenuItem("Create Spacer", + Icons.ofAny("CHANNEL_CREATE_SPACER", "CHANNEL_CREATE")); + spacer.setToolTipText("Create a new spacer"); + spacer.addActionListener(a -> actions.createSpacer()); + menu.add(spacer); + return menu; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java index 5670b5f..d510abc 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java @@ -1,6 +1,7 @@ package com.ts3client.ui; import com.ts3client.net.ChannelNode; +import com.ts3client.net.ChannelSettings; import com.ts3client.net.ClientEntry; import com.ts3client.net.Group; import com.ts3client.net.TeamspeakConnection; @@ -208,7 +209,33 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { @Override public void editChannel(ChannelNode channel) { if (!conn.isConnected()) return; - new ChannelEditDialog(host, conn, groupIcons, channel).setVisible(true); + ChannelDialog.toEdit(host, conn, groupIcons, channel).setVisible(true); + } + + @Override + public void createChannel(ChannelNode parent, ChannelSettings.Type type) { + if (!conn.isConnected()) return; + ChannelSettings defaults = new ChannelSettings(); + defaults.type = type; + ChannelDialog.toCreate(host, conn, groupIcons, + parent == null ? "Create Channel" : "Create Sub-Channel of " + parent.name, + parent == null ? 0 : parent.id, defaults).setVisible(true); + } + + @Override + public void createSpacer() { + if (!conn.isConnected()) return; + ChannelSettings defaults = new ChannelSettings(); + // Spacers are cosmetic root channels, so they outlive everyone and, being channels, + // need a name no other channel has — which is what the tag's number is for. + defaults.name = Spacers.freeName(conn.getModel().childrenOf(0)); + ChannelDialog.toCreate(host, conn, groupIcons, "Create Spacer", 0, defaults) + .setVisible(true); + } + + @Override + public boolean isConnected() { + return conn.isConnected(); } @Override diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index 426e345..ceaad23 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -1,6 +1,7 @@ package com.ts3client.ui; import com.ts3client.net.ChannelNode; +import com.ts3client.net.ChannelSettings; import com.ts3client.net.ClientEntry; import com.ts3client.net.Group; import com.ts3client.net.ServerModel; @@ -47,6 +48,20 @@ public final class ServerTreePanel extends JScrollPane { /** Opens the channel editor for a channel. */ void editChannel(ChannelNode channel); + /** + * Opens the channel creator. + * + * @param parent the channel the new one goes below, or {@code null} for a top-level one + * @param type the channel type to start with + */ + void createChannel(ChannelNode parent, ChannelSettings.Type type); + + /** Opens the channel creator pre-filled as a spacer, which is always top-level. */ + void createSpacer(); + + /** Whether the server is there to act on at all. */ + boolean isConnected(); + /** Open the file repository browser for a channel. */ void browseFiles(ChannelNode channel); @@ -196,6 +211,9 @@ public final class ServerTreePanel extends JScrollPane { showClientMenu((ClientEntry) obj, e); } else if (obj instanceof ChannelNode) { showChannelMenu((ChannelNode) obj, e); + } else if (path.getPathCount() == 1 && actions.isConnected()) { + // The root row is the server itself, whose user object is just its name. + ServerMenu.build(actions).show(tree, e.getX(), e.getY()); } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java index 593c46f..a0f702c 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java @@ -1,5 +1,10 @@ package com.ts3client.ui; +import com.ts3client.net.ChannelNode; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -21,6 +26,9 @@ public final class Spacers { } } + /** The tag's identifying part, which is what keeps two spacers' names apart. */ + private static final Pattern TAG = Pattern.compile("^\\[(?:\\*|[lcr])?spacer([^\\]]*)\\]"); + private static final Pattern PATTERN = Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$"); @@ -41,6 +49,22 @@ public final class Spacers { return parse(channelName) != null; } + /** + * A spacer name no channel among {@code siblings} is using: {@code [spacerN]} with the + * lowest free N. TeamSpeak needs channel names to be unique, and the number in the tag + * is how spacers — which usually all read the same — get away with it. + */ + public static String freeName(List siblings) { + Set taken = new HashSet<>(); + for (ChannelNode sibling : siblings) { + Matcher m = TAG.matcher(sibling.name == null ? "" : sibling.name); + if (m.find()) taken.add(m.group(1)); + } + for (int n = 0; ; n++) { + if (taken.add(Integer.toString(n))) return "[spacer" + n + "]"; + } + } + /** Builds the visible label for a spacer at roughly the given character width. */ public static String render(Spacer s, int width) { String caption = s.caption == null ? "" : s.caption;