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

84
ts3-client/swing/pom.xml Normal file
View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-parent</artifactId>
<version>0.1.0</version>
</parent>
<artifactId>ts3-client-swing</artifactId>
<name>TS3J Client Swing Frontend</name>
<description>Swing desktop UI</description>
<properties>
<exec.mainClass>com.ts3client.Main</exec.mainClass>
</properties>
<dependencies>
<dependency>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-core</artifactId>
</dependency>
<dependency>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-desktop</artifactId>
</dependency>
<dependency>
<groupId>com.github.manevolent</groupId>
<artifactId>ts3j</artifactId>
</dependency>
</dependencies>
<build>
<finalName>ts3-client</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.ts3client.Main</mainClass>
<manifestEntries>
<Enable-Native-Access>ALL-UNNAMED</Enable-Native-Access>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<mainClass>com.ts3client.Main</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,33 @@
package com.ts3client;
import com.ts3client.config.Settings;
import com.ts3client.ui.MainFrame;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
/**
* Application entry point. Loads settings, applies the system look-and-feel and
* shows the main window on the Swing event dispatch thread.
*/
public final class Main {
public static void main(String[] args) {
// ts3j is verbose by default; keep the console quiet unless debugging.
try {
com.github.manevolent.ts3j.util.Ts3Debugging.setEnabled(false);
} catch (Throwable ignored) {
}
final Settings settings = Settings.load();
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception ignored) {
// fall back to cross-platform L&F
}
new MainFrame(settings).setVisible(true);
});
}
}

View File

@@ -0,0 +1,155 @@
package com.ts3client.ui;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridLayout;
import java.util.function.Consumer;
/** Manage saved servers: add, edit, remove and quick-connect. */
public final class BookmarksDialog extends JDialog {
private final Bookmarks bookmarks;
private final Consumer<Bookmark> onConnect;
private final Runnable onChanged;
private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>();
private final JList<Bookmark> list = new JList<>(listModel);
public BookmarksDialog(Frame owner, Bookmarks bookmarks, Consumer<Bookmark> onConnect, Runnable onChanged) {
super(owner, "Manage Bookmarks", true);
this.bookmarks = bookmarks;
this.onConnect = onConnect;
this.onChanged = onChanged;
reload();
list.setVisibleRowCount(10);
JScrollPane scroll = new JScrollPane(list);
scroll.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JPanel buttons = new JPanel();
buttons.setLayout(new BoxLayout(buttons, BoxLayout.Y_AXIS));
buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8));
addButton(buttons, "Connect", this::connectSelected);
addButton(buttons, "Add…", this::addBookmark);
addButton(buttons, "Edit…", this::editSelected);
addButton(buttons, "Remove", this::removeSelected);
buttons.add(Box.createVerticalGlue());
addButton(buttons, "Close", this::dispose);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(scroll, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.EAST);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(420, 300));
setLocationRelativeTo(owner);
}
private void addButton(JPanel panel, String text, Runnable action) {
JButton b = new JButton(text);
b.setAlignmentX(LEFT_ALIGNMENT);
b.setMaximumSize(new Dimension(Integer.MAX_VALUE, b.getPreferredSize().height));
b.addActionListener(e -> action.run());
panel.add(b);
panel.add(Box.createVerticalStrut(4));
}
private void reload() {
listModel.clear();
for (Bookmark b : bookmarks.all()) listModel.addElement(b);
}
private void connectSelected() {
Bookmark b = list.getSelectedValue();
if (b != null) {
onConnect.accept(b);
dispose();
}
}
private void addBookmark() {
Bookmark b = new Bookmark("", "", 9987, System.getProperty("user.name", "TS3J User"), "");
if (promptBookmark(b)) {
bookmarks.add(b);
persistAndRefresh();
}
}
private void editSelected() {
Bookmark b = list.getSelectedValue();
if (b != null && promptBookmark(b)) {
persistAndRefresh();
}
}
private void removeSelected() {
int idx = list.getSelectedIndex();
if (idx >= 0) {
bookmarks.remove(idx);
persistAndRefresh();
}
}
private void persistAndRefresh() {
bookmarks.save();
reload();
if (onChanged != null) onChanged.run();
}
/** Modal add/edit form. Mutates {@code b} and returns whether the user confirmed. */
private boolean promptBookmark(Bookmark b) {
JTextField label = new JTextField(b.label == null ? "" : b.label);
JTextField address = new JTextField(b.address == null ? "" : b.address);
JTextField port = new JTextField(Integer.toString(b.port));
JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname);
JPasswordField password = new JPasswordField(b.password == null ? "" : b.password);
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
form.add(new JLabel("Label:"));
form.add(label);
form.add(new JLabel("Address:"));
form.add(address);
form.add(new JLabel("Port:"));
form.add(port);
form.add(new JLabel("Nickname:"));
form.add(nick);
form.add(new JLabel("Password (optional):"));
form.add(password);
int result = JOptionPane.showConfirmDialog(this, form,
"Bookmark", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
if (result != JOptionPane.OK_OPTION) return false;
if (address.getText().trim().isEmpty()) {
JOptionPane.showMessageDialog(this, "Address is required.");
return false;
}
b.label = label.getText().trim();
b.address = address.getText().trim();
try {
b.port = Integer.parseInt(port.getText().trim());
} catch (NumberFormatException e) {
b.port = 9987;
}
b.nickname = nick.getText().trim();
b.password = new String(password.getPassword());
return true;
}
}

View File

@@ -0,0 +1,127 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* Chat log with an input line and a target selector (current channel or whole
* server). Coloured styling mimics the TS3 chat pane.
*/
public final class ChatPanel extends JPanel {
/** Where an outgoing message should go. */
public enum Target {CHANNEL, SERVER}
public interface SendHandler {
void send(Target target, String text);
}
private final JTextPane log = new JTextPane();
private final JTextField input = new JTextField();
private final JComboBox<String> targetBox = new JComboBox<>(new String[]{"Channel", "Server"});
private final SimpleDateFormat time = new SimpleDateFormat("HH:mm:ss");
private SendHandler sendHandler;
public ChatPanel() {
super(new BorderLayout());
log.setEditable(false);
log.setBackground(Theme.CHAT_BG);
log.setFont(Theme.UI_FONT);
log.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
JScrollPane scroll = new JScrollPane(log);
scroll.setBorder(BorderFactory.createLineBorder(new Color(0xD0D0D0)));
add(scroll, BorderLayout.CENTER);
JPanel bottom = new JPanel(new BorderLayout(4, 0));
bottom.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0));
targetBox.setPreferredSize(new Dimension(90, 24));
bottom.add(targetBox, BorderLayout.WEST);
bottom.add(input, BorderLayout.CENTER);
JButton send = new JButton("Send");
bottom.add(send, BorderLayout.EAST);
add(bottom, BorderLayout.SOUTH);
Runnable doSend = this::fireSend;
send.addActionListener(e -> doSend.run());
input.addActionListener(e -> doSend.run());
setInputEnabled(false);
}
public void setSendHandler(SendHandler h) {
this.sendHandler = h;
}
public void setInputEnabled(boolean enabled) {
input.setEnabled(enabled);
targetBox.setEnabled(enabled);
}
private void fireSend() {
String text = input.getText().trim();
if (text.isEmpty() || sendHandler == null) return;
Target t = targetBox.getSelectedIndex() == 1 ? Target.SERVER : Target.CHANNEL;
sendHandler.send(t, text);
input.setText("");
}
// ---- append helpers (safe from any thread) ----
public void appendSystem(String text) {
edt(() -> append("[" + time.format(new Date()) + "] ", Theme.CHAT_SYSTEM, false,
text, Theme.CHAT_SYSTEM, false));
}
public void appendMessage(String from, String text) {
edt(() -> {
append("[" + time.format(new Date()) + "] ", Theme.CHAT_SYSTEM, false, "", Theme.CHAT_SYSTEM, false);
append(from + ": ", Theme.CHAT_NAME, true, text, Theme.CHAT_TEXT, false);
});
}
private void edt(Runnable r) {
if (SwingUtilities.isEventDispatchThread()) r.run();
else SwingUtilities.invokeLater(r);
}
private void append(String prefix, Color prefixColor, boolean prefixBold,
String body, Color bodyColor, boolean bodyBold) {
StyledDocument doc = log.getStyledDocument();
try {
if (prefix != null && !prefix.isEmpty()) {
doc.insertString(doc.getLength(), prefix, style(prefixColor, prefixBold));
}
if (body != null && !body.isEmpty()) {
doc.insertString(doc.getLength(), body, style(bodyColor, bodyBold));
}
doc.insertString(doc.getLength(), "\n", style(bodyColor, false));
log.setCaretPosition(doc.getLength());
} catch (BadLocationException ignored) {
}
}
private static SimpleAttributeSet style(Color c, boolean bold) {
SimpleAttributeSet a = new SimpleAttributeSet();
StyleConstants.setForeground(a, c);
StyleConstants.setBold(a, bold);
StyleConstants.setFontFamily(a, "SansSerif");
StyleConstants.setFontSize(a, 12);
return a;
}
}

View File

@@ -0,0 +1,119 @@
package com.ts3client.ui;
import com.ts3client.config.Settings;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JTextField;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
/** Modal "Connect to Server" dialog. */
public final class ConnectDialog extends JDialog {
private final JTextField addressField;
private final JTextField portField;
private final JTextField nickField;
private final JPasswordField passwordField;
private boolean confirmed;
public ConnectDialog(Frame owner, Settings settings) {
super(owner, "Connect to Server", true);
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) {
}
}
addressField = new JTextField(addr, 18);
portField = new JTextField(Integer.toString(port), 6);
nickField = new JTextField(settings.nickname, 18);
passwordField = new JPasswordField(settings.serverPassword, 18);
JPanel form = new JPanel(new GridBagLayout());
form.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
int row = 0;
add(form, c, row++, "Server address:", addressField);
add(form, c, row++, "Port:", portField);
add(form, c, row++, "Nickname:", nickField);
add(form, c, row++, "Password (optional):", passwordField);
JPanel buttons = new JPanel(new BorderLayout());
JPanel right = new JPanel();
JButton connect = new JButton("Connect");
JButton cancel = new JButton("Cancel");
connect.addActionListener(e -> {
confirmed = true;
dispose();
});
cancel.addActionListener(e -> dispose());
right.add(connect);
right.add(cancel);
buttons.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(connect);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(form, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack();
setMinimumSize(new Dimension(340, getHeight()));
setLocationRelativeTo(owner);
}
private void add(JPanel form, GridBagConstraints c, int row, String label, java.awt.Component field) {
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
form.add(new JLabel(label), c);
c.gridx = 1;
c.weightx = 1;
form.add(field, c);
}
public boolean isConfirmed() {
return confirmed;
}
public String getAddress() {
return addressField.getText().trim();
}
public int getPort() {
try {
return Integer.parseInt(portField.getText().trim());
} catch (NumberFormatException e) {
return 9987;
}
}
public String getNickname() {
String n = nickField.getText().trim();
return n.isEmpty() ? "TS3J User" : n;
}
public String getPassword() {
return new String(passwordField.getPassword());
}
}

View File

@@ -0,0 +1,326 @@
package com.ts3client.ui;
import com.ts3client.net.ConnectionStats;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTabbedPane;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.text.DecimalFormat;
/**
* Live "Client Connection Info" window, reachable from a client's right-click
* menu. Mirrors the TeamSpeak 3 dialog: a summary header (address, version,
* platform, idle/connected time, ping, filetransfer) above a
* <em>Total / Speech / Keep Alive / Control</em> tab strip, each tab showing
* that category's packet loss, packet and byte totals and live bandwidth.
*
* <p>Polls the {@link TeamspeakConnection} once a second while open; for the
* local client the figures update live from local counters, for a remote client
* they refresh from the server's connection report.
*/
public final class ConnectionInfoDialog extends JDialog {
private static final int REFRESH_MS = 1000;
private final TeamspeakConnection conn;
private final int clientId;
private final javax.swing.Timer timer;
private final java.util.concurrent.atomic.AtomicBoolean inFlight =
new java.util.concurrent.atomic.AtomicBoolean();
private final JLabel addressValue = value();
private final JLabel versionValue = value();
private final JLabel platformValue = value();
private final JLabel idleValue = value();
private final JLabel connectedValue = value();
private final JLabel pingValue = value();
private final JLabel filetransferValue = value();
private final KindTab totalTab = new KindTab();
private final KindTab speechTab = new KindTab();
private final KindTab keepAliveTab = new KindTab();
private final KindTab controlTab = new KindTab();
public ConnectionInfoDialog(Frame owner, TeamspeakConnection conn, int clientId, String nickname) {
super(owner, "Connection Info — " + nickname, false);
this.conn = conn;
this.clientId = clientId;
JPanel content = new JPanel(new BorderLayout(0, 10));
content.setBackground(Theme.WINDOW_BG);
content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
content.add(buildSummary(), BorderLayout.NORTH);
content.add(buildTabs(), BorderLayout.CENTER);
content.add(buildButtons(), BorderLayout.SOUTH);
setContentPane(content);
pack();
setLocationRelativeTo(owner);
addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
timer.stop();
}
});
timer = new javax.swing.Timer(REFRESH_MS, e -> refresh());
timer.setInitialDelay(0);
timer.start();
}
// ---- construction ----
private JPanel buildSummary() {
JPanel grid = new JPanel(new GridBagLayout());
grid.setBackground(Theme.WINDOW_BG);
int row = 0;
addRow(grid, row++, "Address", addressValue);
addRow(grid, row++, "Client version", versionValue);
addRow(grid, row++, "Platform", platformValue);
addRow(grid, row++, "Ping", pingValue);
addRow(grid, row++, "Idle time", idleValue);
addRow(grid, row++, "Connected", connectedValue);
addRow(grid, row, "Filetransfer (↑/↓)", filetransferValue);
return grid;
}
private JTabbedPane buildTabs() {
JTabbedPane tabs = new JTabbedPane();
tabs.setFont(Theme.UI_FONT);
tabs.setBackground(Theme.WINDOW_BG);
tabs.addTab("Total", totalTab);
tabs.addTab("Speech", speechTab);
tabs.addTab("Keep Alive", keepAliveTab);
tabs.addTab("Control", controlTab);
return tabs;
}
private JPanel buildButtons() {
JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG);
bar.add(Box.createHorizontalGlue());
JButton close = new JButton("Close");
close.addActionListener(e -> dispose());
bar.add(close);
return bar;
}
private static JLabel value() {
JLabel l = new JLabel("");
l.setFont(Theme.UI_FONT);
l.setForeground(Theme.TREE_TEXT);
return l;
}
private static void addRow(JPanel grid, int row, String label, JLabel valueLabel) {
GridBagConstraints lc = new GridBagConstraints();
lc.gridx = 0;
lc.gridy = row;
lc.anchor = GridBagConstraints.WEST;
lc.insets = new Insets(2, 0, 2, 16);
JLabel key = new JLabel(label);
key.setFont(Theme.UI_FONT);
key.setForeground(new Color(0x5A6B7B));
grid.add(key, lc);
GridBagConstraints vc = new GridBagConstraints();
vc.gridx = 1;
vc.gridy = row;
vc.weightx = 1;
vc.anchor = GridBagConstraints.WEST;
vc.fill = GridBagConstraints.HORIZONTAL;
vc.insets = new Insets(2, 0, 2, 0);
grid.add(valueLabel, vc);
}
// ---- live update ----
private void refresh() {
if (!conn.isConnected()) {
timer.stop();
return;
}
if (!inFlight.compareAndSet(false, true)) return; // a previous poll is still running
conn.requestConnectionInfo(clientId, stats -> SwingUtilities.invokeLater(() -> {
inFlight.set(false);
apply(stats);
}));
}
private void apply(ConnectionStats s) {
if (!isDisplayable()) return;
addressValue.setText(s.self ? "This computer" : orDash(s.ip));
versionValue.setText(orDash(s.version));
platformValue.setText(orDash(s.platform));
pingValue.setText(formatPing(s.pingMs, s.pingDeviationMs));
idleValue.setText(formatDuration(s.idleTimeMs));
connectedValue.setText(formatDuration(s.connectedTimeMs));
filetransferValue.setText(formatRate(s.filetransferBandwidthSent)
+ " / " + formatRate(s.filetransferBandwidthReceived));
totalTab.update(totalSample(s));
speechTab.update(kindSample(s, ConnectionStats.Kind.SPEECH));
keepAliveTab.update(kindSample(s, ConnectionStats.Kind.KEEPALIVE));
controlTab.update(kindSample(s, ConnectionStats.Kind.CONTROL));
growToFit();
}
/**
* The window is first packed around placeholder text; once real (longer)
* values arrive it may need more room. Grow to fit, but never shrink, so the
* window doesn't jitter as counters tick up each second.
*/
private void growToFit() {
Dimension pref = getContentPane().getPreferredSize();
Dimension have = getContentPane().getSize();
if (pref.width > have.width || pref.height > have.height) {
pack();
}
}
private static Sample totalSample(ConnectionStats s) {
Sample sample = new Sample();
sample.packetLoss = s.packetLoss;
sample.packetsSent = s.packetsSentTotal;
sample.packetsReceived = s.packetsReceivedTotal;
sample.bytesSent = s.bytesSentTotal;
sample.bytesReceived = s.bytesReceivedTotal;
sample.bwSentSecond = s.bandwidthSentLastSecond;
sample.bwReceivedSecond = s.bandwidthReceivedLastSecond;
sample.bwSentMinute = s.bandwidthSentLastMinute;
sample.bwReceivedMinute = s.bandwidthReceivedLastMinute;
return sample;
}
private static Sample kindSample(ConnectionStats s, ConnectionStats.Kind kind) {
Sample sample = new Sample();
ConnectionStats.KindStats k = s.kind(kind);
if (k == null) return sample; // all fields stay unknown (-1)
sample.packetLoss = k.packetLoss;
sample.packetsSent = k.packetsSent;
sample.packetsReceived = k.packetsReceived;
sample.bytesSent = k.bytesSent;
sample.bytesReceived = k.bytesReceived;
sample.bwSentSecond = k.bandwidthSentLastSecond;
sample.bwReceivedSecond = k.bandwidthReceivedLastSecond;
sample.bwSentMinute = k.bandwidthSentLastMinute;
sample.bwReceivedMinute = k.bandwidthReceivedLastMinute;
return sample;
}
// ---- one category tab ----
/** Numeric snapshot fed to a {@link KindTab}; -1 means "unknown". */
private static final class Sample {
double packetLoss = -1;
long packetsSent = -1;
long packetsReceived = -1;
long bytesSent = -1;
long bytesReceived = -1;
long bwSentSecond = -1;
long bwReceivedSecond = -1;
long bwSentMinute = -1;
long bwReceivedMinute = -1;
}
private static final class KindTab extends JPanel {
private final JLabel packetLoss = value();
private final JLabel packets = value();
private final JLabel bytes = value();
private final JLabel bandwidthSecond = value();
private final JLabel bandwidthMinute = value();
KindTab() {
super(new GridBagLayout());
setBackground(Theme.WINDOW_BG);
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
int row = 0;
addRow(this, row++, "Packet loss", packetLoss);
addRow(this, row++, "Packets (↑/↓)", packets);
addRow(this, row++, "Transferred (↑/↓)", bytes);
addRow(this, row++, "Bandwidth · last second (↑/↓)", bandwidthSecond);
addRow(this, row, "Bandwidth · last minute (↑/↓)", bandwidthMinute);
}
void update(Sample s) {
packetLoss.setText(formatLoss(s.packetLoss));
packets.setText(formatCount(s.packetsSent) + " / " + formatCount(s.packetsReceived));
bytes.setText(formatBytes(s.bytesSent) + " / " + formatBytes(s.bytesReceived));
bandwidthSecond.setText(formatRate(s.bwSentSecond) + " / " + formatRate(s.bwReceivedSecond));
bandwidthMinute.setText(formatRate(s.bwSentMinute) + " / " + formatRate(s.bwReceivedMinute));
}
}
// ---- formatting ----
private static final DecimalFormat GROUPED = new DecimalFormat("#,##0");
private static String orDash(String s) {
return (s == null || s.isEmpty()) ? "" : s;
}
private static String formatPing(double ms, double deviationMs) {
if (ms < 0) return "";
String base = Math.round(ms) + " ms";
if (deviationMs > 0) base += "" + Math.round(deviationMs) + " ms)";
return base;
}
private static String formatLoss(double fraction) {
if (fraction < 0) return "";
return new DecimalFormat("0.00").format(fraction * 100.0) + " %";
}
private static String formatCount(long n) {
return n < 0 ? "" : GROUPED.format(n);
}
private static String formatBytes(long bytes) {
if (bytes < 0) return "";
if (bytes < 1024) return bytes + " B";
double kib = bytes / 1024.0;
if (kib < 1024) return new DecimalFormat("0.0").format(kib) + " KiB";
double mib = kib / 1024.0;
if (mib < 1024) return new DecimalFormat("0.00").format(mib) + " MiB";
return new DecimalFormat("0.00").format(mib / 1024.0) + " GiB";
}
private static String formatRate(long bytesPerSecond) {
return bytesPerSecond < 0 ? "" : formatBytes(bytesPerSecond) + "/s";
}
private static String formatDuration(long ms) {
if (ms < 0) return "";
long totalSeconds = ms / 1000;
long days = totalSeconds / 86400;
long hours = (totalSeconds % 86400) / 3600;
long minutes = (totalSeconds % 3600) / 60;
long seconds = totalSeconds % 60;
StringBuilder sb = new StringBuilder();
if (days > 0) sb.append(days).append("d ");
if (days > 0 || hours > 0) sb.append(hours).append("h ");
if (days > 0 || hours > 0 || minutes > 0) sb.append(minutes).append("m ");
sb.append(seconds).append("s");
return sb.toString();
}
}

View File

@@ -0,0 +1,196 @@
package com.ts3client.ui;
import javax.swing.ImageIcon;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
/**
* Programmatically drawn vector icons (no external image assets), so the client
* is fully self-contained. All icons are rendered at 16&times;16 with a small
* cache.
*/
public final class Icons {
private static final int SZ = 16;
private Icons() {
}
private interface Painter {
void paint(Graphics2D g);
}
private static ImageIcon make(Painter p) {
BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
p.paint(g);
g.dispose();
return new ImageIcon(img);
}
// ---- tree icons ----
public static ImageIcon server() {
return make(g -> {
g.setColor(new Color(0x2C6EA5));
g.fillRoundRect(2, 3, 12, 4, 2, 2);
g.fillRoundRect(2, 9, 12, 4, 2, 2);
g.setColor(new Color(0x9FD0F0));
g.fillOval(4, 4, 2, 2);
g.fillOval(4, 10, 2, 2);
});
}
public static ImageIcon channel() {
return channelPainted(new Color(0x3E7CB1), false);
}
public static ImageIcon channelLocked() {
return channelPainted(new Color(0x8A6D3B), true);
}
private static ImageIcon channelPainted(Color c, boolean lock) {
return make(g -> {
g.setColor(c);
g.setStroke(new BasicStroke(1.6f));
// simple speaker-cone glyph
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120);
if (lock) {
g.setColor(new Color(0xB8860B));
g.fillRoundRect(10, 9, 5, 5, 1, 1);
g.setColor(Color.WHITE);
g.fillRect(12, 10, 1, 2);
}
});
}
// ---- client status icons ----
public static ImageIcon clientIdle() {
return person(Theme.IDLE_CLIENT);
}
public static ImageIcon clientTalking() {
return person(Theme.TALKING);
}
public static ImageIcon clientAway() {
return person(Theme.AWAY);
}
private static ImageIcon person(Color c) {
return make(g -> {
g.setColor(c);
g.fillOval(5, 2, 6, 6); // head
g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders
});
}
public static ImageIcon micMuted() {
return make(g -> {
g.setColor(Theme.MUTED);
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14); // slash
});
}
public static ImageIcon speakerMuted() {
return make(g -> {
g.setColor(Theme.MUTED);
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
});
}
// ---- toolbar / action icons ----
public static ImageIcon connect() {
return make(g -> {
g.setColor(new Color(0x2E8B57));
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(10, 3, 10, 5);
g.drawLine(12, 3, 12, 5);
});
}
public static ImageIcon disconnect() {
return make(g -> {
g.setColor(Theme.MUTED);
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(2, 3, 6, 13);
});
}
public static ImageIcon mic() {
return make(g -> {
g.setColor(new Color(0x37474F));
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.drawLine(6, 14, 10, 14);
});
}
public static ImageIcon speaker() {
return make(g -> {
g.setColor(new Color(0x37474F));
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120);
});
}
public static ImageIcon settings() {
return make(g -> {
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(2f));
g.drawOval(5, 5, 6, 6);
for (int a = 0; a < 360; a += 45) {
double r = Math.toRadians(a);
int x1 = (int) (8 + Math.cos(r) * 5);
int y1 = (int) (8 + Math.sin(r) * 5);
int x2 = (int) (8 + Math.cos(r) * 7);
int y2 = (int) (8 + Math.sin(r) * 7);
g.drawLine(x1, y1, x2, y2);
}
});
}
public static ImageIcon app() {
return make(g -> {
g.setColor(Theme.ACCENT);
g.fillRoundRect(1, 1, 14, 14, 4, 4);
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(1.6f));
g.drawArc(4, 5, 8, 8, 30, 120);
g.drawArc(2, 3, 12, 12, 30, 120);
g.fillOval(7, 9, 2, 2);
});
}
}

View File

@@ -0,0 +1,100 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import javax.swing.BorderFactory;
import javax.swing.JEditorPane;
import javax.swing.JScrollPane;
import java.util.List;
/**
* Read-only detail view for the currently selected channel or client, mirroring
* the TeamSpeak 3 info box: channel topic/description, or a client's server and
* channel groups, platform and version.
*/
public final class InfoPanel extends JScrollPane {
private final JEditorPane pane = new JEditorPane();
public InfoPanel() {
pane.setContentType("text/html");
pane.setEditable(false);
pane.setBackground(Theme.CHAT_BG);
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
setViewportView(pane);
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
clear();
}
public void clear() {
setHtml("<i style='color:#8a8a8a'>Select a channel or client to see details.</i>");
}
public void showChannel(ChannelNode ch) {
StringBuilder sb = new StringBuilder();
sb.append(heading(esc(ch.name)));
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
if (ch.maxClients >= 0) row(sb, "Max clients", Integer.toString(ch.maxClients));
if (ch.hasPassword) row(sb, "Password", "protected");
if (!ch.topic.isEmpty()) row(sb, "Topic", esc(ch.topic));
sb.append("<hr>");
if (ch.description != null && !ch.description.isEmpty()) {
sb.append("<div>").append(multiline(ch.description)).append("</div>");
} else if (ch.descriptionLoaded) {
sb.append("<i style='color:#8a8a8a'>No description.</i>");
} else {
sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
}
setHtml(sb.toString());
}
public void showClient(ClientEntry cl, ServerModel model) {
StringBuilder sb = new StringBuilder();
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
List<String> serverGroups = model.serverGroupNames(cl.serverGroupIds);
row(sb, "Server groups", serverGroups.isEmpty() ? "" : esc(String.join(", ", serverGroups)));
String channelGroup = model.channelGroupName(cl.channelGroupId);
row(sb, "Channel group", channelGroup != null ? esc(channelGroup) : "#" + cl.channelGroupId);
if (cl.talkPower != 0) row(sb, "Talk power", Integer.toString(cl.talkPower));
if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
if (!cl.version.isEmpty()) row(sb, "Version", esc(cl.version));
if (cl.away) row(sb, "Status", "Away");
if (cl.inputMuted) row(sb, "Microphone", "muted");
if (cl.outputMuted) row(sb, "Speakers", "muted");
if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId));
if (cl.description != null && !cl.description.isEmpty()) {
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
}
setHtml(sb.toString());
}
private void setHtml(String body) {
pane.setText("<html><body style='font-family:sans-serif;font-size:11px;color:#202020'>"
+ body + "</body></html>");
pane.setCaretPosition(0);
}
private static String heading(String text) {
return "<div style='font-weight:bold;font-size:13px;margin-bottom:4px'>" + text + "</div>";
}
private static void row(StringBuilder sb, String label, String value) {
sb.append("<div style='margin:1px 0'><span style='color:#5a6b7b'>")
.append(label).append(":</span> ").append(value).append("</div>");
}
private static String multiline(String text) {
return esc(text).replace("\n", "<br>");
}
private static String esc(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}

View File

@@ -0,0 +1,69 @@
package com.ts3client.ui;
import javax.swing.JComponent;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
/**
* Horizontal audio level meter (dBFS) with an optional VAD threshold marker.
* The filled portion turns green once the level crosses the threshold, giving
* immediate visual feedback while tuning voice activation.
*/
public final class LevelMeter extends JComponent {
private static final double MIN_DB = -70.0;
private static final double MAX_DB = 0.0;
private volatile double levelDb = MIN_DB;
private volatile double thresholdDb = -45.0;
private volatile boolean showThreshold = true;
public LevelMeter() {
setPreferredSize(new Dimension(240, 18));
}
public void setLevel(double db) {
this.levelDb = db;
repaint();
}
public void setThreshold(double db) {
this.thresholdDb = db;
repaint();
}
public void setShowThreshold(boolean show) {
this.showThreshold = show;
repaint();
}
private int dbToX(double db, int w) {
double clamped = Math.max(MIN_DB, Math.min(MAX_DB, db));
return (int) ((clamped - MIN_DB) / (MAX_DB - MIN_DB) * w);
}
@Override
protected void paintComponent(Graphics g0) {
Graphics2D g = (Graphics2D) g0;
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
int w = getWidth();
int h = getHeight();
g.setColor(new Color(0x2B2B2B));
g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
int level = dbToX(levelDb, w - 2);
boolean over = levelDb >= thresholdDb;
g.setColor(over ? Theme.TALKING : new Color(0x5A9BD4));
g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
if (showThreshold) {
int tx = dbToX(thresholdDb, w - 2);
g.setColor(new Color(0xF0C419));
g.fillRect(tx, 1, 2, h - 3);
}
}
}

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);
});
}
}

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();
}
}
}

View File

@@ -0,0 +1,587 @@
package com.ts3client.ui;
import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.config.Settings;
import javax.sound.sampled.TargetDataLine;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import javax.swing.JTabbedPane;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.List;
/**
* Options dialog: audio device selection plus voice-activation / push-to-talk
* tuning with a live input meter. Changes are applied to the running audio
* subsystem immediately and persisted to {@link Settings} on OK.
*/
public final class SettingsDialog extends JDialog {
private final Settings settings;
private final VoiceInput liveMic;
private final VoiceOutput livePlayback;
private final Runnable onApply;
private JComboBox<String> inputCombo;
private JComboBox<String> outputCombo;
private JSlider inputGain;
private JSlider outputVol;
private JCheckBox denoiseCheck;
private JSlider denoiseLevel;
private JCheckBox typingCheck;
private JCheckBox agcCheck;
private JRadioButton vadRadio;
private JRadioButton pttRadio;
private JRadioButton contRadio;
private JComboBox<String> vadModeCombo;
private JSlider thresholdSlider;
private JSlider speechSlider;
private JCheckBox vadOverPttCheck;
private JLabel thresholdLabel;
private JLabel speechLabel;
private LevelMeter meter;
private JButton pttKeyButton;
private int pttKey;
private JSlider bitrateSlider;
private JLabel bitrateLabel;
private JSlider complexitySlider;
private JCheckBox vbrCheck;
private JCheckBox fecCheck;
private JCheckBox musicCheck;
private volatile boolean meterRunning;
private Thread meterThread;
public SettingsDialog(Frame owner, Settings settings,
VoiceInput liveMic, VoiceOutput livePlayback,
Runnable onApply) {
super(owner, "Options", true);
this.settings = settings;
this.liveMic = liveMic;
this.livePlayback = livePlayback;
this.onApply = onApply;
this.pttKey = settings.pushToTalkKey;
JTabbedPane tabs = new JTabbedPane();
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
tabs.addTab("Voice Activation", scrollable(buildVoiceTab()));
JPanel buttons = new JPanel(new BorderLayout());
JPanel right = new JPanel();
JButton ok = new JButton("OK");
JButton cancel = new JButton("Cancel");
ok.addActionListener(e -> {
apply();
close();
});
cancel.addActionListener(e -> close());
right.add(ok);
right.add(cancel);
buttons.add(right, BorderLayout.EAST);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(tabs, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosed(java.awt.event.WindowEvent e) {
stopMeter();
}
});
pack();
setSize(new Dimension(480, 540));
setLocationRelativeTo(owner);
startMeter();
}
private JPanel buildDevicesTab() {
JPanel p = new JPanel(new GridBagLayout());
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
GridBagConstraints c = gbc();
List<String> ins = AudioDevices.inputDeviceNames();
List<String> outs = AudioDevices.outputDeviceNames();
ins.add(0, "(System default)");
outs.add(0, "(System default)");
inputCombo = new JComboBox<>(ins.toArray(new String[0]));
outputCombo = new JComboBox<>(outs.toArray(new String[0]));
selectOrDefault(inputCombo, settings.inputDevice);
selectOrDefault(outputCombo, settings.outputDevice);
int row = 0;
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
addRow(p, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
addRow(p, c, row++, new JLabel("Microphone gain:"), inputGain);
addRow(p, c, row++, new JLabel("Playback volume:"), outputVol);
outputVol.addChangeListener(e -> {
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
});
inputGain.addChangeListener(e -> {
if (liveMic != null) liveMic.setInputGain(inputGain.getValue() / 100.0);
});
inputCombo.addActionListener(e -> restartMeter());
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
c.insets = new Insets(14, 4, 2, 4);
p.add(new JLabel("Noise reduction"), c);
c.insets = new Insets(4, 4, 4, 4);
c.gridwidth = 1;
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
+ "reduce the sounds made by typing.</html>");
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
+ "microphone loudness to a target level, boosting quiet mics and taming "
+ "loud ones.</html>");
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(denoiseCheck, c);
c.gridwidth = 1;
addRow(p, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(typingCheck, c);
c.gridy = row++;
p.add(agcCheck, c);
c.gridwidth = 1;
Runnable syncNoise = () -> {
denoiseLevel.setEnabled(denoiseCheck.isSelected());
if (liveMic != null) {
liveMic.setNoiseSuppression(denoiseCheck.isSelected());
liveMic.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
liveMic.setTypingAttenuation(typingCheck.isSelected());
liveMic.setAgc(agcCheck.isSelected());
}
};
denoiseCheck.addActionListener(e -> syncNoise.run());
typingCheck.addActionListener(e -> syncNoise.run());
agcCheck.addActionListener(e -> syncNoise.run());
denoiseLevel.addChangeListener(e -> {
if (liveMic != null) liveMic.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
});
syncNoise.run();
// filler
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
p.add(Box.createGlue(), c);
return p;
}
private JPanel buildVoiceTab() {
JPanel p = new JPanel(new GridBagLayout());
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
GridBagConstraints c = gbc();
vadRadio = new JRadioButton("Voice Activation Detection");
pttRadio = new JRadioButton("Push-To-Talk");
contRadio = new JRadioButton("Continuous");
ButtonGroup group = new ButtonGroup();
group.add(vadRadio);
group.add(pttRadio);
group.add(contRadio);
switch (settings.inputMode) {
case PUSH_TO_TALK:
pttRadio.setSelected(true);
break;
case CONTINUOUS:
contRadio.setSelected(true);
break;
default:
vadRadio.setSelected(true);
}
int row = 0;
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(vadRadio, c);
c.gridy = row++;
p.add(pttRadio, c);
c.gridy = row++;
p.add(contRadio, c);
c.gridwidth = 1;
meter = new LevelMeter();
meter.setThreshold(settings.vadThresholdDb);
c.gridx = 0;
c.gridy = row;
c.gridwidth = 2;
c.insets = new Insets(10, 4, 2, 4);
p.add(new JLabel("Input level (speak to test):"), c);
c.gridy = ++row;
p.add(meter, c);
c.insets = new Insets(4, 4, 4, 4);
c.gridwidth = 1;
row++;
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
addRow(p, c, row++, new JLabel("Detection:"), vadModeCombo);
thresholdSlider = new JSlider(-70, 0, (int) Math.round(settings.vadThresholdDb));
thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
thresholdSlider.addChangeListener(e -> {
meter.setThreshold(thresholdSlider.getValue());
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
if (liveMic != null) liveMic.setThresholdDb(thresholdSlider.getValue());
});
addRow(p, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
speechSlider.addChangeListener(e -> {
speechLabel.setText(speechSlider.getValue() + "%");
if (liveMic != null) liveMic.setSpeechThreshold(speechSlider.getValue() / 100.0);
});
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
pttKeyButton = new JButton(keyName(pttKey));
pttKeyButton.addActionListener(e -> capturePttKey());
addRow(p, c, row++, new JLabel("Push-to-talk key:"), pttKeyButton);
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(vadOverPttCheck, c);
c.gridwidth = 1;
bitrateSlider = new JSlider(8, 128, settings.bitrate / 1000);
bitrateLabel = new JLabel(settings.bitrate / 1000 + " kbit/s");
bitrateSlider.addChangeListener(e -> {
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
pushOpusLive();
});
JPanel brPanel = new JPanel(new BorderLayout(6, 0));
brPanel.add(bitrateSlider, BorderLayout.CENTER);
brPanel.add(bitrateLabel, BorderLayout.EAST);
addRow(p, c, row++, new JLabel("Opus bitrate:"), brPanel);
complexitySlider = new JSlider(0, 10, settings.complexity);
complexitySlider.addChangeListener(e -> pushOpusLive());
addRow(p, c, row++, new JLabel("Opus complexity:"), complexitySlider);
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
musicCheck = new JCheckBox("Music codec (higher fidelity)", settings.music);
vbrCheck.addActionListener(e -> pushOpusLive());
fecCheck.addActionListener(e -> pushOpusLive());
musicCheck.addActionListener(e -> pushOpusLive());
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(vbrCheck, c);
c.gridy = row++;
p.add(fecCheck, c);
c.gridy = row++;
p.add(musicCheck, c);
c.gridwidth = 1;
Runnable syncEnabled = () -> {
boolean vad = vadRadio.isSelected();
boolean ptt = pttRadio.isSelected();
boolean vadContext = vad || (ptt && vadOverPttCheck.isSelected());
Settings.VadMode vm = currentVadMode();
boolean usesGate = vm != Settings.VadMode.AUTOMATIC;
boolean usesSpeech = vm != Settings.VadMode.VOLUME_GATE;
vadModeCombo.setEnabled(vadContext);
thresholdSlider.setEnabled(vadContext && usesGate);
speechSlider.setEnabled(vadContext && usesSpeech);
pttKeyButton.setEnabled(ptt);
vadOverPttCheck.setEnabled(ptt);
meter.setShowThreshold(vadContext && usesGate);
if (liveMic != null) {
liveMic.setMode(ptt ? Settings.InputMode.PUSH_TO_TALK
: vad ? Settings.InputMode.VOICE_ACTIVATION
: Settings.InputMode.CONTINUOUS);
liveMic.setVadMode(vm);
liveMic.setVadOverPtt(vadOverPttCheck.isSelected());
}
};
vadRadio.addActionListener(e -> syncEnabled.run());
pttRadio.addActionListener(e -> syncEnabled.run());
contRadio.addActionListener(e -> syncEnabled.run());
vadModeCombo.addActionListener(e -> syncEnabled.run());
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
syncEnabled.run();
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
p.add(Box.createGlue(), c);
return p;
}
private static int vadModeIndex(Settings.VadMode m) {
switch (m) {
case AUTOMATIC:
return 0;
case VOLUME_GATE:
return 1;
default:
return 2;
}
}
private Settings.VadMode currentVadMode() {
switch (vadModeCombo.getSelectedIndex()) {
case 0:
return Settings.VadMode.AUTOMATIC;
case 1:
return Settings.VadMode.VOLUME_GATE;
default:
return Settings.VadMode.HYBRID;
}
}
private static javax.swing.JScrollPane scrollable(JPanel content) {
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
javax.swing.ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
sp.setBorder(null);
sp.getVerticalScrollBar().setUnitIncrement(16);
return sp;
}
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
JPanel panel = new JPanel(new BorderLayout(6, 0));
panel.add(slider, BorderLayout.CENTER);
valueLabel.setPreferredSize(new Dimension(48, valueLabel.getPreferredSize().height));
panel.add(valueLabel, BorderLayout.EAST);
return panel;
}
private OpusParameters currentOpusParameters() {
return new OpusParameters(
bitrateSlider.getValue() * 1000,
complexitySlider.getValue(),
vbrCheck.isSelected(),
fecCheck.isSelected(),
settings.packetLoss,
musicCheck.isSelected());
}
/** Applies the current Opus controls to the running encoder immediately. */
private void pushOpusLive() {
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
}
private void capturePttKey() {
pttKeyButton.setText("Press a key…");
pttKeyButton.requestFocusInWindow();
KeyAdapter ka = new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
pttKey = e.getKeyCode();
pttKeyButton.setText(keyName(pttKey));
pttKeyButton.removeKeyListener(this);
}
};
pttKeyButton.addKeyListener(ka);
}
private static String keyName(int code) {
String t = KeyEvent.getKeyText(code);
return (t == null || t.isEmpty()) ? ("Key " + code) : t;
}
private void apply() {
settings.inputDevice = comboValue(inputCombo);
settings.outputDevice = comboValue(outputCombo);
settings.inputVolume = inputGain.getValue() / 100.0;
settings.outputVolume = outputVol.getValue() / 100.0;
settings.denoise = denoiseCheck.isSelected();
settings.denoiserLevel = denoiseLevel.getValue() / 100.0;
settings.typingAttenuation = typingCheck.isSelected();
settings.agc = agcCheck.isSelected();
settings.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
: contRadio.isSelected() ? Settings.InputMode.CONTINUOUS
: Settings.InputMode.VOICE_ACTIVATION;
settings.vadMode = currentVadMode();
settings.vadThresholdDb = thresholdSlider.getValue();
settings.speechThreshold = speechSlider.getValue() / 100.0;
settings.vadOverPtt = vadOverPttCheck.isSelected();
settings.pushToTalkKey = pttKey;
settings.bitrate = bitrateSlider.getValue() * 1000;
settings.complexity = complexitySlider.getValue();
settings.vbr = vbrCheck.isSelected();
settings.fec = fecCheck.isSelected();
settings.music = musicCheck.isSelected();
settings.save();
if (liveMic != null) {
liveMic.setMode(settings.inputMode);
liveMic.setVadMode(settings.vadMode);
liveMic.setThresholdDb(settings.vadThresholdDb);
liveMic.setSpeechThreshold(settings.speechThreshold);
liveMic.setVadOverPtt(settings.vadOverPtt);
liveMic.setInputGain(settings.inputVolume);
liveMic.setNoiseSuppression(settings.denoise);
liveMic.setDenoiserLevel(settings.denoiserLevel);
liveMic.setTypingAttenuation(settings.typingAttenuation);
liveMic.setAgc(settings.agc);
liveMic.setOpusParameters(OpusParameters.from(settings));
}
if (livePlayback != null) {
livePlayback.setMasterVolume(settings.outputVolume);
livePlayback.setOutputDevice(settings.outputDevice);
}
if (onApply != null) onApply.run();
}
private void close() {
stopMeter();
dispose();
}
// ---- live meter ----
private void startMeter() {
meterRunning = true;
meterThread = new Thread(this::meterLoop, "settings-meter");
meterThread.setDaemon(true);
meterThread.start();
}
private void restartMeter() {
stopMeter();
startMeter();
}
private void stopMeter() {
meterRunning = false;
if (meterThread != null) {
meterThread.interrupt();
meterThread = null;
}
}
private void meterLoop() {
String device = comboValue(inputCombo);
TargetDataLine line = null;
try {
line = AudioDevices.openCapture(device);
line.start();
int frame = AudioDevices.FRAME_SIZE;
byte[] buf = new byte[frame * 2];
double gain = (inputGain != null ? inputGain.getValue() / 100.0 : 1.0);
while (meterRunning) {
int read = 0;
while (read < buf.length) {
int n = line.read(buf, read, buf.length - read);
if (n <= 0) break;
read += n;
}
if (read < buf.length) break;
double sumSq = 0;
for (int i = 0; i < frame; i++) {
short s = (short) ((buf[2 * i + 1] << 8) | (buf[2 * i] & 0xFF));
double f = s / 32768.0 * gain;
sumSq += f * f;
}
double rms = Math.sqrt(sumSq / frame);
double db = rms <= 1e-9 ? -100 : 20 * Math.log10(rms);
final double fdb = db;
if (meter != null) SwingUtilities.invokeLater(() -> meter.setLevel(fdb));
}
} catch (Exception ignored) {
} finally {
if (line != null) {
try {
line.stop();
line.close();
} catch (Exception ignored) {
}
}
}
}
// ---- small helpers ----
private static GridBagConstraints gbc() {
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
return c;
}
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, java.awt.Component field) {
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
c.gridwidth = 1;
p.add(label, c);
c.gridx = 1;
c.weightx = 1;
p.add(field, c);
}
private static void selectOrDefault(JComboBox<String> combo, String value) {
if (value == null || value.isEmpty()) {
combo.setSelectedIndex(0);
return;
}
for (int i = 0; i < combo.getItemCount(); i++) {
if (value.equals(combo.getItemAt(i))) {
combo.setSelectedIndex(i);
return;
}
}
combo.setSelectedIndex(0);
}
private static String comboValue(JComboBox<String> combo) {
int idx = combo.getSelectedIndex();
if (idx <= 0) return "";
Object v = combo.getSelectedItem();
return v == null ? "" : v.toString();
}
}

View File

@@ -0,0 +1,63 @@
package com.ts3client.ui;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Parses TeamSpeak 3 "spacer" channel names — cosmetic root channels used as
* separators, e.g. {@code [spacer0]---}, {@code [*spacer1]=}, {@code [cspacer]Rules}.
* The tag selects alignment ({@code l}/{@code c}/{@code r}) or fill ({@code *}).
*/
public final class Spacers {
/** Result of parsing a spacer name. */
public static final class Spacer {
public final char align; // 'l', 'c', 'r', or '*' (repeat/fill)
public final String caption;
Spacer(char align, String caption) {
this.align = align;
this.caption = caption;
}
}
private static final Pattern PATTERN =
Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$");
private Spacers() {
}
/** Returns spacer info if {@code channelName} is a spacer, else {@code null}. */
public static Spacer parse(String channelName) {
if (channelName == null) return null;
Matcher m = PATTERN.matcher(channelName);
if (!m.matches()) return null;
String tag = m.group(1);
char align = (tag == null || tag.isEmpty()) ? 'l' : tag.charAt(0);
return new Spacer(align, m.group(2));
}
public static boolean isSpacer(String channelName) {
return parse(channelName) != null;
}
/** Builds the visible label for a spacer at roughly the given character width. */
public static String render(Spacer s, int width) {
String caption = s.caption == null ? "" : s.caption;
if (s.align == '*') {
if (caption.isEmpty()) return "";
StringBuilder sb = new StringBuilder();
while (sb.length() < width) sb.append(caption);
return sb.substring(0, Math.max(caption.length(), Math.min(sb.length(), width)));
}
if (s.align == 'c') {
int pad = Math.max(0, (width - caption.length()) / 2);
return " ".repeat(pad) + caption;
}
if (s.align == 'r') {
int pad = Math.max(0, width - caption.length());
return " ".repeat(pad) + caption;
}
return caption;
}
}

View File

@@ -0,0 +1,35 @@
package com.ts3client.ui;
import java.awt.Color;
import java.awt.Font;
/** Central palette + fonts approximating the light TeamSpeak 3 look. */
public final class Theme {
public static final Color WINDOW_BG = new Color(0xF0F0F0);
public static final Color TREE_BG = new Color(0xFFFFFF);
public static final Color TREE_SELECTION = new Color(0xCFE3FB);
public static final Color TREE_TEXT = new Color(0x1E1E1E);
public static final Color CHANNEL_TEXT = new Color(0x21486B);
public static final Color SERVER_TEXT = new Color(0x123456);
public static final Color TALKING = new Color(0x33B35A);
public static final Color IDLE_CLIENT = new Color(0x6E7B87);
public static final Color AWAY = new Color(0xC98A1B);
public static final Color MUTED = new Color(0xC0392B);
public static final Color TOOLBAR_BG = new Color(0xE6E9ED);
public static final Color STATUS_BG = new Color(0xE6E9ED);
public static final Color ACCENT = new Color(0x2C7BE5);
public static final Color CHAT_BG = new Color(0xFAFAFA);
public static final Color CHAT_SYSTEM = new Color(0x8A8A8A);
public static final Color CHAT_NAME = new Color(0x2C7BE5);
public static final Color CHAT_TEXT = new Color(0x202020);
public static final Font UI_FONT = new Font("SansSerif", Font.PLAIN, 12);
public static final Font UI_BOLD = new Font("SansSerif", Font.BOLD, 12);
private Theme() {
}
}