From 03c6ea9a3b195ed38b361d760917009ca266f430 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 13 Aug 2026 15:18:47 +0000 Subject: [PATCH] Tabbed servers: multiple simultaneous connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/com/ts3client/config/Bookmarks.java | 9 +- .../ts3client/net/TeamspeakConnection.java | 58 +- .../com/ts3client/ui/BookmarksDialog.java | 9 +- .../src/main/java/com/ts3client/ui/Icons.java | 13 + .../main/java/com/ts3client/ui/MainFrame.java | 601 +++++++----------- .../main/java/com/ts3client/ui/ServerTab.java | 451 +++++++++++++ .../java/com/ts3client/ui/ServerTabBar.java | 75 +++ 7 files changed, 843 insertions(+), 373 deletions(-) create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabBar.java diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java index f7740a6..f0572f8 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java @@ -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 startupBookmarks() { + List 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() { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index fa8dc32..af28e97 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -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 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"); } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java index 1598aac..c0a3245 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java @@ -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(); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java index d29758c..9a7bf78 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java @@ -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)); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index c15eb0b..026f2b2 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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. + * + *

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 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)); + // Deferred so the window is on screen before we start connecting. + List 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); - }); - } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java new file mode 100644 index 0000000..0001f0d --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -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. + * + *

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); + }); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabBar.java new file mode 100644 index 0000000..d22a2be --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabBar.java @@ -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 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; + } +}