Replace the chat target dropdown with a tab strip along the bottom of the chat panel: a Server and a Channel tab plus one closable tab per private chat, each with its own log and an unread marker. Render logs and channel/client descriptions as HTML through a new BBCode renderer in core, supporting b/i/u/s, color, size, url, center and lists, with auto-linked bare URLs. Input is escaped and tag values validated, so server-supplied text cannot inject markup or unsafe schemes. Sender names and TeamSpeak's own client:// links open the same context menu as the server tree (menus extracted into ClientMenu/ChannelMenu); channel:// links open the channel menu. Links are styled by CSS class: identities bold in the author colour, other TS protocols plain, and links leaving the client underlined so they cannot be mistaken for an identity. Dragging a client or channel out of the tree yields the TS3 link BBCode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
705 lines
27 KiB
Java
705 lines
27 KiB
Java
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.IdentityEntry;
|
|
import com.ts3client.config.IdentityStore;
|
|
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 com.ts3client.text.TsLink;
|
|
|
|
import javax.swing.BorderFactory;
|
|
import javax.swing.JButton;
|
|
import javax.swing.JCheckBox;
|
|
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.JTextField;
|
|
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.GridLayout;
|
|
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 IdentityStore identities;
|
|
private final AudioBackend audio = new JavaSoundAudioBackend();
|
|
private TeamspeakConnection conn;
|
|
|
|
/** Identity of the current/last connection, so it can be saved into a bookmark. */
|
|
private String currentIdentityId = "";
|
|
|
|
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;
|
|
this.identities = IdentityStore.load(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);
|
|
chatPanel.setLinkHandler(new ChatPanel.LinkHandler() {
|
|
@Override
|
|
public void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
|
MainFrame.this.onClientLink(ref, source, x, y);
|
|
}
|
|
|
|
@Override
|
|
public void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
|
MainFrame.this.onChannelLink(ref, source, x, y);
|
|
}
|
|
});
|
|
|
|
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 identitiesItem = new JMenuItem("Identities…");
|
|
identitiesItem.addActionListener(e -> showIdentities());
|
|
JMenuItem options = new JMenuItem("Options…");
|
|
options.addActionListener(e -> showSettings());
|
|
tools.add(identitiesItem);
|
|
tools.addSeparator();
|
|
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, identities,
|
|
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, identities);
|
|
dlg.setVisible(true);
|
|
if (!dlg.isConfirmed()) return;
|
|
startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword(), dlg.getIdentityId(),
|
|
"", "");
|
|
}
|
|
|
|
/**
|
|
* @param identityId identity to use, or empty for the default one
|
|
* @param channel channel path to join on connect, or empty for the server's default channel
|
|
* @param channelPassword password for that channel, if any
|
|
*/
|
|
private void startConnection(String address, int port, String nickname, String password, String identityId,
|
|
String channel, String channelPassword) {
|
|
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
|
|
+ (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + " …");
|
|
|
|
// Resolving may have to generate a first identity, so keep it off the EDT.
|
|
onStatus("Loading identity…");
|
|
new Thread(() -> {
|
|
final IdentityEntry entry;
|
|
try {
|
|
entry = identities.resolve(settings, identityId);
|
|
} catch (Exception e) {
|
|
onError("Could not load identity: " + e.getMessage());
|
|
return;
|
|
}
|
|
SwingUtilities.invokeLater(() -> {
|
|
currentIdentityId = entry.getId();
|
|
chatPanel.appendSystem("Using identity \"" + entry.getName() + "\".");
|
|
});
|
|
conn.connect(address, port, nickname, password, entry.getIdentity(), channel, channelPassword);
|
|
}, "identity-resolve").start();
|
|
}
|
|
|
|
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, b.identityId, b.channel, b.channelPassword);
|
|
}
|
|
|
|
/** Path of the channel we are currently in, or empty when not connected. */
|
|
private String currentChannelPath() {
|
|
if (conn == null || !conn.isConnected()) return "";
|
|
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
|
return self == null ? "" : conn.getModel().channelPath(self.channelId);
|
|
}
|
|
|
|
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 channelPath = currentChannelPath();
|
|
|
|
JTextField labelField = new JTextField(addr);
|
|
JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty());
|
|
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
|
|
form.add(new JLabel("Bookmark label:"));
|
|
form.add(labelField);
|
|
if (!channelPath.isEmpty()) form.add(joinChannel);
|
|
if (JOptionPane.showConfirmDialog(this, form, "Add bookmark",
|
|
JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) != JOptionPane.OK_OPTION) {
|
|
return;
|
|
}
|
|
String label = labelField.getText();
|
|
Bookmark bookmark = new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword);
|
|
bookmark.identityId = currentIdentityId;
|
|
if (joinChannel.isSelected()) bookmark.channel = channelPath;
|
|
bookmarks.add(bookmark);
|
|
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 showIdentities() {
|
|
new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true);
|
|
}
|
|
|
|
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, int clientId, String text) {
|
|
if (!conn.isConnected()) {
|
|
chatPanel.appendSystem("Not connected.");
|
|
return;
|
|
}
|
|
String me = settings.nickname + " (you)";
|
|
int myId = conn.getSelfClientId();
|
|
switch (target) {
|
|
case SERVER:
|
|
conn.sendServerMessage(text);
|
|
chatPanel.appendServerMessage(myId, me, text);
|
|
break;
|
|
case PRIVATE:
|
|
conn.sendPrivateMessage(clientId, text);
|
|
chatPanel.appendPrivateMessage(clientId, peerName(clientId), myId, me, text);
|
|
break;
|
|
default:
|
|
conn.sendChannelMessage(text);
|
|
chatPanel.appendChannelMessage(myId, me, text);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** A client link in the chat log was clicked: show the same menu as the tree does. */
|
|
private void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
|
ClientEntry client = conn.getModel().getClient(ref.id);
|
|
// The id is only valid for the session the link was made in; fall back to
|
|
// the unique id (and finally the nickname) so older links still resolve.
|
|
if (client == null || (!ref.uniqueId.isEmpty() && !ref.uniqueId.equals(client.uniqueId))) {
|
|
ClientEntry byUid = ref.uniqueId.isEmpty() ? null
|
|
: conn.getModel().findClientByUniqueId(ref.uniqueId);
|
|
if (byUid == null && !ref.name.isEmpty()) byUid = conn.getModel().findClientByName(ref.name);
|
|
if (byUid != null) client = byUid;
|
|
}
|
|
if (client == null) {
|
|
chatPanel.appendSystem("That client is no longer on the server.");
|
|
return;
|
|
}
|
|
ClientMenu.build(client, client.id == conn.getSelfClientId(), this).show(source, x, y);
|
|
}
|
|
|
|
private void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
|
ChannelNode channel = conn.getModel().getChannel(ref.id);
|
|
if (channel == null) {
|
|
chatPanel.appendSystem("That channel no longer exists.");
|
|
return;
|
|
}
|
|
ChannelMenu.build(channel, this).show(source, x, y);
|
|
}
|
|
|
|
private String peerName(int clientId) {
|
|
ClientEntry c = conn.getModel().getClient(clientId);
|
|
return c != null ? c.nickname : "Client " + clientId;
|
|
}
|
|
|
|
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) {
|
|
chatPanel.openPrivateChat(client.id, client.nickname);
|
|
}
|
|
|
|
@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 void browseFiles(ChannelNode channel) {
|
|
if (!conn.canTransferFiles()) return;
|
|
new FileBrowserDialog(this, conn, channel).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.closePrivateChats();
|
|
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) {
|
|
switch (scope) {
|
|
case PRIVATE:
|
|
chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message);
|
|
break;
|
|
case SERVER:
|
|
chatPanel.appendServerMessage(fromClientId, fromName, message);
|
|
break;
|
|
default:
|
|
chatPanel.appendChannelMessage(fromClientId, fromName, message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
@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);
|
|
});
|
|
}
|
|
}
|