package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.desktop.DesktopAudioBackend;
import com.ts3client.config.AwayMessages;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
import com.ts3client.sound.SoundNotifier;
import com.ts3client.sound.SoundPlayer;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JTextField;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridLayout;
import java.awt.KeyEventDispatcher;
import java.awt.KeyboardFocusManager;
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* The main application window: toolbar, tab bar, status bar and menus, hosting
* one {@link ServerTab} per open server connection.
*
*
Several servers can be connected at once — their incoming voice is mixed
* into the same output — but only one of them owns the microphone at a time
* ("active" tab, marked in the tab strip and switched from the toolbar).
*/
public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private final Settings settings;
private final Bookmarks bookmarks = Bookmarks.load();
private final AwayMessages awayMessages = AwayMessages.load();
private final IdentityStore identities;
private final AudioBackend audio = new DesktopAudioBackend();
/** Sound pack playback, shared by every connection. */
private final SoundNotifier sounds;
private final SoundPlayer soundPlayer;
private final List tabs = new ArrayList<>();
private final ServerTabPane tabPane = new ServerTabPane(this);
/** The tab whose views are on screen. */
private ServerTab selected;
/** The tab that owns the capture device; null when nobody is capturing. */
private ServerTab micTab;
private JMenu bookmarksMenu;
private JCheckBoxMenuItem awayItem;
private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem;
private javax.swing.Timer statusTimer;
private final JLabel statusLabel = new JLabel("Not connected");
private final JLabel codecLabel = new JLabel();
private JToolBar toolbar;
private JButton connectButton;
private JButton disconnectButton;
private JToggleButton activeButton;
private JToggleButton micButton;
private JToggleButton speakerButton;
private DropDownToggleButton awayButton;
private boolean pttPressed;
/** Guards {@link #shutdown()} so the window listener and JVM hook don't both run it. */
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
private final Thread shutdownHook = new Thread(this::shutdown, "ts3j-shutdown");
public MainFrame(Settings settings) {
super("TS3J — TeamSpeak 3 Java Client");
this.settings = settings;
this.identities = IdentityStore.load(settings);
this.sounds = new SoundNotifier(settings);
this.soundPlayer = audio.createSoundPlayer(settings);
this.sounds.setPlayer(soundPlayer);
setIconImage(Icons.app().getImage());
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
shutdown();
System.exit(0);
}
});
// Catches Ctrl+C / SIGTERM so we still leave the servers cleanly.
Runtime.getRuntime().addShutdownHook(shutdownHook);
setMinimumSize(new Dimension(720, 480));
setJMenuBar(buildMenuBar());
toolbar = buildToolbar();
add(toolbar, BorderLayout.NORTH);
// Switching the icon pack in the options dialog re-decorates the whole window.
IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons));
add(tabPane, BorderLayout.CENTER);
add(buildStatusBar(), BorderLayout.SOUTH);
codecLabel.setText(audio.description());
ServerTab first = newTab();
selectTab(first);
first.chat().appendSystem("Welcome to the TS3J Swing client.");
first.chat().appendSystem("Use Connections → Connect to join a server.");
installPushToTalk();
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
statusTimer.start();
setSize(880, 560);
setLocationRelativeTo(null);
// Deferred so the window is on screen before we start connecting.
List startup = bookmarks.startupBookmarks();
if (!startup.isEmpty()) {
SwingUtilities.invokeLater(() -> {
for (Bookmark b : startup) connectToBookmark(b);
});
}
}
// ---- UI construction ----
private JMenuBar buildMenuBar() {
JMenuBar bar = new JMenuBar();
JMenu connections = new JMenu("Connections");
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT"));
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
connect.addActionListener(e -> showConnectDialog());
JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
disconnect.addActionListener(e -> doDisconnect());
JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON"));
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
closeTab.addActionListener(e -> closeTab(selected));
JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
quit.addActionListener(e -> {
shutdown();
System.exit(0);
});
connections.add(connect);
connections.add(disconnect);
connections.add(closeTab);
connections.addSeparator();
connections.add(quit);
bookmarksMenu = new JMenu("Bookmarks");
rebuildBookmarksMenu();
JMenu self = new JMenu("Self");
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
mute.addActionListener(e -> micButton.doClick());
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK"));
deaf.addActionListener(e -> speakerButton.doClick());
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
awayItem.addActionListener(e -> toggleAway());
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
awayStatusItem.addActionListener(e -> setAwayStatus());
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
commanderItem.addActionListener(e -> {
if (selected != null) selected.setCommander(commanderItem.isSelected());
});
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
nick.addActionListener(e -> changeNickname());
self.add(mute);
self.add(deaf);
self.addSeparator();
self.add(awayItem);
self.add(awayStatusItem);
self.add(commanderItem);
self.addSeparator();
self.add(nick);
JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> showIdentities());
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
options.addActionListener(e -> showSettings());
tools.add(identitiesItem);
tools.addSeparator();
tools.add(options);
JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
about.addActionListener(e -> showAbout());
help.add(about);
bar.add(connections);
bar.add(bookmarksMenu);
bar.add(self);
bar.add(tools);
bar.add(help);
return bar;
}
private void rebuildBookmarksMenu() {
bookmarksMenu.removeAll();
for (Bookmark b : bookmarks.all()) {
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
item.addActionListener(e -> connectToBookmark(b));
bookmarksMenu.add(item);
}
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD"));
addCurrent.addActionListener(e -> addCurrentServerBookmark());
JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER"));
manage.addActionListener(e -> new BookmarksDialog(this, bookmarks, identities,
this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true));
bookmarksMenu.add(addCurrent);
bookmarksMenu.add(manage);
}
private JToolBar buildToolbar() {
JToolBar tb = new JToolBar();
tb.setFloatable(false);
tb.setBackground(Theme.TOOLBAR_BG);
tb.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
connectButton = new JButton(Icons.connect());
connectButton.setToolTipText("Connect to a server");
connectButton.addActionListener(e -> showConnectDialog());
disconnectButton = new JButton(Icons.disconnect());
disconnectButton.setToolTipText("Disconnect");
disconnectButton.addActionListener(e -> doDisconnect());
activeButton = new JToggleButton(Icons.micActive());
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)");
activeButton.addActionListener(e -> {
if (selected != null && selected.isConnected()) setMicTab(selected);
updateToolbar();
});
micButton = new JToggleButton(Icons.mic());
micButton.setToolTipText("Mute / unmute microphone on this server");
micButton.addActionListener(e -> {
if (selected == null) return;
selected.setMicMuted(micButton.isSelected());
updateToolbar();
});
speakerButton = new JToggleButton(Icons.speaker());
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
speakerButton.addActionListener(e -> {
if (selected == null) return;
selected.setDeafened(speakerButton.isSelected());
updateToolbar();
});
awayButton = new DropDownToggleButton(Icons.away(),
"Away on this server (the arrow offers the global actions and presets)",
this::buildAwayMenu);
awayButton.addActionListener(e -> {
if (selected == null) return;
// The plain toggle carries no message; the menu is where messages are chosen.
selected.setAway(awayButton.isSelected(), "");
updateToolbar();
});
JButton settingsButton = new JButton(Icons.settings());
settingsButton.setToolTipText("Options");
settingsButton.addActionListener(e -> showSettings());
tb.add(connectButton);
tb.add(disconnectButton);
tb.addSeparator();
tb.add(activeButton);
tb.add(micButton);
tb.add(speakerButton);
tb.add(awayButton);
tb.addSeparator();
tb.add(settingsButton);
tb.add(Box.createHorizontalGlue());
return tb;
}
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
private void rebuildIcons() {
setIconImage(Icons.app().getImage());
setJMenuBar(buildMenuBar());
remove(toolbar);
toolbar = buildToolbar();
add(toolbar, BorderLayout.NORTH);
updateToolbar();
refreshTabs();
revalidate();
repaint();
}
private JPanel buildStatusBar() {
JPanel bar = new JPanel(new BorderLayout());
bar.setBackground(Theme.STATUS_BG);
bar.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
statusLabel.setFont(Theme.UI_FONT);
codecLabel.setFont(Theme.UI_FONT);
codecLabel.setForeground(Theme.CHAT_SYSTEM);
bar.add(statusLabel, BorderLayout.WEST);
bar.add(codecLabel, BorderLayout.EAST);
return bar;
}
// ---- tab management ----
private ServerTab newTab() {
ServerTab tab = new ServerTab(this, settings, identities, audio, sounds);
tabs.add(tab);
tabPane.addTab(tab);
refreshTabs();
return tab;
}
/** The tab a new connection should use: the current one if it is free, else a new one. */
private ServerTab tabForNewConnection() {
if (selected != null && !selected.isBusy()) return selected;
ServerTab tab = newTab();
selectTab(tab);
return tab;
}
@Override
public void selectTab(ServerTab tab) {
if (tab == null || !tabs.contains(tab)) return;
selected = tab;
tabPane.setSelected(tab);
refreshTabs();
updateToolbar();
updateStatusLabel();
}
@Override
public void closeTab(ServerTab tab) {
if (tab == null) return;
tab.disconnect();
if (tabs.size() == 1) return; // always keep one view around
tabs.remove(tab);
tab.dispose();
tabPane.removeTab(tab);
if (micTab == tab) micTab = null;
if (selected == tab) {
selected = null;
selectTab(tabs.get(0));
} else {
selectTab(selected); // the pane rebuilt itself; restore the selection
}
assignMicrophoneIfFree();
}
/** Moves the capture device to {@code tab}, taking it from whoever held it. */
private void setMicTab(ServerTab tab) {
if (micTab == tab) return;
ServerTab previous = micTab;
micTab = tab;
refreshTabs();
// Closing and reopening the capture line can block briefly; keep it off the EDT.
new Thread(() -> {
if (previous != null) previous.setMicrophoneActive(false);
if (tab != null) tab.setMicrophoneActive(true);
}, "mic-handover").start();
}
/** Gives the microphone to some connected tab when nobody holds it. */
private void assignMicrophoneIfFree() {
if (micTab != null && tabs.contains(micTab) && micTab.isConnected()) return;
micTab = null;
for (ServerTab t : tabs) {
if (t.isConnected()) {
setMicTab(t);
return;
}
}
refreshTabs();
}
/** Repaints the tab labels (title, microphone marker). */
private void refreshTabs() {
tabPane.refresh(micTab);
}
// ---- callbacks from ServerTab ----
/** A tab's title, status or connection state changed. */
void tabUpdated(ServerTab tab) {
if (!tabs.contains(tab)) return;
refreshTabs();
if (tab == selected) {
updateToolbar();
updateStatusLabel();
}
}
void tabConnected(ServerTab tab) {
if (!tabs.contains(tab)) return;
assignMicrophoneIfFree();
tabUpdated(tab);
}
void tabDisconnected(ServerTab tab) {
if (!tabs.contains(tab)) return;
// A disconnected tab cannot capture; hand the microphone on if it held it.
if (micTab == tab) micTab = null;
assignMicrophoneIfFree();
tabUpdated(tab);
}
// ---- push to talk ----
private void installPushToTalk() {
KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(new KeyEventDispatcher() {
@Override
public boolean dispatchKeyEvent(KeyEvent e) {
if (settings.inputMode != Settings.InputMode.PUSH_TO_TALK) return false;
ServerTab tab = micTab;
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return false;
if (e.getKeyCode() != settings.pushToTalkKey) return false;
if (e.getID() == KeyEvent.KEY_PRESSED && !pttPressed) {
pttPressed = true;
tab.connection().getMicrophone().setPushToTalk(true);
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
pttPressed = false;
tab.connection().getMicrophone().setPushToTalk(false);
}
return false;
}
});
}
// ---- actions ----
private void showConnectDialog() {
ConnectDialog dlg = new ConnectDialog(this, settings, identities);
dlg.setVisible(true);
if (!dlg.isConfirmed()) return;
startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword(),
dlg.getIdentityId(), "", "");
}
/**
* Opens a connection in the current tab when it is free, otherwise in a new one.
*
* @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
*/
private void startConnection(String address, int port, String nickname, String password, String identityId,
String channel, String channelPassword) {
settings.lastAddress = address + ":" + port;
settings.nickname = nickname;
settings.serverPassword = password;
settings.save();
ServerTab tab = tabForNewConnection();
// The first connection to come up takes the microphone; later ones are muted
// until the user activates them.
if (micTab == null) setMicTab(tab);
tab.connect(address, port, nickname, password, identityId, channel, channelPassword);
}
private void connectToBookmark(Bookmark b) {
String nick = (b.nickname != null && !b.nickname.isBlank()) ? b.nickname : settings.nickname;
startConnection(b.address, b.port, nick, b.password, b.identityId, b.channel, b.channelPassword);
}
private void addCurrentServerBookmark() {
if (selected == null || !selected.isConnected()) {
JOptionPane.showMessageDialog(this, "Connect to a server first.",
"Add bookmark", JOptionPane.INFORMATION_MESSAGE);
return;
}
String addr = selected.address();
int port = selected.port();
String channelPath = selected.currentChannelPath();
JTextField labelField = new JTextField(selected.title());
JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty());
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
form.add(new JLabel("Bookmark label:"));
form.add(labelField);
if (!channelPath.isEmpty()) form.add(joinChannel);
if (JOptionPane.showConfirmDialog(this, form, "Add bookmark",
JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) != JOptionPane.OK_OPTION) {
return;
}
Bookmark bookmark = new Bookmark(labelField.getText().trim(), addr, port,
settings.nickname, settings.serverPassword);
bookmark.identityId = selected.identityId();
if (joinChannel.isSelected()) bookmark.channel = channelPath;
bookmarks.add(bookmark);
bookmarks.save();
rebuildBookmarksMenu();
}
/** The away button's drop-down: the global actions, the presets and their editor. */
private JPopupMenu buildAwayMenu() {
JPopupMenu menu = new JPopupMenu();
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), isGloballyAway());
globally.setEnabled(anyConnected);
globally.addActionListener(e -> setAwayEverywhere(globally.isSelected(), null));
JMenuItem globalStatus = new JMenuItem("Set Globally Away Status…", Icons.of("EDIT"));
globalStatus.setEnabled(anyConnected);
globalStatus.addActionListener(e -> {
String message = askAwayMessage(currentAwayMessage());
if (message != null) setAwayEverywhere(true, message);
});
menu.add(globally);
menu.add(globalStatus);
menu.addSeparator();
for (String preset : awayMessages.all()) {
JMenuItem item = new JMenuItem(preset);
item.setEnabled(anyConnected);
item.addActionListener(e -> setAwayEverywhere(true, preset));
menu.add(item);
}
if (!awayMessages.all().isEmpty()) menu.addSeparator();
JMenuItem manage = new JMenuItem("Manage away messages…", Icons.of("EDIT"));
manage.addActionListener(e -> new AwayMessagesDialog(this, awayMessages, null).setVisible(true));
menu.add(manage);
return menu;
}
private void toggleAway() {
if (selected == null) return;
selected.setAway(awayItem.isSelected(), "");
updateToolbar();
}
/** Sets the away message on the selected server only. */
private void setAwayStatus() {
if (selected == null) return;
String message = askAwayMessage(selected.awayMessage());
if (message == null) return;
selected.setAway(true, message);
updateToolbar();
}
private void setAwayEverywhere(boolean away, String message) {
for (ServerTab tab : tabs) {
if (tab.isConnected()) tab.setAway(away, message);
}
updateToolbar();
}
/** @return true when every connected server is marked away (and there is one) */
private boolean isGloballyAway() {
boolean any = false;
for (ServerTab tab : tabs) {
if (!tab.isConnected()) continue;
any = true;
if (!tab.isAway()) return false;
}
return any;
}
/** The message to preload the prompt with: the selected tab's, else any set one. */
private String currentAwayMessage() {
if (selected != null && !selected.awayMessage().isEmpty()) return selected.awayMessage();
for (ServerTab tab : tabs) {
if (tab.isConnected() && !tab.awayMessage().isEmpty()) return tab.awayMessage();
}
return "";
}
/** @return the entered message (possibly empty), or null when cancelled */
private String askAwayMessage(String initial) {
return (String) JOptionPane.showInputDialog(this, "Away message (optional):", "Away status",
JOptionPane.PLAIN_MESSAGE, Icons.of("AWAY"), null, initial);
}
private void doDisconnect() {
if (selected != null) selected.disconnect();
}
/**
* Tear everything down before the process exits: disconnect from every server
* synchronously (so the "Leaving" notification actually reaches them) and stop
* background timers. Idempotent and safe to call from any thread — the window
* close handler, the Quit menu and the JVM shutdown hook may all invoke it.
*/
private void shutdown() {
if (!shuttingDown.compareAndSet(false, true)) return;
if (statusTimer != null) statusTimer.stop();
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
} catch (IllegalStateException ignored) {
// Already shutting down (hook itself is running); nothing to remove.
}
for (ServerTab tab : new ArrayList<>(tabs)) {
tab.shutdown();
}
soundPlayer.shutdown();
}
private void showIdentities() {
new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true);
}
private void showSettings() {
// Device and voice-gating changes apply to the capturing connection and the
// playback of the visible one; the rest pick the new settings up on connect.
SettingsDialog dlg = new SettingsDialog(this, settings,
micTab == null ? null : micTab.connection().getMicrophone(),
selected == null ? null : selected.connection().getPlayback(),
sounds,
this::applyOutputSettingsToAllTabs);
dlg.setVisible(true);
}
/** Master volume / output device are global, so push them to every open connection. */
private void applyOutputSettingsToAllTabs() {
soundPlayer.setOutputDevice(settings.outputDevice);
for (ServerTab tab : tabs) {
if (tab.connection().getPlayback() == null) continue;
tab.connection().getPlayback().setMasterVolume(settings.outputVolume);
tab.connection().getPlayback().setOutputDevice(settings.outputDevice);
}
}
private void changeNickname() {
String n = JOptionPane.showInputDialog(this, "New nickname:", settings.nickname);
if (n != null && !n.trim().isEmpty()) {
settings.nickname = n.trim();
settings.save();
for (ServerTab tab : tabs) tab.setNickname(settings.nickname);
}
}
private void showAbout() {
JOptionPane.showMessageDialog(this,
"TS3J Swing Client\n\n" +
"An open-source TeamSpeak 3 desktop client built on the ts3j\n" +
"reverse-engineered protocol library, with native Opus voice,\n" +
"voice-activation detection and push-to-talk.\n\n" +
codecLabel.getText(),
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
}
// ---- toolbar / status ----
private void updateToolbar() {
boolean connected = selected != null && selected.isConnected();
disconnectButton.setEnabled(connected);
micButton.setEnabled(connected);
speakerButton.setEnabled(connected);
activeButton.setEnabled(connected);
awayItem.setEnabled(connected);
awayStatusItem.setEnabled(connected);
// The menu's actions are global, so the arrow stays live while any server is up.
awayButton.setEnabled(tabs.stream().anyMatch(ServerTab::isConnected));
awayButton.setToggleEnabled(connected);
commanderItem.setEnabled(connected);
boolean micMuted = connected && selected.isMicMuted();
boolean deaf = connected && selected.isDeafened();
micButton.setSelected(micMuted);
micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
speakerButton.setSelected(deaf);
speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker());
activeButton.setSelected(selected != null && selected == micTab);
boolean away = connected && selected.isAway();
awayItem.setSelected(away);
awayButton.setSelected(away);
commanderItem.setSelected(connected && selected.isCommander());
}
private void updateStatusLabel() {
statusLabel.setText(selected == null ? "Not connected" : selected.status());
}
private void updateConnectionStatus() {
ServerTab tab = selected;
if (tab == null || !tab.isConnected()) return;
int users = tab.connection().getModel().clientCount();
StringBuilder s = new StringBuilder("Connected to ")
.append(tab.connection().getModel().getServerName())
.append(" | ").append(users).append(users == 1 ? " user" : " users");
double ping = tab.connection().getPingMillis();
if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms");
if (tab != micTab) s.append(" | microphone on another tab");
statusLabel.setText(s.toString());
}
}