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.util.ArrayList; import java.util.List; 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. * *
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;
private JSplitPane leftColumn;
private int normalDividerSize;
private int savedDividerLocation = -1;
/** 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 micLocalMuted;
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);
infoPanel.setDescriptionHandler(new InfoPanel.DescriptionHandler() {
@Override
public void showInfoTab(String html, Runnable onClose) {
chatPanel.openDescriptionTab("info", Icons.channelClientPair(), "", html, onClose);
}
@Override
public void closeInfoTab() {
chatPanel.closeNoteTab("info");
}
@Override
public void setPanelHidden(boolean hidden) {
ServerTab.this.setInfoPanelHidden(hidden);
}
});
leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
leftColumn.setResizeWeight(0.68);
leftColumn.setContinuousLayout(true);
normalDividerSize = leftColumn.getDividerSize();
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 isMicLocalMuted() {
return micLocalMuted;
}
boolean isDeafened() {
return deafened;
}
boolean isAway() {
return away;
}
String awayMessage() {
return awayMessage;
}
boolean isCommander() {
return commander;
}
/** What the local client looks like on this server, for the tray icon. */
SelfState selfState() {
if (!conn.isConnected()) return SelfState.DISCONNECTED;
if (deafened) return SelfState.DEAFENED;
if (micMuted) return SelfState.MIC_MUTED;
if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED;
if (away) return SelfState.AWAY;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
boolean talking = self != null && self.talking;
if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER;
return talking ? SelfState.TALKING : SelfState.IDLE;
}
/** 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);
}
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
void setMicLocalMuted(boolean muted) {
micLocalMuted = muted;
conn.setMicLocalMuted(muted);
}
void setDeafened(boolean deaf) {
deafened = deaf;
conn.setDeafened(deaf);
if (deaf) micMuted = true;
}
/** 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);
}
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;
}
/** Joins the channel at a "/"-separated path, as the hotkey action names it. */
void joinChannelPath(String path) {
if (!conn.isConnected() || path == null || path.isBlank()) return;
ChannelNode target = conn.getModel().findChannelByPath(path);
if (target != null) conn.joinChannel(target.id, null);
}
/** Every channel of this server as a "/"-separated path, in the tree's own order. */
List