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 channelPaths() { List out = new ArrayList<>(); if (!conn.isConnected()) return out; for (ChannelNode root : conn.getModel().buildTree()) collectPaths(root, out); return out; } private void collectPaths(ChannelNode channel, List out) { out.add(conn.getModel().channelPath(channel.id)); for (ChannelNode child : channel.children) collectPaths(child, out); } /** The channel we are in, or null when not connected. */ ChannelNode currentChannel() { if (!conn.isConnected()) return null; ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); return self == null ? null : conn.getModel().getChannel(self.channelId); } // ---- 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 kickClientFromChannel(ClientEntry client) { String reason = kickReason("Kick Client from Channel", client); if (reason != null) conn.kickFromChannel(client.id, reason); } @Override public void kickClientFromServer(ClientEntry client) { String reason = kickReason("Kick Client from Server", client); if (reason != null) conn.kickFromServer(client.id, reason); } private String kickReason(String title, ClientEntry client) { if (!conn.isConnected()) return null; return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":", ReasonDialog.KICK_REASON_LIMIT); } @Override public void banClient(ClientEntry client) { if (!conn.isConnected()) return; BanDialog dialog = new BanDialog(host, client.nickname); dialog.setVisible(true); if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason()); } @Override public void toggleClientMute(ClientEntry client) { if (conn.getPlayback() == null) return; boolean now = !conn.getPlayback().isClientMuted(client.id); conn.getPlayback().setClientMuted(client.id, now); } @Override public void findClientInTree(ClientEntry client) { host.selectTab(this); treePanel.selectClient(client.id); } @Override public void showConnectionInfo(ClientEntry client) { if (!conn.isConnected()) return; new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true); } @Override public void moveClientToOwnChannel(ClientEntry client) { if (!conn.isConnected() || client.id == conn.getSelfClientId()) return; ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); if (self != null) conn.moveClient(client.id, self.channelId, null); } @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(); } } /** Collapses the info panel to nothing while its content lives in the chat tab, or restores it. */ private void setInfoPanelHidden(boolean hidden) { if (hidden == !infoPanel.isVisible()) return; if (hidden) { savedDividerLocation = leftColumn.getDividerLocation(); infoPanel.setVisible(false); leftColumn.setDividerSize(0); leftColumn.setDividerLocation(1.0); } else { infoPanel.setVisible(true); leftColumn.setDividerSize(normalDividerSize); if (savedDividerLocation >= 0) leftColumn.setDividerLocation(savedDividerLocation); } leftColumn.revalidate(); } // ---- 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; micLocalMuted = 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.closeNoteTabs(); 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(); if (clientId == conn.getSelfClientId()) host.selfStateChanged(this); }); } @Override public void onError(String message) { SwingUtilities.invokeLater(() -> { chatPanel.appendSystem("Error: " + message); status = message; host.tabUpdated(this); }); } @Override public void onServerLog(String message) { SwingUtilities.invokeLater(() -> chatPanel.appendServerLog(message)); } @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); }); } }