Files
ts3j/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
ericek111 46206e3047 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>
2026-08-14 13:57:25 +00:00

503 lines
17 KiB
Java

package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ConnectionListener;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink;
import javax.swing.JComponent;
import javax.swing.JOptionPane;
import javax.swing.JSplitPane;
import javax.swing.SwingUtilities;
import java.awt.Component;
/**
* One server connection and the views bound to it (tree, info and chat). The
* client keeps several of these side by side; {@link MainFrame} shows one at a
* time and owns the toolbar, menus and status bar that act on it.
*
* <p>Everything that is per-server lives here: the connection, its microphone
* and speaker mute state, the away/commander flags and the chat history.
*/
final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private final MainFrame host;
private final Settings settings;
private final IdentityStore identities;
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final ServerTreePanel treePanel;
private final ChatPanel chatPanel;
private final InfoPanel infoPanel = new InfoPanel();
private final JComponent component;
/** Label shown in the tab bar: the server name once known, the address before that. */
private String title = "New connection";
private String status = "Not connected";
private volatile boolean connecting;
/** Identity used for the current connection, so it can be saved into a bookmark. */
private String identityId = "";
private boolean micMuted;
private boolean deafened;
private boolean away;
/** Away message currently published, empty when away carries no message. */
private String awayMessage = "";
private boolean commander;
private Object currentSelection;
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
SoundNotifier sounds) {
this.host = host;
this.settings = settings;
this.identities = identities;
this.conn = new TeamspeakConnection(settings, audio, this, sounds);
this.groupIcons = new GroupIcons(conn.getIcons());
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, this);
this.chatPanel = new ChatPanel();
chatPanel.setSendHandler(this::onSendChat);
chatPanel.setLinkHandler(new ChatPanel.LinkHandler() {
@Override
public void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
ServerTab.this.onClientLink(ref, source, x, y);
}
@Override
public void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
ServerTab.this.onChannelLink(ref, source, x, y);
}
});
chatPanel.setInputEnabled(false);
JSplitPane leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
leftColumn.setResizeWeight(0.68);
leftColumn.setContinuousLayout(true);
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
split.setResizeWeight(0.55);
split.setDividerLocation(400);
split.setContinuousLayout(true);
this.component = split;
}
// ---- accessors ----
JComponent component() {
return component;
}
TeamspeakConnection connection() {
return conn;
}
ChatPanel chat() {
return chatPanel;
}
String title() {
return title;
}
String status() {
return status;
}
boolean isConnected() {
return conn.isConnected();
}
/** Connected or still connecting — i.e. this tab is not free for a new server. */
boolean isBusy() {
return connecting || conn.isConnected();
}
String address() {
return conn.getServerHost();
}
int port() {
return conn.getServerPort();
}
String identityId() {
return identityId;
}
boolean isMicMuted() {
return micMuted;
}
boolean isDeafened() {
return deafened;
}
boolean isAway() {
return away;
}
String awayMessage() {
return awayMessage;
}
boolean isCommander() {
return commander;
}
/** Path of the channel we are in, or empty when not connected. */
String currentChannelPath() {
if (!conn.isConnected()) return "";
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
return self == null ? "" : conn.getModel().channelPath(self.channelId);
}
// ---- connection lifecycle ----
/**
* @param identityId identity to use, or empty for the default one
* @param channel channel path to join on connect, or empty for the default channel
* @param channelPassword password for that channel, if any
*/
void connect(String address, int port, String nickname, String password, String identityId,
String channel, String channelPassword) {
if (isBusy()) return;
connecting = true;
title = address + ":" + port;
chatPanel.appendSystem("Connecting to " + address + ":" + port
+ (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + "");
onStatus("Loading identity…");
host.tabUpdated(this);
// Resolving may have to generate a first identity, so keep it off the EDT.
new Thread(() -> {
final IdentityEntry entry;
try {
entry = identities.resolve(settings, identityId);
} catch (Exception e) {
connecting = false;
onError("Could not load identity: " + e.getMessage());
return;
}
SwingUtilities.invokeLater(() -> {
this.identityId = entry.getId();
chatPanel.appendSystem("Using identity \"" + entry.getName() + "\".");
});
conn.connect(address, port, nickname, password, entry.getIdentity(), channel, channelPassword);
}, "identity-resolve").start();
}
void disconnect() {
if (conn.isConnected()) conn.disconnect();
}
/** Releases the background resources of a tab that is being thrown away. */
void dispose() {
conn.getIcons().shutdown();
}
/** Synchronous teardown for shutdown paths, so the server sees us leave. */
void shutdown() {
if (conn.isConnected()) conn.disconnectBlocking("Leaving");
}
// ---- self state ----
void setMicMuted(boolean muted) {
micMuted = muted;
conn.setMicMuted(muted);
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
}
void setDeafened(boolean deaf) {
deafened = deaf;
conn.setDeafened(deaf);
if (deaf) micMuted = true;
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
}
/** Hands the capture device to (or takes it from) this connection. */
void setMicrophoneActive(boolean active) {
conn.setMicrophoneActive(active);
}
/**
* @param message the away message, or null to keep the one already set — the
* message outlives coming back, so toggling away again restores it
*/
void setAway(boolean away, String message) {
this.away = away;
if (message != null) this.awayMessage = message;
conn.setAway(away, awayMessage);
chatPanel.appendSystem(!away ? "No longer away."
: awayMessage.isEmpty() ? "Away." : "Away: " + awayMessage);
}
void setCommander(boolean commander) {
this.commander = commander;
conn.setChannelCommander(commander);
}
void setNickname(String nickname) {
if (conn.isConnected()) conn.setNickname(nickname);
}
// ---- chat ----
private void onSendChat(ChatPanel.Target target, int clientId, String text) {
if (!conn.isConnected()) {
chatPanel.appendSystem("Not connected.");
return;
}
String me = settings.nickname + " (you)";
int myId = conn.getSelfClientId();
switch (target) {
case SERVER:
conn.sendServerMessage(text);
chatPanel.appendServerMessage(myId, me, text);
break;
case PRIVATE:
conn.sendPrivateMessage(clientId, text);
chatPanel.appendPrivateMessage(clientId, peerName(clientId), myId, me, text);
break;
default:
conn.sendChannelMessage(text);
chatPanel.appendChannelMessage(myId, me, text);
break;
}
}
/** A client link in the chat log was clicked: show the same menu as the tree does. */
private void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
ClientEntry client = conn.getModel().getClient(ref.id);
// The id is only valid for the session the link was made in; fall back to
// the unique id (and finally the nickname) so older links still resolve.
if (client == null || (!ref.uniqueId.isEmpty() && !ref.uniqueId.equals(client.uniqueId))) {
ClientEntry byUid = ref.uniqueId.isEmpty() ? null
: conn.getModel().findClientByUniqueId(ref.uniqueId);
if (byUid == null && !ref.name.isEmpty()) byUid = conn.getModel().findClientByName(ref.name);
if (byUid != null) client = byUid;
}
if (client == null) {
chatPanel.appendSystem("That client is no longer on the server.");
return;
}
ClientMenu.build(client, client.id == conn.getSelfClientId(), this).show(source, x, y);
}
private void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
ChannelNode channel = conn.getModel().getChannel(ref.id);
if (channel == null) {
chatPanel.appendSystem("That channel no longer exists.");
return;
}
ChannelMenu.build(channel, this).show(source, x, y);
}
private String peerName(int clientId) {
ClientEntry c = conn.getModel().getClient(clientId);
return c != null ? c.nickname : "Client " + clientId;
}
// ---- ServerTreePanel.Actions ----
@Override
public void joinChannel(int channelId) {
if (conn.isConnected()) conn.joinChannel(channelId, null);
}
@Override
public void moveClientToChannel(ClientEntry client, ChannelNode target) {
if (!conn.isConnected()) return;
if (client.id == conn.getSelfClientId()) {
conn.joinChannel(target.id, null);
} else {
conn.moveClient(client.id, target.id, null);
}
}
@Override
public void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId) {
if (conn.isConnected()) conn.moveChannel(channel.id, newParentId, orderPredecessorId);
}
@Override
public void openPrivateChat(ClientEntry client) {
chatPanel.openPrivateChat(client.id, client.nickname);
}
@Override
public void pokeClient(ClientEntry client) {
String msg = JOptionPane.showInputDialog(host, "Poke message for " + client.nickname + ":", "Poke!");
if (msg != null) conn.poke(client.id, msg);
}
@Override
public void toggleClientMute(ClientEntry client) {
if (conn.getPlayback() == null) return;
boolean now = !conn.getPlayback().isClientMuted(client.id);
conn.getPlayback().setClientMuted(client.id, now);
chatPanel.appendSystem((now ? "Muted " : "Unmuted ") + client.nickname + ".");
}
@Override
public void showConnectionInfo(ClientEntry client) {
if (!conn.isConnected()) return;
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;
new FileBrowserDialog(host, conn, channel).setVisible(true);
}
@Override
public boolean isClientLocallyMuted(int clientId) {
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
}
@Override
public void onSelectionChanged(Object userObject) {
currentSelection = userObject;
renderInfo();
if (!conn.isConnected()) return;
if (userObject instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) userObject;
if (!ch.descriptionLoaded) conn.requestChannelInfo(ch.id);
} else if (userObject instanceof ClientEntry) {
conn.requestClientInfo(((ClientEntry) userObject).id);
}
}
private void renderInfo() {
Object sel = currentSelection;
if (sel instanceof ChannelNode) {
infoPanel.showChannel((ChannelNode) sel);
} else if (sel instanceof ClientEntry) {
infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons());
} else {
infoPanel.clear();
}
}
// ---- ConnectionListener (marshal to EDT) ----
@Override
public void onStatus(String text) {
SwingUtilities.invokeLater(() -> {
status = text;
host.tabUpdated(this);
});
}
@Override
public void onConnected() {
SwingUtilities.invokeLater(() -> {
connecting = false;
treePanel.setSelfClientId(conn.getSelfClientId());
micMuted = false;
deafened = false;
away = false;
awayMessage = "";
commander = false;
chatPanel.setInputEnabled(true);
chatPanel.appendSystem("Connected.");
host.tabConnected(this);
});
}
@Override
public void onDisconnected(String reason) {
SwingUtilities.invokeLater(() -> {
connecting = false;
conn.getModel().clear();
treePanel.showDisconnected();
currentSelection = null;
infoPanel.clear();
chatPanel.setInputEnabled(false);
chatPanel.closePrivateChats();
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
host.tabDisconnected(this);
});
}
@Override
public void onModelChanged() {
SwingUtilities.invokeLater(() -> {
treePanel.rebuild();
renderInfo();
String name = conn.getModel().getServerName();
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(title)) {
title = name;
host.tabUpdated(this);
}
});
}
@Override
public void onInfoUpdated() {
SwingUtilities.invokeLater(this::renderInfo);
}
@Override
public void onIconsUpdated() {
SwingUtilities.invokeLater(() -> {
treePanel.refreshRowSizes();
renderInfo();
});
}
@Override
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
switch (scope) {
case PRIVATE:
chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message);
break;
case SERVER:
chatPanel.appendServerMessage(fromClientId, fromName, message);
break;
default:
chatPanel.appendChannelMessage(fromClientId, fromName, message);
break;
}
}
@Override
public void onTalkStateChanged(int clientId, boolean talking) {
SwingUtilities.invokeLater(treePanel::refreshVisual);
}
@Override
public void onError(String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("Error: " + message);
status = message;
host.tabUpdated(this);
});
}
@Override
public void onPoke(String fromName, String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
host.selectTab(this);
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
"Poke", JOptionPane.INFORMATION_MESSAGE);
});
}
}