Initial commit: TS3J TeamSpeak 3 Java client

Swing desktop client (core/desktop/swing Maven modules) built on the
ts3j protocol library, included as a submodule. Native Opus voice with
voice-activation detection, push-to-talk, and audio pre-processing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 22:01:17 +00:00
commit 0f76258a05
48 changed files with 6347 additions and 0 deletions

View File

@@ -0,0 +1,575 @@
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);
});
}
}