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