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,247 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import javax.swing.ImageIcon;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTree;
import javax.swing.SwingUtilities;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.TreePath;
import java.awt.Component;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
/**
* The server view: a tree of channels each containing its clients, styled to
* resemble the TeamSpeak 3 client. Talk state colours clients green live.
*/
public final class ServerTreePanel extends JScrollPane {
/** Actions the tree can request of the controller. */
public interface Actions {
void joinChannel(int channelId);
void openPrivateChat(ClientEntry client);
void pokeClient(ClientEntry client);
void toggleClientMute(ClientEntry client);
void showConnectionInfo(ClientEntry client);
boolean isClientLocallyMuted(int clientId);
/** A channel or client node was selected (or {@code null} when cleared). */
void onSelectionChanged(Object userObject);
}
private final JTree tree;
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
private final ServerModel model;
private final Actions actions;
private int selfClientId = -1;
public ServerTreePanel(ServerModel model, Actions actions) {
this.model = model;
this.actions = actions;
root.setUserObject("Not connected");
this.tree = new JTree(treeModel);
tree.setRootVisible(true);
tree.setShowsRootHandles(true);
tree.setRowHeight(20);
tree.setBackground(Theme.TREE_BG);
tree.setFont(Theme.UI_FONT);
tree.setCellRenderer(new Renderer());
setViewportView(tree);
getViewport().setBackground(Theme.TREE_BG);
tree.addTreeSelectionListener(e -> {
TreePath path = tree.getSelectionPath();
Object obj = null;
if (path != null) {
obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
}
actions.onSelectionChanged(obj);
});
tree.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
maybePopup(e);
}
@Override
public void mouseReleased(MouseEvent e) {
maybePopup(e);
}
@Override
public void mouseClicked(MouseEvent e) {
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
Object obj = nodeAt(e);
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
actions.joinChannel(((ChannelNode) obj).id);
}
}
}
});
}
public void setSelfClientId(int id) {
this.selfClientId = id;
}
private Object nodeAt(MouseEvent e) {
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
if (path == null) return null;
DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent();
return n.getUserObject();
}
private void maybePopup(MouseEvent e) {
if (!e.isPopupTrigger()) return;
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
if (path == null) return;
tree.setSelectionPath(path);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
if (obj instanceof ClientEntry) {
showClientMenu((ClientEntry) obj, e);
} else if (obj instanceof ChannelNode) {
showChannelMenu((ChannelNode) obj, e);
}
}
private void showClientMenu(ClientEntry client, MouseEvent e) {
JPopupMenu menu = new JPopupMenu();
if (client.id != selfClientId) {
JMenuItem pm = new JMenuItem("Open text chat");
pm.addActionListener(a -> actions.openPrivateChat(client));
menu.add(pm);
JMenuItem poke = new JMenuItem("Poke");
poke.addActionListener(a -> actions.pokeClient(client));
menu.add(poke);
menu.addSeparator();
boolean muted = actions.isClientLocallyMuted(client.id);
JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client");
mute.addActionListener(a -> actions.toggleClientMute(client));
menu.add(mute);
} else {
JMenuItem self = new JMenuItem("This is you");
self.setEnabled(false);
menu.add(self);
}
menu.addSeparator();
JMenuItem info = new JMenuItem("Connection Info");
info.addActionListener(a -> actions.showConnectionInfo(client));
menu.add(info);
menu.show(tree, e.getX(), e.getY());
}
private void showChannelMenu(ChannelNode channel, MouseEvent e) {
if (Spacers.isSpacer(channel.name)) return; // spacers aren't interactive
JPopupMenu menu = new JPopupMenu();
JMenuItem join = new JMenuItem("Join channel");
join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join);
menu.show(tree, e.getX(), e.getY());
}
/** Rebuilds the tree from the model, preserving full expansion. */
public void rebuild() {
root.setUserObject(model.getServerName());
root.removeAllChildren();
List<ChannelNode> roots = model.buildTree();
for (ChannelNode c : roots) {
root.add(buildChannel(c));
}
treeModel.reload();
for (int i = 0; i < tree.getRowCount(); i++) {
tree.expandRow(i);
}
}
private DefaultMutableTreeNode buildChannel(ChannelNode c) {
DefaultMutableTreeNode node = new DefaultMutableTreeNode(c);
for (ClientEntry client : c.clients) {
node.add(new DefaultMutableTreeNode(client));
}
for (ChannelNode child : c.children) {
node.add(buildChannel(child));
}
return node;
}
/** Clears the tree back to the disconnected placeholder state. */
public void showDisconnected() {
root.setUserObject("Not connected");
root.removeAllChildren();
treeModel.reload();
}
/** Repaint only (e.g. talk-state changes) without rebuilding structure. */
public void refreshVisual() {
tree.repaint();
}
private final class Renderer extends DefaultTreeCellRenderer {
@Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
boolean expanded, boolean leaf, int row,
boolean hasFocus) {
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
setBackgroundNonSelectionColor(Theme.TREE_BG);
setBackgroundSelectionColor(Theme.TREE_SELECTION);
setBorderSelectionColor(Theme.TREE_SELECTION);
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
if (obj instanceof ChannelNode) {
ChannelNode c = (ChannelNode) obj;
Spacers.Spacer spacer = Spacers.parse(c.name);
if (spacer != null) {
setText(Spacers.render(spacer, 40));
setIcon(null);
setForeground(Theme.IDLE_CLIENT);
setFont(Theme.UI_FONT);
} else {
setText(c.name);
setIcon(c.hasPassword ? Icons.channelLocked() : Icons.channel());
setForeground(Theme.CHANNEL_TEXT);
setFont(Theme.UI_BOLD);
}
} else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
String label = cl.nickname;
if (primaryGroup != null) label += " [" + primaryGroup + "]";
setText(label);
setIcon(iconFor(cl));
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
setFont(cl.talking ? Theme.UI_BOLD : Theme.UI_FONT);
} else {
// root / server
setText(String.valueOf(obj));
setIcon(Icons.server());
setForeground(Theme.SERVER_TEXT);
setFont(Theme.UI_BOLD);
}
return this;
}
private ImageIcon iconFor(ClientEntry cl) {
if (cl.outputMuted) return Icons.speakerMuted();
if (cl.inputMuted) return Icons.micMuted();
if (cl.away) return Icons.clientAway();
if (cl.talking) return Icons.clientTalking();
return Icons.clientIdle();
}
}
}