From 46206e30477527feb47da9f6af82c602173cbb06 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 14 Aug 2026 13:57:25 +0000 Subject: [PATCH] Show and control channel subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/com/ts3client/net/ChannelNode.java | 33 +++++++++ .../ts3client/net/TeamspeakConnection.java | 43 ++++++++++++ .../java/com/ts3client/ui/ChannelMenu.java | 38 +++++++++++ .../src/main/java/com/ts3client/ui/Icons.java | 68 +++++++++++++++---- .../main/java/com/ts3client/ui/ServerTab.java | 5 ++ .../com/ts3client/ui/ServerTreePanel.java | 13 +++- 6 files changed, 183 insertions(+), 17 deletions(-) diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ChannelNode.java b/ts3-client/core/src/main/java/com/ts3client/net/ChannelNode.java index c586f2e..6ec902d 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ChannelNode.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ChannelNode.java @@ -17,6 +17,8 @@ public final class ChannelNode { public int maxClients = -1; /** Id of the channel's custom icon in the server's file repository, or 0 for none. */ 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. */ public final List children = new ArrayList<>(); @@ -26,4 +28,35 @@ public final class ChannelNode { this.id = id; 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 familyIds(boolean family) { + List ids = new ArrayList<>(); + collectIds(family, ids); + return ids; + } + + private void collectIds(boolean family, List into) { + into.add(id); + if (!family) return; + for (ChannelNode child : children) child.collectIds(true, into); + } } 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 f23c67b..e73cc43 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 @@ -2,6 +2,7 @@ package com.ts3client.net; import com.github.manevolent.ts3j.api.Channel; 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.parameter.CommandSingleParameter; import com.github.manevolent.ts3j.event.*; @@ -24,6 +25,9 @@ import com.ts3client.sound.SoundNotifier; import java.io.File; import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; @@ -534,6 +538,28 @@ public final class TeamspeakConnection implements TS3Listener { }, "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 channelIds, boolean subscribed) { + if (channelIds.isEmpty()) return; + List ids = List.copyOf(channelIds); + String name = subscribed ? "channelsubscribe" : "channelunsubscribe"; + new Thread(() -> { + try { + List 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) { new Thread(() -> { 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 public void onServerEdit(ServerEditedEvent e) { if (has(e, "virtualserver_name")) model.setServerName(e.get("virtualserver_name")); 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 7025a07..4c8188b 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 @@ -20,9 +20,47 @@ final class ChannelMenu { join.addActionListener(a -> actions.joinChannel(channel.id)); menu.add(join); menu.addSeparator(); + addSubscriptionItems(menu, channel, actions); JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER")); files.addActionListener(a -> actions.browseFiles(channel)); menu.add(files); 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); + } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java index 2c5bbe1..f0d84e6 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java @@ -32,6 +32,18 @@ public final class Icons { 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 */ public static ImageIcon of(String key, int size) { return IconTheme.icon(key, size); @@ -46,6 +58,18 @@ public final class Icons { 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) { BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB); Graphics2D g = img.createGraphics(); @@ -62,18 +86,26 @@ public final class Icons { return themed("SERVER_GREEN", Icons::paintServer); } - public static ImageIcon channel() { - return themed("CHANNEL_GREEN", g -> paintChannel(g, new Color(0x3E7CB1), false)); + public static ImageIcon channel(boolean subscribed) { + return themed(keys("CHANNEL_GREEN", subscribed), + g -> paintChannel(g, new Color(0x3E7CB1), false, subscribed)); } /** A channel nobody can join without its password. */ - public static ImageIcon channelLocked() { - return themed("CHANNEL_PRIVATE", g -> paintChannel(g, new Color(0x8A6D3B), true)); + public static ImageIcon channelLocked(boolean subscribed) { + return themed(keys("CHANNEL_PRIVATE", subscribed), + g -> paintChannel(g, new Color(0x8A6D3B), true, subscribed)); } /** A channel that has reached its client limit. */ - public static ImageIcon channelFull() { - return themed("CHANNEL_RED", g -> paintChannel(g, new Color(0xA53F3F), false)); + public static ImageIcon channelFull(boolean subscribed) { + 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 ---- @@ -172,16 +204,24 @@ public final class Icons { g.fillOval(4, 10, 2, 2); } - private static void paintChannel(Graphics2D g, Color c, boolean lock) { - g.setColor(c); - g.setStroke(new BasicStroke(1.6f)); - // simple speaker-cone glyph - g.fillRect(2, 6, 3, 4); + /** + * The speaker-cone glyph. An unsubscribed channel is drawn hollow and without the + * sound waves, so the two states stay apart even without an icon pack. + */ + private static void paintChannel(Graphics2D g, Color c, boolean lock, boolean subscribed) { int[] xs = {5, 9, 9, 5}; int[] ys = {6, 3, 13, 10}; - g.fillPolygon(xs, ys, 4); - g.setStroke(new BasicStroke(1.4f)); - g.drawArc(9, 5, 4, 6, -60, 120); + 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.setStroke(new BasicStroke(1.4f)); + g.drawArc(9, 5, 4, 6, -60, 120); + } else { + g.drawRect(2, 6, 3, 4); + g.drawPolygon(xs, ys, 4); + } if (lock) { g.setColor(new Color(0xB8860B)); g.fillRoundRect(10, 9, 5, 5, 1, 1); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index bf02adc..d7461f4 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -354,6 +354,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { 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 public void browseFiles(ChannelNode channel) { if (!conn.canTransferFiles()) return; 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 2d4c96d..224501a 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 @@ -53,6 +53,13 @@ public final class ServerTreePanel extends JScrollPane { /** Open the file repository browser for a 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); /** 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) { - if (c.hasPassword) return Icons.channelLocked(); - if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(); - return Icons.channel(); + if (c.hasPassword) return Icons.channelLocked(c.subscribed); + if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed); + return Icons.channel(c.subscribed); } /** The client's state, in the order the official client gives them priority. */