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:
2026-08-13 15:18:47 +00:00
parent 5db3f4d8c4
commit 03c6ea9a3b
7 changed files with 843 additions and 373 deletions

View File

@@ -30,12 +30,13 @@ public final class Bookmarks {
if (index >= 0 && index < entries.size()) entries.remove(index);
}
/** First bookmark flagged to connect at startup, or {@code null}. */
public Bookmark startupBookmark() {
/** Bookmarks flagged to connect at startup, each in its own tab. */
public List<Bookmark> startupBookmarks() {
List<Bookmark> out = new ArrayList<>();
for (Bookmark b : entries) {
if (b.connectOnStartup) return b;
if (b.connectOnStartup) out.add(b);
}
return null;
return out;
}
public static Bookmarks load() {

View File

@@ -48,6 +48,14 @@ public final class TeamspeakConnection implements TS3Listener {
private volatile int selfClientId = -1;
private volatile long connectedAtMs;
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. */
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
@@ -83,6 +91,41 @@ public final class TeamspeakConnection implements TS3Listener {
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 ----
/**
@@ -112,6 +155,8 @@ public final class TeamspeakConnection implements TS3Listener {
String channel, String channelPassword) {
try {
identity = withIdentity;
serverHost = address;
serverPort = port;
model.clear();
playback = audio.createOutput(settings);
@@ -154,7 +199,6 @@ public final class TeamspeakConnection implements TS3Listener {
// Protocol connection established; anything past this point is best-effort.
selfClientId = client.getClientId();
serverHost = address;
fileTransfers = new FileTransferManager(client, () -> serverHost);
client.setMicrophone(microphone);
connected = true;
@@ -167,16 +211,16 @@ public final class TeamspeakConnection implements TS3Listener {
joinDefaultChannelIfNeeded(channel, channelPassword);
ui.onStatus("Connected to " + model.getServerName());
try {
microphone.start();
} catch (Exception micError) {
ui.onError("Microphone unavailable: " + rootMessage(micError));
}
// Only captures if this connection holds the microphone.
applyMicrophoneState();
} catch (Exception e) {
connected = false;
ui.onError("Connection failed: " + rootMessage(e));
ui.onStatus("Disconnected");
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");
}
}

View File

@@ -108,7 +108,8 @@ public final class BookmarksDialog extends JDialog {
channel.setToolTipText("Channel path, e.g. \"Lobby/Games\", or a channel id. "
+ "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;
addRow(panel, row++, "Label:", label);
@@ -199,12 +200,6 @@ public final class BookmarksDialog extends JDialog {
editing.channelPassword = new String(channelPassword.getPassword());
editing.identityId = identity.getSelectedIdentityId();
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();
}

View File

@@ -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() {
return make(g -> {
g.setColor(new Color(0x37474F));

View File

@@ -4,16 +4,12 @@ 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.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem;
@@ -24,14 +20,13 @@ 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.CardLayout;
import java.awt.Dimension;
import java.awt.GridLayout;
import java.awt.KeyEventDispatcher;
@@ -39,38 +34,47 @@ import java.awt.KeyboardFocusManager;
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.util.ArrayList;
import java.util.List;
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.
* The main application window: toolbar, tab bar, status bar and menus, hosting
* 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 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 final List<ServerTab> tabs = new ArrayList<>();
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 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 activeButton;
private JToggleButton micButton;
private JToggleButton speakerButton;
@@ -86,7 +90,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
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.
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
addWindowListener(new WindowAdapter() {
@Override
@@ -95,57 +99,39 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
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);
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);
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);
codecLabel.setText(audio.description());
chatPanel.appendSystem("Welcome to the TS3J Swing client.");
chatPanel.appendSystem("Use Connections → Connect to join a server.");
ServerTab first = newTab();
selectTab(first);
first.chat().appendSystem("Welcome to the TS3J Swing client.");
first.chat().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);
Bookmark startup = bookmarks.startupBookmark();
if (startup != null) {
// Deferred so the window is on screen before we start connecting.
SwingUtilities.invokeLater(() -> connectToBookmark(startup));
List<Bookmark> startup = bookmarks.startupBookmarks();
if (!startup.isEmpty()) {
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());
JMenuItem disconnect = new JMenuItem("Disconnect");
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");
quit.addActionListener(e -> {
shutdown();
@@ -167,6 +156,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
});
connections.add(connect);
connections.add(disconnect);
connections.add(closeTab);
connections.addSeparator();
connections.add(quit);
@@ -180,17 +170,19 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
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());
commanderItem = new JCheckBoxMenuItem("Channel commander");
commanderItem.addActionListener(e -> {
if (selected != null) selected.setCommander(commanderItem.isSelected());
});
JMenuItem nick = new JMenuItem("Change nickname…");
nick.addActionListener(e -> changeNickname());
self.add(mute);
self.add(deaf);
self.addSeparator();
self.add(awayItem);
self.add(commanderItem);
self.addSeparator();
self.add(rename);
self.add(nick);
JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…");
@@ -245,26 +237,27 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
disconnectButton.setToolTipText("Disconnect");
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.setToolTipText("Mute / unmute microphone");
micButton.setToolTipText("Mute / unmute microphone on this server");
micButton.addActionListener(e -> {
boolean muted = micButton.isSelected();
micButton.setIcon(muted ? Icons.micMuted() : Icons.mic());
conn.setMicMuted(muted);
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
if (selected == null) return;
selected.setMicMuted(micButton.isSelected());
updateToolbar();
});
speakerButton = new JToggleButton(Icons.speaker());
speakerButton.setToolTipText("Deafen / undeafen (mute speakers)");
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
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.");
if (selected == null) return;
selected.setDeafened(speakerButton.isSelected());
updateToolbar();
});
JButton settingsButton = new JButton(Icons.settings());
@@ -274,10 +267,12 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
tb.add(connectButton);
tb.add(disconnectButton);
tb.addSeparator();
tb.add(activeButton);
tb.add(micButton);
tb.add(speakerButton);
tb.addSeparator();
tb.add(settingsButton);
tb.add(Box.createHorizontalGlue());
return tb;
}
@@ -293,6 +288,112 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
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 ----
private void installPushToTalk() {
@@ -300,14 +401,15 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
@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;
ServerTab tab = micTab;
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return false;
if (e.getKeyCode() != settings.pushToTalkKey) return false;
if (e.getID() == KeyEvent.KEY_PRESSED && !pttPressed) {
pttPressed = true;
conn.getMicrophone().setPushToTalk(true);
tab.connection().getMicrophone().setPushToTalk(true);
} else if (e.getID() == KeyEvent.KEY_RELEASED) {
pttPressed = false;
conn.getMicrophone().setPushToTalk(false);
tab.connection().getMicrophone().setPushToTalk(false);
}
return false;
}
@@ -317,53 +419,32 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
// ---- 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(),
"", "");
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 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
*/
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();
ServerTab tab = tabForNewConnection();
// The first connection to come up takes the microphone; later ones are muted
// until the user activates them.
if (micTab == null) setMicTab(tab);
tab.connect(address, port, nickname, password, identityId, channel, channelPassword);
}
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);
}
/** 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) {
if (selected == null || !selected.isConnected()) {
JOptionPane.showMessageDialog(this, "Connect to a server first.",
"Add bookmark", JOptionPane.INFORMATION_MESSAGE);
return;
}
}
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());
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
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) {
return;
}
String label = labelField.getText();
Bookmark bookmark = new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword);
bookmark.identityId = currentIdentityId;
Bookmark bookmark = new Bookmark(labelField.getText().trim(), addr, port,
settings.nickname, settings.serverPassword);
bookmark.identityId = selected.identityId();
if (joinChannel.isSelected()) bookmark.channel = channelPath;
bookmarks.add(bookmark);
bookmarks.save();
@@ -411,6 +482,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
}
private void toggleAway() {
if (selected == null) return;
boolean away = awayItem.isSelected();
String message = null;
if (away) {
@@ -420,18 +492,16 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
return;
}
}
conn.setAway(away, message);
selected.setAway(away, message);
}
private void doDisconnect() {
if (conn.isConnected()) {
conn.disconnect();
}
if (selected != null) selected.disconnect();
}
/**
* Tear everything down before the process exits: disconnect from the server
* synchronously (so the "Leaving" notification actually reaches it) and stop
* Tear everything down before the process exits: disconnect from every server
* synchronously (so the "Leaving" notification actually reaches them) 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.
*/
@@ -443,8 +513,8 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
} catch (IllegalStateException ignored) {
// Already shutting down (hook itself is running); nothing to remove.
}
if (conn.isConnected()) {
conn.disconnectBlocking("Leaving");
for (ServerTab tab : new ArrayList<>(tabs)) {
tab.shutdown();
}
}
@@ -453,18 +523,30 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
}
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,
conn.getMicrophone(), conn.getPlayback(), () -> {
});
micTab == null ? null : micTab.connection().getMicrophone(),
selected == null ? null : selected.connection().getPlayback(),
this::applyOutputSettingsToAllTabs);
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() {
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);
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);
}
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;
}
}
// ---- toolbar / status ----
/** 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);
private void updateToolbar() {
boolean connected = selected != null && selected.isConnected();
disconnectButton.setEnabled(connected);
micButton.setEnabled(connected);
speakerButton.setEnabled(connected);
activeButton.setEnabled(connected);
awayItem.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() {
if (!conn.isConnected()) return;
int users = conn.getModel().clientCount();
ServerTab tab = selected;
if (tab == null || !tab.isConnected()) return;
int users = tab.connection().getModel().clientCount();
StringBuilder s = new StringBuilder("Connected to ")
.append(conn.getModel().getServerName())
.append(tab.connection().getModel().getServerName())
.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 (tab != micTab) s.append(" | microphone on another tab");
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);
});
}
}

View 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);
});
}
}

View File

@@ -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;
}
}