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.net.ChannelNode; import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundPlayer; import javax.swing.BorderFactory; import javax.swing.JCheckBox; import javax.swing.JCheckBoxMenuItem; 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.SwingUtilities; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.GridLayout; 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 MainMenuBar menuBar; private javax.swing.Timer statusTimer; /** Mirrors the active server's own client state next to the clock. */ private TrayController tray; private MainToolbar toolbar; private StatusBar statusBar; private boolean pttPressed; /** 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); this.sounds = new SoundNotifier(settings); this.soundPlayer = audio.createSoundPlayer(settings); this.sounds.setPlayer(soundPlayer); this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices); 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) { quit(); } }); // Catches Ctrl+C / SIGTERM so we still leave the servers cleanly. Runtime.getRuntime().addShutdownHook(shutdownHook); setMinimumSize(new Dimension(720, 480)); tray = new TrayController(this, this::quit); 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); 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(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.setCommander(commander); } @Override public void onChangeNickname() { changeNickname(); } @Override public void onShowIdentities() { showIdentities(); } @Override public void onShowSettings() { showSettings(); } @Override public void onShowAbout() { showAbout(); } @Override public void onConnectBookmark(Bookmark bookmark) { connectToBookmark(bookmark); } @Override public void onAddCurrentServerBookmark() { addCurrentServerBookmark(); } @Override public void onManageBookmarks() { new BookmarksDialog(MainFrame.this, bookmarks, identities, 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.setMicMuted(muted); updateToolbar(); } @Override public void onDeafenToggle(boolean deafened) { if (selected == null) return; selected.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() { setIconImage(Icons.app().getImage()); 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); 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(); updateTray(); // 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(); } 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; 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); } // ---- 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.isConnected()) out.add(selected); return out; } for (ServerTab tab : tabs) { if (tab.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) { pttPressed = talking; ServerTab tab = micTab; if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return; tab.connection().getMicrophone().setPushToTalk(talking); } void moveMicrophoneToSelectedTab() { if (selected != null && selected.isConnected()) setMicTab(selected); 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(); for (ServerTab tab : tabs) tab.setNickname(settings.nickname); } void browseCurrentChannel() { if (selected == null || !selected.isConnected()) return; ChannelNode channel = selected.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.isConnected()) { out.addAll(selected.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) { 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(); 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 = 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(boolean away) { if (selected == null) return; selected.setAway(away, ""); 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(); hotkeys.dispose(); if (tray != null) tray.dispose(); } /** Leaves every server and ends the process. */ private void quit() { shutdown(); System.exit(0); } 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, hotkeys, 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.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.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.isConnected(); boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected); boolean micMuted = connected && selected.isMicMuted(); boolean deaf = connected && selected.isDeafened(); boolean active = selected != null && selected == micTab; boolean away = connected && selected.isAway(); boolean commander = connected && selected.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() { if (micTab != null && micTab.isConnected()) return micTab; return selected != null && selected.isConnected() ? selected : null; } private void updateTray() { if (tray == null) return; ServerTab tab = trayTab(); if (tab == null) { tray.update(SelfState.DISCONNECTED, null); } else { tray.update(tab.selfState(), tab.title()); } } private void updateStatusLabel() { statusBar.setStatus(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"); statusBar.setStatus(s.toString()); } }