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>
327 lines
12 KiB
Java
327 lines
12 KiB
Java
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();
|
|
}
|
|
}
|