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:
@@ -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<ChannelNode> 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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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) {
|
||||
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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user