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,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;
|
||||
}
|
||||
}
|
||||
127
ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java
Normal file
127
ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
196
ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java
Normal file
196
ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java
Normal 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×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);
|
||||
});
|
||||
}
|
||||
}
|
||||
100
ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java
Normal file
100
ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java
Normal 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("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
575
ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
Normal file
575
ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
63
ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java
Normal file
63
ts3-client/swing/src/main/java/com/ts3client/ui/Spacers.java
Normal 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;
|
||||
}
|
||||
}
|
||||
35
ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java
Normal file
35
ts3-client/swing/src/main/java/com/ts3client/ui/Theme.java
Normal 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() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user