Tabbed servers: multiple simultaneous connections
Each connection now lives in its own ServerTab with its own tree, info and chat views plus its own mic-mute, deafen, away and commander state. MainFrame keeps only the shell (menus, toolbar, status bar) and shows one tab at a time; the tab bar below the toolbar appears once a second server is open. Incoming voice from every connection mixes into the same output — each connection already renders one line per speaker. Capture is exclusive: a connection only opens the microphone while it holds it, and a toolbar button moves it to the visible tab. The mic follows a disconnect to another connected tab, and push-to-talk drives whoever holds it. Startup bookmarks are no longer mutually exclusive: every flagged bookmark opens its own tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,12 +30,13 @@ public final class Bookmarks {
|
|||||||
if (index >= 0 && index < entries.size()) entries.remove(index);
|
if (index >= 0 && index < entries.size()) entries.remove(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** First bookmark flagged to connect at startup, or {@code null}. */
|
/** Bookmarks flagged to connect at startup, each in its own tab. */
|
||||||
public Bookmark startupBookmark() {
|
public List<Bookmark> startupBookmarks() {
|
||||||
|
List<Bookmark> out = new ArrayList<>();
|
||||||
for (Bookmark b : entries) {
|
for (Bookmark b : entries) {
|
||||||
if (b.connectOnStartup) return b;
|
if (b.connectOnStartup) out.add(b);
|
||||||
}
|
}
|
||||||
return null;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Bookmarks load() {
|
public static Bookmarks load() {
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
private volatile int selfClientId = -1;
|
private volatile int selfClientId = -1;
|
||||||
private volatile long connectedAtMs;
|
private volatile long connectedAtMs;
|
||||||
private volatile String serverHost;
|
private volatile String serverHost;
|
||||||
|
private volatile int serverPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this connection owns the capture device. Only one connection may
|
||||||
|
* capture at a time, so the frontend hands the microphone to a single
|
||||||
|
* connection and the others keep their (idle) input pipeline.
|
||||||
|
*/
|
||||||
|
private volatile boolean microphoneActive;
|
||||||
|
|
||||||
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
||||||
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
|
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
|
||||||
@@ -83,6 +91,41 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Address this connection was last asked to connect to. */
|
||||||
|
public String getServerHost() {
|
||||||
|
return serverHost == null ? "" : serverHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getServerPort() {
|
||||||
|
return serverPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives this connection the capture device, or takes it away. Capturing only
|
||||||
|
* starts once connected; calling this before or during a connect is fine, the
|
||||||
|
* requested state is applied as soon as the microphone exists.
|
||||||
|
*/
|
||||||
|
public synchronized void setMicrophoneActive(boolean active) {
|
||||||
|
microphoneActive = active;
|
||||||
|
applyMicrophoneState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMicrophoneActive() {
|
||||||
|
return microphoneActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts or stops capture to match {@link #microphoneActive}. */
|
||||||
|
private synchronized void applyMicrophoneState() {
|
||||||
|
VoiceInput mic = microphone;
|
||||||
|
if (mic == null || !connected) return;
|
||||||
|
try {
|
||||||
|
if (microphoneActive) mic.start();
|
||||||
|
else mic.stop();
|
||||||
|
} catch (Exception e) {
|
||||||
|
ui.onError("Microphone unavailable: " + rootMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- connection lifecycle ----
|
// ---- connection lifecycle ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +155,8 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
String channel, String channelPassword) {
|
String channel, String channelPassword) {
|
||||||
try {
|
try {
|
||||||
identity = withIdentity;
|
identity = withIdentity;
|
||||||
|
serverHost = address;
|
||||||
|
serverPort = port;
|
||||||
|
|
||||||
model.clear();
|
model.clear();
|
||||||
playback = audio.createOutput(settings);
|
playback = audio.createOutput(settings);
|
||||||
@@ -154,7 +199,6 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
|
|
||||||
// Protocol connection established; anything past this point is best-effort.
|
// Protocol connection established; anything past this point is best-effort.
|
||||||
selfClientId = client.getClientId();
|
selfClientId = client.getClientId();
|
||||||
serverHost = address;
|
|
||||||
fileTransfers = new FileTransferManager(client, () -> serverHost);
|
fileTransfers = new FileTransferManager(client, () -> serverHost);
|
||||||
client.setMicrophone(microphone);
|
client.setMicrophone(microphone);
|
||||||
connected = true;
|
connected = true;
|
||||||
@@ -167,16 +211,16 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
joinDefaultChannelIfNeeded(channel, channelPassword);
|
joinDefaultChannelIfNeeded(channel, channelPassword);
|
||||||
ui.onStatus("Connected to " + model.getServerName());
|
ui.onStatus("Connected to " + model.getServerName());
|
||||||
|
|
||||||
try {
|
// Only captures if this connection holds the microphone.
|
||||||
microphone.start();
|
applyMicrophoneState();
|
||||||
} catch (Exception micError) {
|
|
||||||
ui.onError("Microphone unavailable: " + rootMessage(micError));
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
connected = false;
|
connected = false;
|
||||||
ui.onError("Connection failed: " + rootMessage(e));
|
ui.onError("Connection failed: " + rootMessage(e));
|
||||||
ui.onStatus("Disconnected");
|
|
||||||
safeCleanup();
|
safeCleanup();
|
||||||
|
// Report the failed attempt as a disconnect too, so the UI leaves the
|
||||||
|
// "connecting" state and the tab becomes reusable.
|
||||||
|
ui.onDisconnected("connection failed");
|
||||||
|
ui.onStatus("Disconnected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ public final class BookmarksDialog extends JDialog {
|
|||||||
|
|
||||||
channel.setToolTipText("Channel path, e.g. \"Lobby/Games\", or a channel id. "
|
channel.setToolTipText("Channel path, e.g. \"Lobby/Games\", or a channel id. "
|
||||||
+ "Leave empty for the server's default channel.");
|
+ "Leave empty for the server's default channel.");
|
||||||
connectOnStartup.setToolTipText("Connect to this server automatically when the client starts.");
|
connectOnStartup.setToolTipText("Connect to this server automatically when the client starts. "
|
||||||
|
+ "Several bookmarks may be flagged; each opens its own tab.");
|
||||||
|
|
||||||
int row = 0;
|
int row = 0;
|
||||||
addRow(panel, row++, "Label:", label);
|
addRow(panel, row++, "Label:", label);
|
||||||
@@ -199,12 +200,6 @@ public final class BookmarksDialog extends JDialog {
|
|||||||
editing.channelPassword = new String(channelPassword.getPassword());
|
editing.channelPassword = new String(channelPassword.getPassword());
|
||||||
editing.identityId = identity.getSelectedIdentityId();
|
editing.identityId = identity.getSelectedIdentityId();
|
||||||
editing.connectOnStartup = connectOnStartup.isSelected();
|
editing.connectOnStartup = connectOnStartup.isSelected();
|
||||||
// Only one bookmark can be connected to at startup.
|
|
||||||
if (editing.connectOnStartup) {
|
|
||||||
for (Bookmark other : bookmarks.all()) {
|
|
||||||
if (other != editing) other.connectOnStartup = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
list.repaint();
|
list.repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,19 @@ public final class Icons {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marks the server that currently owns the microphone. */
|
||||||
|
public static ImageIcon micActive() {
|
||||||
|
return make(g -> {
|
||||||
|
g.setColor(Theme.TALKING);
|
||||||
|
g.fillOval(1, 1, 14, 14);
|
||||||
|
g.setColor(Color.WHITE);
|
||||||
|
g.fillRoundRect(6, 3, 4, 6, 2, 2);
|
||||||
|
g.setStroke(new BasicStroke(1.4f));
|
||||||
|
g.drawArc(4, 6, 8, 6, 200, 140);
|
||||||
|
g.drawLine(8, 11, 8, 13);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static ImageIcon settings() {
|
public static ImageIcon settings() {
|
||||||
return make(g -> {
|
return make(g -> {
|
||||||
g.setColor(new Color(0x37474F));
|
g.setColor(new Color(0x37474F));
|
||||||
|
|||||||
@@ -4,16 +4,12 @@ import com.ts3client.audio.AudioBackend;
|
|||||||
import com.ts3client.audio.desktop.JavaSoundAudioBackend;
|
import com.ts3client.audio.desktop.JavaSoundAudioBackend;
|
||||||
import com.ts3client.config.Bookmark;
|
import com.ts3client.config.Bookmark;
|
||||||
import com.ts3client.config.Bookmarks;
|
import com.ts3client.config.Bookmarks;
|
||||||
import com.ts3client.config.IdentityEntry;
|
|
||||||
import com.ts3client.config.IdentityStore;
|
import com.ts3client.config.IdentityStore;
|
||||||
import com.ts3client.config.Settings;
|
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.BorderFactory;
|
||||||
|
import javax.swing.Box;
|
||||||
|
import javax.swing.BoxLayout;
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JCheckBox;
|
import javax.swing.JCheckBox;
|
||||||
import javax.swing.JCheckBoxMenuItem;
|
import javax.swing.JCheckBoxMenuItem;
|
||||||
@@ -24,14 +20,13 @@ import javax.swing.JMenuBar;
|
|||||||
import javax.swing.JMenuItem;
|
import javax.swing.JMenuItem;
|
||||||
import javax.swing.JOptionPane;
|
import javax.swing.JOptionPane;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
import javax.swing.JSplitPane;
|
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
import javax.swing.JToggleButton;
|
import javax.swing.JToggleButton;
|
||||||
import javax.swing.JToolBar;
|
import javax.swing.JToolBar;
|
||||||
import javax.swing.KeyStroke;
|
import javax.swing.KeyStroke;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Component;
|
import java.awt.CardLayout;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.GridLayout;
|
import java.awt.GridLayout;
|
||||||
import java.awt.KeyEventDispatcher;
|
import java.awt.KeyEventDispatcher;
|
||||||
@@ -39,38 +34,47 @@ import java.awt.KeyboardFocusManager;
|
|||||||
import java.awt.event.KeyEvent;
|
import java.awt.event.KeyEvent;
|
||||||
import java.awt.event.WindowAdapter;
|
import java.awt.event.WindowAdapter;
|
||||||
import java.awt.event.WindowEvent;
|
import java.awt.event.WindowEvent;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The main application window: toolbar, server tree, chat and status bar,
|
* The main application window: toolbar, tab bar, status bar and menus, hosting
|
||||||
* wired to a {@link TeamspeakConnection}. Resembles the TeamSpeak 3 client layout.
|
* one {@link ServerTab} per open server connection.
|
||||||
|
*
|
||||||
|
* <p>Several servers can be connected at once — their incoming voice is mixed
|
||||||
|
* into the same output — but only one of them owns the microphone at a time
|
||||||
|
* ("active" tab, marked in the tab bar and switched from the toolbar).
|
||||||
*/
|
*/
|
||||||
public final class MainFrame extends JFrame implements ConnectionListener, ServerTreePanel.Actions {
|
public final class MainFrame extends JFrame implements ServerTabBar.Listener {
|
||||||
|
|
||||||
private final Settings settings;
|
private final Settings settings;
|
||||||
private final Bookmarks bookmarks = Bookmarks.load();
|
private final Bookmarks bookmarks = Bookmarks.load();
|
||||||
private final IdentityStore identities;
|
private final IdentityStore identities;
|
||||||
private final AudioBackend audio = new JavaSoundAudioBackend();
|
private final AudioBackend audio = new JavaSoundAudioBackend();
|
||||||
private TeamspeakConnection conn;
|
|
||||||
|
|
||||||
/** Identity of the current/last connection, so it can be saved into a bookmark. */
|
private final List<ServerTab> tabs = new ArrayList<>();
|
||||||
private String currentIdentityId = "";
|
private final CardLayout cards = new CardLayout();
|
||||||
|
private final JPanel cardPanel = new JPanel(cards);
|
||||||
|
private final ServerTabBar tabBar = new ServerTabBar(this);
|
||||||
|
private int tabCounter;
|
||||||
|
|
||||||
|
/** The tab whose views are on screen. */
|
||||||
|
private ServerTab selected;
|
||||||
|
/** The tab that owns the capture device; null when nobody is capturing. */
|
||||||
|
private ServerTab micTab;
|
||||||
|
|
||||||
private JMenu bookmarksMenu;
|
private JMenu bookmarksMenu;
|
||||||
private JCheckBoxMenuItem awayItem;
|
private JCheckBoxMenuItem awayItem;
|
||||||
private JCheckBoxMenuItem commanderItem;
|
private JCheckBoxMenuItem commanderItem;
|
||||||
private javax.swing.Timer statusTimer;
|
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 statusLabel = new JLabel("Not connected");
|
||||||
private final JLabel codecLabel = new JLabel();
|
private final JLabel codecLabel = new JLabel();
|
||||||
|
|
||||||
private JButton connectButton;
|
private JButton connectButton;
|
||||||
private JButton disconnectButton;
|
private JButton disconnectButton;
|
||||||
|
private JToggleButton activeButton;
|
||||||
private JToggleButton micButton;
|
private JToggleButton micButton;
|
||||||
private JToggleButton speakerButton;
|
private JToggleButton speakerButton;
|
||||||
|
|
||||||
@@ -86,7 +90,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
this.identities = IdentityStore.load(settings);
|
this.identities = IdentityStore.load(settings);
|
||||||
|
|
||||||
setIconImage(Icons.app().getImage());
|
setIconImage(Icons.app().getImage());
|
||||||
// We tear the connection down ourselves on close, so don't let Swing kill the JVM.
|
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
|
||||||
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
|
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
|
||||||
addWindowListener(new WindowAdapter() {
|
addWindowListener(new WindowAdapter() {
|
||||||
@Override
|
@Override
|
||||||
@@ -95,57 +99,39 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
System.exit(0);
|
System.exit(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Catches Ctrl+C / SIGTERM so we still leave the server cleanly.
|
// Catches Ctrl+C / SIGTERM so we still leave the servers cleanly.
|
||||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||||
setMinimumSize(new Dimension(720, 480));
|
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());
|
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);
|
|
||||||
|
|
||||||
|
JPanel top = new JPanel();
|
||||||
|
top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
|
||||||
|
top.add(buildToolbar());
|
||||||
|
top.add(tabBar);
|
||||||
|
add(top, BorderLayout.NORTH);
|
||||||
|
add(cardPanel, BorderLayout.CENTER);
|
||||||
add(buildStatusBar(), BorderLayout.SOUTH);
|
add(buildStatusBar(), BorderLayout.SOUTH);
|
||||||
|
|
||||||
codecLabel.setText(audio.description());
|
codecLabel.setText(audio.description());
|
||||||
|
|
||||||
chatPanel.appendSystem("Welcome to the TS3J Swing client.");
|
ServerTab first = newTab();
|
||||||
chatPanel.appendSystem("Use Connections → Connect to join a server.");
|
selectTab(first);
|
||||||
|
first.chat().appendSystem("Welcome to the TS3J Swing client.");
|
||||||
|
first.chat().appendSystem("Use Connections → Connect to join a server.");
|
||||||
|
|
||||||
installPushToTalk();
|
installPushToTalk();
|
||||||
updateButtons(false);
|
|
||||||
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
||||||
statusTimer.start();
|
statusTimer.start();
|
||||||
setSize(880, 560);
|
setSize(880, 560);
|
||||||
setLocationRelativeTo(null);
|
setLocationRelativeTo(null);
|
||||||
|
|
||||||
Bookmark startup = bookmarks.startupBookmark();
|
// Deferred so the window is on screen before we start connecting.
|
||||||
if (startup != null) {
|
List<Bookmark> startup = bookmarks.startupBookmarks();
|
||||||
// Deferred so the window is on screen before we start connecting.
|
if (!startup.isEmpty()) {
|
||||||
SwingUtilities.invokeLater(() -> connectToBookmark(startup));
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
for (Bookmark b : startup) connectToBookmark(b);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +146,9 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
connect.addActionListener(e -> showConnectDialog());
|
connect.addActionListener(e -> showConnectDialog());
|
||||||
JMenuItem disconnect = new JMenuItem("Disconnect");
|
JMenuItem disconnect = new JMenuItem("Disconnect");
|
||||||
disconnect.addActionListener(e -> doDisconnect());
|
disconnect.addActionListener(e -> doDisconnect());
|
||||||
|
JMenuItem closeTab = new JMenuItem("Close tab");
|
||||||
|
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
|
||||||
|
closeTab.addActionListener(e -> closeTab(selected));
|
||||||
JMenuItem quit = new JMenuItem("Quit");
|
JMenuItem quit = new JMenuItem("Quit");
|
||||||
quit.addActionListener(e -> {
|
quit.addActionListener(e -> {
|
||||||
shutdown();
|
shutdown();
|
||||||
@@ -167,6 +156,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
});
|
});
|
||||||
connections.add(connect);
|
connections.add(connect);
|
||||||
connections.add(disconnect);
|
connections.add(disconnect);
|
||||||
|
connections.add(closeTab);
|
||||||
connections.addSeparator();
|
connections.addSeparator();
|
||||||
connections.add(quit);
|
connections.add(quit);
|
||||||
|
|
||||||
@@ -180,17 +170,19 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
deaf.addActionListener(e -> speakerButton.doClick());
|
deaf.addActionListener(e -> speakerButton.doClick());
|
||||||
awayItem = new JCheckBoxMenuItem("Away");
|
awayItem = new JCheckBoxMenuItem("Away");
|
||||||
awayItem.addActionListener(e -> toggleAway());
|
awayItem.addActionListener(e -> toggleAway());
|
||||||
commanderItem = new JCheckBoxMenuItem("Channel Commander");
|
commanderItem = new JCheckBoxMenuItem("Channel commander");
|
||||||
commanderItem.addActionListener(e -> conn.setChannelCommander(commanderItem.isSelected()));
|
commanderItem.addActionListener(e -> {
|
||||||
JMenuItem rename = new JMenuItem("Change nickname…");
|
if (selected != null) selected.setCommander(commanderItem.isSelected());
|
||||||
rename.addActionListener(e -> changeNickname());
|
});
|
||||||
|
JMenuItem nick = new JMenuItem("Change nickname…");
|
||||||
|
nick.addActionListener(e -> changeNickname());
|
||||||
self.add(mute);
|
self.add(mute);
|
||||||
self.add(deaf);
|
self.add(deaf);
|
||||||
self.addSeparator();
|
self.addSeparator();
|
||||||
self.add(awayItem);
|
self.add(awayItem);
|
||||||
self.add(commanderItem);
|
self.add(commanderItem);
|
||||||
self.addSeparator();
|
self.addSeparator();
|
||||||
self.add(rename);
|
self.add(nick);
|
||||||
|
|
||||||
JMenu tools = new JMenu("Tools");
|
JMenu tools = new JMenu("Tools");
|
||||||
JMenuItem identitiesItem = new JMenuItem("Identities…");
|
JMenuItem identitiesItem = new JMenuItem("Identities…");
|
||||||
@@ -245,26 +237,27 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
disconnectButton.setToolTipText("Disconnect");
|
disconnectButton.setToolTipText("Disconnect");
|
||||||
disconnectButton.addActionListener(e -> doDisconnect());
|
disconnectButton.addActionListener(e -> doDisconnect());
|
||||||
|
|
||||||
|
activeButton = new JToggleButton(Icons.micActive());
|
||||||
|
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)");
|
||||||
|
activeButton.addActionListener(e -> {
|
||||||
|
if (selected != null && selected.isConnected()) setMicTab(selected);
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
|
||||||
micButton = new JToggleButton(Icons.mic());
|
micButton = new JToggleButton(Icons.mic());
|
||||||
micButton.setToolTipText("Mute / unmute microphone");
|
micButton.setToolTipText("Mute / unmute microphone on this server");
|
||||||
micButton.addActionListener(e -> {
|
micButton.addActionListener(e -> {
|
||||||
boolean muted = micButton.isSelected();
|
if (selected == null) return;
|
||||||
micButton.setIcon(muted ? Icons.micMuted() : Icons.mic());
|
selected.setMicMuted(micButton.isSelected());
|
||||||
conn.setMicMuted(muted);
|
updateToolbar();
|
||||||
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
speakerButton = new JToggleButton(Icons.speaker());
|
speakerButton = new JToggleButton(Icons.speaker());
|
||||||
speakerButton.setToolTipText("Deafen / undeafen (mute speakers)");
|
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
|
||||||
speakerButton.addActionListener(e -> {
|
speakerButton.addActionListener(e -> {
|
||||||
boolean deaf = speakerButton.isSelected();
|
if (selected == null) return;
|
||||||
speakerButton.setIcon(deaf ? Icons.speakerMuted() : Icons.speaker());
|
selected.setDeafened(speakerButton.isSelected());
|
||||||
conn.setDeafened(deaf);
|
updateToolbar();
|
||||||
if (deaf) {
|
|
||||||
micButton.setSelected(true);
|
|
||||||
micButton.setIcon(Icons.micMuted());
|
|
||||||
}
|
|
||||||
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
JButton settingsButton = new JButton(Icons.settings());
|
JButton settingsButton = new JButton(Icons.settings());
|
||||||
@@ -274,10 +267,12 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
tb.add(connectButton);
|
tb.add(connectButton);
|
||||||
tb.add(disconnectButton);
|
tb.add(disconnectButton);
|
||||||
tb.addSeparator();
|
tb.addSeparator();
|
||||||
|
tb.add(activeButton);
|
||||||
tb.add(micButton);
|
tb.add(micButton);
|
||||||
tb.add(speakerButton);
|
tb.add(speakerButton);
|
||||||
tb.addSeparator();
|
tb.addSeparator();
|
||||||
tb.add(settingsButton);
|
tb.add(settingsButton);
|
||||||
|
tb.add(Box.createHorizontalGlue());
|
||||||
return tb;
|
return tb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +288,112 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
return bar;
|
return bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- tab management ----
|
||||||
|
|
||||||
|
private ServerTab newTab() {
|
||||||
|
ServerTab tab = new ServerTab(this, settings, identities, audio);
|
||||||
|
String name = "tab" + (tabCounter++);
|
||||||
|
tab.component().setName(name); // CardLayout addresses cards by this name
|
||||||
|
tabs.add(tab);
|
||||||
|
cardPanel.add(tab.component(), name);
|
||||||
|
refreshTabBar();
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tab a new connection should use: the current one if it is free, else a new one. */
|
||||||
|
private ServerTab tabForNewConnection() {
|
||||||
|
if (selected != null && !selected.isBusy()) return selected;
|
||||||
|
ServerTab tab = newTab();
|
||||||
|
selectTab(tab);
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void selectTab(ServerTab tab) {
|
||||||
|
if (tab == null || !tabs.contains(tab)) return;
|
||||||
|
selected = tab;
|
||||||
|
cards.show(cardPanel, tab.component().getName());
|
||||||
|
refreshTabBar();
|
||||||
|
updateToolbar();
|
||||||
|
updateStatusLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void closeTab(ServerTab tab) {
|
||||||
|
if (tab == null) return;
|
||||||
|
tab.disconnect();
|
||||||
|
if (tabs.size() == 1) return; // always keep one view around
|
||||||
|
|
||||||
|
tabs.remove(tab);
|
||||||
|
cardPanel.remove(tab.component());
|
||||||
|
if (micTab == tab) micTab = null;
|
||||||
|
if (selected == tab) {
|
||||||
|
selected = null;
|
||||||
|
selectTab(tabs.get(0));
|
||||||
|
} else {
|
||||||
|
refreshTabBar();
|
||||||
|
}
|
||||||
|
assignMicrophoneIfFree();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves the capture device to {@code tab}, taking it from whoever held it. */
|
||||||
|
private void setMicTab(ServerTab tab) {
|
||||||
|
if (micTab == tab) return;
|
||||||
|
ServerTab previous = micTab;
|
||||||
|
micTab = tab;
|
||||||
|
refreshTabBar();
|
||||||
|
// Closing and reopening the capture line can block briefly; keep it off the EDT.
|
||||||
|
new Thread(() -> {
|
||||||
|
if (previous != null) previous.setMicrophoneActive(false);
|
||||||
|
if (tab != null) tab.setMicrophoneActive(true);
|
||||||
|
}, "mic-handover").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gives the microphone to some connected tab when nobody holds it. */
|
||||||
|
private void assignMicrophoneIfFree() {
|
||||||
|
if (micTab != null && tabs.contains(micTab) && micTab.isConnected()) return;
|
||||||
|
micTab = null;
|
||||||
|
for (ServerTab t : tabs) {
|
||||||
|
if (t.isConnected()) {
|
||||||
|
setMicTab(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshTabBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshTabBar() {
|
||||||
|
tabBar.rebuild(tabs, selected, micTab);
|
||||||
|
// The bar appears/disappears with the second connection.
|
||||||
|
getContentPane().revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- callbacks from ServerTab ----
|
||||||
|
|
||||||
|
/** A tab's title, status or connection state changed. */
|
||||||
|
void tabUpdated(ServerTab tab) {
|
||||||
|
if (!tabs.contains(tab)) return;
|
||||||
|
refreshTabBar();
|
||||||
|
if (tab == selected) {
|
||||||
|
updateToolbar();
|
||||||
|
updateStatusLabel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void tabConnected(ServerTab tab) {
|
||||||
|
if (!tabs.contains(tab)) return;
|
||||||
|
assignMicrophoneIfFree();
|
||||||
|
tabUpdated(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tabDisconnected(ServerTab tab) {
|
||||||
|
if (!tabs.contains(tab)) return;
|
||||||
|
// A disconnected tab cannot capture; hand the microphone on if it held it.
|
||||||
|
if (micTab == tab) micTab = null;
|
||||||
|
assignMicrophoneIfFree();
|
||||||
|
tabUpdated(tab);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- push to talk ----
|
// ---- push to talk ----
|
||||||
|
|
||||||
private void installPushToTalk() {
|
private void installPushToTalk() {
|
||||||
@@ -300,14 +401,15 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
@Override
|
@Override
|
||||||
public boolean dispatchKeyEvent(KeyEvent e) {
|
public boolean dispatchKeyEvent(KeyEvent e) {
|
||||||
if (settings.inputMode != Settings.InputMode.PUSH_TO_TALK) return false;
|
if (settings.inputMode != Settings.InputMode.PUSH_TO_TALK) return false;
|
||||||
if (conn == null || !conn.isConnected() || conn.getMicrophone() == null) return false;
|
ServerTab tab = micTab;
|
||||||
|
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return false;
|
||||||
if (e.getKeyCode() != settings.pushToTalkKey) return false;
|
if (e.getKeyCode() != settings.pushToTalkKey) return false;
|
||||||
if (e.getID() == KeyEvent.KEY_PRESSED && !pttPressed) {
|
if (e.getID() == KeyEvent.KEY_PRESSED && !pttPressed) {
|
||||||
pttPressed = true;
|
pttPressed = true;
|
||||||
conn.getMicrophone().setPushToTalk(true);
|
tab.connection().getMicrophone().setPushToTalk(true);
|
||||||
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
|
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
|
||||||
pttPressed = false;
|
pttPressed = false;
|
||||||
conn.getMicrophone().setPushToTalk(false);
|
tab.connection().getMicrophone().setPushToTalk(false);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -317,53 +419,32 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
// ---- actions ----
|
// ---- actions ----
|
||||||
|
|
||||||
private void showConnectDialog() {
|
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);
|
ConnectDialog dlg = new ConnectDialog(this, settings, identities);
|
||||||
dlg.setVisible(true);
|
dlg.setVisible(true);
|
||||||
if (!dlg.isConfirmed()) return;
|
if (!dlg.isConfirmed()) return;
|
||||||
startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword(), dlg.getIdentityId(),
|
startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword(),
|
||||||
"", "");
|
dlg.getIdentityId(), "", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Opens a connection in the current tab when it is free, otherwise in a new one.
|
||||||
|
*
|
||||||
* @param identityId identity to use, or empty for the default one
|
* @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 channel channel path to join on connect, or empty for the default channel
|
||||||
* @param channelPassword password for that channel, if any
|
* @param channelPassword password for that channel, if any
|
||||||
*/
|
*/
|
||||||
private void startConnection(String address, int port, String nickname, String password, String identityId,
|
private void startConnection(String address, int port, String nickname, String password, String identityId,
|
||||||
String channel, String channelPassword) {
|
String channel, String channelPassword) {
|
||||||
if (conn.isConnected()) {
|
|
||||||
JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.",
|
|
||||||
"Connect", JOptionPane.INFORMATION_MESSAGE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settings.lastAddress = address + ":" + port;
|
settings.lastAddress = address + ":" + port;
|
||||||
settings.nickname = nickname;
|
settings.nickname = nickname;
|
||||||
settings.serverPassword = password;
|
settings.serverPassword = password;
|
||||||
settings.save();
|
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.
|
ServerTab tab = tabForNewConnection();
|
||||||
onStatus("Loading identity…");
|
// The first connection to come up takes the microphone; later ones are muted
|
||||||
new Thread(() -> {
|
// until the user activates them.
|
||||||
final IdentityEntry entry;
|
if (micTab == null) setMicTab(tab);
|
||||||
try {
|
tab.connect(address, port, nickname, password, identityId, channel, channelPassword);
|
||||||
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) {
|
private void connectToBookmark(Bookmark b) {
|
||||||
@@ -371,27 +452,17 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
startConnection(b.address, b.port, nick, b.password, b.identityId, b.channel, b.channelPassword);
|
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() {
|
private void addCurrentServerBookmark() {
|
||||||
String addr = settings.lastAddress;
|
if (selected == null || !selected.isConnected()) {
|
||||||
int port = 9987;
|
JOptionPane.showMessageDialog(this, "Connect to a server first.",
|
||||||
int colon = addr.lastIndexOf(':');
|
"Add bookmark", JOptionPane.INFORMATION_MESSAGE);
|
||||||
if (colon > 0) {
|
return;
|
||||||
try {
|
|
||||||
port = Integer.parseInt(addr.substring(colon + 1));
|
|
||||||
addr = addr.substring(0, colon);
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
String channelPath = currentChannelPath();
|
String addr = selected.address();
|
||||||
|
int port = selected.port();
|
||||||
|
String channelPath = selected.currentChannelPath();
|
||||||
|
|
||||||
JTextField labelField = new JTextField(addr);
|
JTextField labelField = new JTextField(selected.title());
|
||||||
JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty());
|
JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty());
|
||||||
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
|
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
|
||||||
form.add(new JLabel("Bookmark label:"));
|
form.add(new JLabel("Bookmark label:"));
|
||||||
@@ -401,9 +472,9 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) != JOptionPane.OK_OPTION) {
|
JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) != JOptionPane.OK_OPTION) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String label = labelField.getText();
|
Bookmark bookmark = new Bookmark(labelField.getText().trim(), addr, port,
|
||||||
Bookmark bookmark = new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword);
|
settings.nickname, settings.serverPassword);
|
||||||
bookmark.identityId = currentIdentityId;
|
bookmark.identityId = selected.identityId();
|
||||||
if (joinChannel.isSelected()) bookmark.channel = channelPath;
|
if (joinChannel.isSelected()) bookmark.channel = channelPath;
|
||||||
bookmarks.add(bookmark);
|
bookmarks.add(bookmark);
|
||||||
bookmarks.save();
|
bookmarks.save();
|
||||||
@@ -411,6 +482,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void toggleAway() {
|
private void toggleAway() {
|
||||||
|
if (selected == null) return;
|
||||||
boolean away = awayItem.isSelected();
|
boolean away = awayItem.isSelected();
|
||||||
String message = null;
|
String message = null;
|
||||||
if (away) {
|
if (away) {
|
||||||
@@ -420,18 +492,16 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
conn.setAway(away, message);
|
selected.setAway(away, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void doDisconnect() {
|
private void doDisconnect() {
|
||||||
if (conn.isConnected()) {
|
if (selected != null) selected.disconnect();
|
||||||
conn.disconnect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tear everything down before the process exits: disconnect from the server
|
* Tear everything down before the process exits: disconnect from every server
|
||||||
* synchronously (so the "Leaving" notification actually reaches it) and stop
|
* synchronously (so the "Leaving" notification actually reaches them) and stop
|
||||||
* background timers. Idempotent and safe to call from any thread — the window
|
* 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.
|
* close handler, the Quit menu and the JVM shutdown hook may all invoke it.
|
||||||
*/
|
*/
|
||||||
@@ -443,8 +513,8 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
} catch (IllegalStateException ignored) {
|
} catch (IllegalStateException ignored) {
|
||||||
// Already shutting down (hook itself is running); nothing to remove.
|
// Already shutting down (hook itself is running); nothing to remove.
|
||||||
}
|
}
|
||||||
if (conn.isConnected()) {
|
for (ServerTab tab : new ArrayList<>(tabs)) {
|
||||||
conn.disconnectBlocking("Leaving");
|
tab.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,18 +523,30 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void showSettings() {
|
private void showSettings() {
|
||||||
|
// Device and voice-gating changes apply to the capturing connection and the
|
||||||
|
// playback of the visible one; the rest pick the new settings up on connect.
|
||||||
SettingsDialog dlg = new SettingsDialog(this, settings,
|
SettingsDialog dlg = new SettingsDialog(this, settings,
|
||||||
conn.getMicrophone(), conn.getPlayback(), () -> {
|
micTab == null ? null : micTab.connection().getMicrophone(),
|
||||||
});
|
selected == null ? null : selected.connection().getPlayback(),
|
||||||
|
this::applyOutputSettingsToAllTabs);
|
||||||
dlg.setVisible(true);
|
dlg.setVisible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Master volume / output device are global, so push them to every open connection. */
|
||||||
|
private void applyOutputSettingsToAllTabs() {
|
||||||
|
for (ServerTab tab : tabs) {
|
||||||
|
if (tab.connection().getPlayback() == null) continue;
|
||||||
|
tab.connection().getPlayback().setMasterVolume(settings.outputVolume);
|
||||||
|
tab.connection().getPlayback().setOutputDevice(settings.outputDevice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void changeNickname() {
|
private void changeNickname() {
|
||||||
String n = JOptionPane.showInputDialog(this, "New nickname:", settings.nickname);
|
String n = JOptionPane.showInputDialog(this, "New nickname:", settings.nickname);
|
||||||
if (n != null && !n.trim().isEmpty()) {
|
if (n != null && !n.trim().isEmpty()) {
|
||||||
settings.nickname = n.trim();
|
settings.nickname = n.trim();
|
||||||
settings.save();
|
settings.save();
|
||||||
if (conn.isConnected()) conn.setNickname(settings.nickname);
|
for (ServerTab tab : tabs) tab.setNickname(settings.nickname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,233 +560,42 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
|||||||
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
|
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onSendChat(ChatPanel.Target target, int clientId, String text) {
|
// ---- toolbar / status ----
|
||||||
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 updateToolbar() {
|
||||||
private void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
boolean connected = selected != null && selected.isConnected();
|
||||||
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);
|
disconnectButton.setEnabled(connected);
|
||||||
micButton.setEnabled(connected);
|
micButton.setEnabled(connected);
|
||||||
speakerButton.setEnabled(connected);
|
speakerButton.setEnabled(connected);
|
||||||
|
activeButton.setEnabled(connected);
|
||||||
awayItem.setEnabled(connected);
|
awayItem.setEnabled(connected);
|
||||||
commanderItem.setEnabled(connected);
|
commanderItem.setEnabled(connected);
|
||||||
chatPanel.setInputEnabled(connected);
|
|
||||||
|
boolean micMuted = connected && selected.isMicMuted();
|
||||||
|
boolean deaf = connected && selected.isDeafened();
|
||||||
|
micButton.setSelected(micMuted);
|
||||||
|
micButton.setIcon(micMuted ? Icons.micMuted() : Icons.mic());
|
||||||
|
speakerButton.setSelected(deaf);
|
||||||
|
speakerButton.setIcon(deaf ? Icons.speakerMuted() : Icons.speaker());
|
||||||
|
activeButton.setSelected(selected != null && selected == micTab);
|
||||||
|
awayItem.setSelected(connected && selected.isAway());
|
||||||
|
commanderItem.setSelected(connected && selected.isCommander());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateStatusLabel() {
|
||||||
|
statusLabel.setText(selected == null ? "Not connected" : selected.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateConnectionStatus() {
|
private void updateConnectionStatus() {
|
||||||
if (!conn.isConnected()) return;
|
ServerTab tab = selected;
|
||||||
int users = conn.getModel().clientCount();
|
if (tab == null || !tab.isConnected()) return;
|
||||||
|
int users = tab.connection().getModel().clientCount();
|
||||||
StringBuilder s = new StringBuilder("Connected to ")
|
StringBuilder s = new StringBuilder("Connected to ")
|
||||||
.append(conn.getModel().getServerName())
|
.append(tab.connection().getModel().getServerName())
|
||||||
.append(" | ").append(users).append(users == 1 ? " user" : " users");
|
.append(" | ").append(users).append(users == 1 ? " user" : " users");
|
||||||
double ping = conn.getPingMillis();
|
double ping = tab.connection().getPingMillis();
|
||||||
if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms");
|
if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms");
|
||||||
|
if (tab != micTab) s.append(" | microphone on another tab");
|
||||||
statusLabel.setText(s.toString());
|
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
451
ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
Normal file
451
ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.audio.AudioBackend;
|
||||||
|
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.JComponent;
|
||||||
|
import javax.swing.JOptionPane;
|
||||||
|
import javax.swing.JSplitPane;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import java.awt.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One server connection and the views bound to it (tree, info and chat). The
|
||||||
|
* client keeps several of these side by side; {@link MainFrame} shows one at a
|
||||||
|
* time and owns the toolbar, menus and status bar that act on it.
|
||||||
|
*
|
||||||
|
* <p>Everything that is per-server lives here: the connection, its microphone
|
||||||
|
* and speaker mute state, the away/commander flags and the chat history.
|
||||||
|
*/
|
||||||
|
final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||||
|
|
||||||
|
private final MainFrame host;
|
||||||
|
private final Settings settings;
|
||||||
|
private final IdentityStore identities;
|
||||||
|
|
||||||
|
private final TeamspeakConnection conn;
|
||||||
|
private final ServerTreePanel treePanel;
|
||||||
|
private final ChatPanel chatPanel;
|
||||||
|
private final InfoPanel infoPanel = new InfoPanel();
|
||||||
|
private final JComponent component;
|
||||||
|
|
||||||
|
/** Label shown in the tab bar: the server name once known, the address before that. */
|
||||||
|
private String title = "New connection";
|
||||||
|
private String status = "Not connected";
|
||||||
|
private volatile boolean connecting;
|
||||||
|
|
||||||
|
/** Identity used for the current connection, so it can be saved into a bookmark. */
|
||||||
|
private String identityId = "";
|
||||||
|
|
||||||
|
private boolean micMuted;
|
||||||
|
private boolean deafened;
|
||||||
|
private boolean away;
|
||||||
|
private boolean commander;
|
||||||
|
|
||||||
|
private Object currentSelection;
|
||||||
|
|
||||||
|
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio) {
|
||||||
|
this.host = host;
|
||||||
|
this.settings = settings;
|
||||||
|
this.identities = identities;
|
||||||
|
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) {
|
||||||
|
ServerTab.this.onClientLink(ref, source, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||||
|
ServerTab.this.onChannelLink(ref, source, x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chatPanel.setInputEnabled(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.component = split;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- accessors ----
|
||||||
|
|
||||||
|
JComponent component() {
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
TeamspeakConnection connection() {
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatPanel chat() {
|
||||||
|
return chatPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
String title() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
String status() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isConnected() {
|
||||||
|
return conn.isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connected or still connecting — i.e. this tab is not free for a new server. */
|
||||||
|
boolean isBusy() {
|
||||||
|
return connecting || conn.isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
String address() {
|
||||||
|
return conn.getServerHost();
|
||||||
|
}
|
||||||
|
|
||||||
|
int port() {
|
||||||
|
return conn.getServerPort();
|
||||||
|
}
|
||||||
|
|
||||||
|
String identityId() {
|
||||||
|
return identityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isMicMuted() {
|
||||||
|
return micMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isDeafened() {
|
||||||
|
return deafened;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isAway() {
|
||||||
|
return away;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isCommander() {
|
||||||
|
return commander;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path of the channel we are in, or empty when not connected. */
|
||||||
|
String currentChannelPath() {
|
||||||
|
if (!conn.isConnected()) return "";
|
||||||
|
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||||
|
return self == null ? "" : conn.getModel().channelPath(self.channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- connection lifecycle ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param identityId identity to use, or empty for the default one
|
||||||
|
* @param channel channel path to join on connect, or empty for the default channel
|
||||||
|
* @param channelPassword password for that channel, if any
|
||||||
|
*/
|
||||||
|
void connect(String address, int port, String nickname, String password, String identityId,
|
||||||
|
String channel, String channelPassword) {
|
||||||
|
if (isBusy()) return;
|
||||||
|
connecting = true;
|
||||||
|
title = address + ":" + port;
|
||||||
|
chatPanel.appendSystem("Connecting to " + address + ":" + port
|
||||||
|
+ (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + " …");
|
||||||
|
onStatus("Loading identity…");
|
||||||
|
host.tabUpdated(this);
|
||||||
|
|
||||||
|
// Resolving may have to generate a first identity, so keep it off the EDT.
|
||||||
|
new Thread(() -> {
|
||||||
|
final IdentityEntry entry;
|
||||||
|
try {
|
||||||
|
entry = identities.resolve(settings, identityId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
connecting = false;
|
||||||
|
onError("Could not load identity: " + e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
this.identityId = entry.getId();
|
||||||
|
chatPanel.appendSystem("Using identity \"" + entry.getName() + "\".");
|
||||||
|
});
|
||||||
|
conn.connect(address, port, nickname, password, entry.getIdentity(), channel, channelPassword);
|
||||||
|
}, "identity-resolve").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void disconnect() {
|
||||||
|
if (conn.isConnected()) conn.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous teardown for shutdown paths, so the server sees us leave. */
|
||||||
|
void shutdown() {
|
||||||
|
if (conn.isConnected()) conn.disconnectBlocking("Leaving");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- self state ----
|
||||||
|
|
||||||
|
void setMicMuted(boolean muted) {
|
||||||
|
micMuted = muted;
|
||||||
|
conn.setMicMuted(muted);
|
||||||
|
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDeafened(boolean deaf) {
|
||||||
|
deafened = deaf;
|
||||||
|
conn.setDeafened(deaf);
|
||||||
|
if (deaf) micMuted = true;
|
||||||
|
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands the capture device to (or takes it from) this connection. */
|
||||||
|
void setMicrophoneActive(boolean active) {
|
||||||
|
conn.setMicrophoneActive(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAway(boolean away, String message) {
|
||||||
|
this.away = away;
|
||||||
|
conn.setAway(away, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCommander(boolean commander) {
|
||||||
|
this.commander = commander;
|
||||||
|
conn.setChannelCommander(commander);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setNickname(String nickname) {
|
||||||
|
if (conn.isConnected()) conn.setNickname(nickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- chat ----
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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(host, "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(host, conn, client.id, client.nickname).setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void browseFiles(ChannelNode channel) {
|
||||||
|
if (!conn.canTransferFiles()) return;
|
||||||
|
new FileBrowserDialog(host, 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 text) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
status = text;
|
||||||
|
host.tabUpdated(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConnected() {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
connecting = false;
|
||||||
|
treePanel.setSelfClientId(conn.getSelfClientId());
|
||||||
|
micMuted = false;
|
||||||
|
deafened = false;
|
||||||
|
away = false;
|
||||||
|
commander = false;
|
||||||
|
chatPanel.setInputEnabled(true);
|
||||||
|
chatPanel.appendSystem("Connected.");
|
||||||
|
host.tabConnected(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisconnected(String reason) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
connecting = false;
|
||||||
|
conn.getModel().clear();
|
||||||
|
treePanel.showDisconnected();
|
||||||
|
currentSelection = null;
|
||||||
|
infoPanel.clear();
|
||||||
|
chatPanel.setInputEnabled(false);
|
||||||
|
chatPanel.closePrivateChats();
|
||||||
|
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
||||||
|
host.tabDisconnected(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onModelChanged() {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
treePanel.rebuild();
|
||||||
|
renderInfo();
|
||||||
|
String name = conn.getModel().getServerName();
|
||||||
|
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(title)) {
|
||||||
|
title = name;
|
||||||
|
host.tabUpdated(this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
status = message;
|
||||||
|
host.tabUpdated(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPoke(String fromName, String message) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
|
||||||
|
host.selectTab(this);
|
||||||
|
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
|
||||||
|
"Poke", JOptionPane.INFORMATION_MESSAGE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JToggleButton;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.FlowLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip of open server connections, shown between the toolbar and the server
|
||||||
|
* view. The bar only appears while more than one connection is open; with a
|
||||||
|
* single server the client looks exactly as it did before.
|
||||||
|
*/
|
||||||
|
final class ServerTabBar extends JPanel {
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
void selectTab(ServerTab tab);
|
||||||
|
|
||||||
|
void closeTab(ServerTab tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Listener listener;
|
||||||
|
|
||||||
|
ServerTabBar(Listener listener) {
|
||||||
|
super(new FlowLayout(FlowLayout.LEFT, 3, 2));
|
||||||
|
this.listener = listener;
|
||||||
|
setBackground(Theme.TOOLBAR_BG);
|
||||||
|
setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Theme.WINDOW_BG));
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param micTab the connection holding the microphone, marked with an icon
|
||||||
|
*/
|
||||||
|
void rebuild(List<ServerTab> tabs, ServerTab selected, ServerTab micTab) {
|
||||||
|
removeAll();
|
||||||
|
for (ServerTab tab : tabs) {
|
||||||
|
add(buildCell(tab, tab == selected, tab == micTab, tabs.size() > 1));
|
||||||
|
}
|
||||||
|
setVisible(tabs.size() > 1);
|
||||||
|
revalidate();
|
||||||
|
repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel buildCell(ServerTab tab, boolean selected, boolean hasMic, boolean closable) {
|
||||||
|
JPanel cell = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
||||||
|
cell.setOpaque(false);
|
||||||
|
|
||||||
|
JToggleButton button = new JToggleButton(tab.title(), hasMic ? Icons.micActive() : null);
|
||||||
|
button.setSelected(selected);
|
||||||
|
button.setFocusable(false);
|
||||||
|
button.setFont(selected ? Theme.UI_BOLD : Theme.UI_FONT);
|
||||||
|
button.setMargin(new Insets(2, 8, 2, 8));
|
||||||
|
button.setToolTipText(tab.status());
|
||||||
|
button.addActionListener(e -> listener.selectTab(tab));
|
||||||
|
cell.add(button);
|
||||||
|
|
||||||
|
if (closable) {
|
||||||
|
JButton close = new JButton("✕");
|
||||||
|
close.setFocusable(false);
|
||||||
|
close.setFont(Theme.UI_FONT);
|
||||||
|
close.setMargin(new Insets(2, 5, 2, 5));
|
||||||
|
// Keep the cell compact but never so small that the glyph is clipped.
|
||||||
|
Dimension size = close.getPreferredSize();
|
||||||
|
close.setPreferredSize(new Dimension(size.width, button.getPreferredSize().height));
|
||||||
|
close.setToolTipText("Close this connection");
|
||||||
|
close.addActionListener(e -> listener.closeTab(tab));
|
||||||
|
cell.add(close);
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user