Add the Create Channel dialog

The official client creates and edits channels with one dialog, so
ChannelEditDialog becomes ChannelDialog with two entry points. Creating
starts from caller-chosen defaults instead of a channelinfo and sends a
channelcreate carrying only what deviates from a plain new channel —
TeamSpeak checks a create permission per property that is present.

The server row now has a context menu with "Create Channel" and "Create
Spacer" (pre-filled with the lowest free [spacerN], since channel names
must be unique), and a channel's menu offers "Create Channel" and
"Create Sub-Channel", both starting out temporary.

Verified against the test server: a temporary channel is created, and a
permanent one a guest may not create reports the server's refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 15:31:07 +00:00
parent d8a56566d4
commit f2885d33ad
13 changed files with 279 additions and 29 deletions

View File

@@ -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<String, String> properties) throws Exception {
SingleCommand cmd = new SingleCommand("channelcreate", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cpid", Integer.toString(parentId)));
for (Map.Entry<String, String> 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<String, String> changes) throws Exception {
if (changes.isEmpty()) return;

View File

@@ -143,6 +143,32 @@ public final class ChannelSettings {
return changes;
}
/**
* The {@code channelcreate} parameters for a channel that does not exist yet.
*
* <p>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<String, String> creationParameters() {
Map<String, String> 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<String, String> into, String key, String value, String was) {
if (!value.equals(was)) into.put(key, value);
}

View File

@@ -218,17 +218,34 @@ public final class ServerModel {
public synchronized List<ChannelNode> siblingsOf(int channelId) {
ChannelNode channel = channels.get(channelId);
if (channel == null) return new ArrayList<>();
List<ChannelNode> 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<ChannelNode> 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<ChannelNode> childrenOf(int parentId) {
List<ChannelNode> 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);

View File

@@ -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<String, String> properties,
Map<String, Integer> permissions,
BiConsumer<Integer, String> 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<List<Long>, String> callback) {
run("ts3j-icon-list", callback, channels::listIcons);

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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.
*
* <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.
* <p>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.
*
* <p>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),</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'>[lSpacer0] a left aligned text</td></tr>"
+ "<tr><td style='white-space:nowrap'>[cSpacer1] a centered text</td></tr>"
+ "<tr><td style='white-space:nowrap'>[rSpacer2] a right aligned text</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;
/** 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<String, String> changes = edited.changesFrom(original);
Map<String, Integer> setPermissions = permissionsPanel.changed();
List<String> 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);
}
}

View File

@@ -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);

View File

@@ -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");

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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<ChannelNode> siblings) {
Set<String> 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;