package com.ts3client.ui; import com.ts3client.audio.AudioBackend; import com.ts3client.audio.desktop.JavaSoundAudioBackend; import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmarks; import com.ts3client.config.IdentityStore; import com.ts3client.config.Settings; 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.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 IdentityStore identities; private final AudioBackend audio = new JavaSoundAudioBackend(); 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 JCheckBoxMenuItem commanderItem; private javax.swing.Timer statusTimer; private final JLabel statusLabel = new JLabel("Not connected"); private final JLabel codecLabel = new JLabel(); private JButton connectButton; private JButton disconnectButton; private JToggleButton activeButton; private JToggleButton micButton; private JToggleButton speakerButton; 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); 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()); add(buildToolbar(), BorderLayout.NORTH); 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…"); connect.setAccelerator(KeyStroke.getKeyStroke("control S")); connect.addActionListener(e -> showConnectDialog()); JMenuItem disconnect = new JMenuItem("Disconnect"); disconnect.addActionListener(e -> doDisconnect()); JMenuItem closeTab = new JMenuItem("Close tab"); closeTab.setAccelerator(KeyStroke.getKeyStroke("control W")); closeTab.addActionListener(e -> closeTab(selected)); JMenuItem quit = new JMenuItem("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"); mute.addActionListener(e -> micButton.doClick()); JMenuItem deaf = new JMenuItem("Toggle speakers"); deaf.addActionListener(e -> speakerButton.doClick()); awayItem = new JCheckBoxMenuItem("Away"); awayItem.addActionListener(e -> toggleAway()); commanderItem = new JCheckBoxMenuItem("Channel commander"); commanderItem.addActionListener(e -> { if (selected != null) selected.setCommander(commanderItem.isSelected()); }); JMenuItem nick = new JMenuItem("Change nickname…"); nick.addActionListener(e -> changeNickname()); self.add(mute); self.add(deaf); self.addSeparator(); self.add(awayItem); self.add(commanderItem); self.addSeparator(); self.add(nick); JMenu tools = new JMenu("Tools"); JMenuItem identitiesItem = new JMenuItem("Identities…"); identitiesItem.addActionListener(e -> showIdentities()); JMenuItem options = new JMenuItem("Options…"); options.addActionListener(e -> showSettings()); tools.add(identitiesItem); tools.addSeparator(); tools.add(options); JMenu help = new JMenu("Help"); JMenuItem about = new JMenuItem("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()); item.addActionListener(e -> connectToBookmark(b)); bookmarksMenu.add(item); } if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator(); JMenuItem addCurrent = new JMenuItem("Add current server…"); addCurrent.addActionListener(e -> addCurrentServerBookmark()); JMenuItem manage = new JMenuItem("Manage bookmarks…"); 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(); }); 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.addSeparator(); tb.add(settingsButton); tb.add(Box.createHorizontalGlue()); return tb; } 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); 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); 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(); } private void toggleAway() { if (selected == null) return; boolean away = awayItem.isSelected(); String message = null; if (away) { message = JOptionPane.showInputDialog(this, "Away message (optional):", ""); if (message == null) { // cancelled awayItem.setSelected(false); return; } } selected.setAway(away, message); } 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(); } } 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(), this::applyOutputSettingsToAllTabs); dlg.setVisible(true); } /** Master volume / output device are global, so push them to every open connection. */ private void applyOutputSettingsToAllTabs() { 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); commanderItem.setEnabled(connected); boolean micMuted = connected && selected.isMicMuted(); boolean deaf = connected && selected.isDeafened(); micButton.setSelected(micMuted); micButton.setIcon(micMuted ? Icons.micMuted() : Icons.mic()); speakerButton.setSelected(deaf); speakerButton.setIcon(deaf ? Icons.speakerMuted() : Icons.speaker()); activeButton.setSelected(selected != null && selected == micTab); awayItem.setSelected(connected && selected.isAway()); 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()); } }