package com.ts3client.ui; import com.formdev.flatlaf.util.UIScale; import com.ts3client.audio.AudioBackend; import com.ts3client.audio.desktop.DesktopAudioBackend; import com.ts3client.config.AwayMessages; import com.ts3client.config.BanReasonPresets; import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmarks; import com.ts3client.config.IdentityStore; import com.ts3client.config.Settings; import com.ts3client.myts.MyTeamSpeakLogin; import com.ts3client.teamspeak.TeamSpeakImporter; import com.ts3client.contacts.ContactStore; import com.ts3client.net.ChannelNode; import com.ts3client.session.SelfState; import com.ts3client.session.ServerSession; import com.ts3client.session.Sessions; import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundPlayer; import javax.swing.AbstractAction; import javax.swing.JCheckBox; import javax.swing.JCheckBoxMenuItem; import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JMenuItem; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JTextField; import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.GridLayout; import java.awt.event.ActionEvent; 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 MyTeamSpeakLogin myTeamSpeak = MyTeamSpeakLogin.load(); /** Friends and blocked clients, shared by every connection. */ private final ContactStore contacts = ContactStore.load(); 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<>(); /** The tabs' sessions: who holds the microphone, and the state shared by every server. */ private final Sessions sessions = new Sessions(() -> { refreshTabs(); updateTray(); }); private final ServerTabPane tabPane = new ServerTabPane(this); /** The tab whose views are on screen. */ private ServerTab selected; private BanReasonPresets banReasonPresets; private MainMenuBar menuBar; private javax.swing.Timer statusTimer; /** Mirrors the active server's own client state next to the clock; set once the window is up. */ private volatile TrayController tray; private MainToolbar toolbar; private StatusBar statusBar; /** Global hotkeys: the bindings, the matching engine and the platform input hook. */ private final HotkeyService hotkeys; /** 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); new TeamSpeakImporter(identities, bookmarks, settings).seedIfFresh(); this.sounds = new SoundNotifier(settings); this.soundPlayer = audio.createSoundPlayer(settings); this.sounds.setPlayer(soundPlayer); this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices); hotkeys.addKeyPressListener(() -> SwingUtilities.invokeLater(sessions::keyPressed)); contacts.addListener(() -> SwingUtilities.invokeLater(this::contactsChanged)); setIconImages(Icons.appImages()); // 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) { quit(); } @Override public void windowOpened(WindowEvent e) { // Docking waits for the panel to adopt the icon, so it must not hold up the window. SwingUtilities.invokeLater(MainFrame.this::installTray); } }); // Catches Ctrl+C / SIGTERM so we still leave the servers cleanly. Runtime.getRuntime().addShutdownHook(shutdownHook); setMinimumSize(UIScale.scale(new Dimension(720, 480))); menuBar = buildMenuBar(); setJMenuBar(menuBar); 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); // The tree and the chat each claim Ctrl+F for themselves while they have the focus; // from anywhere else it searches the chat. getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) .put(KeyStroke.getKeyStroke("control F"), "ts3-find"); getRootPane().getActionMap().put("ts3-find", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { if (selected != null) selected.chat().openFind(); } }); statusBar = new StatusBar(); statusBar.setVisible(settings.showStatusBar); add(statusBar, BorderLayout.SOUTH); statusBar.setCodec(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."); statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus()); statusTimer.start(); setSize(UIScale.scale(new Dimension(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 MainMenuBar buildMenuBar() { return new MainMenuBar(bookmarks, new MainMenuBar.Listener() { @Override public void onConnect() { showConnectDialog(); } @Override public void onDisconnect() { doDisconnect(); } @Override public void onCloseTab() { closeTab(selected); } @Override public void onQuit() { quit(); } @Override public void onToggleMic() { toolbar.clickMic(); } @Override public void onToggleSpeaker() { toolbar.clickSpeaker(); } @Override public void onAwayToggle(boolean away) { toggleAway(away); } @Override public void onAwayStatus() { setAwayStatus(); } @Override public void onCommanderToggle(boolean commander) { if (selected != null) selected.session().setCommander(commander); } @Override public void onChangeNickname() { changeNickname(); } @Override public void onSetAvatar() { if (selected != null && selected.session().isConnected()) selected.showAvatarDialog(); } @Override public void onShowIdentities() { showIdentities(); } @Override public void onShowContacts() { showContacts(null); } @Override public boolean canViewBanList() { return selected != null && selected.session().canViewBanList(); } @Override public void onShowBanList() { if (selected != null) selected.showBanList(banReasonPresets()); } @Override public void onShowSettings() { showSettings(); } @Override public void onShowAbout() { showAbout(); } @Override public void onConnectBookmark(Bookmark bookmark, boolean newTab) { if (newTab) selectTab(newTab()); connectToBookmark(bookmark); } @Override public void onAddCurrentServerBookmark() { addCurrentServerBookmark(); } @Override public void onManageBookmarks() { new BookmarksDialog(MainFrame.this, bookmarks, identities, settings, MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true); } }); } private MainToolbar buildToolbar() { return new MainToolbar(settings, new MainToolbar.Listener() { @Override public void onConnect() { showConnectDialog(); } @Override public void onDisconnect() { doDisconnect(); } @Override public void onActivate() { moveMicrophoneToSelectedTab(); } @Override public void onMicMuteToggle(boolean muted) { if (selected == null) return; selected.session().setMicMuted(muted); updateToolbar(); } @Override public void onDeafenToggle(boolean deafened) { if (selected == null) return; selected.session().setDeafened(deafened); updateToolbar(); } @Override public void onAwayToggle(boolean away) { toggleAway(away); } @Override public JPopupMenu buildAwayMenu() { return MainFrame.this.buildAwayMenu(); } @Override public void onSettings() { showSettings(); } @Override public void onMasterVolumeChanged() { applyOutputSettingsToAllTabs(); } @Override public void onStatusBarVisibilityChanged(boolean visible) { statusBar.setVisible(visible); revalidate(); repaint(); } }); } /** Rebuilds the icon-bearing chrome after the active icon pack changed. */ private void rebuildIcons() { setIconImages(Icons.appImages()); menuBar = buildMenuBar(); setJMenuBar(menuBar); remove(toolbar); toolbar = buildToolbar(); add(toolbar, BorderLayout.NORTH); updateToolbar(); refreshTabs(); if (tray != null) tray.refreshIcon(); revalidate(); repaint(); } // ---- tab management ---- private ServerTab newTab() { ServerTab tab = new ServerTab(this, settings, identities, audio, sounds, contacts); tabs.add(tab); sessions.add(tab.session()); 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.session().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.session().disconnect(); if (tabs.size() == 1) return; // always keep one view around tabs.remove(tab); tab.session().dispose(); tabPane.removeTab(tab); if (selected == tab) { selected = null; selectTab(tabs.get(0)); } else { selectTab(selected); // the pane rebuilt itself; restore the selection } sessions.remove(tab.session()); } /** The tab that holds the capture device; null when nobody is capturing. */ private ServerTab micTab() { ServerSession owner = sessions.microphoneOwner(); for (ServerTab tab : tabs) { if (tab.session() == owner) return tab; } return null; } /** 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(); } updateTray(); } /** The local client on {@code tab} started or stopped talking, muted itself, … */ void selfStateChanged(ServerTab tab) { if (tabs.contains(tab)) updateTray(); } void tabConnected(ServerTab tab) { if (!tabs.contains(tab)) return; sessions.connected(tab.session()); tabUpdated(tab); } void tabDisconnected(ServerTab tab) { if (!tabs.contains(tab)) return; sessions.disconnected(tab.session()); tabUpdated(tab); } // ---- hotkeys ---- /** * Which connections a fired hotkey reaches: with "on active server" ticked only the * selected tab, otherwise every connected one. */ List hotkeyTargets(boolean activeServerOnly) { List out = new ArrayList<>(); if (activeServerOnly) { if (selected != null && selected.session().isConnected()) out.add(selected); return out; } for (ServerTab tab : tabs) { if (tab.session().isConnected()) out.add(tab); } return out; } ServerTab selectedTab() { return selected; } List allTabs() { return new ArrayList<>(tabs); } /** Opens the microphone while a push-to-talk hotkey is held. */ void setPushToTalk(boolean talking) { sessions.setPushToTalk(talking); } void moveMicrophoneToSelectedTab() { if (selected != null && selected.session().isConnected()) sessions.giveMicrophoneTo(selected.session()); updateToolbar(); } /** Connects to the bookmark with this label; no label means the first one. */ void connectBookmark(String label, boolean newTab) { Bookmark match = null; for (Bookmark b : bookmarks.all()) { if (label == null || label.isBlank() || label.equalsIgnoreCase(b.label)) { match = b; break; } } if (match == null) return; if (newTab) selectTab(newTab()); connectToBookmark(match); } /** Selects the tab with this 1-based number, as the "Select Server Tab" action names it. */ void selectTabNumber(int number) { if (number >= 1 && number <= tabs.size()) selectTab(tabs.get(number - 1)); } void stepTab(int delta) { if (tabs.isEmpty()) return; int index = Math.max(0, tabs.indexOf(selected)); selectTab(tabs.get(Math.floorMod(index + delta, tabs.size()))); } void setSoundsMuted(boolean muted) { sounds.setMuted(muted); } boolean areSoundsMuted() { return sounds.isMuted(); } void adjustMasterVolume(double delta) { settings.outputVolume = Math.max(0, Math.min(2.0, settings.outputVolume + delta)); settings.save(); applyOutputSettingsToAllTabs(); updateStatusLabel(); } /** Applies a new nickname, or asks for one when the hotkey carries none. */ void changeNickname(String nickname) { if (nickname == null || nickname.isBlank()) { changeNickname(); return; } settings.nickname = nickname.trim(); settings.save(); sessions.setNickname(settings.nickname); } void browseCurrentChannel() { if (selected == null || !selected.session().isConnected()) return; ChannelNode channel = selected.session().currentChannel(); if (channel != null) selected.browseFiles(channel); } void reloadSkin() { IconTheme.get().reload(settings); } void bringToFront() { setVisible(true); setExtendedState(getExtendedState() & ~JFrame.ICONIFIED); toFront(); requestFocus(); } void sendToBack() { setExtendedState(getExtendedState() | JFrame.ICONIFIED); } /** * The values a parameterised hotkey action can take, for the tree in the hotkey * dialog: the saved bookmarks, the installed sound packs and the channels of the * server on screen. */ private List hotkeyArgumentChoices(com.ts3client.hotkey.HotkeyAction action) { List out = new ArrayList<>(); switch (action.argument()) { case BOOKMARK -> { for (Bookmark b : bookmarks.all()) { if (b.label != null && !b.label.isBlank()) out.add(b.label); } } case PROFILE -> { if (action == com.ts3client.hotkey.HotkeyAction.SOUNDPACK_ACTIVATE) { for (com.ts3client.sound.SoundPack pack : sounds.availablePacks()) out.add(pack.name()); } } case CHANNEL -> { if (selected != null && selected.session().isConnected()) { out.addAll(selected.session().channelPaths()); } } default -> { } } return out; } /** Repaints the chrome after a hotkey changed the local client's state. */ void refreshAfterHotkey() { updateToolbar(); updateTray(); refreshTabs(); } // ---- 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) { startConnection(address, port, nickname, password, identityId, channel, channelPassword, false); } /** * @param replaceCurrent connect on the current tab, leaving the server it is on, instead of * moving to a free tab */ private void startConnection(String address, int port, String nickname, String password, String identityId, String channel, String channelPassword, boolean replaceCurrent) { settings.lastAddress = address + ":" + port; settings.nickname = nickname; settings.serverPassword = password; settings.save(); ServerTab tab = (replaceCurrent && selected != null) ? selected : tabForNewConnection(); // The first connection to come up takes the microphone; later ones are muted // until the user activates them. if (sessions.microphoneOwner() == null) sessions.giveMicrophoneTo(tab.session()); tab.session().switchTo(address, port, nickname, password, identityId, channel, channelPassword); } /** Connects on the current tab, leaving whatever server it is on. */ 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, true); } private void addCurrentServerBookmark() { if (selected == null || !selected.session().isConnected()) { JOptionPane.showMessageDialog(this, "Connect to a server first.", "Add bookmark", JOptionPane.INFORMATION_MESSAGE); return; } String addr = selected.session().address(); int port = selected.session().port(); String channelPath = selected.session().currentChannelPath(); JTextField labelField = new JTextField(selected.session().title()); JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty()); JPanel form = new JPanel(new GridLayout(0, 1, 0, UIScale.scale(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.session().identityId(); if (joinChannel.isSelected()) bookmark.channel = channelPath; bookmarks.add(bookmark); bookmarks.save(); menuBar.rebuildBookmarks(); } /** The away button's drop-down: the global actions, the presets and their editor. */ private JPopupMenu buildAwayMenu() { JPopupMenu menu = new JPopupMenu(); boolean anyConnected = sessions.anyConnected(); JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), sessions.isAwayEverywhere()); 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(sessions.awayMessage(selected == null ? null : selected.session())); 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(boolean away) { if (selected == null) return; selected.session().setAway(away, ""); updateToolbar(); } /** Sets the away message on the selected server only. */ private void setAwayStatus() { if (selected == null) return; String message = askAwayMessage(selected.session().awayMessage()); if (message == null) return; selected.session().setAway(true, message); updateToolbar(); } private void setAwayEverywhere(boolean away, String message) { sessions.setAwayEverywhere(away, message); updateToolbar(); } /** @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.session().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(); settings.save(); // keeps the layout the user left behind (info panel height, …) try { Runtime.getRuntime().removeShutdownHook(shutdownHook); } catch (IllegalStateException ignored) { // Already shutting down (hook itself is running); nothing to remove. } sessions.shutdown(); soundPlayer.shutdown(); hotkeys.dispose(); if (tray != null) tray.dispose(); } /** Leaves every server and ends the process. */ private void quit() { shutdown(); System.exit(0); } /** Loaded the first time a ban dialog needs them, and shared by every tab from then on. */ private BanReasonPresets banReasonPresets() { if (banReasonPresets == null) banReasonPresets = BanReasonPresets.load(); return banReasonPresets; } private void showIdentities() { new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true); } Settings settings() { return settings; } /** The one contacts window, kept so a second request brings it forward instead of doubling it. */ private ContactsDialog contactsDialog; /** * Opens the contact list, or brings it forward. * * @param uniqueId the contact to select, or {@code null} to keep the current selection */ void showContacts(String uniqueId) { if (contactsDialog == null || !contactsDialog.isDisplayable()) { contactsDialog = new ContactsDialog(this, contacts, settings); } if (uniqueId != null) contactsDialog.select(uniqueId); contactsDialog.setVisible(true); contactsDialog.toFront(); } /** The contact list changed somewhere: every open server follows. */ private void contactsChanged() { for (ServerTab tab : tabs) tab.contactsChanged(); } 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, audio, sessions.microphoneOwner() == null ? null : sessions.microphoneOwner().connection().getMicrophone(), selected == null ? null : selected.connection().getPlayback(), sounds, hotkeys, new MyTeamSpeakPanel(myTeamSpeak, new TeamSpeakImporter(identities, bookmarks, settings), menuBar::rebuildBookmarks), () -> { applyOutputSettingsToAllTabs(); contactsChanged(); }); 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.effectiveOutputVolume()); 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.session().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" + statusBar.codecText(), "About TS3J", JOptionPane.INFORMATION_MESSAGE); } // ---- toolbar / status ---- private void updateToolbar() { boolean connected = selected != null && selected.session().isConnected(); boolean anyConnected = sessions.anyConnected(); boolean micMuted = connected && selected.session().isMicMuted(); boolean deaf = connected && selected.session().isDeafened(); boolean active = selected != null && selected == micTab(); boolean away = connected && selected.session().isAway(); boolean commander = connected && selected.session().isCommander(); toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away); menuBar.refresh(connected, away, commander); updateTray(); } /** * The server the tray icon speaks for: the one holding the microphone, or the * visible one when nobody is capturing. */ private ServerTab trayTab() { ServerTab micTab = micTab(); if (micTab != null && micTab.session().isConnected()) return micTab; return selected != null && selected.session().isConnected() ? selected : null; } private void installTray() { if (shuttingDown.get()) return; tray = new TrayController(this, this::quit); updateTray(); } private void updateTray() { if (tray == null) return; ServerTab tab = trayTab(); if (tab == null) { tray.update(SelfState.DISCONNECTED, null); } else { tray.update(tab.session().selfState(), tab.session().title()); } } private void updateStatusLabel() { statusBar.setStatus(selected == null ? "Not connected" : selected.session().status()); } private void updateConnectionStatus() { ServerTab tab = selected; if (tab == null || !tab.session().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"); statusBar.setStatus(s.toString()); } }