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>
503 lines
17 KiB
Java
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);
|
|
});
|
|
}
|
|
}
|