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

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

View File

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

View File

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

View File

@@ -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. */