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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user