Show and control channel subscriptions

Track the subscription state from the notifychannelsubscribed events, draw
subscribed and unsubscribed channels apart with the pack's icons (and the
built-in glyph as a hollow cone), and offer subscribe/unsubscribe entries —
channel and family — in the channel context menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 13:57:25 +00:00
parent 245ae5ecc5
commit 46206e3047
6 changed files with 183 additions and 17 deletions

View File

@@ -17,6 +17,8 @@ public final class ChannelNode {
public int maxClients = -1; public int maxClients = -1;
/** Id of the channel's custom icon in the server's file repository, or 0 for none. */ /** Id of the channel's custom icon in the server's file repository, or 0 for none. */
public long iconId; public long iconId;
/** Whether the server sends us this channel's client list; set from the subscription events. */
public boolean subscribed;
/** Populated when the tree is rebuilt. */ /** Populated when the tree is rebuilt. */
public final List<ChannelNode> children = new ArrayList<>(); public final List<ChannelNode> children = new ArrayList<>();
@@ -26,4 +28,35 @@ public final class ChannelNode {
this.id = id; this.id = id;
this.name = name; this.name = name;
} }
/** @return whether this channel or any channel below it is subscribed */
public boolean anySubscribed() {
if (subscribed) return true;
for (ChannelNode child : children) {
if (child.anySubscribed()) return true;
}
return false;
}
/** @return whether this channel and every channel below it is subscribed */
public boolean allSubscribed() {
if (!subscribed) return false;
for (ChannelNode child : children) {
if (!child.allSubscribed()) return false;
}
return true;
}
/** @return this channel's id, followed by those of the channels below it when {@code family} */
public List<Integer> familyIds(boolean family) {
List<Integer> ids = new ArrayList<>();
collectIds(family, ids);
return ids;
}
private void collectIds(boolean family, List<Integer> into) {
into.add(id);
if (!family) return;
for (ChannelNode child : children) child.collectIds(true, into);
}
} }

View File

@@ -2,6 +2,7 @@ package com.ts3client.net;
import com.github.manevolent.ts3j.api.Channel; import com.github.manevolent.ts3j.api.Channel;
import com.github.manevolent.ts3j.api.Client; import com.github.manevolent.ts3j.api.Client;
import com.github.manevolent.ts3j.command.MultiCommand;
import com.github.manevolent.ts3j.command.SingleCommand; import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter; import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.*; import com.github.manevolent.ts3j.event.*;
@@ -24,6 +25,9 @@ import com.ts3client.sound.SoundNotifier;
import java.io.File; import java.io.File;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -534,6 +538,28 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-move-channel").start(); }, "ts3j-move-channel").start();
} }
/**
* Subscribes to (or unsubscribes from) a set of channels in one command. The model
* is left alone: the server answers with the subscription events that update it.
*/
public void setChannelsSubscribed(Collection<Integer> channelIds, boolean subscribed) {
if (channelIds.isEmpty()) return;
List<Integer> ids = List.copyOf(channelIds);
String name = subscribed ? "channelsubscribe" : "channelunsubscribe";
new Thread(() -> {
try {
List<SingleCommand> parts = new ArrayList<>(ids.size());
for (int id : ids) {
parts.add(new SingleCommand(name, ProtocolRole.CLIENT,
new CommandSingleParameter("cid", Integer.toString(id))));
}
client.executeCommand(new MultiCommand(name, ProtocolRole.CLIENT, parts)).complete();
} catch (Exception e) {
error((subscribed ? "Could not subscribe: " : "Could not unsubscribe: ") + rootMessage(e));
}
}, "ts3j-channel-subscribe").start();
}
public void sendChannelMessage(String text) { public void sendChannelMessage(String text) {
new Thread(() -> { new Thread(() -> {
try { try {
@@ -926,6 +952,23 @@ public final class TeamspeakConnection implements TS3Listener {
} }
} }
@Override
public void onChannelSubscribed(ChannelSubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), true);
}
@Override
public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), false);
}
private void setSubscribed(int cid, boolean subscribed) {
ChannelNode ch = model.getChannel(cid);
if (ch == null || ch.subscribed == subscribed) return;
ch.subscribed = subscribed;
ui.onModelChanged();
}
@Override @Override
public void onServerEdit(ServerEditedEvent e) { public void onServerEdit(ServerEditedEvent e) {
if (has(e, "virtualserver_name")) model.setServerName(e.get("virtualserver_name")); if (has(e, "virtualserver_name")) model.setServerName(e.get("virtualserver_name"));

View File

@@ -20,9 +20,47 @@ final class ChannelMenu {
join.addActionListener(a -> actions.joinChannel(channel.id)); join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join); menu.add(join);
menu.addSeparator(); menu.addSeparator();
addSubscriptionItems(menu, channel, actions);
JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER")); JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER"));
files.addActionListener(a -> actions.browseFiles(channel)); files.addActionListener(a -> actions.browseFiles(channel));
menu.add(files); menu.add(files);
return menu; return menu;
} }
/**
* The subscription entries, offered the way the official client does: the channel
* itself toggles, and the family entries appear only where they would do something.
*/
private static void addSubscriptionItems(JPopupMenu menu, ChannelNode channel,
ServerTreePanel.Actions actions) {
if (channel.subscribed) {
add(menu, "Unsubscribe from Channel",
() -> actions.setChannelSubscribed(channel, false, false),
"UNSUBSCRIBE_FROM_CHANNEL");
} else {
add(menu, "Subscribe to Channel",
() -> actions.setChannelSubscribed(channel, false, true),
"SUBSCRIBE_TO_CHANNEL");
}
if (!channel.children.isEmpty()) {
if (!channel.allSubscribed()) {
add(menu, "Subscribe to Channel Family",
() -> actions.setChannelSubscribed(channel, true, true),
"SUBSCRIBE_TO_CHANNEL_FAMILY", "SUBSCRIBE_TO_ALL_CHANNELS");
}
if (channel.anySubscribed()) {
add(menu, "Unsubscribe from Channel Family",
() -> actions.setChannelSubscribed(channel, true, false),
"UNSUBSCRIBE_FROM_CHANNEL_FAMILY", "UNSUBSCRIBE_FROM_ALL_CHANNELS");
}
}
menu.addSeparator();
}
/** Adds an item, icon-less when the pack has none of the given keys. */
private static void add(JPopupMenu menu, String text, Runnable action, String... iconKeys) {
JMenuItem item = new JMenuItem(text, Icons.ofAny(iconKeys));
item.addActionListener(a -> action.run());
menu.add(item);
}
} }

View File

@@ -32,6 +32,18 @@ public final class Icons {
return IconTheme.icon(key); return IconTheme.icon(key);
} }
/**
* @return the pack's icon for the first key it has, or {@code null} when it has none
* of them — packs differ in which variants they ship
*/
public static ImageIcon ofAny(String... keys) {
for (String key : keys) {
ImageIcon icon = IconTheme.icon(key);
if (icon != null) return icon;
}
return null;
}
/** @return the pack's icon at a given size, or {@code null} when it has none */ /** @return the pack's icon at a given size, or {@code null} when it has none */
public static ImageIcon of(String key, int size) { public static ImageIcon of(String key, int size) {
return IconTheme.icon(key, size); return IconTheme.icon(key, size);
@@ -46,6 +58,18 @@ public final class Icons {
return icon != null ? icon : make(fallback); return icon != null ? icon : make(fallback);
} }
/**
* Like {@link #themed(String, Painter)}, but tries the keys in turn — packs need not
* ship every variant (none of TeamSpeak's own has a subscribed password channel).
*/
private static ImageIcon themed(String[] keys, Painter fallback) {
for (String key : keys) {
ImageIcon icon = IconTheme.icon(key, SZ);
if (icon != null) return icon;
}
return make(fallback);
}
private static ImageIcon make(Painter p) { private static ImageIcon make(Painter p) {
BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB); BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics(); Graphics2D g = img.createGraphics();
@@ -62,18 +86,26 @@ public final class Icons {
return themed("SERVER_GREEN", Icons::paintServer); return themed("SERVER_GREEN", Icons::paintServer);
} }
public static ImageIcon channel() { public static ImageIcon channel(boolean subscribed) {
return themed("CHANNEL_GREEN", g -> paintChannel(g, new Color(0x3E7CB1), false)); return themed(keys("CHANNEL_GREEN", subscribed),
g -> paintChannel(g, new Color(0x3E7CB1), false, subscribed));
} }
/** A channel nobody can join without its password. */ /** A channel nobody can join without its password. */
public static ImageIcon channelLocked() { public static ImageIcon channelLocked(boolean subscribed) {
return themed("CHANNEL_PRIVATE", g -> paintChannel(g, new Color(0x8A6D3B), true)); return themed(keys("CHANNEL_PRIVATE", subscribed),
g -> paintChannel(g, new Color(0x8A6D3B), true, subscribed));
} }
/** A channel that has reached its client limit. */ /** A channel that has reached its client limit. */
public static ImageIcon channelFull() { public static ImageIcon channelFull(boolean subscribed) {
return themed("CHANNEL_RED", g -> paintChannel(g, new Color(0xA53F3F), false)); return themed(keys("CHANNEL_RED", subscribed),
g -> paintChannel(g, new Color(0xA53F3F), false, subscribed));
}
/** The pack keys to try for a channel icon, most specific first. */
private static String[] keys(String base, boolean subscribed) {
return subscribed ? new String[]{base + "_SUBSCRIBED", base} : new String[]{base};
} }
// ---- client status icons ---- // ---- client status icons ----
@@ -172,16 +204,24 @@ public final class Icons {
g.fillOval(4, 10, 2, 2); g.fillOval(4, 10, 2, 2);
} }
private static void paintChannel(Graphics2D g, Color c, boolean lock) { /**
g.setColor(c); * The speaker-cone glyph. An unsubscribed channel is drawn hollow and without the
g.setStroke(new BasicStroke(1.6f)); * sound waves, so the two states stay apart even without an icon pack.
// simple speaker-cone glyph */
g.fillRect(2, 6, 3, 4); private static void paintChannel(Graphics2D g, Color c, boolean lock, boolean subscribed) {
int[] xs = {5, 9, 9, 5}; int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10}; int[] ys = {6, 3, 13, 10};
g.setColor(c);
g.setStroke(new BasicStroke(subscribed ? 1.6f : 1.2f));
if (subscribed) {
g.fillRect(2, 6, 3, 4);
g.fillPolygon(xs, ys, 4); g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120); g.drawArc(9, 5, 4, 6, -60, 120);
} else {
g.drawRect(2, 6, 3, 4);
g.drawPolygon(xs, ys, 4);
}
if (lock) { if (lock) {
g.setColor(new Color(0xB8860B)); g.setColor(new Color(0xB8860B));
g.fillRoundRect(10, 9, 5, 5, 1, 1); g.fillRoundRect(10, 9, 5, 5, 1, 1);

View File

@@ -354,6 +354,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true); new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true);
} }
@Override
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
}
@Override @Override
public void browseFiles(ChannelNode channel) { public void browseFiles(ChannelNode channel) {
if (!conn.canTransferFiles()) return; if (!conn.canTransferFiles()) return;

View File

@@ -53,6 +53,13 @@ public final class ServerTreePanel extends JScrollPane {
/** Open the file repository browser for a channel. */ /** Open the file repository browser for a channel. */
void browseFiles(ChannelNode channel); void browseFiles(ChannelNode channel);
/**
* Changes a channel's subscription.
*
* @param family whether the channels below it are included
*/
void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed);
boolean isClientLocallyMuted(int clientId); boolean isClientLocallyMuted(int clientId);
/** A channel or client node was selected (or {@code null} when cleared). */ /** A channel or client node was selected (or {@code null} when cleared). */
@@ -606,9 +613,9 @@ public final class ServerTreePanel extends JScrollPane {
} }
private ImageIcon iconFor(ChannelNode c) { private ImageIcon iconFor(ChannelNode c) {
if (c.hasPassword) return Icons.channelLocked(); if (c.hasPassword) return Icons.channelLocked(c.subscribed);
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(); if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed);
return Icons.channel(); return Icons.channel(c.subscribed);
} }
/** The client's state, in the order the official client gives them priority. */ /** The client's state, in the order the official client gives them priority. */