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.Settings; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; import com.ts3client.net.ConnectionListener; import com.ts3client.net.TeamspeakConnection; import javax.swing.BorderFactory; import javax.swing.JButton; 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.JSplitPane; import javax.swing.JToggleButton; import javax.swing.JToolBar; import javax.swing.KeyStroke; import javax.swing.SwingUtilities; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Dimension; 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.concurrent.atomic.AtomicBoolean; /** * The main application window: toolbar, server tree, chat and status bar, * wired to a {@link TeamspeakConnection}. Resembles the TeamSpeak 3 client layout. */ public final class MainFrame extends JFrame implements ConnectionListener, ServerTreePanel.Actions { private final Settings settings; private final Bookmarks bookmarks = Bookmarks.load(); private final AudioBackend audio = new JavaSoundAudioBackend(); private TeamspeakConnection conn; private JMenu bookmarksMenu; private JCheckBoxMenuItem awayItem; private JCheckBoxMenuItem commanderItem; private javax.swing.Timer statusTimer; private final ServerTreePanel treePanel; private final ChatPanel chatPanel; private final InfoPanel infoPanel = new InfoPanel(); private Object currentSelection; private final JLabel statusLabel = new JLabel("Not connected"); private final JLabel codecLabel = new JLabel(); private JButton connectButton; private JButton disconnectButton; 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; setIconImage(Icons.app().getImage()); // We tear the connection 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 server cleanly. Runtime.getRuntime().addShutdownHook(shutdownHook); setMinimumSize(new Dimension(720, 480)); this.conn = new TeamspeakConnection(settings, audio, this); this.treePanel = new ServerTreePanel(conn.getModel(), this); this.chatPanel = new ChatPanel(); chatPanel.setSendHandler(this::onSendChat); setJMenuBar(buildMenuBar()); add(buildToolbar(), BorderLayout.NORTH); JSplitPane leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel); leftColumn.setResizeWeight(0.68); leftColumn.setContinuousLayout(true); JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel); split.setResizeWeight(0.55); split.setDividerLocation(400); split.setContinuousLayout(true); add(split, BorderLayout.CENTER); add(buildStatusBar(), BorderLayout.SOUTH); codecLabel.setText(audio.description()); chatPanel.appendSystem("Welcome to the TS3J Swing client."); chatPanel.appendSystem("Use Connections → Connect to join a server."); installPushToTalk(); updateButtons(false); statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus()); statusTimer.start(); setSize(880, 560); setLocationRelativeTo(null); } // ---- 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 quit = new JMenuItem("Quit"); quit.addActionListener(e -> { shutdown(); System.exit(0); }); connections.add(connect); connections.add(disconnect); 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 -> conn.setChannelCommander(commanderItem.isSelected())); JMenuItem rename = new JMenuItem("Change nickname…"); rename.addActionListener(e -> changeNickname()); self.add(mute); self.add(deaf); self.addSeparator(); self.add(awayItem); self.add(commanderItem); self.addSeparator(); self.add(rename); JMenu tools = new JMenu("Tools"); JMenuItem options = new JMenuItem("Options…"); options.addActionListener(e -> showSettings()); 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, 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()); micButton = new JToggleButton(Icons.mic()); micButton.setToolTipText("Mute / unmute microphone"); micButton.addActionListener(e -> { boolean muted = micButton.isSelected(); micButton.setIcon(muted ? Icons.micMuted() : Icons.mic()); conn.setMicMuted(muted); chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active."); }); speakerButton = new JToggleButton(Icons.speaker()); speakerButton.setToolTipText("Deafen / undeafen (mute speakers)"); speakerButton.addActionListener(e -> { boolean deaf = speakerButton.isSelected(); speakerButton.setIcon(deaf ? Icons.speakerMuted() : Icons.speaker()); conn.setDeafened(deaf); if (deaf) { micButton.setSelected(true); micButton.setIcon(Icons.micMuted()); } chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active."); }); JButton settingsButton = new JButton(Icons.settings()); settingsButton.setToolTipText("Options"); settingsButton.addActionListener(e -> showSettings()); tb.add(connectButton); tb.add(disconnectButton); tb.addSeparator(); tb.add(micButton); tb.add(speakerButton); tb.addSeparator(); tb.add(settingsButton); 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; } // ---- 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; if (conn == null || !conn.isConnected() || conn.getMicrophone() == null) return false; if (e.getKeyCode() != settings.pushToTalkKey) return false; if (e.getID() == KeyEvent.KEY_PRESSED && !pttPressed) { pttPressed = true; conn.getMicrophone().setPushToTalk(true); } else if (e.getID() == KeyEvent.KEY_RELEASED) { pttPressed = false; conn.getMicrophone().setPushToTalk(false); } return false; } }); } // ---- actions ---- private void showConnectDialog() { if (conn.isConnected()) { JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.", "Connect", JOptionPane.INFORMATION_MESSAGE); return; } ConnectDialog dlg = new ConnectDialog(this, settings); dlg.setVisible(true); if (!dlg.isConfirmed()) return; startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword()); } private void startConnection(String address, int port, String nickname, String password) { if (conn.isConnected()) { JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.", "Connect", JOptionPane.INFORMATION_MESSAGE); return; } settings.lastAddress = address + ":" + port; settings.nickname = nickname; settings.serverPassword = password; settings.save(); chatPanel.appendSystem("Connecting to " + address + ":" + port + " …"); conn.connect(address, port, nickname, password); } 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); } private void addCurrentServerBookmark() { String addr = settings.lastAddress; int port = 9987; int colon = addr.lastIndexOf(':'); if (colon > 0) { try { port = Integer.parseInt(addr.substring(colon + 1)); addr = addr.substring(0, colon); } catch (NumberFormatException ignored) { } } String label = JOptionPane.showInputDialog(this, "Bookmark label:", addr); if (label == null) return; bookmarks.add(new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword)); bookmarks.save(); rebuildBookmarksMenu(); } private void toggleAway() { boolean away = awayItem.isSelected(); String message = null; if (away) { message = JOptionPane.showInputDialog(this, "Away message (optional):", ""); if (message == null) { // cancelled awayItem.setSelected(false); return; } } conn.setAway(away, message); } private void doDisconnect() { if (conn.isConnected()) { conn.disconnect(); } } /** * Tear everything down before the process exits: disconnect from the server * synchronously (so the "Leaving" notification actually reaches it) 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. } if (conn.isConnected()) { conn.disconnectBlocking("Leaving"); } } private void showSettings() { SettingsDialog dlg = new SettingsDialog(this, settings, conn.getMicrophone(), conn.getPlayback(), () -> { }); dlg.setVisible(true); } private void changeNickname() { String n = JOptionPane.showInputDialog(this, "New nickname:", settings.nickname); if (n != null && !n.trim().isEmpty()) { settings.nickname = n.trim(); settings.save(); if (conn.isConnected()) conn.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); } private void onSendChat(ChatPanel.Target target, String text) { if (!conn.isConnected()) { chatPanel.appendSystem("Not connected."); return; } if (target == ChatPanel.Target.SERVER) { conn.sendServerMessage(text); } else { conn.sendChannelMessage(text); } chatPanel.appendMessage(settings.nickname + " (you)", text); } private void updateButtons(boolean connected) { connectButton.setEnabled(!connected); disconnectButton.setEnabled(connected); micButton.setEnabled(connected); speakerButton.setEnabled(connected); awayItem.setEnabled(connected); commanderItem.setEnabled(connected); chatPanel.setInputEnabled(connected); } private void updateConnectionStatus() { if (!conn.isConnected()) return; int users = conn.getModel().clientCount(); StringBuilder s = new StringBuilder("Connected to ") .append(conn.getModel().getServerName()) .append(" | ").append(users).append(users == 1 ? " user" : " users"); double ping = conn.getPingMillis(); if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms"); statusLabel.setText(s.toString()); } // ---- ServerTreePanel.Actions ---- @Override public void joinChannel(int channelId) { if (conn.isConnected()) conn.joinChannel(channelId, null); } @Override public void openPrivateChat(ClientEntry client) { String msg = JOptionPane.showInputDialog(this, "Message to " + client.nickname + ":"); if (msg != null && !msg.trim().isEmpty()) { conn.sendPrivateMessage(client.id, msg.trim()); chatPanel.appendMessage("You → " + client.nickname, msg.trim()); } } @Override public void pokeClient(ClientEntry client) { String msg = JOptionPane.showInputDialog(this, "Poke message for " + client.nickname + ":", "Poke!"); if (msg != null) { conn.poke(client.id, msg); } } @Override public void toggleClientMute(ClientEntry client) { if (conn.getPlayback() == null) return; boolean now = !conn.getPlayback().isClientMuted(client.id); conn.getPlayback().setClientMuted(client.id, now); chatPanel.appendSystem((now ? "Muted " : "Unmuted ") + client.nickname + "."); } @Override public void showConnectionInfo(ClientEntry client) { if (!conn.isConnected()) return; new ConnectionInfoDialog(this, conn, client.id, client.nickname).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()); } else { infoPanel.clear(); } } // ---- ConnectionListener (marshal to EDT) ---- @Override public void onStatus(String status) { SwingUtilities.invokeLater(() -> statusLabel.setText(status)); } @Override public void onConnected() { SwingUtilities.invokeLater(() -> { updateButtons(true); treePanel.setSelfClientId(conn.getSelfClientId()); micButton.setSelected(false); micButton.setIcon(Icons.mic()); speakerButton.setSelected(false); speakerButton.setIcon(Icons.speaker()); awayItem.setSelected(false); commanderItem.setSelected(false); chatPanel.appendSystem("Connected."); }); } @Override public void onDisconnected(String reason) { SwingUtilities.invokeLater(() -> { updateButtons(false); conn.getModel().clear(); treePanel.showDisconnected(); currentSelection = null; infoPanel.clear(); chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason)); }); } @Override public void onModelChanged() { SwingUtilities.invokeLater(() -> { treePanel.rebuild(); renderInfo(); }); } @Override public void onInfoUpdated() { SwingUtilities.invokeLater(this::renderInfo); } @Override public void onChat(ChatScope scope, int fromClientId, String fromName, String message) { String prefix = scope == ChatScope.PRIVATE ? "[PM] " : scope == ChatScope.SERVER ? "[Server] " : ""; chatPanel.appendMessage(prefix + fromName, message); } @Override public void onTalkStateChanged(int clientId, boolean talking) { SwingUtilities.invokeLater(treePanel::refreshVisual); } @Override public void onError(String message) { SwingUtilities.invokeLater(() -> { chatPanel.appendSystem("Error: " + message); statusLabel.setText(message); }); } @Override public void onPoke(String fromName, String message) { SwingUtilities.invokeLater(() -> { chatPanel.appendSystem("You were poked by " + fromName + ": " + message); JOptionPane.showMessageDialog(this, fromName + " poked you:\n\n" + message, "Poke", JOptionPane.INFORMATION_MESSAGE); }); } }