Keep each server's session state in core

What a server tab knows beyond its views - the connection lifecycle,
title and status, the identity used, the microphone/speaker/away and
commander flags, chat sending and the channel lookups - moves from the
Swing ServerTab and ServerTabSelfState into core's ServerSession, so
another frontend gets the same behaviour without copying it.

ServerSession also takes over what the Swing event bridge decided on its
own: clearing the model on disconnect, naming the tab after the server,
showing senders under their contact names, and the chat's system lines.
The bridge now only updates views. SelfState moves along, while its
icons stay with the tray.

A server switch no longer needs to queue behind the UI thread: the
disconnect is reported synchronously, so the session is free again by
the time it reconnects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:09:23 +00:00
parent 24f4b3813d
commit 8f385b9343
10 changed files with 657 additions and 580 deletions

View File

@@ -1,23 +1,19 @@
package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import com.ts3client.net.ConnectionListener.ChatScope;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.session.ServerSession;
import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink;
import javax.swing.JComponent;
import javax.swing.JSplitPane;
import javax.swing.SwingUtilities;
import java.util.ArrayList;
import java.util.List;
import java.awt.Component;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
@@ -27,21 +23,18 @@ import java.awt.event.ComponentEvent;
* 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>ServerTab itself owns the connection, the panels and this tab's identity
* (title/status); the mic/away/deafen flags live in {@link ServerTabSelfState},
* tree context-menu actions and selection in {@link ServerTabTreeActions}, and
* {@link com.ts3client.net.ConnectionListener} callbacks in {@link ServerTabConnectionEvents}.
* <p>The connection and the client's own state on it live in the {@link ServerSession};
* ServerTab owns the panels, tree context-menu actions and selection live in
* {@link ServerTabTreeActions}, and session callbacks in {@link ServerTabConnectionEvents}.
*/
final class ServerTab implements ServerTabConnectionEvents.Listener {
final class ServerTab {
private final MainFrame host;
private final Settings settings;
private final IdentityStore identities;
private final ServerSession session;
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final ServerTabConnectionEvents events;
private final ServerTabSelfState selfState;
private final ServerTabTreeActions treeActions;
private final ServerTreePanel treePanel;
private final ChatPanel chatPanel;
@@ -53,14 +46,6 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
/** Whether the saved divider share has been applied; before that, layout is not the user's doing. */
private boolean splitRestored;
/** 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 = "";
/** The one open ban list window for this server, if any. */
private BanListDialog banList;
@@ -68,21 +53,20 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
SoundNotifier sounds, ContactStore contacts) {
this.host = host;
this.settings = settings;
this.identities = identities;
// ServerTabConnectionEvents must exist before the connection (which needs a
// listener up front), and TeamspeakConnection must exist before the tree/chat
// panels and the other tab helpers that read from it — so wiring finishes with
// an explicit attach() once everything is built. Nothing fires callbacks before then.
this.events = new ServerTabConnectionEvents(host, this, this);
this.conn = new TeamspeakConnection(settings, audio, events, sounds, contacts);
// ServerTabConnectionEvents must exist before the session (which needs a listener
// up front), and the session must exist before the tree/chat panels and the other
// tab helpers that read from it — so wiring finishes with an explicit attach() once
// everything is built. Nothing fires callbacks before then.
ServerTabConnectionEvents events = new ServerTabConnectionEvents(host, this, settings);
this.session = new ServerSession(settings, identities, audio, sounds, contacts, events);
this.conn = session.connection();
this.groupIcons = new GroupIcons(conn.getIcons());
this.selfState = new ServerTabSelfState(conn);
this.chatPanel = new ChatPanel();
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons);
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, contacts, settings, treeActions);
treeActions.attach(treePanel);
events.attach(conn, treePanel, chatPanel, selfState, treeActions);
events.attach(session, treePanel, chatPanel, treeActions);
chatPanel.setSendHandler(this::onSendChat);
chatPanel.setLinkHandler(new ChatPanel.LinkHandler() {
@@ -143,6 +127,10 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
return component;
}
ServerSession session() {
return session;
}
TeamspeakConnection connection() {
return conn;
}
@@ -151,140 +139,9 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
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 selfState.isMicMuted();
}
boolean isMicLocalMuted() {
return selfState.isMicLocalMuted();
}
boolean isDeafened() {
return selfState.isDeafened();
}
boolean isAway() {
return selfState.isAway();
}
String awayMessage() {
return selfState.awayMessage();
}
boolean isCommander() {
return selfState.isCommander();
}
/** What the local client looks like on this server, for the tray icon. */
SelfState selfState() {
return selfState.compute();
}
/** 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 + "\")") + " …");
events.onStatus("Loading identity…");
// 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;
events.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();
}
/**
* Replaces this tab's connection: leaves the server it is on, then connects to the
* new one in its place — what left-clicking a bookmark does.
*/
void switchTo(String address, int port, String nickname, String password, String identityId,
String channel, String channelPassword) {
if (!isBusy()) {
connect(address, port, nickname, password, identityId, channel, channelPassword);
return;
}
new Thread(() -> {
conn.disconnectBlocking("Switching servers");
// Queued behind the disconnect's own UI work, which clears the "connecting" flag.
SwingUtilities.invokeLater(() ->
connect(address, port, nickname, password, identityId, channel, channelPassword));
}, "ts3j-switch-server").start();
}
void disconnect() {
if (conn.isConnected()) conn.disconnect();
}
/**
* Whether the server grants us {@code b_client_ban_list}. Until it has named its
* permissions nothing can be resolved, so the list is offered and the server left to refuse.
*/
boolean canViewBanList() {
if (!conn.isConnected()) return false;
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0;
}
/** Opens the server's ban list, or brings the already open one forward. */
void showBanList(BanReasonPresets presets) {
if (!conn.isConnected()) return;
if (!session.isConnected()) return;
if (banList == null || !banList.isDisplayable()) {
banList = new BanListDialog(host, conn, settings, presets);
}
@@ -306,68 +163,7 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
treeActions.renderInfo();
}
/** Releases the background resources of a tab that is being thrown away. */
void dispose() {
conn.getIcons().shutdown();
}
/** Synchronous teardown for shutdown paths, so the server sees us leave. */
void shutdown() {
if (conn.isConnected()) conn.disconnectBlocking("Leaving");
}
// ---- ServerTabConnectionEvents.Listener ----
@Override
public void setStatus(String status) {
this.status = status;
}
@Override
public void setTitle(String title) {
this.title = title;
}
@Override
public void setConnecting(boolean connecting) {
this.connecting = connecting;
}
// ---- self state ----
void setMicMuted(boolean muted) {
selfState.setMicMuted(muted);
}
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
void setMicLocalMuted(boolean muted) {
selfState.setMicLocalMuted(muted);
}
void setDeafened(boolean deaf) {
selfState.setDeafened(deaf);
}
/** Hands the capture device to (or takes it from) this connection. */
void setMicrophoneActive(boolean active) {
conn.setMicrophoneActive(active);
}
/**
* @param message the away message, or null to keep the one already set — the
* message outlives coming back, so toggling away again restores it
*/
void setAway(boolean away, String message) {
selfState.setAway(away, message);
}
void setCommander(boolean commander) {
selfState.setCommander(commander);
}
void setNickname(String nickname) {
if (conn.isConnected()) conn.setNickname(nickname);
}
// ---- dialogs ----
void showAvatarDialog() {
if (conn.isConnected()) new AvatarDialog(host, conn).setVisible(true);
@@ -376,58 +172,12 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
// ---- 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;
}
}
private String peerName(int clientId) {
ClientEntry c = conn.getModel().getClient(clientId);
return c != null ? conn.displayName(c) : "Client " + clientId;
}
/** Joins the channel at a "/"-separated path, as the hotkey action names it. */
void joinChannelPath(String path) {
if (!conn.isConnected() || path == null || path.isBlank()) return;
ChannelNode target = conn.getModel().findChannelByPath(path);
if (target != null) conn.joinChannel(target.id, null);
}
/** Every channel of this server as a "/"-separated path, in the tree's own order. */
List<String> channelPaths() {
List<String> out = new ArrayList<>();
if (!conn.isConnected()) return out;
for (ChannelNode root : conn.getModel().buildTree()) collectPaths(root, out);
return out;
}
private void collectPaths(ChannelNode channel, List<String> out) {
out.add(conn.getModel().channelPath(channel.id));
for (ChannelNode child : channel.children) collectPaths(child, out);
}
/** The channel we are in, or null when not connected. */
ChannelNode currentChannel() {
if (!conn.isConnected()) return null;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
return self == null ? null : conn.getModel().getChannel(self.channelId);
ChatScope scope = switch (target) {
case SERVER -> ChatScope.SERVER;
case PRIVATE -> ChatScope.PRIVATE;
default -> ChatScope.CHANNEL;
};
session.sendChat(scope, clientId, text);
}
/** Opens the file repository browser for a channel, as the "Browse Files" hotkey/menu action does. */